Reviving the Momentum: Building QRQuick's Backend from Scratch

Since coming back from my break, I've been wrestling with lack of motivation to dive into my work. But, lo and behold, last Monday, I summoned the strength to crack open my laptop and kickstart this project. Why? Because I made a commitment to you to see this application through. And let me tell you, when I make a promise, I stick to it like glue.

eyaadh@Ahmeds-MacBook-Pro-173 WebstormProjects %
npm create hono@latest qrquickBackend
Need to install the following packages:
[email protected]
Ok to proceed? (y) y
create-hono version 0.7.3
✔ Using target directory … qrquickBackend
? Which template do you want to use? nodejs
✔ Cloning the template
? Do you want to install project dependencies? yes
? Which package manager do you want to use? npm
✔ Installing project dependencies
🎉 Copied project files
Get started with: cd qrquickBackend

This will set up a default Hono project for us. But before we dive in, let's lay down the foundation with a basic directory structure. Usually, I'm all about the MVC design pattern, but Hono's got some strong opinions against using "Controllers" whenever we can avoid it. Since our project's pretty straightforward, we'll stick to creating Helpers, Models, Types, and Routers directories within our project. Then, we'll use some sweet ES6 functions/modules to tie everything together. Here's how our folder structure will shape up:

qrQuickBackend
├── src
│   ├── helpers
│   ├── models
│   ├── routers
│   └── types
├── index.ts
└── .env

Now, let's install the core dependencies we need for the project. I will explain the purpose of each dependency as we code the corresponding module.

npm i log4js
npm i mongoose
npm i bcrypt
npm i dotenv

Transitioning from Python to JavaScript, one of the things I've missed most in my projects is Python's extensive built-in logging functionalities. In Node.js, achieving a similar experience requires using an external library. There are many options available, and while I haven't settled on a single one, for this project I will be using log4js.

To begin, let's create the logging module. Create an empty file named logger.ts within the helpers directory we created earlier. The helpers directory in our project will serve as a central repository for all our external scripts and most of the functions that would typically reside in a controller in an MVC pattern.

Below is the source code for logger.ts:

import log4js from 'log4js';  
  
// Configuring Log4js with a custom layout for the console appender  
log4js.configure({  
  appenders: {  
    console: {  
      type: 'console',  
      layout: {  
        type: 'pattern',  
        pattern: '%[[%d] [%h][%p] [PID: %z]%]: %m',  
      }    
    }  
  },  
  categories: {  
    default: { appenders: ['console'], level: 'all' }  
  }  
});  
  
// Getting the logger instance  
const aLogger = log4js.getLogger();  
  
// Defining a custom logging function to log messages  
const customWebLogger = (message: string, ...rest: string[]) => {  
  aLogger.debug(message, ...rest);  // use aLogger.info to log messages at the info level  
};  
  
export { aLogger, customWebLogger };

In summary, this code sets up a console appender with a custom layout pattern to include necessary information we need for the project:

log4js.configure({
  appenders: {
    console: {
      type: 'console',
      layout: {
        type: 'pattern',
        pattern: '%[[%d] [%h][%p] [PID: %z]%]: %m',
      }
    }
  },
  categories: {
    default: { appenders: ['console'], level: 'all' }
  }
});

Then, it retrieves the logger instance. Additionally, a custom logging function is defined to be used with Hono logger middleware:

const customWebLogger = (message: string, ...rest: string[]) => {  
  aLogger.debug(message, ...rest);  // use aLogger.info to log messages at the info level  
};

Finally, the logger and the custom logging function are exported so they can be utilised in other parts of the application.

Now, let's switch gears and focus on setting up our web server and establishing a connection to the database. We'll handle all of this in our project's entry point, which happens to be index.js. In a standard Python project, this file would go by the name main.py. By this point, I'm guessing you've already got a MongoDB server up and running, complete with authentication using a username and password, just like we talked about in the last blog post.

Here's what my index.js source code looks like.

import { serve } from '@hono/node-server'  
import {aLogger, customWebLogger} from "./helpers/logger";  
import {logger} from "hono/logger";  
import { Hono } from 'hono'  
import dotenv from "dotenv";  
import mongoose from "mongoose";  
import {cors} from "hono/cors";  
  
dotenv.config();  
  
mongoose.connect(process.env.DB_URI as string, {  
    maxPoolSize: 10,  
    authSource: "admin",  
    auth: {username: process.env.DB_USERNAME as string, password: process.env.DB_PASSWORD as string},  
})  
    .then(() => aLogger.info('DB Connection established'))  
    .catch(err => aLogger.error(err.message))  
  
