MERN Stack Advanced

AI SaaS Chatbot (Backend)

MERN Stack Advanced

This is an advanced project following the beginner-friendly project, make sure to check it out here: Refresher Project as I will not be explaining the things in this blog that have already been explained in the refresher blog. This project not only creates CRUD APIs in the backend, but we also make it secure and scalable using JWT tokens, HTTP-Only cookies, Signed cookies, Password encryption, and Middleware chains.

Create a folder with a backend folder within it:

Backend

Setting up

Create a package.json file, paste the below code, and run npm install in the terminal to install the packages. We use concurrently to run two commands concurrently. The first command is npx tsc — watch for compiling the typescript code to a javascript file and keeps watching for changes made in the code to automatically recompile and the second command is nodemon -q dist/index.js that uses nodemon to monitor the changes done in dist/index.js file and restart node.js application when changes are detected. The -q flag is called quite which is used to reduce the output to only essential information when the application is restarted. The build is used to run the built javascript files. There are dependencies that we will use in the project such as bcrypt for encryption, cookie-parser etc.

{
  "name": "backend",
  "version": "1.0.0",
  "description": "",
  "main": "index.ts",
  "type": "module",
  "scripts": {
    "dev": "concurrently \"npx tsc --watch\" \"nodemon -q dist/index.js\" ",
    "build": "node dist/index.js",
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "bcrypt": "^5.1.0",
    "concurrently": "^8.2.0",
    "cookie-parser": "^1.4.6",
    "cors": "^2.8.5",
    "dotenv": "^16.3.1",
    "express": "^4.18.2",
    "express-validator": "^7.0.1",
    "jsonwebtoken": "^9.0.1",
    "mongoose": "^7.4.2",
    "openai": "^3.3.0"
  },
  "devDependencies": {
    "@types/bcrypt": "^5.0.0",
    "@types/cookie-parser": "^1.4.3",
    "@types/cors": "^2.8.13",
    "@types/express": "^4.17.17",
    "@types/jsonwebtoken": "^9.0.1",
    "@types/node": "^20.4.8",
    "nodemon": "^3.0.1",
    "ts-node": "^10.9.1",
    "typescript": "^5.1.6"
  }
}

First, create a src folder and index.ts file inside it, and create a folder called dist as well. Then create a tsconfig.json file and paste this

{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "target": "ES2022",
    "sourceMap": true,
    "outDir": "dist",
    "allowSyntheticDefaultImports": true
  },
  "include": ["src/**/*.ts", "src/index.ts"]
}

Now, run the command npm run dev in the terminal and you will find the index.js file inside the dist folder.

Express setup

In the file index.ts paste the below code for starting and it will be automatically compiled in dist/index.js file.

import express from "express";

const app = express();

// middleware to parse the request body that is sent in json format
app.use(express.json());

// create a route handler using Express.js for the GET method
app.get('/', (req, res, next) => {
  return res.send('Hello');
});

app.listen(5000, () => console.log('Server open'))

Inside a callback function in the middleware app.get(), we have 3 parameters — the request object stores the request made by the client, the response object is used to send back the response, next object is used to move on to the next available middlewares. next is optional as it is used only when there are multiple middlewares to handle the route. Now create a folder inside src called config, controllers, db, models, routes, utils.

Database Connection

Create a new project in the Mongo DB cloud and choose the free version to deploy the database. Set it up, copy the MongoDB URL, create a .env file in the backend main folder (outside the src folder), and paste the below code.

OPEN_AI_SECRET=YOUR_SECRET
OPEN_AI_ORGANIZATION_ID=YOUR_SECRET
MONGODB_URL=YOUR_SECRET
JWT_SECRET=YOUR_SECRET
COOKIE_SECRET=YOUR_SECRET
PORT=5000

In MONGODB_URL, paste your MongoDB URL and replace the <password> with the actual password. Now we import dotenv in our index.ts file that is used to load environment variables from a .env file into process.env. The config() triggers the dotenv to read the .env file and parse its contents, setting the environment variables. These variables become accessible through process.env within the application. So paste the below code inside the index.ts file.

import { config } from "dotenv";

config();

Setting up Mongoose

Create a file inside the db folder called connection.ts and paste the below code:

We are creating a function called connectToDatabase and we use process.env to get the MongoDB URL inside the try-catch block.

import { connect } from 'mongoose';
import { disconnect } from 'process';

async function connectToDatabase() {
    try {
        await connect(process.env.MONGODB_URL);
    } catch (error) {
        console.log(error);
        throw new Error('Cannot connect to the database');
    }
}

async function disconnectFromDatabase() {
    try {
        await disconnect();
    } catch (error) {
        console.log(error);
        throw new Error('Cannot disconnect from the database');
    }
}

