read24 - Admin UI - Managing Classrooms

August 09, 2020

Roger Ngo

This morning, I just realized that I never implemented any API call to manage classrooms! I believe that in order to even manage students, they actually have to be associated with a classroom. Huh! Go figure!‌

So today, let’s add some admin routes to manage classrooms. The following routes will be created:

What is a Slug?

A slug is a basic identifier to allow navigation to a public classroom page in the future. it is also unique, so there should be no other classrooms with the same slug.

I will discuss the use of this more in the future when the time comes.

Retrieving a Classroom

This is a simple GET request to get data about the classroom given an ID. Right now, it is just the resource properties.

app.get('/admin/classroom/:classroomId', async (req, res) => {
    const classroomId = parseInt(req.params.classroomId);
    
    if (!Number.isFinite(classroomId))
        return res.status(400).json({message: 'Invalid classroom ID'});
    
    const classroom = await new Classroom().load(classroomId);
    
    return res.status(200).json({
        id: classroom.id,
        name: classroom.name,
        slug: classroom.slug
    });
});

Then a call to GET /admin/classroom/1 can produce the following as a response:

{
    "id": 1,
    "name": "Room 4",
    "slug": "room4"
}

Creating a Classroom‌

A POST request to create the classroom should also check for whether a classroom already exists with a slug. If so, then the classroom should not be created.

 app.post('/admin/classroom', async (req, res) => {
     const name = req.body.name;
     const slug = req.body.slug;

     if (await Classroom.findBySlugIgnoreNotFound(slug))
        return res.status(400).json({message: 'Slug already exists'});

    const newClassroom = await new Classroom({
        name,
        slug
    }).insert();

    return res.status(200).json({id: newClassroom.id});
 });

The Classroom resource should have a method called findBySlugIgnoreNotFound which can return an existing classroom that has the slug value. This method can be used to check if a classroom already exists by a given slug.

static async findBySlugIgnoreNotFound(slug: string): Promise<Classroom> {
    const classroom = await DatabaseConnector.select('classrooms', undefined, {
        filters: [{column: 'slug', value: slug}]
    });

    if (classroom.length > 0)
        return Promise.resolve(new Classroom().load(classroom[0].id));

    return Promise.resolve(undefined);
}

Now, a user can create a classroom with a request like:

{
    "name": "Room 10",
    "slug": "room10"
}

The response with the ID of the classroom just created is returned:

{
    "id": 2
}

So what happens if a user tries to create a classroom with a slug that already exists?

{
    "name": "Room 99",
    "slug": "room10"
}

As expected, an error is returned:

{
    "message": "Slug already exists"
}

Updating a Classroom‌

First thing’s first, we should allow the slug to pass through if findBySlugIgnoreNotFound returns the same Classroom.

app.put('/admin/classroom', async (req, res) => {
    const id = parseInt(req.body.id);
    
    if (!Number.isFinite(id))
        return res.status(400).json({message: 'Invalid classroom ID'});
    
    const name = req.body.name;
    const slug = req.body.slug;
    
    const classroom = await Classroom.findBySlugIgnoreNotFound(slug);
    if (classroom && classroom.id !== id)
        return res.status(400).json({message: 'Invalid slug. Another classroom has already taken this'});
    
    classroom.name = name;
    classroom.slug = slug;
    classroom.update();
    
    return res.status(200).json({
        id: classroom.id,
        slug: classroom.slug,
        name: classroom.name
    });
});

Now, the following request can be made to update a classroom with the name Room 10 to Room 10 is Awesome.

{
    "id": 2,
    "name": "Room 10 Is Awesome",
    "slug": "room10"
}

The response returned confirms that the resource is updated:

{
    "id": 2,
    "slug": "room10",
    "name": "Room 10 Is Awesome"
}

How about updating the classroom with a slug that already exists?

{
    "id": 2,
    "name": "Room 10 Is Awesome",
    "slug": "room4"
}

As expected, an error is returned!

{
    "message": "Invalid slug. Another classroom has already taken this"
}

Delete a Classroom

Simply, this method will receive the classroom ID, and loads a resource. The resource will then be deleted through a call to the delete method. What is returned is the id and the date of deletion as dateDeleted.

app.delete('/admin/classroom/:classroomId', async (req, res) => {
    const id = parseInt(req.params.classroomId);
    
    if (!Number.isFinite(id))
        return res.status(400).json({message: 'Invalid classroom ID'});
    
    const classroom = await new Classroom().load(id);
    classroom.delete();
    
    return res.status(200).json({
        id: classroom.id,
        dateDeleted: new Date(classroom.dateDeleted)
    });
});

Request body:

DELETE /admin/classroom/2

Response body:

{
    "id": 2,
    "dateDeleted": "2020-08-09T15:56:44.285Z"
}

Listing Classrooms‌

The logic for listing classrooms is similar to that of searching for all books. The results should be paginated. Thus, it is required that the API endpoint receives a page parameter. If it is not received, then the page is defaulted to 1.

 const DEFAULT_LIMIT = 2;

 app.get('/admin/classroom/all/page/:page', async (req, res) => {
    let page = 1;
    if (req.params.page)
        page = parseInt(req.params.page);

    if (page === 0)
        return res.status(400).json({message: 'Invalid page number'});

    const offset = (page - 1) * DEFAULT_LIMIT;

    const pages = Math.ceil(await Classroom.numberOfPages()) / DEFAULT_LIMIT;
    const results = await Classroom.listClassrooms(offset, DEFAULT_LIMIT);

    if (results.length === 0)
        return res.status(200).json({
            classrooms: [],
            _meta: {
                hasMore: false,
                pages: 0
            }
        });

    const lastClassromName = await Classroom.lastClassroomName();
    const hasMore = !!!results.find(r => r.name === lastClassromName);
    
    return res.status(200).json({
        classrooms: results.map(r => r.json()),
        _meta: {
            hasMore,
            pages,
            nextPage: hasMore ? page + 1 : undefined
        }
    });
 });

The Classroom resource should be updated with calls to get information for pagination:

static async numberOfPages() {
    const count = await DatabaseConnector.select('classrooms', undefined, {
        columns: ['count(id)']
    });

    return count[0]['count(id)'];
}

static async lastClassroomName() {
    const classroom = await DatabaseConnector.select('classrooms', undefined, {
        orderBy: {
            column: 'name',
            ascending: false
        },
        limit: 1
    });

    return classroom[0].name;
}

static async listClassrooms(offset: number, limit: number) {
    const classrooms = await DatabaseConnector.select('classrooms', undefined, {
        limit,
        offset,
        orderBy: {
            column: 'name',
            ascending: true
        }
    });

    return await Promise.all(classrooms.map(async c => await new Classroom().load(c.id)));
}

A sample call is shown below:

GET /admin/classroom/all/page/1
{
    "classrooms": [
        {
            "id": 1,
            "name": "Room 4",
            "slug": "room4"
        },
        {
            "id": 3,
            "name": "Room 99",
            "slug": "room10"
        }
    ],
    "_meta": {
        "hasMore": false,
        "pages": 1
    }
}

Cool, I think I have everything I need for now. Next time, it will be time create pages to manage a classroom.