const app = new Hono()  
app.use(logger(customWebLogger))  
app.use('*', cors({origin: ['*']}))  
  
  
const port = 3000  
aLogger.info(`API Core - WebServer is running on port ${port}`)  
  
serve({  
  fetch: app.fetch,  
  port  
})

We use the mongoose ODM to interact with MongoDB. For security, we follow best practices by storing sensitive configuration details, such as database credentials, in environment variables. This approach keeps critical information separate from the codebase, reducing potential security risks and simplifying configuration management. We use the library dotenv to parse environment variables. Here is what my .env file looks like:

DB_URI='mongodb://localhost:27017/qrquick'  
DB_USERNAME='admin'  
DB_PASSWORD='******'

The mongoose.connect(process.env.DB_URI as string, ...) method connects to MongoDB using the URI stored in the DB_URI environment variable. We set up the connection by adjusting configuration options like maxPoolSize, which limits the number of active sockets to 100 by default. Adjusting this value to higher values may lead to rate limits, such as connection limits. The authSource is set to admin, as this is the MongoDB collection used for authentication; if it differs in your environment, adjust it accordingly. Authentication credentials (username and password) are retrieved from the environment variables DB_USERNAME and DB_PASSWORD.

Upon a successful connection, a message is logged, and any errors are captured and reported using the custom logger aLogger.

The line const app = new Hono() creates an instance of the Hono web framework, and app.use(logger(customWebLogger)) adds the custom web logger middleware to the Hono application, enabling custom logging of requests and responses.

Finally, serve({ fetch: app.fetch, port }) starts the web server on the specified port 3000.

Alright, let's dive into setting up authentication for the application. Remember those user requirements we hashed out in our last blog post? We decided to keep things lean, including only the essential details for connecting individual to the data on the system. With that in mind, and knowing we're going to use JWT tokens, let's get started by creating TypeScript interfaces for a user object.

We start by creating a file called User.ts in the types directory. Here are the contents of this file; the properties are straightforward and self-explanatory:

export interface IUser {  
    username: string;  
    salt?: string;  
    createdAt?: Date;  
    lastModifiedAt?: Date;  
    tokenData?: ITokenData;  
}  
  
export interface ITokenData {  
    token?: string | null;  
    tokenExpiration?: number;  
}

With the interface created, let's proceed with creating the model for Auth. In an SQL project, this is where you start thinking about the tables. However, it's important to note that SQL tables and MongoDB models are not exactly the same. Models in MongoDB are fancy constructors compiled from Schema definitions. An instance of a model is called a document. Models are responsible for creating and reading documents from the underlying MongoDB database.

Create a file called Auth.ts under the models directory. Here is the source code for this file:

import { IUser } from "../types/User";  
import { Schema, model } from "mongoose";  
  
const userSchema = new Schema<IUser>({  
    username: { type: String, required: true },  
    salt: { type: String, default: null, required: false },  
    createdAt: { type: Date, default: Date.now, required: false },  
    lastModifiedAt: { type: Date, default: Date.now, required: false },  
});  
  
userSchema.pre<IUser>("save", async function (next) {  
    this.lastModifiedAt = new Date();  
    next();  
});  
  
const User = model<IUser>("User", userSchema);  
  
export default User;

Here we define the userSchema, which outlines the structure of the user documents in the MongoDB collection users. Additionally, we use a pre-save middleware to update the lastModifiedAt field with the current date whenever a document is saved. This ensures that the modified timestamp is accurately tracked, eliminating the possibility of human error.

Now, let's implement the helper functions that we will use to create, update, and delete documents in this collection, as well as other functionalities related to user authentication within the application. Start by creating a file called Auth.ts under the helpers directory. Here are the source code for it:

import bcrypt from 'bcrypt';  
import User from "../models/Auth"  
import {sign, verify} from "hono/jwt"  
import {ITokenData, IUser} from "../types/User";  
import dotenv from 'dotenv';  
import {aLogger} from "./logger";  
  
dotenv.config();  
  
export const jwtSecretKey = process.env.JWT_SECRET as string;  
  
async function hashPassword(password: string) {  
    const saltRounds = 10;  
    try {  
        return await bcrypt.hash(password, saltRounds);  
    } catch (error) {  
        throw error;  
    }}  
  
