read24 - Server: Taking a Quiz

July 5, 2020

Roger Ngo

Today, I will implement two endpoints which will allow a student to complete a quiz for a chosen book. First is to be able to answer quiz questions through /quiz/book/question, and to rate what the student thinks of the book by calling /quiz/book/rate.

First, I will implement the /quiz/book/question endpoint. Per the spec that I had written a week back, the request body is expected to be:

{
    quizToken,
    choiceId
}

The quizToken tells me a lot of things. It will allow me to retrieve more information about the current context of the quiz session. It is found in QuizToken.‌

  1. studentId - Who is the student?
  2. bookId - What is the current book being read?
  3. status - What the status of the current session?

With these key pieces of information, studentId can be used to retrieve more information about the student through Student, or User. The bookId can be used to retrieve the book information.

The second parameter in the request body, choiceId, can be used to find the Choice in the database. It can then be used to determine whether it is the correct answer to the question, and the question itself through the reference to Question using questionId.

In memory.ts, add a new typed property to DataModel called studentAnswers with type StudentAnswer[]. Knowing I will work with ratings too, I will add a ratings property too.

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

Now in mockDb.json I have added empty arrays to new properties: studentAnswers, and ratings.

    "studentAnswers": [],
    "ratings": []

The implementation of /quiz/book/question is simple. Given the quiz token, and choice ID, I can create a StudentAnswer object to be inserted into the database.

Here is a test of the workflow:

Start a new quiz session.

POST localhost:3000/quiz/book

{
    "studentId": 1,
    "bookId": 1
}

This request responds with the quiz token along with the quiz information.

{
    "token": "3c43b65d-5502-4548-97a4-6f94e27639fd",
    "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
                }
            ]
        }
    ]
}

Using the quiz token, I make a few requests to "answer the questions".

POST localhost:3000/quiz/book/question

{
    "quizToken": "3c43b65d-5502-4548-97a4-6f94e27639fd",
    "choiceId": 3
}

---- 

POST localhost:3000/quiz/book/question

{
    "quizToken": "3c43b65d-5502-4548-97a4-6f94e27639fd",
    "choiceId": 8
}

----

POST localhost:3000/quiz/book/question

{
    "quizToken": "3c43b65d-5502-4548-97a4-6f94e27639fd",
    "choiceId": 11
}

The following responses are returned. They are serialized StudentAnswer objects.

{
    "id": 1,
    "quizToken": "3c43b65d-5502-4548-97a4-6f94e27639fd",
    "studentId": 1,
    "questionId": 1,
    "choiceId": 3
}

{
    "id": 1,
    "quizToken": "3c43b65d-5502-4548-97a4-6f94e27639fd",
    "studentId": 1,
    "questionId": 2,
    "choiceId": 8
}

{
    "id": 1,
    "quizToken": "3c43b65d-5502-4548-97a4-6f94e27639fd",
    "studentId": 1,
    "questionId": 3,
    "choiceId": 11
}

I want to point out that the id properties for all these objects here are 1. This is a problem which I will solve after implementing the next endpoint.

Let's implement the /quiz/book/rate endpoint. This one is simple, and takes 2 parameters

{
    quizToken,
    rating
}

This endpoint is also very simple. It will write to the Rating table.

Now, test.

POST localhost:3000/quiz/book/rate

{
    "quizToken": "3c43b65d-5502-4548-97a4-6f94e27639fd",
    "rating": 4
}

The response:

{
    "id": 1,
    "quizToken": "3c43b65d-5502-4548-97a4-6f94e27639fd",
    "rating": 4
}

At this point, I can look at what is in the in-memory database by invoking GET /debug/db. I'm particularly interested in seeing what studentAnswers and ratings contain.

{
    "loaded": true,
    "data": {
        ...,
        
        "quizTokens": [
            {
                "id": 1,
                "status": 0,
                "bookId": 1,
                "studentId": 1,
                "token": "3c43b65d-5502-4548-97a4-6f94e27639fd"
            }
        ],
        
        ...,
        
        "quizQuestions": [
            {
                "id": 1,
                "quizToken": "3c43b65d-5502-4548-97a4-6f94e27639fd",
                "questionId": 1
            },
            {
                "id": 2,
                "quizToken": "3c43b65d-5502-4548-97a4-6f94e27639fd",
                "questionId": 2
            },
            {
                "id": 3,
                "quizToken": "3c43b65d-5502-4548-97a4-6f94e27639fd",
                "questionId": 3
            }
        ],
        "studentAnswers": [
            {
                "id": 1,
                "quizToken": "3c43b65d-5502-4548-97a4-6f94e27639fd",
                "studentId": 1,
                "questionId": 1,
                "choiceId": 3
            },
            {
                "id": 1,
                "quizToken": "3c43b65d-5502-4548-97a4-6f94e27639fd",
                "studentId": 1,
                "questionId": 2,
                "choiceId": 8
            },
            {
                "id": 1,
                "quizToken": "3c43b65d-5502-4548-97a4-6f94e27639fd",
                "studentId": 1,
                "questionId": 3,
                "choiceId": 11
            }
        ],
        "ratings": [
            {
                "id": 1,
                "quizToken": "3c43b65d-5502-4548-97a4-6f94e27639fd",
                "rating": 4
            }
        ]
    }
}