export { connectToDatabase, disconnectFromDatabase }

To ensure our application is secure and the database should be safe in case of any crash in the application we also create a function called disconnectFromDatabase that is used to disconnect from the database whenever there is a problem in the application.

Rearranging the folder structure

Create a file called app.ts inside src folder and paste the below code

import express from "express";
import { config } from "dotenv";

config();

const app = express();

app.use(express.json());

export default app;

This is to modularize our code for better readability and import the app in the index.ts file. Also, add a PORT to the env file and access it in index.ts file using process.env. We will also call the connectToDatabase function.

import app from './app.js';
import { connectToDatabase } from './db/connection.js';

// connections and listeners
const PORT = process.env.PORT || 5000;
connectToDatabase().then(() => {
    app.listen(PORT, () => console.log(`Server running in the port ${PORT}`));
}).catch((error) => console.log(error));

Open AI API

Now, create a new API key in your Open AI account and copy the key to paste it into the .env file in OPEN_AI_SECRET variable. Also, go to the settings, copy the organization ID, and paste it into an OPEN_AI_ORGANIZATION_ID variable in the .env file.

Setting Up Routes For Users and Chats

Install morgan using npm i morgan and it is used only in the development mode (should be removed in the production mode). The morgan is a popular HTTP request logger middleware for Node.js used within web applications built on frameworks like Express. It logs detailed information about the incoming request.

// app.ts file

import morgan from 'morgan';

// remove this in production
app.use(morgan('dev'));

// api routes begin from /api/v1
app.use('/api/v1');

We will create 3 files in the routes folder — index.ts, user_routes.ts, chat_routes.ts to define routes for the user and chat. index.ts is the starting point and it will route to userRoutes when url has /user and chatRoutes when url has /chat.

// routes/index.ts

import { Router } from "express";
import userRoutes from "./user_routes.js";
import chatRoutes from "./chat_routes.js";

const appRouter = Router();

appRouter.use('/user', userRoutes); // base_url/api/v1/user
appRouter.use('/chat', chatRoutes); // base_url/api/v1/chats

export default appRouter;
// routes/user_routes.ts

import { Router } from "express";
import { getAllUsers } from "../controllers/user_controllers.js";

const userRoutes = Router();

userRoutes.get('/', getAllUsers)

export default userRoutes;

Create a file user_controllers.ts inside the controllers folder to handle all the user routes.

// controllers/user_controllers.ts

export const getAllUsers = () => {
    // get all users 
}
// routes/chat_routes.ts

import { Router } from "express";

const chatRoutes = Router();

export default chatRoutes;

We are using /api/v1 here just to distinguish API routes or endpoints from other parts of a web application. The ‘v1’ stands for version 1 to manage different iterations in the API. As the API evolves, the version number in the path helps maintain backward compatibility while allowing the introduction of newer functionalities without disrupting existing clients.

Defining Database Models and Schemas

We define our schema for users in the models folder by creating a file called User.ts. The user will have a name, unique email, password and chats. Chat will have a unique ID, role, and content. We can use randomUUID from crypto to generate an ID for each chat.

import { randomUUID } from "crypto";
import mongoose from "mongoose";

const chatSchema = new mongoose.Schema({
    id: {
        type: String,
        default: randomUUID(),
    },
    role: {
        type: String,
        required: true,
    },
    content: {
        type: String,
        required: true,
    }
})

const userSchema = new mongoose.Schema({
    name: {
        type: String,
        required: true,
    },
    email: {
        type: String,
        required: true,
        unique: true,
    },
    password: {
        type: String,
        required: true,
    },
    chats: [chatSchema],
});

export default mongoose.model('User', userSchema);

Creating HTTP requests

We will use this schema in the user_controllers.ts to create our GET request. Make sure to check the API http://localhost:5000/api/v1/user in Postman. As we have installed morgan, we can see the log messages in the terminal. user_routes.ts will define the routes as below.

import { Router } from "express";
import { getAllUsers, userLogin, userSignup } from "../controllers/user_controllers.js";

const userRoutes = Router();

userRoutes.get('/', getAllUsers);
userRoutes.post('/signup',userSignup);
userRoutes.post('/login', userLogin);

export default userRoutes;

user_controllers.ts will have the below code. We have created 3 routes — one to access all users (getAllUsers), one to create a new user (userSignup), and one to access the exisiting user (userLogin).

import User from '../models/User.js';
import { hash, compare } from 'bcrypt';

export const getAllUsers = async (req, res, next) => {
    try {
        const users = await User.find();
        return res.status(200).json({message: 'OK', users});
    } catch (error) {
        return res.status(404).json({message: 'Error', cause: error.message});
    }
}