async function comparePasswords(plainPassword: string, hashedPassword: string) {  
    try {  
        return await bcrypt.compare(plainPassword, hashedPassword);  
    } catch (error) {  
        aLogger.error('Error comparing passwords:', error);  
        throw error;  
    }}  
  
  
export async function createUser(username: string, password: string) {  
    // 1. check if the user is available  
    const existingUser = await User.findOne({username: username})  
    if (existingUser) {  
        throw new Error('User already exists')  
    }    
    // 2. create a new user  
    const salt = await hashPassword(password)  
    const newUser = new User({  
        username: username,  
        salt: salt,  
        createdAt: new Date()  
    })    await newUser.save()  
    const user: IUser = {  
        username: newUser.username,  
        createdAt: newUser.createdAt,  
        lastModifiedAt: newUser.lastModifiedAt  
    }  
    return user  
}  
  
export async function deleteUser(username: string): Promise<boolean> {  
    const existingUser = await User.findOne({username: username})  
    if (!existingUser) {  
        throw new Error('User not found')  
    }    await User.deleteOne({username: username})  
    return true  
}  
  
export async function verifyUser(username: string, password: string) {  
    const existingUser = await User.findOne({username: username})  
    if (!existingUser) {  
        throw new Error('User not found')  
    }  
    return await comparePasswords(password, existingUser.salt!)  
}  
  
export async function createToken(username: string): Promise<ITokenData> {  
    const tokenExpiration = Math.floor(Date.now() / 1000) + (60 * 60 * 24 * 365 * 999);     //expires in 999 years  
  
    const tokenPayload = {  
        sub: username,  
        role: 'user',  
        exp: tokenExpiration  
    }  
  
    const token = await sign(tokenPayload, jwtSecretKey)  
  
    return {  
        token: token,  
        tokenExpiration: tokenExpiration,  
    }}  
  
export async function getUserFromWebRequest(token: string): Promise<IUser> {  
    const decodedPayload = await verify(token, jwtSecretKey)  
    const username = decodedPayload.sub  
  
    const existingUser = await User.findOne({username: username})  
    if (!existingUser) {  
        throw new Error('User not found')  
    }  
    return existingUser  
}

We begin by setting up the JWT secret key from the environment variable JWT_SECRET. This key is essential for signing and verifying JWT tokens, ensuring that the tokens remain secure and untampered. The key is a random 32-character hexadecimal value, and here’s how the updated .env file looks:

DB_URI='mongodb://localhost:27017/qrquick'  
DB_USERNAME='admin'  
DB_PASSWORD='******'  
JWT_SECRET='2f4d3a1c5a2b**********************d3a1c5a2b9'

The hashPassword function employs the bcrypt library to hash plain text passwords. It takes a password as input and returns its hashed version. The saltRounds variable defines the cost factor, determining how computationally intensive the hashing process will be. While higher values offer more security, they also require more processing time. Bcrypt generates a unique salt for each password, incorporating it with the password before hashing, providing robust protection against dictionary and brute-force attacks.

In contrast, the comparePasswords function checks a plain text password against the hashed password stored in the database. This function is important for verifying user credentials during login. If the passwords align, it returns true; otherwise, it logs and throws the error.

Moving on, the createUser function oversees new user registration. Initially, it verifies if a user with the given username already exists in the database. If the user is present, an error is thrown. Otherwise, it hashes the provided password using the hashPassword function, and creates a new User document with the hashed password, username, and the current date as the creation date. This document is then stored in the database, and the function returns a user object comprising the username and timestamps.

For user deletion, the deleteUser function steps in. It searches for a user with the specified username in the database. If the user is not found, an error is thrown. On locating the user, the respective document is removed from the database, and true is returned.

Now, for user authentication during login, we rely on the verifyUser function. It looks for a user with the provided username in the database. If no user is found, an error is thrown. If located, the function compares the provided password with the stored hashed password via the comparePasswords function. A match results in a true return.

To generate a JWT token for a user, we employ the createToken function. This token carries a payload with the username, user role, and a far-off expiration time. Signed with the JWT secret key, the token's integrity and authenticity are ensured. The function furnishes an object containing the token and its expiration time.

Lastly, the getUserFromWebRequest function extracts user details from a JWT token. By verifying the token through the verify function from the hono/jwt library, it deciphers the username from the decoded token payload. Subsequently, it searches for a user with the specified username in the database. If no user is found, an error is thrown. If one is found, the corresponding user document is returned.

