read24 - Server: Creating a Quiz

July 4, 2020

Roger Ngo

I am trying to keep changes very small. A positive side effect in this is that I am able to enjoy programming so much more as I am limited to about an hour of programming at a time, and about another hour for cleaning up my dev log into a blog post.‌

Today in this sunny Saturday morning on July 4, 2020... I have that hot dog eating contest they play every year on ESPN running in the background. I am more interested this year, because of COVID-19. I was wondering how this event was going to be pulled off. Turns out, there are no spectators allowed.

Unfortunately, the TV is on mute right now as my wife is reading to my daughter. Hey, I can't complain. this is the whole point of this project. To get more kids to read!

The goal for this session is to implement the /quiz/book route which will write to our in-memory database. Before I get right to that, I have some preparation to do in order to make it possible.

The requirement for the /quiz/book endpoint is to receive the POST request body, with the following parameters:

{
    bookId,
    studentId
}

And create a quizToken which acts as a quiz session for the student. The quizToken is just a unique identifier which can link a set of questions, choices, and student to a specific session for a book.

Let's take a step back and go back to student_answer.ts. I forgot to add the quizToken property here. Might as well do that now.

export interface StudentAnswer {
    id: number;
    quizToken: string;
    studentId: number;
    questionId: number;
    choiceId: number;
};

Since quizToken is a unique ID, it can be generated as a UUID. We can use the npm package uuid to do that.

npm install uuid --save
npm install @types/uuid --save-dev

Now once the request body is received, the bookId, and studentId along with a generated token is used to create a QuizToken object in the database.

We need to make the in-memory database loaded mockDb.json a little smarter. I can create an in-memory database manager to maintain the data in memory.

Create a new folder db, and under that the file memory.ts. This will contain code to manage the temporary database.

DataModel is the interface type which will contain all the data objects which exist in mockDb.json.

This data will all be stored in a InMemoryDatabase object type which includes two properties:

  1. A loaded flag indicating that the data has been loaded once by the system.
  2. A data object property referencing all the data in memory of DataModel type.

There is also a function to be called maybeLoadDb() to retrieve the database file in-memory. Here is the entire implementation of memorty.ts.

import * as mockDb from '../mockDb.json';
import { Book } from '../models/book';
import { Student } from '../models/student';
import { Classroom } from '../models/classroom';
import { User } from '../models/user';
import { QuizToken } from '../models/quiztoken';
import { Question } from '../models/question';
import { Choice } from '../models/choice';
import { QuizQuestion } from '../models/quiz_question';

interface DataModel {
    classrooms: Classroom[];
    users: User[];
    books: Book[];
    students: Student[];
    quizTokens: QuizToken[];
    questions: Question[];
    choices: Choice[];
    quizQuestions: QuizQuestion[];
}

interface InMemoryDatabase {
    loaded: boolean;
    data: DataModel | null;
}

const db: InMemoryDatabase = {
    loaded: false,
    data: null
};

export function maybeLoadDb() {
    if (db.loaded)
        return db;

    db.data = mockDb;
    db.loaded = true;

    return db;
}

I know that DataModel can be extended later to have more property types. So I will do that as-needed.

Okay, now let's seed mockDb.json with some development data. Keep an eye on quizTokens. Notice that it is an empty array right now. This is what the POST /quiz/book should be doing: it should write the QuizToken object into the database.

{
    "classrooms": [
        {
            "id": 1,
            "name": "Room 4",
            "slug": "room4"
        }
    ],
    "users": [
        {
            "id": 1,
            "username": "stu01",
            "password": "$2b$07$uLoKxQ9vxs7jKVSZ8u7pMuQx5d0SvYZL0NkXX/S08FqHECGG2yB9u",
            "salt": "$2b$07$uLoKxQ9vxs7jKVSZ8u7pMu"
        }
    ],
    "students": [
        {
            "id": 1,
            "classroomId": 1,
            "firstName": "Stu",
            "middleName": "",
            "lastName": "Dent",
            "grade": 5,
            "userId": 1
        }
    ],
    "books": [
        {
            "id": 1,
            "title": "Where the Red Fern Grows",
            "author": "Wilson Rawls",
            "fiction": true,
            "year": "1974",
            "publisher": "Bantam Books",
            "genre": 0,
            "isbn": "978-0-553-12338-8",
            "lexile": 700,
            "wordCount": 75528
        },
        {
            "id": 2,
            "title": "Esperanza Rising",
            "author": "Pam Munoz Ryan",
            "fiction": true,
            "year": "2000",
            "publisher": "Scholastic, Inc.",
            "genre": 0,
            "isbn": "0-439-12041-1",
            "lexile": 750,
            "wordCount": 41905
        }
    ],
    "quizTokens": [],
    "questions": [
        {
            "id": 1,
            "bookId": 1,
            "content": "WTRFG - First question"
        },
        {
            "id": 2,
            "bookId": 1,
            "content": "WTRFG - Second question"
        },
        {
            "id": 3,
            "bookId": 1,
            "content": "WTRFG - Third question"
        }
    ],
    "choices": [
        {
            "id": 1,
            "questionId": 1,
            "content": "First choice",
            "answer": true
        },
        {
            "id": 2,
            "questionId": 1,
            "content": "Second choice",
            "answer": false
        },
        {
            "id": 3,
            "questionId": 1,
            "content": "Third choice",
            "answer": false
        },
        {
            "id": 4,
            "questionId": 1,
            "content": "Fourth choice",
            "answer": false
        },
        {
            "id": 5,
            "questionId": 2,
            "content": "First choice",
            "answer": false
        },
        {
            "id": 6,
            "questionId": 2,
            "content": "Second choice",
            "answer": false
        },
        {
            "id": 7,
            "questionId": 2,
            "content": "Third choice",
            "answer": false
        },
        {
            "id": 8,
            "questionId": 2,
            "content": "Fourth choice",
            "answer": true
        },
        {
            "id": 9,
            "questionId": 3,
            "content": "First choice",
            "answer": false
        },
        {
            "id": 10,
            "questionId": 3,
            "content": "Second choice",
            "answer": false
        },
        {
            "id": 11,
            "questionId": 3,
            "content": "Third choice",
            "answer": true
        },
        {
            "id": 12,
            "questionId": 3,
            "content": "Fourth choice",
            "answer": false
        }
    ],
    "quizQuestions": []
}