export const userSignup = async (req, res, next) => {
    try {
        const { name, email, password } = req.body;
        const existingUser = await User.findOne({email: email});
        if (existingUser) return res.status(401).send('User already registered');
        const hashedPassword = await hash(password, 10);
        const user = new User({name, email, password: hashedPassword});
        await user.save();
        return res.status(201).json({message: 'OK', id: user._id.toString()});
    } catch (error) {
        return res.status(404).json({message: 'Error', cause: error.message});
    }
}

export const userLogin = async (req, res, next) => {
    try {
        const { email, password } = req.body;
        const user = await User.findOne({ email });
        if (!user) {
            return res.status(401).send('User not registered');
        }
        const isPasswordCorrect = await compare(password, user.password);
        if (!isPasswordCorrect) {
            return res.status(403).send('Incorrect password');
        }
        return res.status(201).json({ message: 'OK', id: user._id.toString()});
    } catch (error) {
        return res.status(404).json({message: 'Error', cause: error.message});
    }
}

In the post request of userSignup, req.body will give us the details entered by the user like name, email, and password. First we will check if the entered email is already in the database by User.findOne({}), we will return the response accordingly and we are using hash from bcrypt to encrypt the password, the second argument is a number that is salt to be used in encryption (the greater the number, the more encrypted is the password). We will store the encrypted password in the database instead of a plain password using the User schema created.

In the userLogin, we are using compare from bcrypt where it’s used to compare a plaintext password provided by a user with a hashed password retrieved from a database.

User input validation

Read the express-validator documentation for a better understanding. We will use this for validation of the request body. Create a file called validators.ts in the utils folder.

Let’s break it down:

body is used to build a validation chain for the request body’s fields, here we are checking if name field is not empty, email is not valid and password is less than 6 characters and if yes, we give a message.

ValidationChain allows the application of multiple validation rules in a particular order to the same field.

validationResult is used to extract the result of validation after applying the defined validation rules to the request.

validate function:

  • For each validation in the validations array, it runs the validation against the req object (the incoming request).

  • If a validation fails (detected by !result.isEmpty()), it breaks out of the loop.

  • After the loop, it collects validation errors using validationResult(req).

  • If there are no errors (errors.isEmpty()), it proceeds to the next middleware.

  • If there are validation errors, it returns a response with a status code of 422 (Unprocessable Entity) and a JSON object containing the validation errors.

import { body, ValidationChain, validationResult } from "express-validator";

const validate = (validations:ValidationChain[]) => {
    return async (req, res, next) => {
        for (let validation of validations) {
            const result = await validation.run(req);
            if(!result.isEmpty()) {
                break;
            }
        }
        const errors = validationResult(req);
        if (errors.isEmpty()) {
            return next();
        }
        return res.status(422).json({errors: errors});
    }
}

const loginValidator = [
    body('email').trim().isEmail().withMessage('Valid email is required'),
    body('password').trim().isLength({min: 6}).withMessage('Password should contain atleast 6 characters'),
]

const signupValidator = [
    body('name').notEmpty().withMessage('Name is required'),
    ...loginValidator,
]

export { signupValidator, loginValidator, validate }

We need to use the validation in user_routes.ts as following:

import { Router } from "express";
import { getAllUsers, userLogin, userSignup } from "../controllers/user_controllers.js";
import { signupValidator, loginValidator, validate } from "../utils/validators.js";

const userRoutes = Router();

userRoutes.get('/', getAllUsers);
userRoutes.post('/signup', validate(signupValidator),userSignup);
userRoutes.post('/login', validate(loginValidator), userLogin);

export default userRoutes;

Authentication and Authorization

We need to understand JWT tokens and HTTP-only cookies

JSON Web Tokens (JWT) are used to securely transmit information between parties as a JSON object by providing authentication. When a user logs in, after the credentials are verified, a JWT is created and given to the user. This token is sent with each subsequent request allowing access to protected routes, services, or resources. They are self-contained, making them ideal for stateless applications where the server doesn’t need to maintain a session state.

How a JWT is used in authentication:

  1. The user sends credentials to the server for authentication.

  2. If the credentials are valid, the server creates a JWT and sends it back to the client.

  3. The client stores the JWT (commonly in local storage or a cookie) and sends it with every subsequent request to the server.

  4. The server verifies the JWT and allows access to the protected resources or routes if the token is valid.

Let us apply this concept to our project. Create a file token_managers.ts in the utils folder. JWT_SECRET is a random string that is provided in the .env file by you.

import jwt from 'jsonwebtoken';

export const createToken = (id:string, email: string, expiresIn: string) => {
    const payload = { id, email };
    const token = jwt.sign(payload, process.env.JWT_SECRET, {
        expiresIn,
    });
    return token;
};