Next, let's implement the router for the auth path. We start by creating a file called Auth.ts in the routers directory. The source code for this file is as follows:

import { Hono } from 'hono';  
import { aLogger } from '../helpers/logger';  
import User from '../models/Auth';  
import { createToken, createUser, deleteUser, verifyUser } from '../helpers/Auth';  
import { IUser } from '../types/User';  
import { jwt, verify } from 'hono/jwt';  
import { jwtSecretKey } from '../helpers/Auth';  
  
const authRouter = new Hono();

// Middleware to protect the routes under /account/*
authRouter.use('/account/*', jwt({  
    secret: jwtSecretKey,  
}));

// Route to create a new user
authRouter.post('/createUser', async (c) => {  
    const reqBody = await c.req.json();  
    try {  
        const newUser = await createUser(reqBody.username, reqBody.password);  
        return c.json({ data: newUser });  
    } catch (e: any) {  
        aLogger.error(e);  
        return c.json({ error: e.message }, 500);  
    }
});

// Route to delete a user account
authRouter.delete('/account/deleteUser', async (c) => {  
    const token = c.req.raw.headers.get('Authorization')?.split(' ')[1];  
    if (token) {  
        try {  
            // Verify the token and get the username from the payload
            const decodedPayload = await verify(token, jwtSecretKey);  
            const username = decodedPayload.sub as string;

            // Delete the user data
            await deleteUser(username);  
            return c.json({ data: 'Successfully deleted' });  
        } catch (e) {  
            aLogger.error(e);  
            return c.json({ error: e }, 500);  
        }  
    } else {  
        return c.json({ error: 'Authorization token missing' }, 400);  
    }
});

// Route to login a user
authRouter.post('/login', async (c) => {  
    const reqBody = await c.req.json();  
    try {  
        // Verify the user credentials
        const loginStatus = await verifyUser(reqBody.username, reqBody.password);  
        if (loginStatus) {  
            // Create a token upon successful login
            const tokenData = await createToken(reqBody.username);

            // Retrieve the user data
            const userData = await User.findOne({ username: reqBody.username });
            const user: IUser = {  
                username: userData!.username,  
                tokenData: tokenData  
            };
            return c.json({ data: user });  
        } else {  
            return c.json({ error: 'Unsuccessful login, username or password incorrect' }, 400);  
        }
    } catch (e: any) {  
        aLogger.error(e);  
        return c.json({ error: e.message }, 500);  
    }
});
  
export default authRouter;

The /createUser route, accessible via POST requests, oversees the creation of new users within our application. It begins by checking if a user with the provided username already exists. If not, it proceeds to hash the password, creates a new user document in the database, and returns the user data. However, if the username is already taken, it responds with an error message.

For handling the deletion of user accounts, the /account/deleteUser route is available through DELETE requests and is reserved for authenticated users. It extracts the JWT token from the Authorization header, verifies it to obtain the username, and then removes the corresponding user from the database. If the token is invalid or missing, or if the user cannot be found, it sends back an error message.

Moving on to the /login route, which is accessible via POST requests, it assists users in logging in. Upon receiving login credentials, it verifies them using the previously discussed functions. If the credentials are correct, it generates a JWT token containing the user's information and role, with a lengthy expiration time. This token, along with the user data, is then sent back. However, if the credentials are incorrect, an error message is returned to indicate an unsuccessful login attempt.

To wrap up the integration of authentication functionalities into our application, we need to incorporate the auth router into the web server. This entails modifying the index.js file. Simply add the line app.route('auth', authRouter) as shown below:

import { serve } from '@hono/node-server'  
import {aLogger, customWebLogger} from "./helpers/logger";  
import {logger} from "hono/logger";  
import { Hono } from 'hono'  
import authRouter from "./routers/Auth";  
import dotenv from "dotenv";  
import mongoose from "mongoose";  
import {cors} from "hono/cors";  
  
// other imports and configurations
...

const app = new Hono()  
app.use(logger(customWebLogger))  
app.use('*', cors({origin: ['*']}))  
  
app.route('auth', authRouter)  // <- Incorporate the 'auth' router into the app

...
// other configurations
  
serve({  
  fetch: app.fetch,  
  port  
})

Now, let's shift our focus to implementing the functionalities needed for managing QR codes within the application. Just like user management, we'll start by creating the necessary interface for QR codes. Begin by creating a file called QR.ts in the types directory. Here is the content for this file:

export interface IQR {  
    id: string,  
    data: string,  
    destination: string,  
    user: string,  
    createdAt?: Date,  
    lastModifiedAt?: Date  
}

Now, onto the model. We're going to create a file called QR.ts within the models directory. Similar to what we did with the Auth module, we'll outline the schema for the QR model. Additionally, we'll incorporate pre-save middleware to seamlessly update the lastModifiedAt field every time a QR document is saved or modified. Below is the source code for this file:

import {model, Schema} from "mongoose";  
import {IQR} from "../types/QR";  
  
const QRSchema = new Schema<IQR>({  
    id: {type: String, required: true},  
    data: {type: String, required: true},  
    destination: {type: String, required: true},  
    user: {type: String, required: true},  
    createdAt: {type: Date, default: Date.now, required: false},  
    lastModifiedAt: {type: Date, default: Date.now, required: false},  
})  
  
QRSchema.pre<IQR>("save", async function (next) {  
    this.lastModifiedAt = new Date();  
    next()  
})  
  
const QR = model<IQR>("qr", QRSchema);  
  
export default QR;

To manage QR documents in the application, we create a file named QR.ts in the helpers directory, containing helper functions. The code defines functions for CRUD operations on QR documents. Here is the code and explanation for each function:

import QR from "../models/QR";  
import {IQR} from "../types/QR";   
  
export async function getQRForUser(user: string): Promise<IQR[]> {  
    return QR.find({user: user})  
}  
  
export async function getQRById(id: string): Promise<IQR | null> {  
    return QR.findOne({id: id})  
}  
  
export async function createQR(user: string, data: string, destination: string): Promise<IQR> {  
    const newID: string = Math.random().toString(36).slice(2)  
    const newQR = new QR({  
        id: newID,  
        data: data,  
        destination: destination,  
        user: user,  
        createdAt: new Date()  
    });  
    await newQR.save()  
  
    return {  
        id: newQR.id,  
        data: newQR.data,  
        destination: newQR.destination,  
        user: newQR.user,  
        createdAt: newQR.createdAt,  
        lastModifiedAt: newQR.lastModifiedAt  
    }  
}  
  
export async function updateQR(id: string, data: string, destination: string): Promise<IQR> {  
    let qr = await QR.findOne({id: id})  
  
    if (!qr) {  
        throw new Error('QR not found')  
    }  
    qr.data = data  
    qr.destination = destination  
  
    await qr.save()  
  
    return {  
        id: qr.id,  
        data: qr.data,  
        destination: qr.destination,  
        user: qr.user,  
        createdAt: qr.createdAt,  
        lastModifiedAt: qr.lastModifiedAt  
    }  
}  
  
export async function deleteQR(id: string): Promise<boolean> {  
    const qr = await QR.findOne({id: id})  
    if (!qr) {  
        throw new Error('QR not found')  
    }  
    await qr.deleteOne({id: id})  
  
    // TODO: also delete any if exists analytics related to this QR  
    
    return true  
}

First off here, we have the getQRForUser function, which retrieves all QR codes linked to a specific user. It dives into the QR model, searching for documents that match the provided user identifier, and serves up an array of matching QR documents.

Moving on to the getQRById function, it fetches a single QR code using its unique ID. It queries the QR model to find documents that match the provided user identifier and returns an array of matching QR documents.

Then, there's the createQR function, tasked with creating new QR codes. It generates a unique ID with a random string, and creates a fresh QR document adorned with the provided user, data, and destination values, along with the current date for createdAt. After saving this new QR document into the database, it returns an object containing with the new QR code's details.

Next in line, we've got the updateQR function which updates an existing QR code. It finds the QR document by its id, and if found, updates its data and destination fields. It then saves the updated document and returns the updated QR code's details. If the QR code is not found, it throws an error.

Rounding out our QR squad is the deleteQR function, tasked with deleting QR codes from our application by their ID. It searches for the QR document by its id, and if found, deletes it from the database. The function returns true upon successful deletion. If the QR code is not found, it throws an error. Additionally, there's a placeholder comment for deleting any related analytics, indicating potential future functionality.

Following that, we set up the qr router by creating the QR.ts file in the routers directory. This router employs JWT token authentication across all routes, guaranteeing that only authenticated users can utilize the QR features. Below is the source code for this router:

import {Hono} from "hono";  
import {aLogger} from "../helpers/logger";  
import {jwt} from 'hono/jwt'  
import {getUserFromWebRequest, jwtSecretKey} from "../helpers/Auth";  
import {IQR} from "../types/QR";  
import {createQR, deleteQR, getQRById, getQRForUser, updateQR} from "../helpers/QR";  
  
  
const qrRouter = new Hono()  
qrRouter.use('*', jwt({  
    secret: jwtSecretKey,  
}))  
  
qrRouter.get('/', async (c) => {  
    const token = c.req.raw.headers.get('Authorization')?.split(' ')[1]  
    try {  
        const user = await getUserFromWebRequest(token!)  
        const qrArray = await getQRForUser(user.username)  
        return c.json({  
            data: qrArray  
        })  
    } catch (e: any) {  
        aLogger.error(e)  
        return c.json({error: e.message}, 500)  
    }})  
  
qrRouter.get('/:id', async (c) => {  
    const token = c.req.raw.headers.get('Authorization')?.split(' ')[1]  
    try {  
        const user = await getUserFromWebRequest(token!)  
        const id = c.req.param('id')  
  
        const qr = await getQRById(id)  
        if (qr) {  
            // check if you are allowed to update this QR  
            if (qr.user === user.username) {  
                return c.json({  
                    id: qr.id,  
                    data: qr.data,  
                    destination: qr.destination,  
                    user: qr.user,  
                    createAt: qr.createdAt,  
                    lastModifiedAt: qr.lastModifiedAt  
                })  
            } else {  
                return c.json({error: 'You are not allowed to view this QR.'}, 401)  
            }        } else {  
            return c.json({error: 'No QR found with the given ID.'}, 404)  
        }  
    } catch (e: any) {  
        aLogger.error(e)  
        return c.json({error: e.message}, 500)  
    }})  
  
qrRouter.post('/', async (c) => {  
    const reqBody = await c.req.json()  
    const token = c.req.raw.headers.get('Authorization')?.split(' ')[1]  
    try {  
        const user = await getUserFromWebRequest(token!)  
  
        const qr: IQR = await createQR(  
            user.username,  
            reqBody.data,  
            reqBody.destination  
        )  
  
        return c.json({  
            id: qr.id,  
            data: qr.data,  
            destination: qr.destination,  
            user: qr.user,  
            createAt: qr.createdAt,  
            lastModifiedAt: qr.lastModifiedAt  
        })  
    } catch (e: any) {  
        aLogger.error(e)  
        return c.json({error: e.message}, 500)  
    }})  
  
qrRouter.put('/:id', async (c) => {  
    const reqBody = await c.req.json()  
    const token = c.req.raw.headers.get('Authorization')?.split(' ')[1]  
    try {  
        const user = await getUserFromWebRequest(token!)  
        const id = c.req.param('id')  
  
        const qr = await getQRById(id)  
        if (qr) {  
            // check if you are allowed to update this QR  
            if (qr.user === user.username) {  
                const updatedQR = await updateQR(  
                    id,  
                    reqBody.data,  
                    reqBody.destination  
                )  
                return c.json({  
                    id: updatedQR.id,  
                    data: updatedQR.data,  
                    destination: updatedQR.destination,  
                    user: updatedQR.user,  
                    createAt: updatedQR.createdAt,  
                    lastModifiedAt: updatedQR.lastModifiedAt  
                })  
            } else {  
                return c.json({error: 'You are not allowed to update this QR.'}, 401)  
            }        } else {  
            return c.json({error: 'No QR found with the given ID.'}, 404)  
        }  
    } catch (e: any) {  
        aLogger.error(e)  
        return c.json({error: e.message}, 500)  
    }})  
  
qrRouter.delete('/:id', async (c) => {  
    const token = c.req.raw.headers.get('Authorization')?.split(' ')[1]  
    try {  
        const user = await getUserFromWebRequest(token!)  
        const id = c.req.param('id')  
  
        const qr = await getQRById(id)  
        if (qr) {  
            // check if you are allowed to update this QR  
            if (qr.user === user.username) {  
                await deleteQR(id)  
                return c.json({status: 'success'})  
            } else {  
                return c.json({error: 'You are not allowed to update this QR.'}, 401)  
            }        } else {  
            return c.json({error: 'No QR found with the given ID.'}, 404)  
        }  
    } catch (e: any) {  
        aLogger.error(e)  
        return c.json({error: e.message}, 500)  
    }})  
  
export default qrRouter

The / route, accessed via GET requests, fetches all QR codes linked to the logged-in user. It checks the JWT token from the request header, verifies it, and fetches the user's QR codes. If all goes well, it displays the QR data. If not, any errors encountered are logged and reported.

