Skip to main content

Node / Express with Typescript - (Re)Introduction 2023

TST, Hongkong

Getting Started

Node.js

Install the latest version of Node.js and create a new Node project using npm:

mkdir hello-next && cd hello-next
npm init -y

Test your Node.js installation by creating a hello world js file:

nano ./index.js

with the following content:

console.log('Hello from Node.js') 

Add a start script to ./package.json

 "scripts": {
    "start": "node index.js"
  },

And execute it using npm:

 npm start

> hello-next@1.0.0 start
> node index.js

Hello from Node.js

Typescript

sudo npm install -g typescript 
npm install -D typescript ts-node nodemon
tsc --init

Created a new tsconfig.json with:

TS
  target: es2016
  module: commonjs
  strict: true
  esModuleInterop: true
  skipLibCheck: true
  forceConsistentCasingInFileNames: true

Move the index file into a src sub-directory and rename the file to index.ts and change the npm script to "start": "ts-node src/index.ts". Verify that everything still works:

npm start

> hello-next@1.0.0 start
> ts-node index.ts

Hello from Node.js

Nodemon

For development we can use "dev": "nodemon src/index.ts" to get live reloading with the following configuration:

nano nodemon.json
{
    "ignore": [
        ".git",
        "node_modules/",
        "dist/",
        "coverage/"
    ],
    "watch": [
        "src/*"
    ],
    "ext": "js, json, ts"
}
npm run dev

> hello-next@1.0.0 dev
> nodemon src/index.ts

[nodemon] 2.0.22
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): src/**/*
[nodemon] watching extensions: js,json,ts
[nodemon] starting `ts-node src/index.ts`
Hello from Node.js
[nodemon] clean exit - waiting for changes before restart

Webserver

Express.js

npm install express cors
npm install -D @types/express @types/cors

Clean out the index file and replace it with the Express boilerplate:

import express, {Request, Response} from 'express'
import cors from 'cors'

const expressPort = 8888;
const app = express();

// use json for API routes
app.use(express.json());
// cors for api address/port
app.use(cors({
    credentials: true,
    origin: ["http://localhost:3000"]
}));

app.get('/', (req: Request, res: Response) => {
    res.send('INFO :: Root route called');
});

app.listen(expressPort, () => {
    console.log('INFO :: Webserver started on port ' + expressPort)
});

Test that the root route is working:

curl localhost:8888
INFO :: Root route called

Express Router

Above we created a route inside the index file. But Express allows us to export routes as well as controller functions - e.g. authentication - from separate files to keep things tidy:

nano src/routes.ts
import { Router } from 'express';
import { Register } from './controller/auth.controller'

export const routes = (router: Router) => {
    router.post('/api/register', Register)
}
nano src/controller/auth.controller.ts
import { Request, Response } from 'express';

export const Register = (req: Request, res: Response) => {
    res.send(req.body);
};

The routes can now be imported into your index file:

import { routes } from './routes';

// import routes from router
routes(app)

We can now test the registration URL:

curl --header "Content-Type: application/json" \
  --request POST \
  --data '{"username":"itsme","password":"secret"}' \
  http://localhost:8888/api/register

This returns the JSON body {"username":"itsme","password":"secret"} as configured.

Request Validation

Use the express request-validation inside your authentication controller to verify that an incoming request is valid:

npm install express-validation
nano ./src/validation/register.validation.ts
import { Joi } from 'express-validation';

export const registerValidation = Joi.object({
    firstName: Joi.string().required(),
    lastName: Joi.string().required(),
    email: Joi.string().email().required(),
    password: Joi.string().required(),
    passwordConfirm: Joi.string().required(),
})

The validation can now be imported into the auth controller to return an error message if the validation fails:

src/controller/auth.controller.ts

import { Request, Response } from 'express';
import { registerValidation } from '../validation/register.validation';

