├── .dockerignore ├── .prettierrc ├── tsconfig.eslint.json ├── src ├── types │ ├── global.d.ts │ ├── express │ │ └── index.d.ts │ └── apiServiceResponse.d.ts ├── configs │ ├── tokens.ts │ ├── constant.ts │ ├── redisClient.ts │ ├── rootSocket.ts │ ├── database.js │ ├── logger.ts │ ├── config.ts │ └── passport.ts ├── dao │ ├── contracts │ │ ├── ITokenDao.ts │ │ ├── IUserDao.ts │ │ └── ISuperDao.ts │ └── implementations │ │ ├── TokenDao.ts │ │ ├── UserDao.ts │ │ └── SuperDao.ts ├── cronJobs.ts ├── models │ ├── interfaces │ │ ├── IToken.ts │ │ └── IUser.ts │ ├── Token.ts │ ├── User.ts │ └── index.ts ├── services │ ├── contracts │ │ ├── IAuthService.ts │ │ ├── IUserService.ts │ │ ├── IRedisService.ts │ │ └── ITokenService.ts │ └── implementations │ │ ├── RedisService.ts │ │ ├── AuthService.ts │ │ ├── AuthService.spec.ts │ │ ├── TokenService.ts │ │ └── UserService.ts ├── routes │ ├── index.ts │ └── authRoute.ts ├── helpers │ ├── utilityHelper.ts │ ├── ApiError.ts │ ├── RedisHelper.ts │ ├── responseHandler.ts │ ├── dataHelper.ts │ └── timeHelper.ts ├── index.ts ├── db │ ├── seeders │ │ └── 20220104131055-create-user-seed.cjs │ └── migrations │ │ ├── 20211014113830-create_token_table.cjs │ │ └── 20210915095343-create-user.cjs ├── middlewares │ ├── error.ts │ └── auth.ts ├── app.ts ├── controllers │ └── AuthController.ts └── validators │ └── UserValidator.ts ├── .sequelizerc ├── Dockerfile ├── .gitignore ├── .env.example ├── .swcrc ├── docker-compose.yml ├── tsconfig.json ├── .eslintrc ├── package.json └── README.md /.dockerignore: -------------------------------------------------------------------------------- 1 | .dockerignore 2 | node_modules 3 | npm-debug.log 4 | Dockerfile 5 | .git 6 | .gitignore 7 | .npmrc 8 | README.md 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "singleQuote": true, 4 | "printWidth": 100, 5 | "tabWidth": 4, 6 | "semi": true 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*.ts", "src/**/*.unit.test.ts", "swc.config.ts", "__tests__/**/*.int.test.ts"], 4 | } 5 | -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable vars-on-top */ 2 | /* eslint-disable no-var */ 3 | import * as io from 'socket.io'; 4 | 5 | export declare global { 6 | var io: io.Server; 7 | } 8 | -------------------------------------------------------------------------------- /src/configs/tokens.ts: -------------------------------------------------------------------------------- 1 | const tokenTypes = { 2 | ACCESS: 'access', 3 | REFRESH: 'refresh', 4 | RESET_PASSWORD: 'resetPassword', 5 | VERIFY_EMAIL: 'verifyEmail', 6 | }; 7 | 8 | export { tokenTypes }; 9 | -------------------------------------------------------------------------------- /src/configs/constant.ts: -------------------------------------------------------------------------------- 1 | const userConstant = { 2 | EMAIL_VERIFIED_TRUE: 1, 3 | EMAIL_VERIFIED_FALSE: 0, 4 | STATUS_ACTIVE: 1, 5 | STATUS_INACTIVE: 0, 6 | STATUS_REMOVED: 2, 7 | }; 8 | 9 | export { userConstant }; 10 | -------------------------------------------------------------------------------- /src/types/express/index.d.ts: -------------------------------------------------------------------------------- 1 | import { IUser } from '../../models/interfaces/IUser'; 2 | 3 | declare global { 4 | namespace Express { 5 | interface Request { 6 | userInfo?: IUser; 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/dao/contracts/ITokenDao.ts: -------------------------------------------------------------------------------- 1 | import { IToken } from '../../models/interfaces/IToken'; 2 | 3 | export default interface ITokenDao { 4 | findOne: (where: object) => Promise; 5 | remove: (where: object) => Promise; 6 | } 7 | -------------------------------------------------------------------------------- /src/cronJobs.ts: -------------------------------------------------------------------------------- 1 | import * as cron from 'node-cron'; 2 | // schedule tasks to be run on the server 3 | export const scheduleCronJobs = () => { 4 | cron.schedule('* * * * *', () => { 5 | console.log('Cron job in every min'); 6 | }); 7 | }; 8 | -------------------------------------------------------------------------------- /src/models/interfaces/IToken.ts: -------------------------------------------------------------------------------- 1 | export interface IToken { 2 | id?: number; 3 | token: string; 4 | user_uuid: string; 5 | type: string; 6 | expires: Date; 7 | blacklisted: boolean; 8 | created_at?: Date; 9 | updated_at?: Date; 10 | } 11 | -------------------------------------------------------------------------------- /.sequelizerc: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | 'config': path.resolve('src/configs', 'database.js'), 5 | 'models-path': path.resolve('src', 'models'), 6 | 'seeders-path': path.resolve('src/db', 'seeders'), 7 | 'migrations-path': path.resolve('src/db', 'migrations') 8 | }; 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-slim 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm install 8 | RUN npm ci --only=production 9 | 10 | COPY . . 11 | 12 | RUN npm run build 13 | 14 | EXPOSE 5000 15 | 16 | # Use tsconfig-paths/register when starting the app 17 | CMD ["node", "build/index.js"] 18 | -------------------------------------------------------------------------------- /src/dao/contracts/IUserDao.ts: -------------------------------------------------------------------------------- 1 | import { IUser } from '../../models/interfaces/IUser'; 2 | 3 | export default interface IUserDao { 4 | findByEmail: (email: string) => Promise; 5 | isEmailExists: (email: string) => Promise; 6 | createWithTransaction: (user: object, transaction: object) => Promise; 7 | } 8 | -------------------------------------------------------------------------------- /src/services/contracts/IAuthService.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { ApiServiceResponse } from '../../types/apiServiceResponse'; 3 | 4 | export default interface IAuthService { 5 | loginWithEmailPassword: (email: string, password: string) => Promise; 6 | logout: (req: Request, res: Response) => Promise; 7 | } 8 | -------------------------------------------------------------------------------- /src/models/interfaces/IUser.ts: -------------------------------------------------------------------------------- 1 | export interface IUser { 2 | id?: number; 3 | uuid: string; 4 | first_name: string; 5 | last_name: string; 6 | email: string; 7 | password?: string; 8 | email_verified?: number; 9 | status?: number; 10 | address?: string; 11 | phone_number?: string; 12 | created_at?: Date; 13 | updated_at?: Date; 14 | } 15 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import authRoute from './authRoute.js'; 3 | 4 | const router = Router(); 5 | 6 | const defaultRoutes = [ 7 | { 8 | path: '/auth', 9 | route: authRoute, 10 | }, 11 | ]; 12 | 13 | defaultRoutes.forEach((route) => { 14 | router.use(route.path, route.route); 15 | }); 16 | 17 | export default router; 18 | -------------------------------------------------------------------------------- /src/helpers/utilityHelper.ts: -------------------------------------------------------------------------------- 1 | export const encodeToBase64 = (string) => Buffer.from(string).toString('base64'); 2 | 3 | export const decodeToAscii = (encodedString) => 4 | Buffer.from(encodedString, 'base64').toString('ascii'); 5 | 6 | export const sleep = (ms: number) => 7 | new Promise((resolve) => { 8 | console.log('Sleeping'); 9 | // eslint-disable-next-line no-promise-executor-return 10 | setTimeout(resolve, ms); 11 | }); 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # dependencies 3 | /node_modules 4 | 5 | #logs 6 | /logs 7 | 8 | #public resources 9 | /public 10 | /tmp 11 | ~/ 12 | # testing 13 | /coverage 14 | 15 | 16 | # misc 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | .env 23 | package-lock.json 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | yarn.lock 28 | 29 | .idea 30 | 31 | #docker-env 32 | docker-env/aflocal-local.yml 33 | 34 | #build 35 | /build 36 | -------------------------------------------------------------------------------- /src/configs/redisClient.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-import-module-exports */ 2 | import * as Redis from 'redis'; 3 | import { config } from '@configs/config.js'; 4 | 5 | const url = `redis://${config.redis.host}:${config.redis.port}`; 6 | const redisClient: Redis.RedisClientType = Redis.createClient({ url }); 7 | if (config.redis.usePassword.toUpperCase() === 'YES') { 8 | redisClient.auth(config.redis.password); 9 | } 10 | 11 | console.log('Redis Client loaded!!!'); 12 | export default redisClient; 13 | -------------------------------------------------------------------------------- /src/helpers/ApiError.ts: -------------------------------------------------------------------------------- 1 | export default class ApiError extends Error { 2 | statusCode: number; 3 | 4 | isOperational: boolean; 5 | 6 | constructor(statusCode: number, message: string | undefined, isOperational = true, stack = '') { 7 | super(message); 8 | this.statusCode = statusCode; 9 | this.isOperational = isOperational; 10 | 11 | if (stack !== '') { 12 | this.stack = stack; 13 | } else Error.captureStackTrace(this, this.constructor); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/services/contracts/IUserService.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import { ApiServiceResponse } from '../../types/apiServiceResponse'; 3 | import { IUser } from '../../models/interfaces/IUser'; 4 | 5 | export default interface IUserService { 6 | createUser: (userBody: IUser) => Promise; 7 | isEmailExists: (email: string) => Promise; 8 | getUserByUuid: (uuid: string) => Promise; 9 | changePassword: (req: Request) => Promise; 10 | } 11 | -------------------------------------------------------------------------------- /src/types/apiServiceResponse.d.ts: -------------------------------------------------------------------------------- 1 | export declare type ApiServiceResponse = { 2 | statusCode: number; 3 | response: { 4 | status: boolean; 5 | code: number; 6 | message: string; 7 | data?: [] | object; 8 | }; 9 | }; 10 | 11 | export declare type DataTableResponse = { 12 | totalItems: number; 13 | data: Partial; 14 | totalPages: number; 15 | currentPage: number; 16 | }; 17 | 18 | export declare type DataTableDaoResponse = { count: number; rows: Partial }; 19 | -------------------------------------------------------------------------------- /src/configs/rootSocket.ts: -------------------------------------------------------------------------------- 1 | import socketIo from 'socket.io'; 2 | 3 | export const rootSocket = (io: socketIo.Server) => { 4 | io.on('connection', (socket) => { 5 | console.log('New connection'); 6 | socket.on('join-new-room', (room) => { 7 | console.log('join new room', room); 8 | socket.join(room); 9 | }); 10 | 11 | socket.on('disconnect', () => { 12 | console.log('disconnected'); 13 | console.log(socket.rooms.size); 14 | }); 15 | }); 16 | return io; 17 | }; 18 | -------------------------------------------------------------------------------- /src/dao/implementations/TokenDao.ts: -------------------------------------------------------------------------------- 1 | import models from '@models/index.js'; 2 | import ITokenDao from '@dao/contracts/ITokenDao.js'; 3 | import SuperDao from './SuperDao.js'; 4 | 5 | const Token = models.token; 6 | 7 | export default class TokenDao extends SuperDao implements ITokenDao { 8 | constructor() { 9 | super(Token); 10 | } 11 | 12 | async findOne(where: object) { 13 | return Token.findOne({ where }); 14 | } 15 | 16 | async remove(where: object) { 17 | return Token.destroy({ where }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/services/contracts/IRedisService.ts: -------------------------------------------------------------------------------- 1 | import { IUser } from '../../models/interfaces/IUser'; 2 | 3 | export default interface IRedisService { 4 | createTokens: ( 5 | uuid: string, 6 | tokens: { access: { token: string }; refresh: { token: string } } 7 | ) => Promise; 8 | hasToken: (token: string, type: string) => Promise; 9 | removeToken: (token: string, type: string) => Promise; 10 | getUser: (uuid: string) => Promise; 11 | setUser: (user: IUser) => Promise; 12 | } 13 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | #Server environment 2 | NODE_ENV=local 3 | #Port number 4 | PORT=5000 5 | 6 | #Mysql Db configuration 7 | DB_HOST=db 8 | DB_USER=root 9 | DB_PASS=root123 10 | DB_NAME=typescript 11 | 12 | #JWT secret key 13 | JWT_SECRET=your-jwt-secret-key 14 | #Number of minutes after which an access token expires 15 | JWT_ACCESS_EXPIRATION_MINUTES=5 16 | #Number of days after which a refresh token expires 17 | JWT_REFRESH_EXPIRATION_DAYS=30 18 | 19 | #Log config 20 | LOG_FOLDER=logs/ 21 | LOG_FILE=%DATE%-app-log.log 22 | LOG_LEVEL=error 23 | 24 | #Redis 25 | REDIS_HOST=redis 26 | REDIS_PORT=6379 27 | REDIS_USE_PASSWORD=no 28 | REDIS_PASSWORD=your-password 29 | 30 | -------------------------------------------------------------------------------- /src/configs/database.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | export default { 4 | logging: (message) => { 5 | if (message.startsWith('Executing (default):')) { 6 | // ignore regular query logs 7 | return; 8 | } 9 | // log anything else (e.g. errors) 10 | console.error(message); 11 | }, 12 | username: process.env.DB_USER, 13 | password: process.env.DB_PASS, 14 | database: process.env.DB_NAME, 15 | host: process.env.DB_HOST, 16 | dialect: 'mysql', 17 | dialectOptions: { 18 | bigNumberStrings: true, 19 | }, 20 | pool: { 21 | max: 50, 22 | min: 0, 23 | acquire: 30000, 24 | idle: 10000, 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /src/services/contracts/ITokenService.ts: -------------------------------------------------------------------------------- 1 | import { IToken } from '../../models/interfaces/IToken'; 2 | import { IUser } from '../../models/interfaces/IUser'; 3 | 4 | export default interface ITokenService { 5 | generateToken: (uuid: string, expires: Date, type: string, secret: string) => string; 6 | verifyToken: (token: string, type: string) => Promise; 7 | saveToken: ( 8 | token: string, 9 | userId: number, 10 | expires: Date, 11 | type: string, 12 | blacklisted: boolean 13 | ) => Promise; 14 | saveMultipleTokens: (tokens: object[]) => Promise; 15 | removeTokenById: (id: number) => Promise; 16 | generateAuthTokens: (user: IUser) => Promise; 17 | } 18 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | import { Server } from 'socket.io'; 3 | import { rootSocket } from '@configs/rootSocket.js'; 4 | import { config } from '@configs/config.js'; 5 | import { app } from './app.js'; 6 | import { scheduleCronJobs } from './cronJobs.js'; 7 | 8 | scheduleCronJobs(); 9 | 10 | console.log('Hello Typescript Express API!!'); 11 | // socket initialization 12 | const server = http.createServer(app); 13 | const io = new Server(server, { 14 | cors: { origin: '*' }, 15 | path: '/api/v1/socket.io', 16 | }); 17 | globalThis.io = io; 18 | // eslint-disable-next-line @typescript-eslint/no-var-requires 19 | rootSocket(io); 20 | 21 | server.listen(config.port, () => { 22 | console.log(`Listening to port ${config.port}`); 23 | }); 24 | -------------------------------------------------------------------------------- /src/models/Token.ts: -------------------------------------------------------------------------------- 1 | import { Model } from 'sequelize'; 2 | 3 | export default (sequelize, DataTypes) => { 4 | class Token extends Model { 5 | /** 6 | * Helper method for defining associations. 7 | * This method is not a part of Sequelize lifecycle. 8 | * The `models/index` file will call this method automatically. 9 | */ 10 | } 11 | 12 | Token.init( 13 | { 14 | token: DataTypes.STRING, 15 | user_uuid: DataTypes.UUID, 16 | type: DataTypes.STRING, 17 | expires: DataTypes.DATE, 18 | blacklisted: DataTypes.BOOLEAN, 19 | }, 20 | { 21 | sequelize, 22 | modelName: 'token', 23 | underscored: true, 24 | } 25 | ); 26 | return Token; 27 | }; 28 | -------------------------------------------------------------------------------- /src/db/seeders/20220104131055-create-user-seed.cjs: -------------------------------------------------------------------------------- 1 | const bcrypt = require('bcrypt'); 2 | 3 | module.exports = { 4 | up: async (queryInterface, Sequelize) => { 5 | return queryInterface.bulkInsert('users', [ 6 | { 7 | uuid: 'c7ba68db-c39d-478b-8df9-46be3de7c366', 8 | first_name: 'John', 9 | last_name: 'Doe', 10 | email: 'user@example.com', 11 | status: 1, 12 | email_verified: 1, 13 | password: bcrypt.hashSync('123456', 8), 14 | created_at: new Date(), 15 | updated_at: new Date(), 16 | }, 17 | ]); 18 | }, 19 | 20 | down: async (queryInterface, Sequelize) => { 21 | return queryInterface.bulkDelete('users', null, {}); 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsc": { 3 | "parser": { 4 | "syntax": "typescript", 5 | "dynamicImport": false, 6 | "decorators": false 7 | }, 8 | "target": "es2015", 9 | "loose": false, 10 | "externalHelpers": false, 11 | "baseUrl": "./", 12 | "paths": { 13 | "@configs/*": ["src/configs/*"], 14 | "@helpers/*": ["src/helpers/*"], 15 | "@controllers/*": ["src/controllers/*"], 16 | "@routes/*": ["src/routes/*"], 17 | "@utils/*": ["src/utils/*"], 18 | "@middlewares/*": ["src/middlewares/*"], 19 | "@services/*": ["src/services/*"], 20 | "@dao/*": ["src/dao/*"], 21 | "@models/*": ["src/models/*"], 22 | "@validators/*": ["src/validators/*"], 23 | "@types/*": ["src/types/*"] 24 | } 25 | }, 26 | "minify": false, 27 | "module": { 28 | "type": "es6" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/dao/implementations/UserDao.ts: -------------------------------------------------------------------------------- 1 | import IUserDao from '@dao/contracts/IUserDao.js'; 2 | import models from '@models/index.js'; 3 | import SuperDao from './SuperDao.js'; 4 | 5 | const User = models.user; 6 | 7 | export default class UserDao extends SuperDao implements IUserDao { 8 | constructor() { 9 | super(User); 10 | } 11 | 12 | async findByEmail(email: string) { 13 | return User.findOne({ where: { email } }); 14 | } 15 | 16 | async isEmailExists(email: string) { 17 | return User.count({ where: { email } }).then((count) => { 18 | if (count != 0) { 19 | return true; 20 | } 21 | return false; 22 | }); 23 | } 24 | 25 | async createWithTransaction(user: object, transaction: object) { 26 | return User.create(user, { transaction }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2.0" 2 | 3 | services: 4 | 5 | # typescript application configuration 6 | 7 | typescript-backend: 8 | image: node:18-alpine 9 | container_name: typescript-backend 10 | restart: unless-stopped 11 | ports: 12 | - 5001:5001 13 | volumes: 14 | - UPDATE_PATH:/app 15 | working_dir: /app 16 | command: sh -c "yarn install && yarn start:dev" 17 | 18 | db: 19 | image: mysql:5.7 20 | container_name: db 21 | volumes: 22 | - UPDATE_PATH:/var/lib/mysql 23 | environment: 24 | - MYSQL_ROOT_PASSWORD=your-password 25 | restart: unless-stopped 26 | 27 | adminer: 28 | image: adminer 29 | restart: always 30 | ports: 31 | - 82:8080 32 | 33 | redis: 34 | image: redis:6.2 35 | container_name: redis 36 | restart: unless-stopped 37 | volumes: 38 | - UPDATE_PATH:/data 39 | -------------------------------------------------------------------------------- /src/routes/authRoute.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import AuthController from '@controllers/AuthController.js'; 3 | import { auth } from '@middlewares/auth.js'; 4 | import UserValidator from '@validators/UserValidator.js'; 5 | 6 | const router = Router(); 7 | 8 | const authController = new AuthController(); 9 | const userValidator = new UserValidator(); 10 | 11 | router.post('/register', userValidator.userCreateValidator, authController.register); 12 | router.post('/email-exists', userValidator.checkEmailValidator, authController.checkEmail); 13 | router.post('/login', userValidator.userLoginValidator, authController.login); 14 | router.post('/refresh-token', authController.refreshTokens); 15 | router.post('/logout', authController.logout); 16 | router.put( 17 | '/change-password', 18 | auth(), 19 | userValidator.changePasswordValidator, 20 | authController.changePassword 21 | ); 22 | 23 | export default router; 24 | -------------------------------------------------------------------------------- /src/models/User.ts: -------------------------------------------------------------------------------- 1 | import { Model } from 'sequelize'; 2 | 3 | export default (sequelize, DataTypes) => { 4 | class User extends Model { 5 | /** 6 | * Helper method for defining associations. 7 | * This method is not a part of Sequelize lifecycle. 8 | * The `models/index` file will call this method automatically. 9 | */ 10 | // static associate(models) { 11 | // // define association here 12 | // } 13 | } 14 | 15 | User.init( 16 | { 17 | uuid: DataTypes.UUID, 18 | first_name: DataTypes.STRING, 19 | last_name: DataTypes.STRING, 20 | email: DataTypes.STRING, 21 | password: DataTypes.STRING, 22 | status: DataTypes.INTEGER, 23 | email_verified: DataTypes.INTEGER, 24 | address: DataTypes.STRING, 25 | phone_number: DataTypes.STRING, 26 | }, 27 | { 28 | sequelize, 29 | modelName: 'user', 30 | underscored: true, 31 | } 32 | ); 33 | return User; 34 | }; 35 | -------------------------------------------------------------------------------- /src/dao/contracts/ISuperDao.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | export default interface ISuperDao { 3 | findAll: () => Promise; 4 | findById: (id: number) => Promise; 5 | findOneByWhere: (where: object, attributes: string[] | null, order: string[]) => Promise; 6 | updateWhere: (data: object, where: object) => Promise; 7 | updateById: (data: object, id: number) => Promise; 8 | create: (data: object) => Promise; 9 | findByWhere: ( 10 | where: object, 11 | attributes: string[] | undefined, 12 | order: string[], 13 | limit: number | null, 14 | offset: number | null 15 | ) => Promise; 16 | deleteByWhere: (where: object) => Promise; 17 | bulkCreate: (data: object[]) => Promise; 18 | getCountByWhere: (where: object) => Promise; 19 | incrementCountInFieldByWhere: ( 20 | fieldName: string, 21 | where: object, 22 | incrementValue: number 23 | ) => Promise; 24 | decrementCountInFieldByWhere: ( 25 | fieldName: string, 26 | where: object, 27 | decrementValue: number 28 | ) => Promise; 29 | } 30 | -------------------------------------------------------------------------------- /src/configs/logger.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | import DailyRotateFile from 'winston-daily-rotate-file'; 3 | import { config } from '@configs/config.js'; 4 | 5 | const enumerateErrorFormat = winston.format((info: any) => { 6 | if (info.message instanceof Error) { 7 | // eslint-disable-next-line no-param-reassign 8 | info.message = { 9 | message: info.message.message, 10 | stack: info.message.stack, 11 | ...info.message, 12 | }; 13 | } 14 | 15 | if (info instanceof Error) { 16 | return { 17 | // message: info.message, 18 | stack: info.stack, 19 | ...info, 20 | }; 21 | } 22 | 23 | return info; 24 | }); 25 | const transport = new DailyRotateFile({ 26 | filename: config.logConfig.logFolder + config.logConfig.logFile, 27 | datePattern: 'YYYY-MM-DD', 28 | zippedArchive: true, 29 | maxSize: '20m', 30 | maxFiles: '3', 31 | // prepend: true, 32 | }); 33 | 34 | export const logger = winston.createLogger({ 35 | format: winston.format.combine(enumerateErrorFormat(), winston.format.json()), 36 | transports: [ 37 | transport, 38 | new winston.transports.Console({ 39 | level: 'info', 40 | }), 41 | ], 42 | }); 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "./", 4 | "outDir": "build", 5 | "module": "ESNext", 6 | "esModuleInterop": true, 7 | "target": "es2017", 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "allowJs": true, 11 | "sourceMap": true, 12 | "noImplicitAny": false, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "skipLibCheck": true, 16 | "typeRoots": ["src/types", "node_modules/@types/"], 17 | "baseUrl": "./", 18 | "paths": { 19 | "@/*": ["*"], 20 | "@configs/*": ["src/configs/*"], 21 | "@helpers/*": ["src/helpers/*"], 22 | "@controllers/*": ["src/controllers/*"], 23 | "@routes/*": ["src/routes/*"], 24 | "@utils/*": ["src/utils/*"], 25 | "@middlewares/*": ["src/middlewares/*"], 26 | "@services/*": ["src/services/*"], 27 | "@dao/*": ["src/dao/*"], 28 | "@models/*": ["src/models/*"], 29 | "@validators/*": ["src/validators/*"], 30 | "@types/*": ["src/types/*"] 31 | } 32 | }, 33 | "lib": ["es2015"], 34 | "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.cjs", "swc.config.ts"], 35 | "exclude": ["node_modules", "build", "__tests__", "coverage"], 36 | } 37 | -------------------------------------------------------------------------------- /src/db/migrations/20211014113830-create_token_table.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.createTable('tokens', { 4 | id: { 5 | allowNull: false, 6 | autoIncrement: true, 7 | primaryKey: true, 8 | type: Sequelize.INTEGER, 9 | }, 10 | token: { 11 | type: Sequelize.STRING, 12 | }, 13 | user_uuid: { 14 | allowNull: false, 15 | type: Sequelize.DataTypes.UUID, 16 | }, 17 | type: { 18 | type: Sequelize.STRING, 19 | }, 20 | blacklisted: { 21 | type: Sequelize.BOOLEAN, 22 | }, 23 | expires: { 24 | allowNull: false, 25 | type: Sequelize.DATE, 26 | }, 27 | created_at: { 28 | allowNull: false, 29 | type: Sequelize.DATE, 30 | }, 31 | updated_at: { 32 | allowNull: false, 33 | type: Sequelize.DATE, 34 | }, 35 | }); 36 | }, 37 | 38 | down: async (queryInterface, Sequelize) => { 39 | await queryInterface.dropTable('tokens'); 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /src/helpers/RedisHelper.ts: -------------------------------------------------------------------------------- 1 | import { RedisClientType } from '@redis/client'; 2 | 3 | export default class RedisHelper { 4 | redisClient: RedisClientType; 5 | 6 | constructor(redisClient) { 7 | this.redisClient = redisClient; 8 | } 9 | 10 | set = async (key: string, valueParam: string | object) => { 11 | try { 12 | let value: string | object = valueParam; 13 | if (typeof value === 'object') value = JSON.stringify(value); 14 | return await this.redisClient.set(key, value); 15 | } catch (e) { 16 | return false; 17 | } 18 | }; 19 | 20 | setEx = async (key: string, seconds: number, valueParam: string | object) => { 21 | try { 22 | let value: string | object = valueParam; 23 | if (typeof value === 'object') value = JSON.stringify(value); 24 | return await this.redisClient.setEx(key, seconds, value); 25 | } catch (e) { 26 | return false; 27 | } 28 | }; 29 | 30 | get = async (key: string) => { 31 | try { 32 | return await this.redisClient.get(key); 33 | } catch (e) { 34 | return null; 35 | } 36 | }; 37 | 38 | del = async (key: string) => { 39 | try { 40 | return await this.redisClient.del(key); 41 | } catch (e) { 42 | return false; 43 | } 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-syntax */ 2 | /* eslint-disable no-await-in-loop */ 3 | /* eslint-disable @typescript-eslint/no-explicit-any */ 4 | import fs from 'fs'; 5 | import path from 'path'; 6 | import { fileURLToPath } from 'url'; 7 | import { Sequelize, DataTypes } from 'sequelize'; 8 | import dbConfig from '@configs/database.js'; 9 | 10 | const filename = fileURLToPath(import.meta.url); 11 | const basename = path.basename(filename); 12 | const dirname = path.dirname(filename); 13 | const db: any = {}; 14 | 15 | let sequelize; 16 | if (dbConfig.database && dbConfig.username) { 17 | sequelize = new Sequelize(dbConfig.database, dbConfig.username, dbConfig.password, dbConfig); 18 | } 19 | 20 | const files = fs 21 | .readdirSync(dirname) 22 | .filter((file) => file.indexOf('.') !== 0 && file !== basename && file.slice(-3) === '.js'); 23 | 24 | for (const file of files) { 25 | const modelPath = path.join(dirname, file); 26 | // Using dynamic import with await 27 | const modelModule = await import(modelPath); 28 | const model = modelModule.default(sequelize, DataTypes); 29 | 30 | db[model.name] = model; 31 | } 32 | 33 | // // // After all models are imported, run the associations (if defined) 34 | Object.keys(db).forEach((modelName) => { 35 | if (db[modelName].associate) { 36 | db[modelName].associate(db); 37 | } 38 | }); 39 | 40 | db.sequelize = sequelize; 41 | db.Sequelize = Sequelize; 42 | 43 | export default db; 44 | -------------------------------------------------------------------------------- /src/middlewares/error.ts: -------------------------------------------------------------------------------- 1 | import httpStatus from 'http-status'; 2 | import { NextFunction, Request, Response } from 'express'; 3 | import { config } from '@configs/config.js'; 4 | import { logger } from '@configs/logger.js'; 5 | import ApiError from '@helpers/ApiError.js'; 6 | 7 | // eslint-disable-next-line no-unused-vars 8 | export const errorHandler = (err: any, req: Request, res: Response) => { 9 | let { statusCode, message } = err; 10 | console.log('errorHandler', err); 11 | 12 | if (config.nodeEnv === 'production' && !err.isOperational) { 13 | statusCode = httpStatus.INTERNAL_SERVER_ERROR; 14 | message = httpStatus[httpStatus.INTERNAL_SERVER_ERROR]; 15 | } 16 | 17 | res.locals.errorMessage = err.message; 18 | 19 | const response = { 20 | code: statusCode, 21 | message, 22 | ...(config.nodeEnv === 'development' && { stack: err.stack }), 23 | }; 24 | 25 | if (config.nodeEnv === 'development') { 26 | logger.error(err); 27 | } 28 | 29 | res.status(statusCode).send(response); 30 | }; 31 | 32 | export const errorConverter = (err: any, req: Request, res: Response, next: NextFunction) => { 33 | let error = err; 34 | if (!(error instanceof ApiError)) { 35 | const statusCode = error.statusCode 36 | ? httpStatus.BAD_REQUEST 37 | : httpStatus.INTERNAL_SERVER_ERROR; 38 | const message = error.message || httpStatus[statusCode]; 39 | error = new ApiError(statusCode, message, false, err.stack); 40 | } 41 | errorHandler(error, req, res); 42 | next(error); 43 | }; 44 | -------------------------------------------------------------------------------- /src/helpers/responseHandler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApiServiceResponse, 3 | DataTableDaoResponse, 4 | DataTableResponse, 5 | } from '../types/apiServiceResponse'; 6 | 7 | const logError = (err) => { 8 | console.error(err); 9 | }; 10 | 11 | const logErrorMiddleware = (err, req, res, next) => { 12 | logError(err); 13 | next(err); 14 | }; 15 | 16 | const returnError = (statusCode: number, message: string) => { 17 | const response: ApiServiceResponse = { 18 | statusCode, 19 | response: { 20 | status: false, 21 | code: statusCode, 22 | message, 23 | }, 24 | }; 25 | return response; 26 | }; 27 | const returnSuccess = (statusCode: number, message: string, data?: [] | object) => { 28 | const response: ApiServiceResponse = { 29 | statusCode, 30 | response: { 31 | status: true, 32 | code: statusCode, 33 | message, 34 | data, 35 | }, 36 | }; 37 | return response; 38 | }; 39 | 40 | const getPaginationData = (rows: DataTableDaoResponse, page: number, limit: number) => { 41 | const { count: totalItems, rows: data } = rows; 42 | const currentPage = page ? +page : 0; 43 | const totalPages = Math.ceil(totalItems / limit); 44 | 45 | const response: DataTableResponse = { 46 | totalItems, 47 | data, 48 | totalPages, 49 | currentPage, 50 | }; 51 | return response; 52 | }; 53 | 54 | export default { 55 | logError, 56 | logErrorMiddleware, 57 | returnError, 58 | returnSuccess, 59 | getPaginationData, 60 | }; 61 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": false, 4 | "node": true 5 | }, 6 | "extends": [ 7 | "airbnb-base", 8 | "airbnb-typescript/base", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:promise/recommended", 11 | "prettier" 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "project": "./tsconfig.eslint.json" 16 | }, 17 | "plugins": ["@typescript-eslint", "promise", "import", "prettier"], 18 | "rules": { 19 | "prettier/prettier": "error", 20 | "import/prefer-default-export": "off", 21 | "import/no-default-export": "off", 22 | "import/no-dynamic-require": "off", 23 | "class-methods-use-this": "off", 24 | "no-use-before-define": [ 25 | "error", 26 | { 27 | "functions": false, 28 | "classes": true, 29 | "variables": true 30 | } 31 | ], 32 | "@typescript-eslint/explicit-function-return-type": "off", 33 | "@typescript-eslint/no-use-before-define": [ 34 | "error", 35 | { 36 | "functions": false, 37 | "classes": true, 38 | "variables": true, 39 | "typedefs": true 40 | } 41 | ], 42 | "import/no-extraneous-dependencies": "off" 43 | }, 44 | "settings": { 45 | "import/resolver": { 46 | "typescript": { 47 | "alwaysTryTypes": true, 48 | "project": "./tsconfig.json" 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/middlewares/auth.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import passport from 'passport'; 3 | import httpStatus from 'http-status'; 4 | import { NextFunction, Request, Response } from 'express'; 5 | import ApiError from '@helpers/ApiError.js'; 6 | import { IUser } from '@models/interfaces/IUser.js'; 7 | import { jwtVerifyManually } from '@configs/passport.js'; 8 | 9 | const verifyCallback = 10 | (req: Request, res: Response, resolve: any, reject: any) => 11 | // eslint-disable-next-line consistent-return 12 | async (err: any, user: IUser, info: any) => { 13 | if (err || info || !user) { 14 | return reject(new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')); 15 | } 16 | console.log('user'); 17 | 18 | req.userInfo = user; 19 | 20 | resolve(); 21 | }; 22 | 23 | export const auth = () => async (req: Request, res: Response, next: NextFunction) => { 24 | new Promise((resolve, reject) => { 25 | passport.authenticate('jwt', { session: false }, verifyCallback(req, res, resolve, reject))( 26 | req, 27 | res, 28 | next 29 | ); 30 | }) 31 | .then(() => next()) 32 | .catch((err) => { 33 | next(err); 34 | }); 35 | }; 36 | 37 | export const authByManuallVerify = 38 | () => async (req: Request, res: Response, next: NextFunction) => { 39 | new Promise((resolve, reject) => { 40 | jwtVerifyManually(req, verifyCallback(req, res, resolve, reject)); 41 | }) 42 | .then(() => next()) 43 | .catch((err) => { 44 | next(err); 45 | }); 46 | }; 47 | -------------------------------------------------------------------------------- /src/db/migrations/20210915095343-create-user.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.createTable('users', { 4 | id: { 5 | allowNull: false, 6 | autoIncrement: true, 7 | primaryKey: true, 8 | type: Sequelize.INTEGER, 9 | }, 10 | uuid: { 11 | allowNull: false, 12 | type: Sequelize.UUID, 13 | defaultValue: Sequelize.UUIDV1, 14 | primaryKey: true, 15 | }, 16 | first_name: { 17 | type: Sequelize.STRING, 18 | }, 19 | last_name: { 20 | type: Sequelize.STRING, 21 | }, 22 | email: { 23 | type: Sequelize.STRING, 24 | }, 25 | password: { 26 | type: Sequelize.STRING, 27 | allowNull: true, 28 | }, 29 | status: { 30 | type: Sequelize.INTEGER, 31 | }, 32 | email_verified: { 33 | type: Sequelize.INTEGER, 34 | }, 35 | address: { 36 | type: Sequelize.STRING, 37 | }, 38 | phone_number: { 39 | type: Sequelize.STRING, 40 | }, 41 | created_at: { 42 | allowNull: false, 43 | type: Sequelize.DATE, 44 | }, 45 | updated_at: { 46 | allowNull: false, 47 | type: Sequelize.DATE, 48 | }, 49 | }); 50 | }, 51 | 52 | down: async (queryInterface, Sequelize) => { 53 | await queryInterface.dropTable('users'); 54 | }, 55 | }; 56 | -------------------------------------------------------------------------------- /src/helpers/dataHelper.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-plusplus */ 2 | const arrayDiff = (a1: Array, a2: Array) => { 3 | const a = []; 4 | const diff: string[] = []; 5 | 6 | for (let i = 0; i < a1.length; i++) { 7 | a[a1[i]] = true; 8 | } 9 | 10 | for (let i = 0; i < a2.length; i++) { 11 | if (a[a2[i]]) { 12 | delete a[a2[i]]; 13 | } else { 14 | a[a2[i]] = true; 15 | } 16 | } 17 | 18 | // eslint-disable-next-line guard-for-in,no-restricted-syntax 19 | for (const k in a) { 20 | diff.push(k); 21 | } 22 | 23 | return diff; 24 | }; 25 | const shuffleArray = (array) => { 26 | for (let i = array.length - 1; i > 0; i--) { 27 | const j = Math.floor(Math.random() * (i + 1)); 28 | const temp = array[i]; 29 | // eslint-disable-next-line no-param-reassign 30 | array[i] = array[j]; 31 | // eslint-disable-next-line no-param-reassign 32 | array[j] = temp; 33 | } 34 | return array; 35 | }; 36 | 37 | const arrayRand = (array, limit = 1) => { 38 | const shuffledArray = shuffleArray(array); 39 | return shuffledArray.splice(0, limit).sort(); 40 | }; 41 | const randomNumberBetweenRange = (min, max) => Math.round(Math.random() * (max - min) + min); 42 | 43 | const randomId = (length) => { 44 | let result = ''; 45 | const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 46 | const charactersLength = characters.length; 47 | for (let i = 0; i < length; i += 1) { 48 | result += characters.charAt(Math.floor(Math.random() * charactersLength)); 49 | } 50 | return result; 51 | }; 52 | 53 | export { arrayDiff, shuffleArray, arrayRand, randomNumberBetweenRange, randomId }; 54 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import cors from 'cors'; 2 | import passport from 'passport'; 3 | import express, { Express, NextFunction, Request, Response } from 'express'; 4 | import httpStatus from 'http-status'; 5 | import helmet from 'helmet'; 6 | import db from '@models/index'; 7 | import routes from '@routes/index.js'; 8 | import { jwtStrategy } from '@configs/passport.js'; 9 | import ApiError from '@helpers/ApiError.js'; 10 | import { errorConverter, errorHandler } from '@middlewares/error.js'; 11 | import redisClient from '@configs/redisClient.js'; 12 | 13 | process.env.PWD = process.cwd(); 14 | 15 | export const app: Express = express(); 16 | 17 | // enable cors 18 | // options for cors middleware 19 | app.use( 20 | cors({ 21 | origin: '*', 22 | }) 23 | ); 24 | 25 | // To enable securities in HTTP headers 26 | app.use(helmet()); 27 | 28 | app.use(express.static(`${process.env.PWD}/public`)); 29 | 30 | app.use(express.urlencoded({ extended: true })); 31 | app.use(express.json()); 32 | 33 | // jwt authentication 34 | passport.use('jwt', jwtStrategy); 35 | app.use(passport.initialize()); 36 | 37 | app.get('/api/v1/test', async (req, res) => { 38 | res.status(200).send('Congratulations!Typescript API is working!'); 39 | }); 40 | 41 | app.use('/api/v1', routes); 42 | 43 | // send back a 404 error for any unknown api request 44 | app.use((req: Request, res: Response, next: NextFunction) => { 45 | next(new ApiError(httpStatus.NOT_FOUND, 'Not found')); 46 | }); 47 | 48 | // convert error to ApiError, if needed 49 | app.use(errorConverter); 50 | // handle error 51 | app.use(errorHandler); 52 | 53 | redisClient.on('error', (err) => { 54 | console.log(err); 55 | redisClient.quit(); 56 | }); 57 | redisClient.connect(); 58 | 59 | // Uncomment this line if you want to sync database model 60 | // db.sequelize.sync(); 61 | -------------------------------------------------------------------------------- /src/services/implementations/RedisService.ts: -------------------------------------------------------------------------------- 1 | import IRedisService from '@services/contracts/IRedisService.js'; 2 | import redisClient from '@configs/redisClient.js'; 3 | import RedisHelper from '@helpers/RedisHelper.js'; 4 | import { IUser } from '@models/interfaces/IUser.js'; 5 | import { config } from '@configs/config.js'; 6 | 7 | export default class RedisService implements IRedisService { 8 | private redisHelper: RedisHelper; 9 | 10 | constructor() { 11 | this.redisHelper = new RedisHelper(redisClient); 12 | } 13 | 14 | createTokens = async ( 15 | uuid: string, 16 | tokens: { access: { token: string }; refresh: { token: string } } 17 | ) => { 18 | const accessKey = `access_token:${tokens.access.token}`; 19 | const refreshKey = `refresh_token:${tokens.refresh.token}`; 20 | const accessKeyExpires = config.jwt.accessExpirationMinutes * 60; 21 | const refreshKeyExpires = config.jwt.refreshExpirationDays * 24 * 60 * 60; 22 | await this.redisHelper.setEx(accessKey, accessKeyExpires, uuid); 23 | await this.redisHelper.setEx(refreshKey, refreshKeyExpires, uuid); 24 | return true; 25 | }; 26 | 27 | hasToken = async (token: string, type = 'access_token') => { 28 | const hasToken = await this.redisHelper.get(`${type}:${token}`); 29 | if (hasToken != null) { 30 | return true; 31 | } 32 | return false; 33 | }; 34 | 35 | removeToken = async (token: string, type = 'access_token') => 36 | this.redisHelper.del(`${type}:${token}`); 37 | 38 | getUser = async (uuid: string) => { 39 | const user = await this.redisHelper.get(`user:${uuid}`); 40 | if (user != null) { 41 | return JSON.parse(user); 42 | } 43 | return false; 44 | }; 45 | 46 | setUser = async (user: IUser) => { 47 | const setUser = await this.redisHelper.set(`user:${user.uuid}`, JSON.stringify(user)); 48 | if (!setUser) { 49 | return true; 50 | } 51 | return false; 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/helpers/timeHelper.ts: -------------------------------------------------------------------------------- 1 | import { parseISO } from 'date-fns'; 2 | import * as dateFns from 'date-fns-tz'; 3 | 4 | const { format, utcToZonedTime, zonedTimeToUtc } = dateFns; 5 | /** 6 | * Covert timezone 7 | * @param {String/Date} inputTime 8 | * @param {String} currentTimezone = 'UTC' 9 | * @param {String} convertTimezone = '' 10 | * @param {String} formatPattern = 'yyyy-MM-dd HH:mm:ss' 11 | * @returns {string} 12 | */ 13 | 14 | export const convertTimezone = ( 15 | inputTime: string | Date, 16 | currentTimezone = 'UTC', 17 | // eslint-disable-next-line @typescript-eslint/no-shadow 18 | convertTimezone = '', 19 | formatPattern = 'yyyy-MM-dd HH:mm:ss' 20 | ) => { 21 | try { 22 | if (convertTimezone === '') { 23 | // eslint-disable-next-line no-param-reassign 24 | convertTimezone = currentTimezone; 25 | } 26 | let currentTimeInGivenTimezone; 27 | 28 | if (currentTimezone === 'UTC') { 29 | currentTimeInGivenTimezone = utcToZonedTime(inputTime, convertTimezone); 30 | } else { 31 | const currentTimezoneToUtc = zonedTimeToUtc(inputTime, currentTimezone); 32 | if (convertTimezone === 'UTC') { 33 | currentTimeInGivenTimezone = currentTimezoneToUtc; 34 | } else { 35 | currentTimeInGivenTimezone = utcToZonedTime(currentTimezoneToUtc, convertTimezone); 36 | } 37 | } 38 | return format(currentTimeInGivenTimezone, formatPattern, { timeZone: convertTimezone }); 39 | } catch (e) { 40 | return format(new Date(), formatPattern); 41 | } 42 | }; 43 | /** 44 | * format time 45 | * @param {String/Date} time 46 | * @param {String} formatPattern = 'yyyy-MM-dd HH:mm:ss' 47 | * @returns {string} 48 | */ 49 | export const formatTime = ( 50 | time: string | Date | undefined = new Date(), 51 | formatPattern = 'yyyy-MM-dd HH:mm:ss' 52 | ) => { 53 | let newDate = new Date(); 54 | if (typeof time !== undefined) { 55 | // eslint-disable-next-line no-param-reassign 56 | newDate = new Date(time); 57 | } 58 | if (typeof time !== 'string') { 59 | return format(newDate, formatPattern); 60 | } 61 | return format(new Date(newDate), formatPattern); 62 | }; 63 | /** 64 | * Parse date or string to date instance 65 | * @param {String/Date} time 66 | * @returns {Date} 67 | */ 68 | export const parseTime = (time: string | Date) => { 69 | if (typeof time !== 'string') { 70 | return time; 71 | } 72 | return parseISO(time); 73 | }; 74 | -------------------------------------------------------------------------------- /src/configs/config.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | 3 | import 'dotenv/config'; 4 | 5 | const envValidation = Joi.object() 6 | .keys({ 7 | NODE_ENV: Joi.string().valid('development', 'production', 'local').required(), 8 | PORT: Joi.number().default(3000), 9 | DB_HOST: Joi.string().default('localhost'), 10 | DB_USER: Joi.string().required(), 11 | DB_PASS: Joi.string().required(), 12 | DB_NAME: Joi.string().required(), 13 | JWT_SECRET: Joi.string().required().description('JWT secret key'), 14 | JWT_ACCESS_EXPIRATION_MINUTES: Joi.number() 15 | .default(30) 16 | .description('minutes after which access tokens expire'), 17 | JWT_REFRESH_EXPIRATION_DAYS: Joi.number() 18 | .default(30) 19 | .description('days after which refresh tokens expire'), 20 | JWT_RESET_PASSWORD_EXPIRATION_MINUTES: Joi.number() 21 | .default(10) 22 | .description('minutes after which reset password token expires'), 23 | JWT_VERIFY_EMAIL_EXPIRATION_MINUTES: Joi.number() 24 | .default(10) 25 | .description('minutes after which verify email token expires'), 26 | LOG_FOLDER: Joi.string().required(), 27 | LOG_FILE: Joi.string().required(), 28 | LOG_LEVEL: Joi.string().required(), 29 | REDIS_HOST: Joi.string().default('127.0.0.1'), 30 | REDIS_PORT: Joi.number().default(6379), 31 | REDIS_USE_PASSWORD: Joi.string().default('no'), 32 | REDIS_PASSWORD: Joi.string(), 33 | }) 34 | .unknown(); 35 | 36 | const { value: envVar, error } = envValidation 37 | .prefs({ errors: { label: 'key' } }) 38 | .validate(process.env); 39 | 40 | if (error) { 41 | throw new Error(`Config validation error: ${error.message}`); 42 | } 43 | 44 | export const config = { 45 | nodeEnv: envVar.NODE_ENV, 46 | appIdentifier: envVar.APP_IDENTIFIER, 47 | port: envVar.PORT, 48 | dbHost: envVar.DB_HOST, 49 | dbUser: envVar.DB_USER, 50 | dbPass: envVar.DB_PASS, 51 | dbName: envVar.DB_NAME, 52 | jwt: { 53 | secret: envVar.JWT_SECRET, 54 | accessExpirationMinutes: envVar.JWT_ACCESS_EXPIRATION_MINUTES, 55 | refreshExpirationDays: envVar.JWT_REFRESH_EXPIRATION_DAYS, 56 | resetPasswordExpirationMinutes: envVar.JWT_RESET_PASSWORD_EXPIRATION_MINUTES, 57 | verifyEmailExpirationMinutes: envVar.JWT_VERIFY_EMAIL_EXPIRATION_MINUTES, 58 | }, 59 | logConfig: { 60 | logFolder: envVar.LOG_FOLDER, 61 | logFile: envVar.LOG_FILE, 62 | logLevel: envVar.LOG_LEVEL, 63 | }, 64 | redis: { 65 | host: envVar.REDIS_HOST, 66 | port: envVar.REDIS_PORT, 67 | usePassword: envVar.REDIS_USE_PASSWORD, 68 | password: envVar.REDIS_PASSWORD, 69 | }, 70 | }; 71 | -------------------------------------------------------------------------------- /src/services/implementations/AuthService.ts: -------------------------------------------------------------------------------- 1 | import httpStatus from 'http-status'; 2 | import * as bcrypt from 'bcrypt'; 3 | import { Request, Response } from 'express'; 4 | import { logger } from '@configs/logger.js'; 5 | import TokenDao from '@dao/implementations/TokenDao.js'; 6 | import UserDao from '@dao/implementations/UserDao.js'; 7 | import responseHandler from '@helpers/responseHandler.js'; 8 | import IAuthService from '@services/contracts/IAuthService.js'; 9 | import { tokenTypes } from '@configs/tokens.js'; 10 | import RedisService from '@services/implementations/RedisService.js'; 11 | 12 | export default class AuthService implements IAuthService { 13 | private userDao: UserDao; 14 | 15 | private tokenDao: TokenDao; 16 | 17 | private redisService: RedisService; 18 | 19 | constructor() { 20 | this.userDao = new UserDao(); 21 | this.tokenDao = new TokenDao(); 22 | this.redisService = new RedisService(); 23 | } 24 | 25 | loginWithEmailPassword = async (email: string, password: string) => { 26 | try { 27 | let message = 'Login Successful'; 28 | let statusCode: number = httpStatus.OK; 29 | let user = await this.userDao.findByEmail(email); 30 | if (user == null) { 31 | return responseHandler.returnError( 32 | httpStatus.BAD_REQUEST, 33 | 'Invalid Email Address!' 34 | ); 35 | } 36 | const isPasswordValid = await bcrypt.compare(password, user.password); 37 | user = user.toJSON(); 38 | delete user.password; 39 | 40 | if (!isPasswordValid) { 41 | statusCode = httpStatus.BAD_REQUEST; 42 | message = 'Wrong Password!'; 43 | return responseHandler.returnError(statusCode, message); 44 | } 45 | 46 | return responseHandler.returnSuccess(statusCode, message, user); 47 | } catch (e) { 48 | logger.error(e); 49 | return responseHandler.returnError(httpStatus.BAD_GATEWAY, 'Something Went Wrong!!'); 50 | } 51 | }; 52 | 53 | logout = async (req: Request, res: Response) => { 54 | const refreshTokenDoc = await this.tokenDao.findOne({ 55 | token: req.body.refresh_token, 56 | type: tokenTypes.REFRESH, 57 | blacklisted: false, 58 | }); 59 | if (!refreshTokenDoc) { 60 | return false; 61 | } 62 | await this.tokenDao.remove({ 63 | token: req.body.refresh_token, 64 | type: tokenTypes.REFRESH, 65 | blacklisted: false, 66 | }); 67 | await this.tokenDao.remove({ 68 | token: req.body.access_token, 69 | type: tokenTypes.ACCESS, 70 | blacklisted: false, 71 | }); 72 | await this.redisService.removeToken(req.body.access_token, 'access_token'); 73 | await this.redisService.removeToken(req.body.refresh_token, 'refresh_token'); 74 | return true; 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Typescript-Express-Mysql-Boilerplate", 3 | "version": "2.0.1", 4 | "description": "Boilerplate for Node-Express with sequelize ORM", 5 | "main": "build/index.js", 6 | "type": "module", 7 | "scripts": { 8 | "start:dev": "concurrently \"npm run watch-compile\" \"npm run watch-dev\"", 9 | "watch-compile": "swc src -w --out-dir build --source-maps inline", 10 | "watch-dev": "nodemon --watch \"build/**/*\" -e js --exec \"node -r source-map-support/register -r tsconfig-paths/register ./build/index.js\"", 11 | "start": "node -r source-map-support/register -r tsconfig-paths/register ./build/index.js", 12 | "build": "npm run check && rimraf ./build && swc ./src -d build --source-maps", 13 | "check": "tsc --noEmit", 14 | "test:mocha": "mocha './build/**/*.spec.js' --require ts-node/register", 15 | "test:watch": "nodemon --watch ./build --exec npm run test:mocha", 16 | "test": "npm run build && npm run test:mocha", 17 | "lint": "eslint --ext .ts src", 18 | "lint:fix": "eslint --ext .ts src --fix" 19 | }, 20 | 21 | "author": "Kazi Naimul Hoque (Aoyan)", 22 | "license": "MIT", 23 | "devDependencies": { 24 | "@types/bcrypt": "^5.0.0", 25 | "@types/cors": "^2.8.12", 26 | "@types/gapi": "^0.0.42", 27 | "@types/lodash": "^4.14.182", 28 | "@types/node-cron": "^3.0.1", 29 | "@types/passport-jwt": "^3.0.7", 30 | "@types/sinon": "^10.0.13", 31 | "concurrently": "^8.2.0", 32 | "eslint": "^8.13.0", 33 | "eslint-config-airbnb-base": "^15.0.0", 34 | "eslint-config-airbnb-typescript": "^17.0.0", 35 | "eslint-config-prettier": "^8.5.0", 36 | "eslint-import-resolver-typescript": "^2.7.1", 37 | "eslint-plugin-import": "^2.24.2", 38 | "eslint-plugin-prettier": "^4.0.0", 39 | "eslint-plugin-promise": "^6.0.0", 40 | "eslint-webpack-plugin": "^3.1.1", 41 | "nodemon": "^2.0.15", 42 | "prettier": "^2.6.0", 43 | "prettier-eslint": "^13.0.0", 44 | "ts-sinon": "^2.0.2" 45 | }, 46 | "dependencies": { 47 | "@swc/cli": "^0.1.62", 48 | "@swc/core": "^1.3.74", 49 | "@types/mocha": "^10.0.1", 50 | "@types/socket.io": "^3.0.2", 51 | "axios": "^0.26.1", 52 | "bcrypt": "^5.1.0", 53 | "chai": "^4.3.7", 54 | "chokidar": "^3.5.3", 55 | "date-fns": "^2.28.0", 56 | "date-fns-tz": "^1.3.4", 57 | "dotenv": "^16.0.0", 58 | "express": "^4.17.3", 59 | "form-data": "^4.0.0", 60 | "googleapis": "^100.0.0", 61 | "helmet": "^6.0.1", 62 | "http-status": "^1.5.0", 63 | "joi": "^17.6.0", 64 | "jsonwebtoken": "^8.5.1", 65 | "lodash": "^4.17.21", 66 | "mocha": "^10.1.0", 67 | "mysql2": "^2.3.3", 68 | "node-cron": "^3.0.0", 69 | "passport": "^0.6.0", 70 | "passport-jwt": "^4.0.0", 71 | "redis": "^4.3.1", 72 | "rimraf": "^3.0.2", 73 | "sequelize": "^6.18.0", 74 | "sequelize-cli": "^6.4.1", 75 | "source-map-support": "^0.5.21", 76 | "tsconfig-paths": "^4.2.0", 77 | "typescript": "^4.6.3", 78 | "uuidv4": "^6.2.13", 79 | "winston": "^3.7.2", 80 | "winston-daily-rotate-file": "^4.6.1" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/services/implementations/AuthService.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import httpStatus from 'http-status'; 3 | import 'mocha'; 4 | import sinon from 'sinon'; 5 | import AuthService from '@services/implementations/AuthService.js'; 6 | import UserDao from '@dao/implementations/UserDao.js'; 7 | import models from '@models/index.js'; 8 | import * as bcrypt from 'bcrypt' 9 | import { IUser } from '@models/interfaces/IUser.js'; 10 | 11 | const User = models.user; 12 | 13 | let authService; 14 | const loginData = { 15 | email: 'john@mail.com', 16 | password: '123123Asd', 17 | }; 18 | const userData:IUser = { 19 | first_name: 'John', 20 | last_name: 'Doe', 21 | email: 'john@mail.com', 22 | uuid: '4d85f12b-6e5b-468b-a971-eabe8acc9d08', 23 | 24 | }; 25 | 26 | describe('User Login test', () => { 27 | beforeEach(() => { 28 | authService = new AuthService(); 29 | }); 30 | afterEach(() => { 31 | sinon.restore(); 32 | }); 33 | 34 | it('User Login successfully', async () => { 35 | const expectedResponse = { 36 | statusCode: httpStatus.OK, 37 | response: { 38 | status: true, 39 | code: httpStatus.OK, 40 | message: 'Login Successful', 41 | data: { 42 | id: 1, 43 | first_name: 'John', 44 | last_name: 'Doe', 45 | email: 'john@mail.com', 46 | email_verified: 1, 47 | uuid: '4d85f12b-6e5b-468b-a971-eabe8acc9d08', 48 | }, 49 | }, 50 | }; 51 | userData.id = 1; 52 | userData.password = bcrypt.hashSync(loginData.password, 8); 53 | userData.email_verified = 1; 54 | const userModel = new User(userData); 55 | 56 | sinon.stub(UserDao.prototype, 'findByEmail').callsFake((email) => { 57 | return userModel; 58 | }); 59 | const userLogin = await authService.loginWithEmailPassword( 60 | loginData.email, 61 | loginData.password, 62 | ); 63 | expect(userLogin).to.deep.include(expectedResponse); 64 | }); 65 | 66 | it('should show INVALID EMAIL ADDRESS message', async () => { 67 | const expectedResponse = { 68 | statusCode: httpStatus.BAD_REQUEST, 69 | response: { 70 | status: false, 71 | code: httpStatus.BAD_REQUEST, 72 | message: 'Invalid Email Address!', 73 | }, 74 | }; 75 | 76 | sinon.stub(UserDao.prototype, 'findByEmail').returns(Promise.resolve(null)) 77 | const response = await authService.loginWithEmailPassword('test@mail.com', '23232132'); 78 | expect(response).to.deep.include(expectedResponse); 79 | }); 80 | 81 | it('Wrong Password', async () => { 82 | const expectedResponse = { 83 | statusCode: httpStatus.BAD_REQUEST, 84 | response: { 85 | status: false, 86 | code: httpStatus.BAD_REQUEST, 87 | message: 'Wrong Password!', 88 | }, 89 | }; 90 | userData.id = 1; 91 | userData.password = bcrypt.hashSync('2322342343', 8); 92 | userData.email_verified = 1; 93 | const userModel = new User(userData); 94 | sinon.stub(UserDao.prototype, 'findByEmail').callsFake((email) => { 95 | return userModel; 96 | }); 97 | const userLogin = await authService.loginWithEmailPassword( 98 | loginData.email, 99 | loginData.password, 100 | ); 101 | expect(userLogin).to.deep.include(expectedResponse); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /src/configs/passport.ts: -------------------------------------------------------------------------------- 1 | import { Strategy, ExtractJwt, VerifyCallbackWithRequest, StrategyOptions } from 'passport-jwt'; 2 | import jwt from 'jsonwebtoken'; 3 | import UserDao from '@dao/implementations/UserDao.js'; 4 | import { config } from '@configs/config.js'; 5 | import { tokenTypes } from '@configs/tokens.js'; 6 | import TokenDao from '@dao/implementations/TokenDao.js'; 7 | import RedisService from '@services/implementations/RedisService.js'; 8 | import models from '@models/index.js'; 9 | 10 | const User = models.user; 11 | const jwtOptions: StrategyOptions = { 12 | secretOrKey: config.jwt.secret, 13 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 14 | passReqToCallback: true, 15 | }; 16 | 17 | const jwtVerify: VerifyCallbackWithRequest = async (req, payload, done) => { 18 | try { 19 | if (payload.type !== tokenTypes.ACCESS) { 20 | throw new Error('Invalid token type'); 21 | } 22 | const userDao = new UserDao(); 23 | const tokenDao = new TokenDao(); 24 | const redisService = new RedisService(); 25 | const authorization = 26 | req.headers.authorization !== undefined ? req.headers.authorization.split(' ') : []; 27 | if (authorization[1] === undefined) { 28 | return done(null, false); 29 | } 30 | let tokenDoc = redisService.hasToken(authorization[1], 'access_token'); 31 | if (!tokenDoc) { 32 | tokenDoc = await tokenDao.findOne({ 33 | token: authorization[1], 34 | type: tokenTypes.ACCESS, 35 | blacklisted: false, 36 | }); 37 | } 38 | 39 | if (!tokenDoc) { 40 | return done(null, false); 41 | } 42 | let user = await redisService.getUser(payload.sub); 43 | if (user) { 44 | user = new User(user); 45 | } 46 | 47 | if (!user) { 48 | console.log('User Cache Missed!'); 49 | user = await userDao.findOneByWhere({ uuid: payload.sub }); 50 | redisService.setUser(user); 51 | } 52 | 53 | if (!user) { 54 | return done(null, false); 55 | } 56 | 57 | return done(null, user); 58 | } catch (error) { 59 | return done(error, false); 60 | } 61 | }; 62 | const jwtVerifyManually = async (req, done) => { 63 | try { 64 | const authorization = 65 | req.headers.authorization !== undefined ? req.headers.authorization.split(' ') : []; 66 | if (authorization[1] === undefined) { 67 | return done(null, false); 68 | } 69 | const payload: any = await jwt.verify( 70 | authorization[1], 71 | config.jwt.secret, 72 | (err, decoded) => { 73 | if (err) { 74 | throw new Error('Token not found'); 75 | } 76 | // if everything is good, save to request for use in other routes 77 | return decoded; 78 | } 79 | ); 80 | 81 | const userDao = new UserDao(); 82 | const tokenDao = new TokenDao(); 83 | const redisService = new RedisService(); 84 | 85 | let tokenDoc = redisService.hasToken(authorization[1], 'access_token'); 86 | if (!tokenDoc) { 87 | console.log('Cache Missed!'); 88 | tokenDoc = await tokenDao.findOne({ 89 | token: authorization[1], 90 | type: tokenTypes.ACCESS, 91 | blacklisted: false, 92 | }); 93 | } 94 | 95 | if (!tokenDoc) { 96 | return done(null, false); 97 | } 98 | let user = await redisService.getUser(payload.sub); 99 | if (user) { 100 | user = new User(user); 101 | } 102 | 103 | if (!user) { 104 | console.log('User Cache Missed!'); 105 | user = await userDao.findOneByWhere({ uuid: payload.sub }); 106 | redisService.setUser(user); 107 | } 108 | 109 | if (!user) { 110 | return done(null, false); 111 | } 112 | 113 | return done(null, user); 114 | } catch (error) { 115 | return done(error, false); 116 | } 117 | }; 118 | 119 | const jwtStrategy = new Strategy(jwtOptions, jwtVerify); 120 | 121 | export { jwtStrategy, jwtVerifyManually }; 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Boilerplate for Typescript-Express with sequelize ORM 2 | 3 | A boilerplate for any enterprise rest api or service with Node.js -Typescript, Express and Sequelize ORM for mysql, postgresql or others. 4 | 5 | By running this project you will get a production ready environment with all necessary supports for validation, unit testing, socket, redis and many more.This repo is the typescript version of my another boilerplate of [Nodejs Express Mysql boilerplate](https://github.com/kazi-naimul/node-express-mysql-boilerplate) 6 | 7 | ## Manual Installation 8 | 9 | Clone the repo: 10 | 11 | ```bash 12 | git clone https://github.com/kazi-naimul/typescript-express-mysql-boilerplate 13 | cd typescript-express-mysql-boilerplate 14 | ``` 15 | 16 | Install the dependencies: 17 | 18 | ```bash 19 | yarn install 20 | ``` 21 | 22 | Set the environment variables: 23 | 24 | ```bash 25 | cp .env.example .env 26 | 27 | # open .env and modify the environment variables (if needed) 28 | ``` 29 | 30 | 31 | ## Features 32 | 33 | - **ECMAScript Modules (ESM)** 34 | - **ORM**: [Sequelize](https://sequelize.org/) orm for object data modeling 35 | - **Migration and Seed**: DB migration and Seed using [Sequelize-CLI](https://github.com/sequelize/cli) 36 | - **Authentication and authorization**: using [passport](http://www.passportjs.org) 37 | - **Error handling**: centralized error handling 38 | - **Validation**: request data validation using [Joi](https://github.com/hapijs/joi) 39 | - **Logging**: using [winston](https://github.com/winstonjs/winston) 40 | - **Testing**: unittests using [Mocha](https://mochajs.org/) 41 | - **Caching**: Caching using [Redis](https://redis.io/) 42 | - **Bidirectional Communication**: using [Scoket](https://socket.io/) 43 | - **Job scheduler**: with [Node-cron](https://www.npmjs.com/package/node-cron) 44 | - **Dependency management**: with [Yarn](https://yarnpkg.com) 45 | - **Environment variables**: using [dotenv](https://github.com/motdotla/dotenv) and [cross-env](https://github.com/kentcdodds/cross-env#readme) 46 | - **CORS**: Cross-Origin Resource-Sharing enabled using [cors](https://github.com/expressjs/cors) 47 | - **Docker support** 48 | - **Linting**: with [ESLint](https://eslint.org) and [Prettier](https://prettier.io) 49 | - **Fast Compilation**: with [SWC](https://swc.rs/) 50 | 51 | ## Commands 52 | 53 | Running locally: 54 | 55 | ```bash 56 | yarn start:dev 57 | ``` 58 | 59 | Running in production: 60 | 61 | ```bash 62 | yarn start 63 | ``` 64 | 65 | Testing: 66 | 67 | ```bash 68 | # run all tests 69 | yarn test 70 | 71 | ``` 72 | 73 | ## Environment Variables 74 | 75 | The environment variables can be found and modified in the `.env` file. They come with these default values: 76 | 77 | ```bash 78 | #Server environment 79 | NODE_ENV=development 80 | #Port number 81 | PORT=5000 82 | 83 | #Db configuration 84 | DB_HOST=db-host 85 | DB_USER=db-user 86 | DB_PASS=db-pass 87 | DB_NAME=db-name 88 | 89 | 90 | # JWT secret key 91 | JWT_SECRET=your-jwt-secret-key 92 | # Number of minutes after which an access token expires 93 | JWT_ACCESS_EXPIRATION_MINUTES=5 94 | # Number of days after which a refresh token expires 95 | JWT_REFRESH_EXPIRATION_DAYS=30 96 | 97 | #Log config 98 | LOG_FOLDER=logs/ 99 | LOG_FILE=%DATE%-app-log.log 100 | LOG_LEVEL=error 101 | 102 | #Redis 103 | REDIS_HOST=redis-host 104 | REDIS_PORT=6379 105 | REDIS_USE_PASSWORD=no 106 | REDIS_PASSWORD=your-password 107 | 108 | ``` 109 | 110 | ## Project Structure 111 | 112 | ``` 113 | src\ 114 | |--config\ # Environment variables and configuration related things 115 | |--controllers\ # Route controllers (controller layer) 116 | |--dao\ # Data Access Object for models 117 | |--contract\ # Contracts for all dao 118 | |--implementation # Implementation of the contracts 119 | |--db\ # Migrations and Seed files 120 | |--models\ # Sequelize models (data layer) 121 | |--routes\ # Routes 122 | |--services\ # Business logic (service layer) 123 | |--contract\ # Contracts for all service 124 | |--implementation # Implementation of the contracts and unit test files 125 | |--helper\ # Helper classes and functions 126 | |--validations\ # Request data validation schemas 127 | |--app.js # Express app 128 | |--cronJobs.ts # Job Scheduler 129 | |--index.ts # App entry point 130 | ``` 131 | 132 | ## License 133 | 134 | [MIT](LICENSE) 135 | -------------------------------------------------------------------------------- /src/controllers/AuthController.ts: -------------------------------------------------------------------------------- 1 | import httpStatus from 'http-status'; 2 | import { Request, Response } from 'express'; 3 | import { ApiServiceResponse } from 'apiServiceResponse.js'; 4 | import { logger } from '@configs/logger.js'; 5 | import { tokenTypes } from '@configs/tokens.js'; 6 | import { IUser } from '@models/interfaces/IUser.js'; 7 | import AuthService from '@services/implementations/AuthService.js'; 8 | import TokenService from '@services/implementations/TokenService.js'; 9 | import UserService from '@services/implementations/UserService.js'; 10 | 11 | export default class AuthController { 12 | private userService: UserService; 13 | 14 | private tokenService: TokenService; 15 | 16 | private authService: AuthService; 17 | 18 | constructor() { 19 | this.userService = new UserService(); 20 | this.tokenService = new TokenService(); 21 | this.authService = new AuthService(); 22 | } 23 | 24 | register = async (req: Request, res: Response) => { 25 | try { 26 | const user: ApiServiceResponse = await this.userService.createUser(req.body); 27 | let tokens = {}; 28 | const { status } = user.response; 29 | if (user.response.status) { 30 | tokens = await this.tokenService.generateAuthTokens(user.response.data); 31 | } 32 | const { message, data } = user.response; 33 | res.status(user.statusCode).send({ status, message, data, tokens }); 34 | } catch (e) { 35 | logger.error(e); 36 | res.status(httpStatus.BAD_GATEWAY).send(e); 37 | } 38 | }; 39 | 40 | checkEmail = async (req: Request, res: Response) => { 41 | try { 42 | const isExists = await this.userService.isEmailExists(req.body.email.toLowerCase()); 43 | res.status(isExists.statusCode).send(isExists.response); 44 | } catch (e) { 45 | logger.error(e); 46 | res.status(httpStatus.BAD_GATEWAY).send(e); 47 | } 48 | }; 49 | 50 | login = async (req: Request, res: Response) => { 51 | try { 52 | const { email, password } = req.body; 53 | const user = await this.authService.loginWithEmailPassword( 54 | email.toLowerCase(), 55 | password 56 | ); 57 | const { message, data, status } = user.response; 58 | const code = user.statusCode; 59 | let tokens = {}; 60 | if (user.response.status) { 61 | tokens = await this.tokenService.generateAuthTokens(data); 62 | } 63 | res.status(user.statusCode).send({ status, code, message, data, tokens }); 64 | } catch (e) { 65 | logger.error(e); 66 | res.status(httpStatus.BAD_GATEWAY).send(e); 67 | } 68 | }; 69 | 70 | logout = async (req: Request, res: Response) => { 71 | await this.authService.logout(req, res); 72 | res.status(httpStatus.NO_CONTENT).send(); 73 | }; 74 | 75 | refreshTokens = async (req: Request, res: Response) => { 76 | try { 77 | const refreshTokenDoc = await this.tokenService.verifyToken( 78 | req.body.refresh_token, 79 | tokenTypes.REFRESH 80 | ); 81 | const user = await this.userService.getUserByUuid(refreshTokenDoc.user_uuid); 82 | if (user == null) { 83 | res.status(httpStatus.BAD_GATEWAY).send('User Not Found!'); 84 | } 85 | if (refreshTokenDoc.id === undefined) { 86 | return res.status(httpStatus.BAD_GATEWAY).send('Bad Request!'); 87 | } 88 | await this.tokenService.removeTokenById(refreshTokenDoc.id); 89 | const tokens = await this.tokenService.generateAuthTokens(user); 90 | res.send(tokens); 91 | } catch (e) { 92 | logger.error(e); 93 | res.status(httpStatus.BAD_GATEWAY).send(e); 94 | } 95 | }; 96 | 97 | changePassword = async (req: Request, res: Response) => { 98 | try { 99 | const responseData = await this.userService.changePassword(req); 100 | res.status(responseData.statusCode).send(responseData.response); 101 | } catch (e) { 102 | logger.error(e); 103 | res.status(httpStatus.BAD_GATEWAY).send(e); 104 | } 105 | }; 106 | } 107 | -------------------------------------------------------------------------------- /src/services/implementations/TokenService.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import { Op } from 'sequelize'; 3 | import { addDays, addMinutes, getUnixTime } from 'date-fns'; 4 | import { tokenTypes } from '@configs/tokens.js'; 5 | import TokenDao from '@dao/implementations/TokenDao.js'; 6 | import ITokenService from '@services/contracts/ITokenService.js'; 7 | import RedisService from '@services/implementations/RedisService.js'; 8 | import { config } from '@configs/config.js'; 9 | import { IUser } from '@models/interfaces/IUser.js'; 10 | import { parseTime } from '@helpers/timeHelper.js'; 11 | import { IToken } from '@models/interfaces/IToken.js'; 12 | 13 | export default class TokenService implements ITokenService { 14 | private tokenDao: TokenDao; 15 | 16 | private redisService: RedisService; 17 | 18 | constructor() { 19 | this.tokenDao = new TokenDao(); 20 | this.redisService = new RedisService(); 21 | } 22 | 23 | generateToken = (uuid: string, expires: Date, type: string, secret = config.jwt.secret) => { 24 | const payload = { 25 | sub: uuid, 26 | iat: getUnixTime(new Date()), 27 | exp: getUnixTime(parseTime(expires)), 28 | type, 29 | }; 30 | return jwt.sign(payload, secret); 31 | }; 32 | 33 | verifyToken = async (token: string, type: string) => { 34 | const payload: any = await jwt.verify(token, config.jwt.secret, (err, decoded) => { 35 | if (err) { 36 | throw new Error('Token not found'); 37 | } else { 38 | // if everything is good, save to request for use in other routes 39 | return decoded; 40 | } 41 | }); 42 | 43 | const tokenDoc: IToken = await this.tokenDao.findOne({ 44 | token, 45 | type, 46 | user_uuid: payload.sub, 47 | blacklisted: false, 48 | }); 49 | if (!tokenDoc) { 50 | throw new Error('Token not found'); 51 | } 52 | return tokenDoc; 53 | }; 54 | 55 | saveToken = async ( 56 | token: string, 57 | userId: number, 58 | expires: Date, 59 | type: string, 60 | blacklisted = false 61 | ) => 62 | this.tokenDao.create({ 63 | token, 64 | user_id: userId, 65 | expires, 66 | type, 67 | blacklisted, 68 | }); 69 | 70 | saveMultipleTokens = async (tokens: object[]) => this.tokenDao.bulkCreate(tokens); 71 | 72 | removeTokenById = async (id: number) => this.tokenDao.remove({ id }); 73 | 74 | generateAuthTokens = async (user: IUser) => { 75 | const accessTokenExpires: Date = addMinutes(new Date(), config.jwt.accessExpirationMinutes); 76 | const accessToken = await this.generateToken( 77 | user.uuid, 78 | accessTokenExpires, 79 | tokenTypes.ACCESS 80 | ); 81 | const refreshTokenExpires: Date = addDays(new Date(), config.jwt.refreshExpirationDays); 82 | const refreshToken = await this.generateToken( 83 | user.uuid, 84 | refreshTokenExpires, 85 | tokenTypes.REFRESH 86 | ); 87 | const authTokens: IToken[] = []; 88 | authTokens.push({ 89 | token: accessToken, 90 | user_uuid: user.uuid, 91 | expires: accessTokenExpires, 92 | type: tokenTypes.ACCESS, 93 | blacklisted: false, 94 | }); 95 | authTokens.push({ 96 | token: refreshToken, 97 | user_uuid: user.uuid, 98 | expires: refreshTokenExpires, 99 | type: tokenTypes.REFRESH, 100 | blacklisted: false, 101 | }); 102 | 103 | await this.saveMultipleTokens(authTokens); 104 | const expiredAccessTokenWhere = { 105 | expires: { 106 | [Op.lt]: new Date(), 107 | }, 108 | type: tokenTypes.ACCESS, 109 | }; 110 | await this.tokenDao.remove(expiredAccessTokenWhere); 111 | const expiredRefreshTokenWhere = { 112 | expires: { 113 | [Op.lt]: new Date(), 114 | }, 115 | type: tokenTypes.REFRESH, 116 | }; 117 | await this.tokenDao.remove(expiredRefreshTokenWhere); 118 | const tokens = { 119 | access: { 120 | token: accessToken, 121 | expires: accessTokenExpires, 122 | }, 123 | refresh: { 124 | token: refreshToken, 125 | expires: refreshTokenExpires, 126 | }, 127 | }; 128 | await this.redisService.createTokens(user.uuid, tokens); 129 | 130 | return tokens; 131 | }; 132 | } 133 | -------------------------------------------------------------------------------- /src/services/implementations/UserService.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-shadow */ 2 | import httpStatus from 'http-status'; 3 | import * as bcrypt from 'bcrypt'; 4 | import { uuid } from 'uuidv4'; 5 | import { Request } from 'express'; 6 | import { logger } from '@configs/logger.js'; 7 | import { userConstant } from '@configs/constant.js'; 8 | import UserDao from '@dao/implementations/UserDao.js'; 9 | import responseHandler from '@helpers/responseHandler.js'; 10 | import { IUser } from '@models/interfaces/IUser.js'; 11 | import IUserService from '@services/contracts/IUserService.js'; 12 | 13 | export default class UserService implements IUserService { 14 | private userDao: UserDao; 15 | 16 | constructor() { 17 | this.userDao = new UserDao(); 18 | } 19 | 20 | createUser = async (userBodyReq: IUser) => { 21 | try { 22 | const userBody: IUser = userBodyReq; 23 | let message = 'Successfully Registered the account! Please Verify your email.'; 24 | if (await this.userDao.isEmailExists(userBody.email)) { 25 | return responseHandler.returnError(httpStatus.BAD_REQUEST, 'Email already taken'); 26 | } 27 | if (userBody.password === undefined) { 28 | return responseHandler.returnError(httpStatus.BAD_REQUEST, 'Password is required!'); 29 | } 30 | const uuidValue = uuid(); 31 | userBody.email = userBody.email.toLowerCase(); 32 | userBody.password = bcrypt.hashSync(userBody.password, 8); 33 | userBody.uuid = uuidValue; 34 | userBody.status = userConstant.STATUS_ACTIVE; 35 | userBody.email_verified = userConstant.EMAIL_VERIFIED_FALSE; 36 | 37 | let userData = await this.userDao.create(userBody); 38 | 39 | if (!userData) { 40 | message = 'Registration Failed! Please Try again.'; 41 | return responseHandler.returnError(httpStatus.BAD_REQUEST, message); 42 | } 43 | 44 | userData = userData.toJSON(); 45 | delete userData.password; 46 | 47 | return responseHandler.returnSuccess(httpStatus.CREATED, message, userData); 48 | } catch (e) { 49 | logger.error(e); 50 | return responseHandler.returnError(httpStatus.BAD_REQUEST, 'Something went wrong!'); 51 | } 52 | }; 53 | 54 | isEmailExists = async (email: string) => { 55 | const message = 'Email found!'; 56 | if (!(await this.userDao.isEmailExists(email))) { 57 | return responseHandler.returnError(httpStatus.BAD_REQUEST, 'Email not Found!!'); 58 | } 59 | return responseHandler.returnSuccess(httpStatus.OK, message); 60 | }; 61 | 62 | getUserByUuid = async (uuid: string) => this.userDao.findOneByWhere({ uuid }); 63 | 64 | changePassword = async (req: Request) => { 65 | try { 66 | // eslint-disable-next-line @typescript-eslint/naming-convention 67 | const { password, confirm_password, old_password } = req.body; 68 | let message = 'Password Successfully Updated!'; 69 | let statusCode: number = httpStatus.OK; 70 | if (req.userInfo === undefined) { 71 | return responseHandler.returnError(httpStatus.UNAUTHORIZED, 'Please Authenticate!'); 72 | } 73 | let user = await this.userDao.findOneByWhere({ uuid: req.userInfo.uuid }); 74 | 75 | if (!user) { 76 | return responseHandler.returnError(httpStatus.NOT_FOUND, 'User Not found!'); 77 | } 78 | 79 | if (password !== confirm_password) { 80 | return responseHandler.returnError( 81 | httpStatus.BAD_REQUEST, 82 | 'Confirm password not matched' 83 | ); 84 | } 85 | 86 | const isPasswordValid = await bcrypt.compare(old_password, user.password); 87 | user = user.toJSON(); 88 | delete user.password; 89 | if (!isPasswordValid) { 90 | statusCode = httpStatus.BAD_REQUEST; 91 | message = 'Wrong old Password!'; 92 | return responseHandler.returnError(statusCode, message); 93 | } 94 | const updateUser = await this.userDao.updateWhere( 95 | { password: bcrypt.hashSync(password, 8) }, 96 | { uuid: user.uuid } 97 | ); 98 | 99 | if (updateUser) { 100 | return responseHandler.returnSuccess( 101 | httpStatus.OK, 102 | 'Password updated Successfully!', 103 | {} 104 | ); 105 | } 106 | 107 | return responseHandler.returnError(httpStatus.BAD_REQUEST, 'Password Update Failed!'); 108 | } catch (e) { 109 | console.log(e); 110 | return responseHandler.returnError(httpStatus.BAD_REQUEST, 'Password Update Failed!'); 111 | } 112 | }; 113 | } 114 | -------------------------------------------------------------------------------- /src/validators/UserValidator.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable consistent-return */ 2 | /* eslint-disable class-methods-use-this */ 3 | import httpStatus from 'http-status'; 4 | import Joi from 'joi'; 5 | import { NextFunction, Request, Response } from 'express'; 6 | import ApiError from '@helpers/ApiError.js'; 7 | 8 | export default class UserValidator { 9 | async userCreateValidator(req: Request, res: Response, next: NextFunction) { 10 | // create schema object 11 | const schema = Joi.object({ 12 | email: Joi.string().email().required(), 13 | password: Joi.string().min(6).required(), 14 | confirm_password: Joi.string().valid(Joi.ref('password')).required(), 15 | first_name: Joi.string(), 16 | last_name: Joi.string(), 17 | }); 18 | 19 | // schema options 20 | const options = { 21 | abortEarly: false, // include all errors 22 | allowUnknown: true, // ignore unknown props 23 | stripUnknown: true, // remove unknown props 24 | }; 25 | 26 | // validate request body against schema 27 | const { error, value } = schema.validate(req.body, options); 28 | 29 | if (error) { 30 | // on fail return comma separated errors 31 | const errorMessage = error.details.map((details) => details.message).join(', '); 32 | next(new ApiError(httpStatus.BAD_REQUEST, errorMessage)); 33 | } else { 34 | // on success replace req.body with validated value and trigger next middleware function 35 | req.body = value; 36 | return next(); 37 | } 38 | } 39 | 40 | async userLoginValidator(req: Request, res: Response, next: NextFunction) { 41 | // create schema object 42 | const schema = Joi.object({ 43 | email: Joi.string().email().required(), 44 | password: Joi.string().min(6).required(), 45 | }); 46 | 47 | // schema options 48 | const options = { 49 | abortEarly: false, // include all errors 50 | allowUnknown: true, // ignore unknown props 51 | stripUnknown: true, // remove unknown props 52 | }; 53 | 54 | // validate request body against schema 55 | const { error, value } = schema.validate(req.body, options); 56 | 57 | if (error) { 58 | // on fail return comma separated errors 59 | const errorMessage = error.details.map((details) => details.message).join(', '); 60 | next(new ApiError(httpStatus.BAD_REQUEST, errorMessage)); 61 | } else { 62 | // on success replace req.body with validated value and trigger next middleware function 63 | req.body = value; 64 | return next(); 65 | } 66 | } 67 | 68 | async checkEmailValidator(req: Request, res: Response, next: NextFunction) { 69 | // create schema object 70 | const schema = Joi.object({ 71 | email: Joi.string().email().required(), 72 | }); 73 | 74 | // schema options 75 | const options = { 76 | abortEarly: false, // include all errors 77 | allowUnknown: true, // ignore unknown props 78 | stripUnknown: true, // remove unknown props 79 | }; 80 | 81 | // validate request body against schema 82 | const { error, value } = schema.validate(req.body, options); 83 | 84 | if (error) { 85 | // on fail return comma separated errors 86 | const errorMessage = error.details.map((details) => details.message).join(', '); 87 | next(new ApiError(httpStatus.BAD_REQUEST, errorMessage)); 88 | } else { 89 | // on success replace req.body with validated value and trigger next middleware function 90 | req.body = value; 91 | return next(); 92 | } 93 | } 94 | 95 | async changePasswordValidator(req: Request, res: Response, next: NextFunction) { 96 | // create schema object 97 | const schema = Joi.object({ 98 | old_password: Joi.string().required(), 99 | password: Joi.string().min(6).required(), 100 | confirm_password: Joi.string().min(6).required(), 101 | }); 102 | 103 | // schema options 104 | const options = { 105 | abortEarly: false, // include all errors 106 | allowUnknown: true, // ignore unknown props 107 | stripUnknown: true, // remove unknown props 108 | }; 109 | 110 | // validate request body against schema 111 | const { error, value } = schema.validate(req.body, options); 112 | 113 | if (error) { 114 | // on fail return comma separated errors 115 | const errorMessage = error.details.map((details) => details.message).join(', '); 116 | next(new ApiError(httpStatus.BAD_REQUEST, errorMessage)); 117 | } else { 118 | // on success replace req.body with validated value and trigger next middleware function 119 | req.body = value; 120 | return next(); 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/dao/implementations/SuperDao.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { logger } from '@configs/logger.js'; 3 | import { DataTableDaoResponse } from 'apiServiceResponse.js'; 4 | import ISuperDao from '@dao/contracts/ISuperDao'; 5 | 6 | export default class SuperDao implements ISuperDao { 7 | private Model: any; 8 | 9 | constructor(model: any) { 10 | this.Model = model; 11 | } 12 | 13 | public async findAll(): Promise { 14 | return this.Model.findAll() 15 | .then((result) => result) 16 | .catch((e) => { 17 | logger.error(e); 18 | console.log(e); 19 | }); 20 | } 21 | 22 | public async findById(id: number): Promise { 23 | return this.Model.findOne({ where: { id } }) 24 | .then((result) => result) 25 | .catch((e) => { 26 | logger.error(e); 27 | console.log(e); 28 | }); 29 | } 30 | 31 | public async findOneByWhere( 32 | where: object, 33 | attributes: string[] | null = null, 34 | order: string[] = ['id', 'desc'] 35 | ): Promise { 36 | if (attributes == null) { 37 | return this.Model.findOne({ 38 | where, 39 | order: [order], 40 | }) 41 | .then((result) => result) 42 | .catch((e) => { 43 | logger.error(e); 44 | console.log(e); 45 | }); 46 | } 47 | return this.Model.findOne({ 48 | where, 49 | attributes, 50 | order: [order], 51 | }) 52 | .then((result) => result) 53 | .catch((e) => { 54 | logger.error(e); 55 | console.log(e); 56 | }); 57 | } 58 | 59 | public async updateWhere(data: object, where: object): Promise { 60 | return this.Model.update(data, { where }) 61 | .then((result) => result) 62 | .catch((e) => { 63 | logger.error(e); 64 | console.log(e); 65 | }); 66 | } 67 | 68 | public async updateById(data: object, id: number): Promise { 69 | return this.Model.update(data, { where: { id } }) 70 | .then((result) => result) 71 | .catch((e) => { 72 | logger.error(e); 73 | console.log(e); 74 | }); 75 | } 76 | 77 | public async create(data: any): Promise { 78 | const newData = new this.Model(data); 79 | return newData 80 | .save() 81 | .then((result) => result) 82 | .catch((e) => { 83 | logger.error(e); 84 | console.log(e); 85 | }); 86 | } 87 | 88 | public async findByWhere( 89 | where: object, 90 | attributes: string[] | undefined | unknown = undefined, 91 | order: string[] = ['id', 'asc'], 92 | limit: number | null = null, 93 | offset: number | null = null 94 | ): Promise { 95 | if (!attributes) { 96 | return this.Model.findAll({ 97 | where, 98 | order: [order], 99 | limit, 100 | offset, 101 | }) 102 | .then((result) => result) 103 | .catch((e) => { 104 | logger.error(e); 105 | console.log(e); 106 | }); 107 | } 108 | 109 | return this.Model.findAll({ 110 | where, 111 | attributes, 112 | order: [order], 113 | limit, 114 | offset, 115 | }) 116 | .then((result) => result) 117 | .catch((e) => { 118 | logger.error(e); 119 | console.log(e); 120 | }); 121 | } 122 | 123 | public async deleteByWhere(where: object): Promise { 124 | return this.Model.destroy({ where }) 125 | .then((result) => result) 126 | .catch((e) => { 127 | logger.error(e); 128 | console.log(e); 129 | }); 130 | } 131 | 132 | public async bulkCreate(data: object[]) { 133 | return this.Model.bulkCreate(data) 134 | .then((result) => result) 135 | .catch((e) => { 136 | logger.error(e); 137 | console.log(e); 138 | }); 139 | } 140 | 141 | public async getCountByWhere(where: object) { 142 | return this.Model.count({ where }) 143 | .then((result) => result) 144 | .catch((e) => { 145 | logger.error(e); 146 | console.log(e); 147 | }); 148 | } 149 | 150 | public async incrementCountInFieldByWhere( 151 | fieldName: string, 152 | where: object, 153 | incrementValue = 1 154 | ): Promise { 155 | const instance = await this.Model.findOne({ where }); 156 | if (!instance) { 157 | return false; 158 | } 159 | return instance 160 | .increment(fieldName, { by: incrementValue }) 161 | .then((result) => result) 162 | .catch((e) => { 163 | logger.error(e); 164 | console.log(e); 165 | }); 166 | } 167 | 168 | public async decrementCountInFieldByWhere( 169 | fieldName: string, 170 | where: object, 171 | decrementValue = 1 172 | ) { 173 | const instance = await this.Model.findOne({ where }); 174 | if (!instance) { 175 | return false; 176 | } 177 | return instance 178 | .decrement(fieldName, { by: decrementValue }) 179 | .then((result) => result) 180 | .catch((e) => { 181 | logger.error(e); 182 | console.log(e); 183 | }); 184 | } 185 | 186 | public async getDataTableData( 187 | where: object, 188 | limit: number, 189 | offset: number, 190 | order: string[][] = [['id', 'DESC']] 191 | ): Promise { 192 | return this.Model.findAndCountAll({ 193 | limit: Number(limit), 194 | offset: Number(offset), 195 | where, 196 | order, 197 | }) 198 | .then((result) => result) 199 | .catch((e) => { 200 | logger.error(e); 201 | console.log(e); 202 | }); 203 | } 204 | } 205 | --------------------------------------------------------------------------------