Next, the /:id route, also accessible via GET requests, fetches a specific QR code using its unique identifier. It follows a similar process to authenticate the user and validate permissions to access the QR code. If authorized, it returns the QR code's details; otherwise, it returns an error message indicating insufficient permissions or the absence of the QR code.

For creating QR codes, the / route, available via POST requests, lets users generate new ones. Once it receives the required data, it checks the user's authentication, creates the QR code, and returns its details. Any errors that occur during this process are logged and reported.

To update existing QR codes, the /:id route, accessed via PUT requests, checks the user's authentication and permissions before updating the QR code's data. If successful, it shows the updated QR code's details. Otherwise, it returns an error.

Lastly, for deleting QR codes, the /:id route, reachable via DELETE requests, authenticates the user, verifies permissions, and deletes the QR code if authorized. Upon successful deletion, it returns a success message. Any encountered errors are logged and returned as responses.

Lastly, just like we added the auth router, we also need to include the qr router in our web application.

import { serve } from '@hono/node-server'  
import {aLogger, customWebLogger} from "./helpers/logger";  
import {logger} from "hono/logger";  
import { Hono } from 'hono'  
import authRouter from "./routers/Auth";  
import dotenv from "dotenv";  
import mongoose from "mongoose";  
import {cors} from "hono/cors";  
import qrRouter from "./routers/QR";
  
// other imports and configurations
...

const app = new Hono()  
app.use(logger(customWebLogger))  
app.use('*', cors({origin: ['*']}))  
  
app.route('auth', authRouter)  //
app.route('qr', qrRouter)  // <- Integrate the 'qr' router into the app
...
// other configurations
  
serve({  
  fetch: app.fetch,  
  port  
})

As the final requirement from our last discussion, let's create the analytics functionalities. We'll follow the same steps we used for auth and qr. Without further ado, let's start with the interface for analytics. We'll include it in our QR.ts file in the types directory. Here is the updated source code with the new interface:

export interface IQR {  
    id: string,  
    data: string,  
    destination: string,  
    user: string,  
    createdAt?: Date,  
    lastModifiedAt?: Date  
}  
  
export interface IAnalyticQR {  
    qr: string,  
    accessedAt?: Date  
}

Next, let's add the model for the analytics. We'll create a file called Analytics.ts in the models directory to introduce the analytics module into our project. The approach is straightforward and similar to the other modules. Here's the code:

import { model, Schema } from "mongoose";  
import { IAnalyticQR } from "../types/QR";  
  
const QRAnalyticsSchema = new Schema<IAnalyticQR>({  
    qr: { type: String, required: true },  
    accessedAt: { type: Date, default: Date.now, required: true }  
});  
  
const QRAnalytic = model<IAnalyticQR>("analytics", QRAnalyticsSchema);  
export default QRAnalytic;

We'll need just three helper functions for analytics: one to retrieve all documents associated with a QR code, another to add a document to the model whenever a QR code is accessed (recording the time and the QR code), and lastly, a function to delete an analytic record when necessary. These functions are in the Analytics.ts file located in the helper directory. Here is the source code:

import { IAnalyticQR } from "../types/QR";  
import QRAnalytic from "../models/Analytics";  
  
export async function getAnalyticsForQR(qr: string): Promise<IAnalyticQR[]> {  
    return QRAnalytic.find({ qr: qr });  
}  
  
export async function updateAnalyticForQR(qr: string): Promise<IAnalyticQR[]> {  
    const newQRAnalyticDoc = new QRAnalytic({  
        qr: qr,  
        accessedAt: new Date(),  
    });  
    await newQRAnalyticDoc.save();  
  
    return QRAnalytic.find({ qr: qr });  
}  
  
export async function deleteAnalyticForQR(qr: string): Promise<boolean> {  
    await QRAnalytic.deleteMany({ qr: qr });  
    return true;  
}

Now, let's implement the router for analytics. As with the other modules, we'll create a file named Analytics.ts in the routers directory. Here is the source code for the file:

import { Hono } from "hono";  
import { aLogger } from "../helpers/logger";  
import { getAnalyticsForQR, updateAnalyticForQR } from "../helpers/Analytics";  
import { getQRById } from "../helpers/QR";  
  
const qrAnalyticsRouter = new Hono();  
  