export const Register = (req: Request, res: Response) => {
    const body = req.body;

    const { error } = registerValidation.validate(body);

    if (error) {
        return res.status(400).send(error.details);
    }

    if (body.password !== body.passwordConfirm){
        return res.status(400).send({
            message: 'ERROR :: Passwords do not match!'
        });
    }

Test the validation with missing data and a valid request:

curl --header "Content-Type: application/json" \
  --request POST \
  --data '{"firstName":"its","lastName":"me","email":"me@email.com","password":"secret"}' \
  http://localhost:8888/api/register
[{"message":"\"passwordConfirm\" is required","path":["passwordConfirm"],"type":"any.required","context":{"label":"passwordConfirm","key":"passwordConfirm"}}]
curl --header "Content-Type: application/json" \
  --request POST \
  --data '{"firstName":"its","lastName":"me","email":"me@email.com","password":"secret","passwordConfirm":"secret"}' \
  http://localhost:8888/api/register
{"firstName":"its","lastName":"me","email":"me@email.com","password":"secret","passwordConfirm":"secret"}

Database

SQL Database

Bring up an SQL database to handle your app data, e.g. with Docker-Compose:

./src/docker/docker-compose.yml

version: '3'

services:

  db:
    image: mariadb
    restart: always
    ports:
      - 3306:3306
    environment:
      MARIADB_ROOT_PASSWORD: secretpassword
      MARIADB_DATABASE: react-app
      MARIADB_USER: app-user
      MARIADB_PASSWORD: secretpassword

  adminer:
    image: adminer
    restart: always
    ports:
      - 8080:8080

You can login on port 8080 to verify that MariaDB is up and the app database was created:

Node.js 2023

MySQL Connector

Now we need to connect the app to freshly created database:

npm install typeorm reflect-metadata @types/node mysql2 bcryptjs @types/bcryptjs

To keep things simple we can move the entire DB logic into it's own file:

./src/db-connector.ts

import { DataSource } from "typeorm";
const dataSourceOptions = require('../dataSourceOptions.json')

export const dataSource = new DataSource(dataSourceOptions);

export const Manager = dataSource.manager
// export const UserRepository = dataSource.getRepository(User)

dataSource
    .initialize()
    .then(() => {
        console.log('INFO :: Data Source has been initialized');
    })
    .catch((err) => {
        console.error('ERROR :: Data Source initialization error', err);
    })

export default dataSource;

The database configuration is exported into a separate JSON file for convenience:

./dataSourceOptions.json

{
    "type": "mysql",
    "host": "localhost",
    "port": 3306,
    "username": "app-user",
    "password": "secretpassword",
    "database": "react-app",
    "entities": [
        "src/entities/*.ts"
    ],
    "logging": false,
    "synchronize": true
}

NOTE that the configuration here has to match up with the configuration set inside the Docker-Compose file you used to start your instance of MariaDB!

The connector can be imported into the index file before calling the Express app - to make sure that the DB connection is establish before the app starts:

./src/index.ts

// Import DB config
import dataSource from "./db-connector";
// Create connection with DB
dataSource

The connector now only connects us to our database. To have actually do something we have to add "entities" in the configured entity folder src/entities/*.ts:

./src/entities/user.entity.ts

import { Column, PrimaryGeneratedColumn, Entity } from "typeorm";

@Entity()
export class User {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    firstName: string;

    @Column()
    lastName: string;

    @Column({
        unique: true
    })
    email: string;

    @Column()
    password: string;

}

This will make sure that the user table is generated as soon as the app is restarted:

Node.js 2023

Authentication

Registering Users

To be able to populate the User table with user logins we now have to add a function to the auth controller:

src/controller/auth.controller.ts

// REGISTER USER
export const Register = async (req: Request, res: Response) => {
    const body = req.body;

    // check if all infos were send
    const { error } = registerValidation.validate(body);
    // break if something is missing
    if (error) {
        return res.status(400).send(error.details);
    }
    // verify that password is confirmed
    if (body.password !== body.passwordConfirm){
        return res.status(400).send({
            message: 'ERROR :: Passwords do not match!'
        });
    }
    // save password to database
    const { password, ...user } = await repository.save({
        firstName: body.firstName,
        lastName: body.lastName,
        email: body.email,
        password: await bcryptjs.hash(body.password, 10)
    })

    res.send(user);
};

Here I am using bcryptjs to make sure that the password is not stored in plain text. The user registration can be tested using curl:

curl --header "Content-Type: application/json" \
  --request POST \
  --data '{"firstName":"Mike","lastName":"Polinowski","email":"me@email.com","password":"secret","passwordConfirm":"secret"}' \
  http://localhost:8888/api/register

Note that the password in the returned body is hashed and also received an ID - the first user registered in the User table:

{"firstName":"Mike","lastName":"Polinowski","email":"me@email.com","password":"$2a$10$6LKB6iIhOjzK7TK3kv1qeOOYkds6mvh1JENfzJDFqtnY3wR999Ue2","id":1}

Node.js 2023

Users Login

Create a login route that points to a function called Login in the auth controller:

./src/routes.ts

import { Router } from 'express';
import { Register, Login } from './controller/auth.controller'

export const routes = (router: Router) => {
    router.post('/api/register', Register)
    router.post('/api/login', Login)
}

And create the login function:

./src/controller/auth.controller.ts

// LOGIN USER
export const Login = async (req: Request, res: Response) => {
    // check if user exists in db
    const user = await repository.findOneBy(
            {
                email: req.body.email
            }
        )
    
    // if does not exists break
    if(!user){
        return res.status(404).send({
            message: 'ERROR :: User does not exists!'
        })
    }

    // if exists but password is wrong break
    if(!await bcryptjs.compare(req.body.password, user.password)) {
        return res.status(404).send({
            message: 'ERROR :: Invalid credentials!'
        })
    }

    // don't return password after successful login
    const { password, ...data } = user;
    res.send(user);
}

Again, we can test the user login with curl:

curl --header "Content-Type: application/json" \
  --request POST \
  --data '{"email":"me@email.com","password":"secret"}' \
  http://localhost:8888/api/login

{"id":1,"firstName":"Mike","lastName":"Polinowski","email":"me@email.com"}

JSON Web Tokens

Return a JWT to keep users authenticated after a successful login:

npm install jsonwebtoken @types/jsonwebtoken cookie-parser @types/cookie-parser

Instead of returning the user we now need to return a JWT in Login function:

./src/controller/auth.controller.ts

// return JWT to authenticated user
const payload = { id: user.id }
const token = sign(payload, 'secretkey')

// don't return password after successful login
// const { password, ...data } = user;
res.send(token);

Test that the login now returns the token:

curl --header "Content-Type: application/json" \
  --request POST \
  --data '{"email":"me@email.com","password":"secret"}' \
  http://localhost:8888/api/login

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiaWF0IjoxNjg1ODkwNjIxfQ.W8D81og0GuWZ8Q8Nvp1XDpqK0XwDFXy0qYgjXxP8uno

Write token to HTTP cookie instead of returning it directly:

// return JWT to authenticated user
const token = sign(
    {
        id: user.id
    }, 'secretkey'
)

res.cookie('jwt', token, {
    // keep cookie in node.js backend
    httpOnly: true,
    maxAge: 24*60*60*1000 //1day
})

res.send({
    message: 'INFO :: Successfully logged in.'
})

Try the login again:

curl --header "Content-Type: application/json" \
  --request POST \
  --data '{"email":"me@email.com","password":"secret"}' \
  http://localhost:8888/api/login

{"message":"INFO :: Successfully logged in."}

Authenticate Users

Start by creating a route for returning the authenticated user:

./src/routes.ts

import { Router } from 'express';
import { Register, Login, AuthenticatedUser } from './controller/auth.controller'

export const routes = (router: Router) => {
    // register new user
    router.post('/api/register', Register)
    // login known user
    router.post('/api/login', Login)
    // get authenticated user from jwt
    router.get('/api/user', AuthenticatedUser)
}

Then create the referenced function for this route:

./src/controller/auth.controller

export const AuthenticatedUser = async (req: Request, res: Response) => {
    // get cookie from authenticated user
    const jwt = req.cookies['jwt'];
    // get user id from jwt
    const payload: any = verify(jwt, secret)

    if(!payload) {
        return res.status(401).send({
            message: 'ERROR :: User unauthenticated!'
        })
    }
    // return user info  for user id
    const {password, ...user} = await repository.findOneBy(payload.id)

    res.send(user)
}

We can test the function in Postman by first logging in:

Node.js 2023

And then calling the /api/user route with the received web token:

Node.js 2023

Our web token contained our user id that the backend could use to identify the authenticated user and return the user information.

Deauthenticate Users

Once logged in we need to provide a function that allows us to remove the web token and log out the user. Start by providing a route:

import { Router } from 'express';
import { Register, Login, AuthenticatedUser, Logout } from './controller/auth.controller'

export const routes = (router: Router) => {
    // register new user
    router.post('/api/register', Register)
    // login known user
    router.post('/api/login', Login)
    // get authenticated user from jwt
    router.get('/api/user', AuthenticatedUser)
    // force expire jwt to log out
    router.post('/api/logout', Logout)
}

To logout we need to expire the active JWT:

./src/controller/auth.controller.ts

export const Logout = async (req: Request, res: Response) => {
    res.cookie('jwt', '',  {maxAge: 0})

    res.send({
        message: 'INFO :: Successfully logged out.'
    })
}

The function can be verified with Postman:

Node.js 2023

Dotenv

Using dot environment variables to pass in global variables like the JWT secretkey:

npm install dotenv @types/dotenv

Collect all global variables in a dotenv file:

./.env

# Express webfrontend port
WEB_PORT = 8888
# JWT secret key 
SECRET_KEY = 'secretkey'
# API url for CORS
API_URL = 'http://localhost:3000'
# Datasource options for TypeORM
DS_HOST = 'localhost'
DS_USER = 'app-user'
DS_PASS = 'secretpassword'
DS_DB = 'react-app'
DS_ENTITIES = 'src/entities/*.ts'

And require the configuration file ON TOP of your index file:

require('dotenv').config();

Now you are able to use these variables in your entire app:

./src/index.ts

const apiUrl = process.env.API_URL;
const expressPort = process.env.WEB_PORT || 8080;

./src/db-connector.ts

export const dataSource = new DataSource({
    type: 'mariadb',
    host: process.env.DS_HOST,
    port: 3306,
    username: process.env.DS_USER,
    password: process.env.DS_PASS,
    database: process.env.DS_DB,
    entities: [process.env.DS_ENTITIES],
    logging: false,
    synchronize: true
  });

Verify Auth Status Middleware

Move the auth status check into middleware to simplify the AuthenticatedUser function that should only return the user details of authenticated users:

src/middleware/auth.middleware.ts

import { Request, Response } from 'express';
import { verify } from 'jsonwebtoken';

import Manager from '../db-connector';
import { User } from '../entities/user.entity';
const secret = process.env.SECRET_KEY

const repository = Manager.getRepository(User);

export const CheckAuthState = async (req: Request, res: Response, next: Function) => {
    
        try {
            // get cookie from authenticated user
            const jwt = req.cookies['jwt'];
            // get user id from jwt
            const payload: any = verify(jwt, secret)

            if(!payload) {
                return res.status(401).send({
                    message: 'ERROR :: User unauthenticated!'
                })
            }
            // return user info  for user id
            req['user'] = await repository.findOneBy(payload.id)

            next();

        } catch (e) {
            return res.status(401).send({
                message: 'ERROR :: User unauthenticated!'
        })
    }
}

The entire verification logic now moved into the middleware. By adding it to our user and logout route we can now directly request the user details inside the auth controller:

src/routes.ts

import { Router } from 'express';
import { Register, Login, AuthenticatedUser, Logout } from './controller/auth.controller'
import { CheckAuthState } from './middleware/auth.middleware'

export const routes = (router: Router) => {
    // register new user
    router.post('/api/register', Register)
    // login known user
    router.post('/api/login', Login)
    // get authenticated user from jwt
    router.get('/api/user', CheckAuthState, AuthenticatedUser)
    // force expire jwt to log out
    router.post('/api/logout', CheckAuthState, Logout)
}

src/controller/auth.controller.ts

// RETURN USER DATA BY JWT ID
export const AuthenticatedUser = async (req: Request, res: Response) => {
    const {password, ...user} = req['user']
    res.send(user);
}

Update User Info

Add two more routes to update the user name and password:

src/routes.ts

import { Router } from 'express';
import { Register, Login, AuthenticatedUser, Logout, UpdateUserInfo, UpdateUserPass } from './controller/auth.controller'
import { CheckAuthState } from './middleware/auth.middleware'

export const routes = (router: Router) => {
    // register new user
    router.post('/api/register', Register)
    // login known user
    router.post('/api/login', Login)
    // get authenticated user from jwt
    router.get('/api/user', CheckAuthState, AuthenticatedUser)
    // force expire jwt to log out
    router.post('/api/logout', CheckAuthState, Logout)
    // update user info
    router.put('/api/users/info', CheckAuthState, UpdateUserInfo)
    // update user password
    router.put('/api/users/pass', CheckAuthState, UpdateUserPass)
}

And add the corresponding controller functions:

src/controller/auth.controller.ts

// UPDATE USER INFO
export const UpdateUserInfo = async (req: Request, res: Response) => {

    const user = req['user'];

    await repository.update(user.id, req.body)

    const { password, ...data } = await repository.findOneBy(user.id)

    res.send({data})
}

// UPDATE USER PASSWORD
export const UpdateUserPass = async (req: Request, res: Response) => {

    const user = req['user'];

    // verify that password is confirmed
    if (req.body.password !== req.body.passwordConfirm){
        return res.status(400).send({
            message: 'ERROR :: Passwords do not match!'
        });
    }

    await repository.update(user.id, {
        password: await bcryptjs.hash(req.body.password, 10)
    })

    const { password, ...data } = await repository.findOneBy(user.id)

    res.send({data})
}

Use the /api/users/info route to update the user name and email:

Node.js 2023

And /api/users/pass to update the user password:

Node.js 2023

User Administration

The admin user has to be able to administer other users. Start by creating routes that:

  • fetch all registered users
  • fetch user by ID
  • create new users
  • update user
  • delete user

src/routes.ts

export const routes = (router: Router) => {

    // register new user
    router.post('/api/register', Register)
    // login known user
    router.post('/api/login', Login)
    // get authenticated user from jwt
    router.get('/api/user', CheckAuthState, AuthenticatedUser)
    // force expire jwt to log out
    router.post('/api/logout', CheckAuthState, Logout)
    // update user info
    router.put('/api/users/info', CheckAuthState, UpdateUserInfo)
    // update user password
    router.put('/api/users/pass', CheckAuthState, UpdateUserPass)

    // user administration - get all users
    router.get('/api/users', CheckAuthState, GetUsers)
    // user administration - get user by ID
    router.get('/api/users/:id', CheckAuthState, GetUser)
    // user administration - create new user
    router.put('/api/users/:id', CheckAuthState, UpdateUser)
    // user administration - create new user
    router.post('/api/users', CheckAuthState, CreateUser)
    // user administration - delete user
    router.delete('/api/users/:id', CheckAuthState, DeleteUser)
}

Get all Users

The administration functions are collected in the user controller. The GetUsers function selects the user table and returns everything:

src/controller/controller.controller.ts

import { Request, Response } from "express";


import Manager from "../db-connector";
import { User } from '../entities/user.entity';
const repository = Manager.getRepository(User);


export const GetUsers = async (req: Request, res: Response) => {
    const users = await repository.find()

    res.send(
        users.map(user => {
            //remove password
            const { password, ...data} = user
            return data
        })
    )
}

Node.js 2023

Get User by ID

To fetch the a single user we can add the user ID as request param:

src/controller/controller.controller.ts

export const GetUser = async (req: Request, res: Response) => {
    const {password, ...user} = await repository.findOne({ where: { id: req.params.id } })
    
    res.send(user)
}

Node.js 2023

Create User

I already created the POST route to create new users in the previous step. Now the function for it is still missing:

src/controller/controller.controller.ts

export const CreateUser = async (req: Request, res: Response) => {
    const { role_id, ...body } = req.body;
    const hashedPassword = await bcryptjs.hash('pass1234', 10);
    const { password, ...user} = await repository.save({
        ...body,
        password: hashedPassword
    })

    res.status(201).send(user)
}

Node.js 2023

Update User

src/controller/controller.controller.ts

export const UpdateUser = async (req: Request, res: Response) => {
    const { role_id, ...body } = req.body;
    const update = await repository.update(req.params.id, body)
    
    res.status(202).send(update)
}

Node.js 2023

Delete User

src/controller/controller.controller.ts

export const DeleteUser = async (req: Request, res: Response) => {
    const deleteUser = await repository.delete(req.params.id)
    
    res.status(204).send(deleteUser)
}

Node.js 2023

User Role Model

Create a new database table to define user roles:

src/entities/role.entity.ts

import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm";

@Entity ()
export class Role extends BaseEntity {
    @PrimaryGeneratedColumn()
    id: any;

    @Column()
    name: string;

}

And link this role into the User table:

src/entities/role.entity.ts

@Entity ()
export class User extends BaseEntity {
    @PrimaryGeneratedColumn()
    id: any;

    @Column()
    firstName: string;

    @Column()
    lastName: string;

    @Column({ unique: true })
    email: string;

    @Column()
    password: string;

    @ManyToOne(() => Role)
    @JoinColumn({name: 'role_id'})
    role: Role;

}

For testing we can manually add an Admin role to the table:

Node.js 2023

And assign this role to existing users in the User table:

Node.js 2023

Add role to the output of the GetUsers function:

src/controller/user.controller.ts

export const GetUsers = async (req: Request, res: Response) => {
    const users = await repository.find({
        relations: ['role']
    })

    res.send(
        users.map(user => {
            const { password, ...data} = user
            return data
        })
    )
}

Node.js 2023

As well as to the CreateUser function:

src/controller/user.controller.ts

export const CreateUser = async (req: Request, res: Response) => {
    const { role_id, ...body } = req.body;
    const hashedPassword = await bcryptjs.hash('pass1234', 10);
    const { password, ...user} = await repository.save({
        ...body,
        password: hashedPassword,
        role: {
            id: role_id
        }
    })

    res.status(201).send(user)
}

Node.js 2023

And to the GetUser function:

src/controller/user.controller.ts

export const GetUser = async (req: Request, res: Response) => {
    const {password, ...user} = await repository.findOne({
        where: { id: req.params.id }, relations: ['role']
    })
    
    res.send(user)
}

Node.js 2023

And to the UpdateUser function:

src/controller/user.controller.ts

export const UpdateUser = async (req: Request, res: Response) => {
    const { role_id, ...body } = req.body;

    await repository.update(req.params.id, {
        ...body,
        role: {
            id: role_id
        }
    });

    const {password, ...user} = await repository.findOne({
            where: { id: req.params.id }, relations: ['role']
        });
    
    res.status(202).send(user)
}

Node.js 2023

User Permissions

Create a new database table to define user permissions:

src/entities/permission.entity.ts

import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm";

@Entity ()
export class Permission extends BaseEntity {
    @PrimaryGeneratedColumn()
    id: any;

    @Column()
    name: string;

}

Permissions are not directly linked to a user but to it's role:

src/entities/role.entity.ts

import { BaseEntity, Column, Entity, JoinTable, ManyToMany, PrimaryGeneratedColumn } from "typeorm";
import { Permission } from "./permission.entity";

@Entity ()
export class Role extends BaseEntity {
    @PrimaryGeneratedColumn()
    id: any;

    @Column()
    name: string;

    @ManyToMany(() => Permission)
    @JoinTable({
        name: 'role_permissions',
        joinColumn: {
            name: 'role_id',
            referencedColumnName: 'id'
        },
        inverseJoinColumn: {
            name: 'permission_id',
            referencedColumnName: 'id'
        }
    })
    permissions: Permission[];

}

TypeORM will automatically create a new table role_permissions for us:

Node.js 2023

Pre-Seeding Roles and Permissions

Please check out the Update Role Seed below... the function following here is not working.

We can add the permissions we want to be able to assign to user roles when the DB connection is established:

src/db-connector.ts

dataSource
    .initialize()
    .then( async () => {

        // create role permissions
        const permissionRepository = Manager.getRepository(Permission)

        const perms = [
            'view_users',
            'edit_users',
            'view_roles',
            'edit_roles',
            'view_products',
            'edit_products',
            'view_orders',
            'edit_orders'
        ]

        let permissions = []

        // insert permissions into Permission table
        for (let i = 0; i< perms.length; i++){
            permissions.push(
                // use upsert instead of save
                await permissionRepository.upsert(
                    { name: perms[i] },
                    // if name exists only update else insert
                    ['name']
                )
            )
        }
        
        console.log('INFO :: Data Source has been initialized');
    })
    .catch((err) => {
        console.error('ERROR :: Data Source initialization error', err);
    })

After app reloaded the permissions are now added:

Node.js 2023

In the same function we can now add the assignment of permissions to their respective roles:

src/db-connector.ts

// assign permissions to roles
const roleRepository = Manager.getRepository(Role)
// admin can do it all
// insert or update
await roleRepository.upsert({ name: 'Admin', permissions }, ['name'])

// editor can do all but to edit roles
delete permissions[3]
// insert or update
await roleRepository.upsert({ name: 'Editor', permissions }, ['name'])
    
// viewer cannot edit anything
delete permissions[1]
delete permissions[5]
delete permissions[7]
// insert or update
await roleRepository.upsert({ name: 'Viewer', permissions }, ['name'])

Node.js 2023

Permissions Controller

Check permissions for active user:

src/routes.ts

router.get('/api/permissions', CheckAuthState, Permissions)

src/controller/permission.controller.ts

import { Request, Response } from "express";
import { Permission } from "../entities/permission.entity";
import Manager from "../db-connector";

const repository = Manager.getRepository(Permission);

export const Permissions = async (req: Request, res: Response) => {
    res.send(await repository.find())
}

Node.js 2023

Role Controller

Get all available roles:

src/routes.ts

router.get('/api/roles', CheckAuthState, Roles)

src/controller/roles.controller.ts

import { Request, Response } from "express";
import { Roles } from "../entities/roles.entity";
import Manager from "../db-connector";

const repository = Manager.getRepository(Roles);

export const Roles = async (req: Request, res: Response) => {
    res.send(await repository.find())
}

Node.js 2023

Manually Adding Roles

Create a new role:

src/routes.ts

router.post('/api/roles', CheckAuthState, CreateRole)

src/controller/roles.controller.ts

export const CreateRole = async (req: Request, res: Response) => {
    const { name, permissions } = req.body;
    const role = await repository.save({
        name,
        permissions: permissions.map( id => {
            return {
                id: id
            }
        })
    })

    res.send(role)
}

Node.js 2023

Node.js 2023

Get Permissions for a Role

Return all permissions for a specified role:

src/routes.ts

router.get('/api/roles/:id', CheckAuthState, CreateRole)

src/controller/roles.controller.ts

export const GetRole = async (req: Request, res: Response) => {
    res.send(await repository.findOne({
        where: { id: req.params.id }, relations: ['permissions']
    })
}

Node.js 2023

Update Permissions for a Role

Add permissions for a specified role:

src/routes.ts

router.post('/api/roles/:id', CheckAuthState, CreateRole)

src/controller/roles.controller.ts

export const UpdateRole = async (req: Request, res: Response) => {
    const { name, permissions } = req.body;
    const role = await repository.save({
        id: parseInt(req.params.id),
        name,
        permissions: permissions.map( id => {
            return {
                id: id
            }
        })
    })

Node.js 2023

Manually Deleting Roles

Delete an existing role:

src/routes.ts

router.delete('/api/roles/:id', CheckAuthState, DeleteRole)

src/controller/roles.controller.ts

export const DeleteRole = async (req: Request, res: Response) => {
    const deleteRole = await repository.delete(req.params.id)

    res.status(204).send(deleteRole)
}

Node.js 2023

Products

Create a table for items to be displayed on the web frontend:

src/entities/product.entity.ts

import { BaseEntity, Column, Entity, Unique, PrimaryGeneratedColumn } from "typeorm";

@Entity ()
@Unique('constraint_name', ['title'])
export class User extends BaseEntity {
    @PrimaryGeneratedColumn()
    id: number;

    @Column({ unique: true })
    title: string;

    @Column()
    description: string;

    @Column()
    image: string;

    @Column()
    price: number;
}

Product Controller

The Product routes and the controller is basically identical to the User pendants:

src/routes.ts

// product administration - get all products
router.get('/api/products', CheckAuthState, GetProducts)
// product administration - get product by ID
router.get('/api/products/:id', CheckAuthState, GetProduct)
// product administration - create new product
router.put('/api/products/:id', CheckAuthState, UpdateProduct)
// product administration - create new product
router.post('/api/products', CheckAuthState, CreateProduct)
// product administration - delete product
router.delete('/api/products/:id', CheckAuthState, DeleteProduct)

src/controller/product.controller.ts


export const GetProducts = async (req: Request, res: Response) => {
    const products = await repository.find()

    res.send(products)
}


export const CreateProduct = async (req: Request, res: Response) => {
    const product = await repository.save(req.body)

    res.status(201).send(product)
}


export const GetProduct = async (req: Request, res: Response) => {
    res.send(await repository.findOne({
        where: { id: parseInt(req.params.id) }
    })
)}

export const UpdateProduct = async (req: Request, res: Response) => {
    await repository.update(parseInt(req.params.id), req.body);
    
    res.status(202).send(await repository.findOne({
        where: { id: parseInt(req.params.id) }
    }))
}


export const DeleteProduct = async (req: Request, res: Response) => {
    const deleteProduct = await repository.delete(req.params.id)
    
    res.status(204).send(deleteProduct)
}

Node.js 2023

Node.js 2023

Node.js 2023

Node.js 2023

Node.js 2023

Product Entity

src/entities/product.entity.ts

import { BaseEntity, Column, Entity, Unique, PrimaryGeneratedColumn } from "typeorm";

@Entity ()
@Unique('constraint_name', ['title'])
export class Product extends BaseEntity {
    @PrimaryGeneratedColumn()
    id: number;

    @Column({ unique: true })
    title: string;

    @Column()
    description: string;

    @Column()
    image: string;

    @Column()
    price: number;
}

Pre-Seeding Products

Use faker to generate items:

npm install @faker-js/faker

src/seeds/product.seed.ts

import { faker } from '@faker-js/faker'
import { Product } from '../entities/product.entity'
import { Manager } from '../db-connector'

export const productSeed = async () => {

    // create role permissions
    const productRepository = Manager.getRepository(Product)
    
    // generate 30 fake items
    for (let i = 0; i< 30; i++){
        // use upsert instead of save
        await productRepository.upsert(
            {
                title: faker.lorem.words(2),
                description: faker.lorem.words(10),
                image: faker.image.url({ width: 200, height: 200 }),
                price: parseInt(faker.finance.amount({ min: 500, max: 1000, dec: 2 }))
            },
            // if name exists only update else insert
            ['title']
        )
    }
}

And use the data source initialization to trigger the seed:

src/db-connector.ts

dataSource
    .initialize()
    .then( () => {
        roleSeed()
        productSeed()

        console.log('INFO :: Data Source has been initialized');
    })

Node.js 2023

Node.js 2023

Pagination

Edit the Product Controller to paginate on getProducts:

src/controller/product.controller.ts

export const GetProducts = async (req: Request, res: Response) => {
    // pagination
    // only retrieve 15 items per page
    const take = 15
    const page = parseInt(req.query.page as string || '1')
    // find 'take' number of items starting from zero or (page-1)*take
    const [data, total] = await repository.findAndCount({
        take: take,
        skip: ( page - 1 ) * take
    })

    res.send({
        data: data,
        // also return active page, last page and total number of items
        meta: {
            total,
            page,
            last_page: Math.ceil(total / take)
        }
    })
}

Node.js 2023

And the same edit for the user table:

src/controller/user.controller.ts

export const GetUsers = async (req: Request, res: Response) => {
    // pagination
    // only retrieve 15 items per page
    const take = 15
    const page = parseInt(req.query.page as string || '1')
    // find 'take' number of items starting from zero or (page-1)*take
    const [data, total] = await repository.findAndCount({
        take: take,
        skip: ( page - 1 ) * take,
        relations: ['role']
    })

    res.send({
        data: data.map(user => {
            const { password, ...data} = user
            return data
        }),
        // also return active page, last page and total number of items
        meta: {
            total,
            page,
            last_page: Math.ceil(total / take)
        }
    })
}

Node.js 2023

File Uploads

npm install multer @types/multer

Add multer middleware to upload route:

src/controller/role.controller.ts

// image upload
router.post('/api/upload', CheckAuthState, FileUpload)

Add a controller for the file upload:

src/controller/upload.controller.ts

import { Request, Response } from "express";
import multer from 'multer';
import { extname } from 'path';


export const FileUpload = async (req: Request, res: Response)  => {
    const storage = multer.diskStorage({
        destination: './uploads',
        filename(_, file, cb){
            const randomName = Math.random().toString(20).substring(2, 12)
            return cb(null, `${randomName}${extname(file.originalname)}`)
        }
    })

    const upload = multer({ storage }).single('image')

    upload(req, res, (err) => {
        
        if(err){
            return res.send(400).send(err)
        }

        res.send({
            url: `http://localhost:8080/api/uploads/${req.file.filename}`
        })
    })
}

Node.js 2023

Static Routes

To be able to access the uploaded files we need to add static routes:

src/controller/role.controller.ts

// image upload
router.post('/api/upload', CheckAuthState, FileUpload)
// make upload route public
router.use('/api/uploads', express.static('./uploads'))

Orders

Create tables for incoming order:

src/entities/order.entity.ts

import { BaseEntity, Column, Entity, Unique, PrimaryGeneratedColumn, CreateDateColumn, OneToMany } from "typeorm";
import { OrderItem } from "./order-item.entity";

@Entity ()
@Unique('constraint_name', ['email'])
export class Order extends BaseEntity {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    first_name: string;

    @Column()
    last_name: string;

    @Column()
    email: string;

    @CreateDateColumn()
    created_at: string;

    @OneToMany( () => OrderItem, OrderItem => OrderItem.order)
    order_items: OrderItem[];

}

src/entities/order-item.entity.ts

import { BaseEntity, Column, Entity, Unique, PrimaryGeneratedColumn, ManyToOne, JoinColumn } from "typeorm";
import { Order } from "./order.entity";

@Entity ()
@Unique('constraint_name', ['product_title'])
export class OrderItem extends BaseEntity {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    product_title: string;

    @Column()
    price: number;

    @Column()
    quantity: number;

    @ManyToOne( () => Order)
    @JoinColumn({ name: 'order_id' })
    order: Order
}

Pre-Seeding Orders

src/seeds/order.seed.ts

import { faker } from '@faker-js/faker';
import { Manager } from '../db-connector'
import { Order } from '../entities/order.entity';
import { randomInt } from 'crypto';
import { OrderItem } from '../entities/order-item.entity';

export const orderSeed = async () => {
    
    const orderRepository = Manager.getRepository(Order)
    const orderItemsRepository = Manager.getRepository(OrderItem)
    
    // generate 30 fake orders
    for (let i = 0; i< 30; i++){
        const order = await orderRepository.save(
            {
                first_name: faker.person.firstName(),
                last_name: faker.person.lastName(),
                email: faker.internet.email(),
                created_at: faker.date.recent({ days: 10, refDate: '2023-06-10T00:00:00.000Z' })
            }
        );
        console.log(order)
        // add number of items in order
        for(let j = 0; j < randomInt(1,5); j++) {
            await orderItemsRepository.save(
                {
                    product_title: faker.lorem.words(2),
                    price: parseInt(faker.finance.amount({ min: 500, max: 1000, dec: 2 })),
                    quantity: parseInt(faker.finance.amount({ min: 1, max: 5, dec: 0 })),
                    order: order
                }
            )
        }
    }
}

Node.js 2023

Node.js 2023

Order Controller

Add a route:

src/routes.ts

// get all orders
router.get('/api/orders', CheckAuthState, GetOrders)

src/controller/order.controller.ts

import { Request, Response } from "express";

import Manager from "../db-connector";
import { Order } from "../entities/order.entity";
const repository = Manager.getRepository(Order);


export const GetOrders = async (req: Request, res: Response) => {
    // pagination
    // only retrieve 15 items per page
    const take = 15
    const page = parseInt(req.query.page as string || '1')
    // find 'take' number of items starting from zero or (page-1)*take
    const [data, total] = await repository.findAndCount({
        take: take,
        skip: ( page - 1 ) * take,
        relations: ['order_items']
    })

    res.send({
        data: data.map((Order) => ({
            id: Order.id,
            name: Order.name,
            email: Order.email,
            total: Order.total,
            created_at: Order.created_at,
            order_items: Order.order_items
        })),
        // also return active page, last page and total number of items
        meta: {
            total,
            page,
            last_page: Math.ceil(total / take)
        }
    })
}

Node.js 2023

Exporting Order to CSV

npm install json2csv @types/json2csv @types/json2csv

Add a route:

src/routes.ts

// export orders
router.post('/api/orders/export/csv', CheckAuthState, ExportCsv)

src/controller/order.controller.ts

export const ExportCsv = async (req: Request, res: Response) => {
    const parser = new Parser({
        fields: ['ID', 'Name', 'Email', 'Product', 'Price', 'Quantity']
    })

    const orders = await repository.find({relations: ['order_items']})

    const json = []

    orders.forEach((order:Order) => {
        json.push({
            ID: order.id,
            Name: order.name,
            Email: order.email,
            Product: '',
            Price: '',
            Quantity: ''
        })

        order.order_items.forEach((item: OrderItem) => {
            json.push({
                ID: '',
                Name: '',
                Email: '',
                Product: item.product_title,
                Price: item.price,
                Quantity: item.quantity
            })
        })
    })

    const csv = parser.parse(json)

    res.header('Content-Type', 'text/csv')
    res.attachment('orders.csv')
    res.send(csv)
}

Node.js 2023

Charting Order Data

Add a route:

src/routes.ts

// order chart data
router.get('/api/orders/chart', CheckAuthState, ChartData)

src/controller/order.controller.ts

export const ChartData = async (req: Request, res: Response) => {
    const result = await Manager.query(`
        SELECT DATE_FORMAT(o.created_at, '%Y-%m-%d') as date, SUM(oi.price * oi.quantity) as sum
        FROM \`order\` o
            JOIN order_item oi
        on o.id = oi.order_id
        GROUP BY date
    `)

    res.send(result)
}

Node.js 2023

Route Permissions

Update Role Seed

I noticed that the upsert pre-seed created here -> Pre-Seeding Roles and Permissions did not create the role permissions. Which meant that the roles were being created but none of the roles had any permissions assigned to them. Unfortunately, I am not sure where the issue is. So I replace the upsert with a save - and now everything works as expected. With the caveat that you have to make sure that this function is only ever executed once!

To make sure I created a new file for this function:

src/seeds/role.preseed.ts

import { Request, Response } from 'express';
import { Permission } from '../entities/permission.entity'
import { Role } from '../entities/role.entity'
import { Manager } from '../db-connector'


export const roleSeed = async (req: Request, res: Response) => {

    // create role permissions
    const permissionRepository = Manager.getRepository(Permission)

    const perms = [
        'view_users',
        'edit_users',
        'view_roles',
        'edit_roles',
        'view_products',
        'edit_products',
        'view_orders',
        'edit_orders'
    ]

    let permissions = []
    
    // insert permissions into Permission table
    for (let i = 0; i< perms.length; i++){
        permissions.push(
            await permissionRepository.save(
                { name: perms[i] }
            )
        )
    }

    // assign permissions to roles
    const roleRepository = Manager.getRepository(Role)
    // admin gets all the permissions
    await roleRepository.save({
        name: 'Admin',
        permissions: permissions
    })

    // editor is not allowed to edit roles
    delete permissions[3];

    await roleRepository.save({
        name: 'Editor',
        permissions: permissions
    })

    // viewer cannot edit at all
    delete permissions[1];
    delete permissions[5];
    delete permissions[7];

    await roleRepository.save({
        name: 'Viewer',
        permissions: permissions
    })

    // // debug
    // console.log(permissions[0].identifiers)
    // console.log(permissions.map(id => {
    //     return {
    //         id: id.raw[0].id
    //     }}))
    
    res.status(201).send({
        message: 'default user roles created'
    })
}

And assigned it to a route so that it can be triggered manually once by visiting the route:

src/routes.ts

// pre-seed roles
router.put('/api/preseed/roles', roleSeed)

Node.js 2023

Now the insert works perfectly and all roles have their permissions attached... if you know why upsert don't work the same way (and how I could have fixed that) please let me know...

Node.js 2023

Permission Middleware

With the role permissions fixed now back to actually implementing the restrictions for every route. Add CheckPermissions function to every route and provide the class users, orders, products, etc. - e.g.:

router.get('/api/users', CheckAuthState, CheckPermissions('users'), GetUsers)

Create the CheckPermissions function to check if it is a GET route (which only requires view rights) and verify that the users has the corresponding permission. Else edit rights are required:

src/middleware/permission.middleware.ts

import { Request, Response } from 'express';
import { User } from '../entities/user.entity';

export const CheckPermissions = (access: string) => {
    return (req: Request, res: Response, next: Function) => {
        const user: User = req['user'];
        
        // debug
        // console.log(user[0].role.permissions)

        // get permissions array
        const permissions = user[0].role.permissions

        // loop though array of objects and get permClasses
        const permClasses = []

        for (let i = 0; i < permissions.length; i++) {
            permClasses.push(permissions[i].name)
          }

        // debug
        // console.log(permClasses)

        // if route is GET require `view_` or `edit_` perm else you need `edit_`
        if  (req.method === 'GET') {
            if(!permClasses.includes('view_' + access) || !permClasses.includes('edit_' + access)) {
                return res.status(401).send({
                    message: 'ERROR :: Unauthorized!'
                })
        } else {
            if(!permClasses.includes('edit_' + access)) {
                return res.status(401).send({
                    message: 'ERROR :: Unauthorized!'
                })
            }
        }}

        next()
    }
}

Create a user that has the rights to view a given route and one that has not - verify that the latter is blocked from access:

Node.js 2023