Here, we import jwt library for generating and verifying JSON web tokens. The payload is the information embedded within the JWT. The jwt.sign function generates a signed token based on the payload and a provided secret key, setting an expiration time for the token and returning the generated token.

We will use this token in our user_controllers.ts file.

import { createToken } from '../utils/token_manager.js';

export const getAllUsers = async (req, res, next) => {
  // rest of the code
} 

export const userSignup = async (req, res, next) => {
  try {
    // rest of the code
    const token = createToken(user._id.toString(), user.email, '7d');
     return res.status(201).json({message: 'OK', id: user._id.toString()});
    } catch (error) {
        return res.status(404).json({message: 'Error', cause: error.message});
    }
}

export const userLogin = async (req, res, next) => {
  try {
    // rest of the code
    const token = createToken(user._id.toString(), user.email, '7d');
    return res.status(201).json({ message: 'OK', id: user._id.toString()});
    } catch (error) {
        return res.status(404).json({message: 'Error', cause: error.message});
    }
  }
}

Here, we are passing user id, email and setting the expiration as 7 days is the duration after which the token will expire and no longer be considered valid. The user should log in again to generate a new token. This can be commonly seen in applications like your bank online account where it expires in 1 hour and you need to log in again.

HTTP-only cookies:

As the name itself suggests, these cookies are set with the HttpOnly attribute and their purpose is to prevent client-side scripts (like JavaScript) from accessing the cookie information. It is used to provide a layer of security to the cookie’s contents. When the cookie is transmitted between the client and the server in HTTP requests, it cannot be accessed by client-side scripts. Hence, this kind of cookie can be used to store session identifiers, tokens, and sensitive information related to user authentication and authorization. Since we are storing authentication tokens, it makes sense to use HTTP-only cookies.

Now we understood the significance of both, we will store the JWT token generated by createToken() function in an HTTP-only cookie. We are using res.cookie() function in Express.js to set a cookie in the response object. It takes the cookie name, the content of the cookie, and optional parameters.

We will set the cookie name in the constants.ts file inside the utils folder and we will import it in our user_controllers.ts.

// rest of the imports
import { COOKIE_NAME } from '../utils/constants.js';

export const getAllUsers = async (req, res, next) => {
    // rest of the code
}

export const userSignup = async (req, res, next) => {
    try {

        // create token and store cookie
        res.clearCookie(COOKIE_NAME, {
            httpOnly: true,
            domain: 'localhost', 
            signed: true,
            path: '/'
        });

        const token = createToken(user._id.toString(), user.email, '7d');
        const expires = new Date();
        expires.setDate(expires.getDate() + 7);
        res.cookie(COOKIE_NAME, token, { 
            path: '/', 
            domain: 'localhost', 
            expires, 
            httpOnly: true,
            signed: true,
        });

        return res.status(201).json({ message: 'OK', name: user.name, email: user.email });
    } catch (error) {
        return res.status(404).json({ message: 'Error', cause: error.message });
    }
}

export const userLogin = async (req, res, next) => {
    try {
        // rest of the code

        res.clearCookie(COOKIE_NAME, {
            httpOnly: true,
            domain: 'localhost', 
            signed: true,
            path: '/'
        });

        const token = createToken(user._id.toString(), user.email, '7d');
        const expires = new Date();
        expires.setDate(expires.getDate() + 7);
        res.cookie(COOKIE_NAME, token, { 
            path: '/', 
            domain: 'localhost', 
            expires, 
            httpOnly: true,
            signed: true,
        });

        return res.status(201).json({ message: 'OK', name: user.name, email: user.email });
    } catch (error) {
        return res.status(404).json({message: 'Error', cause: error.message});
    }
}

In the above code, first, we create a token both in signup and login functions. We set expires variable from the present date up to 7 days so that users don't have to login for 7 days once they signup.

Syntax of cookie:

  1. COOKIE_NAME: this is the name of the cookie and we can set it to anything. It is crucial as it serves as the key or label by which the browser and the server can recognize and interact with the specific cookie. Each cookie must have a unique name and descriptive name to avoid conflicts.

  2. In the second argument, we are passing a token as it is the content we intend to store in a cookie.

  3. Path: Defines the path on the server that the cookie will be valid, ‘/’ makes the cookie available for the entire website.

  4. Domain: Specifies the domain for which the cookie is valid.

  5. Expires: Sets the expiration date of the cookie.

  6. httpOnly is set to true and signing a cookie involves adding a signature to the cookie’s content using a secret key known to the server

Before we create cookies, we have to clear the cookies that might be present previously. Hence, we use clearCookie function. We will discuss the frontend part in the next blog.

Acknowledgement: freeCodeCamp.org