qrAnalyticsRouter.get('/:id', async (c) => {  
    try {  
        const id = c.req.param('id');  
        const analyticsData = await getAnalyticsForQR(id);  
        return c.json({ data: analyticsData });  
    } catch (e: any) {  
        aLogger.error(e);  
        return c.json({ error: e.message }, 500);  
    }  
});  
  
qrAnalyticsRouter.get('/:id/redirect', async (c) => {  
    try {  
        const id = c.req.param('id');  
  
        // Check if QR is available  
        const qr = await getQRById(id);  
        if (!qr) {  
            return c.json({ error: 'QR data not found' }, 404);  
        }  
  
        // Add the analytics doc to collection  
        await updateAnalyticForQR(qr.id);  
  
        return c.redirect(qr.destination);  
    } catch (e: any) {  
        aLogger.error(e);  
        return c.json({ error: e.message }, 500);  
    }  
});  
  
export default qrAnalyticsRouter;

The first route, GET /analytics/:id, retrieves all analytics data for a specific QR code. When a request hits this path, the QR code's ID is extracted from the URL. The getAnalyticsForQR function is then called with this ID, querying the database for all related records. If successful, the analytics data is returned in JSON format. If an error occurs, it is logged, and a JSON response with a 500 status code and error message is sent to the client.

The second route, GET /analytics/:id/redirect, redirects the user to the QR code's destination URL while logging the access event. The QR code ID is extracted from the URL, and getQRById is called to check if the QR code exists in the database. If not found, a 404 error response with "QR data not found" is returned. If found, updateAnalyticForQR logs the access time and QR code. The user is then redirected to the URL in the QR code's destination field. Any errors are logged, and a JSON response with a 500 status code and error message is sent to the client.

Let's also update the helper function deleteQR from QR.ts to include deleting all associated analytics for a QR when it's deleted. Here's the modified function:

export async function deleteQR(id: string): Promise<boolean> {  
    const qr = await QR.findOne({id: id});  
    if (!qr) {  
        throw new Error('QR not found');  
    }  
    await qr.deleteOne({id: id});  
  
    // Also, delete any associated analytics for this QR if they exist  
    await deleteAnalyticForQR(id);  
  
    return true;  
}

This adjustment ensures that when a QR code is deleted, any analytics data linked to it is also removed, maintaining data consistency within the system.

Lastly, we'll integrate the Analytics router into our application by updating the index.js file, similar to how we handled the other modules. Below is the revised source code for index.js, reflecting all the modifications we've implemented thus far:

import { serve } from '@hono/node-server';  
import { aLogger, customWebLogger } from "./helpers/logger";  
import { logger } from "hono/logger";  
import { Hono } from 'hono';  
import authRouter from "./routers/Auth";  
import dotenv from "dotenv";  
import mongoose from "mongoose";  
import { cors } from "hono/cors";  
import qrRouter from "./routers/QR";  
import qrAnalyticsRouter from "./routers/Analytics";  
  
dotenv.config();  
  
mongoose.connect(process.env.DB_URI as string, {  
    maxPoolSize: 10,  
    authSource: "admin",  
    auth: { username: process.env.DB_USERNAME as string, password: process.env.DB_PASSWORD as string },  
})  
    .then(() => aLogger.info('DB Connection established'))  
    .catch(err => aLogger.error(err.message));  
  
const app = new Hono();  
app.use(logger(customWebLogger));  
app.use('*', cors({ origin: ['*'] }));  
  
app.route('auth', authRouter);  
app.route('qr', qrRouter);  
app.route('analytics', qrAnalyticsRouter);  
  
const port = 3000;  
aLogger.info(`API Core - WebServer is running on port ${port}`);  
  
serve({  
    fetch: app.fetch,  
    port  
});

Now that we've got that implemented, we've ticked off all the requirements for our REST API and database that we outlined in the last blog post. I apologize for the delay in completing this part—it's been a bit of a journey. In the next post, I'll walk you through how we'll tackle creating the frontend for the application, just like we did with this one. However, I've got a lot on my plate with other projects and my regular workload, so progress might be a tad slow in the days ahead. But rest assured, I never forget a promise, and I'll definitely deliver. Oh, and could you do me a favor? Shoot a message over to @mn519 and ask him to send us our app icon—we need that to get our application onto the app stores. With that said, until next time, ciao!

Popular posts from this blog

Turning a Joke into Innovation: AI Integration in our Daily Task Manager

Zapping Through Multicast Madness: A Fun Python Script to Keep Your IPTV Streams Rocking!