Finally, implement the /quiz/book route. Upon generating a quizToken object, an entire image of the quiz is also returned with the response. This is basically the set of randomly chosen questions and choices for the specific book. The front-end can use this to render out the quiz questions.

import { IRouter } from "express";
import * as uuid from "uuid";
import { QuizToken, QuizStatus } from "../models/quiztoken";
import { maybeLoadDb } from '../db/memory';

const db = maybeLoadDb();

function buildQuiz(bookId: number) {
    const result = [];
    const questions = db.data.questions.filter(q => q.bookId === bookId);
    for(const q of questions) {
        result.push({
            question: q,
            choices: db.data.choices.filter(c => c.questionId === q.id)
        });
    }

    return result;
}

export function mountQuiz(app: IRouter) {
    app.post('/quiz/book', (req, res) => {
        const bookId = req.body.bookId;
        const studentId = req.body.studentId;
        const token = uuid.v4();

        const quizToken: QuizToken = {
            id: 1,
            status: QuizStatus.Incomplete,
            bookId,
            studentId,
            token
        };

        const existing = db.data.quizTokens.find(t => t.id === quizToken.id);

        if (existing)
            db.data.quizTokens = db.data.quizTokens.filter(t => t.id === existing.id).concat(quizToken);
        else
            db.data.quizTokens.push(quizToken);

        const quiz = buildQuiz(bookId);

        // Associate each question with the quiz
        let i = 1;
        for(const el of quiz) {
            db.data.quizQuestions.push({
                id: i++,
                quizToken: token,
                questionId: el.question.id
            });    
        }
        
        res.status(200).json({token, quiz});
    });

    app.post('/quiz/book/question', (req, res) => {
        res.status(200).send(req.body);


    });

    app.post('/quiz/book/rate', (req, res) => {
        res.status(200).send(req.body);
    });
}

Here is a sample result of the POST /quiz/book call given the payload to generate a quiz for Where the Red Fern Grows.

{
    "token": "8a9a6225-9b9c-4377-a996-c56e162b03bf",
    "quiz": [
        {
            "question": {
                "id": 1,
                "bookId": 1,
                "content": "WTRFG - First question"
            },
            "choices": [
                {
                    "id": 1,
                    "questionId": 1,
                    "content": "First choice",
                    "answer": true
                },
                {
                    "id": 2,
                    "questionId": 1,
                    "content": "Second choice",
                    "answer": false
                },
                {
                    "id": 3,
                    "questionId": 1,
                    "content": "Third choice",
                    "answer": false
                },
                {
                    "id": 4,
                    "questionId": 1,
                    "content": "Fourth choice",
                    "answer": false
                }
            ]
        },
        {
            "question": {
                "id": 2,
                "bookId": 1,
                "content": "WTRFG - Second question"
            },
            "choices": [
                {
                    "id": 5,
                    "questionId": 2,
                    "content": "First choice",
                    "answer": false
                },
                {
                    "id": 6,
                    "questionId": 2,
                    "content": "Second choice",
                    "answer": false
                },
                {
                    "id": 7,
                    "questionId": 2,
                    "content": "Third choice",
                    "answer": false
                },
                {
                    "id": 8,
                    "questionId": 2,
                    "content": "Fourth choice",
                    "answer": true
                }
            ]
        },
        {
            "question": {
                "id": 3,
                "bookId": 1,
                "content": "WTRFG - Third question"
            },
            "choices": [
                {
                    "id": 9,
                    "questionId": 3,
                    "content": "First choice",
                    "answer": false
                },
                {
                    "id": 10,
                    "questionId": 3,
                    "content": "Second choice",
                    "answer": false
                },
                {
                    "id": 11,
                    "questionId": 3,
                    "content": "Third choice",
                    "answer": true
                },
                {
                    "id": 12,
                    "questionId": 3,
                    "content": "Fourth choice",
                    "answer": false
                }
            ]
        }
    ]
}