Now, let's address the problem with IDs not being auto-incremented when data is pushed to a collection belonging to the in-memory database.

The easiest solution right now is to abstract insertion and retrieval of objects within the in-memory database into operations in a class instance.

I have removed usage of maybeLoadDb and instead created a new class called _MemoryDb which will handle basic insertion, and retrieval of data from the database. The implementation is very basic, and is just good enough to address my needs for now.

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';
import { StudentAnswer } from '../models/student_answer';
import { Rating } from '../models/rating';

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

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

const db: InMemoryDatabase = {
    loaded: true,
    data: mockDb
};

export interface DataObject {
    id: number;
    bookId?: number;
    questionId?: number;
    token?: string;
}

class _MemoryDb {
    public insert(tableName: string, data: DataObject) {
        let maxId = 0;
        const table = db.data[tableName];

        for (const row of table) {
            if (row.id > maxId)
                maxId = row.id;
        }

        maxId++;

        data.id = maxId;

        db.data[tableName].push(data);
    }

    public find(tableName: string, id: number) {
        return db.data[tableName].find((o: DataObject) => o.id === id);
    }

    public select(tableName: string, whereFunc?: (o: DataObject) => boolean) {
        if (!whereFunc)
            return db.data[tableName];

        return db.data[tableName].filter(whereFunc);
    }

    public dump() {
        return db;
    }
}

export const MemoryDb = new _MemoryDb();

MemoryDb is an instance of _MemoryDb, and is accessed throughout the rest of the codebase. insert, find, and select do what they are named.

The only thing that is strange here is that I have defined a new object type that will be a generic object from the database called DataObject. Yes, I know it is lazy but I will improve it down the line.

Then I can use MemoryDb to insert data into the database. For example to fix the problem of non-incrementing IDs whenever a quiz question is answered in /quiz/book/question, I can rewrite the code to be:

app.post('/quiz/book/question', (req, res) => {
    const quizToken = req.body.quizToken;
    const choiceId = req.body.choiceId;

    if (!quizToken || !choiceId)
        return res.status(400).json({message: 'Must provide both the quiz token, and choice.'});

    const qt = MemoryDb.select('quizTokens', t => t.token === quizToken)[0];
    const c = MemoryDb.select('choices', c => c.id === choiceId);

    const studentAnswer = {
        id: 1,
        quizToken: qt.token,
        studentId: qt.studentId,
        questionId: c.questionId,
        choiceId: c.id
    };

    MemoryDb.insert('studentAnswers', studentAnswer);

    return res.status(200).json(studentAnswer);
});

For now, I also don't care what I assign as the ID for the StudentAnswer object. A TODO item in the future is to make the id property optional so that the developer never has to care about setting it before writing the object to the database.

After performing repeated POST /quiz/book/question requests, here is what the studentAnswers collection looks like within the in-memory database:

{
    ...,
    
    "studentAnswers": [
        {
            "id": 1,
            "quizToken": "5acddaca-a22e-46d0-a1cc-eff7f4fc35a5",
            "studentId": 1
        },
        {
            "id": 2,
            "quizToken": "5acddaca-a22e-46d0-a1cc-eff7f4fc35a5",
            "studentId": 1
        },
        {
            "id": 3,
            "quizToken": "5acddaca-a22e-46d0-a1cc-eff7f4fc35a5",
            "studentId": 1
        }
    ],
    
    ...
}

Cool! I have auto-incrementing IDs now. ‌

Here is the entire /quiz/ route implementation as of now:

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

function buildQuiz(bookId: number) {
    const result = [];
    const questions = MemoryDb.select('questions', q => q.bookId === bookId);
    for(const q of questions) {
        result.push({
            question: q,
            choices: MemoryDb.select('choices', 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
        };

        MemoryDb.insert('quizTokens', quizToken);

        const quiz = buildQuiz(bookId);

        // Associate each question with the quiz
        let i = 1;
        for(const el of quiz) {
            const quizQuestion = {
                id: i++,
                quizToken: token,
                questionId: el.question.id
            };

            MemoryDb.insert('quizQuestions', quizQuestion);
        }
        
        return res.status(200).json({token, quiz});
    });

    app.post('/quiz/book/question', (req, res) => {
        const quizToken = req.body.quizToken;
        const choiceId = req.body.choiceId;

        if (!quizToken || !choiceId)
            return res.status(400).json({message: 'Must provide both the quiz token, and choice.'});

        const qt = MemoryDb.select('quizTokens', t => t.token === quizToken)[0];
        const c = MemoryDb.select('choices', c => c.id === choiceId);

        const studentAnswer = {
            id: 1,
            quizToken: qt.token,
            studentId: qt.studentId,
            questionId: c.questionId,
            choiceId: c.id
        };

        MemoryDb.insert('studentAnswers', studentAnswer);

        return res.status(200).json(studentAnswer);
    });

    app.post('/quiz/book/rate', (req, res) => {
        const quizToken = req.body.quizToken;
        const rating = req.body.rating;

        const r = {
            id: 1,
            quizToken,
            rating
        };

        MemoryDb.insert('ratings', r);

        return res.status(200).json(r);
    });
}

It looks like I am happy with this session.

I am still missing a couple of features in the server API. The most obvious is handling the status of the quiz session when the student either exits the quiz, or has completed it. I will implement this tomorrow in the next post along with the /student/report endpoint.