From 3546f88848a7d95fe8fe619eb08e6234d42dc54f Mon Sep 17 00:00:00 2001 From: Chinedu Ekene Okpala Date: Tue, 29 Oct 2024 09:48:23 +0000 Subject: [PATCH] refactor(Auth,-Redis,-SocketIO,-reconfigure-ioc-container): refactoring middleware ad services --- README.md | 1 + package.json | 11 +- pnpm-lock.yaml | 103 +++++++++++++++++ src/app.ts | 57 +++++---- src/controllers/auth.controller.ts | 2 +- src/controllers/user.controller.ts | 18 ++- src/db/models/user.model.ts | 41 ++++--- src/di/container.ts | 14 +-- ...rdparty.module.ts => middleware.module.ts} | 4 +- src/di/modules/server.module.ts | 20 ++++ src/di/modules/services.module.ts | 11 +- src/di/types.ts | 9 +- src/index.ts | 11 ++ src/middlewares/authHandler.ts | 7 +- src/middlewares/corsHandler.ts | 8 +- src/middlewares/defineRoutes.ts | 4 +- src/middlewares/rateLimitHandler.ts | 13 ++- src/middlewares/sessionHandler.ts | 12 +- src/repositories/base.repository.ts | 15 ++- src/server.ts | 9 -- src/services/auth.service.ts | 30 +++-- .../redis.service.ts} | 4 +- src/services/socket.service.ts | 109 ++++++++++++++++++ src/services/user.service.ts | 20 ++-- src/tests/{server.test.ts => app.test.ts} | 9 +- src/tests/models/user.model.test.ts | 1 - .../repositories/user.repository.test.ts | 1 - src/utils/catchAsync.ts | 10 +- src/validators/user.zod.schema.ts | 15 +-- 29 files changed, 427 insertions(+), 142 deletions(-) rename src/di/modules/{thirdparty.module.ts => middleware.module.ts} (71%) create mode 100644 src/di/modules/server.module.ts create mode 100644 src/index.ts delete mode 100644 src/server.ts rename src/{configs/redis.config.ts => services/redis.service.ts} (92%) create mode 100644 src/services/socket.service.ts rename src/tests/{server.test.ts => app.test.ts} (89%) diff --git a/README.md b/README.md index ce61bf1..f1cd243 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ An express server boilerplate built using the latest version of Node.js and inte ```bash pnpm run db:create pnpm run db:migrate:up + pnpm run db:seed:all ``` 6. **Start the development server** diff --git a/package.json b/package.json index d831319..8a8a8ca 100644 --- a/package.json +++ b/package.json @@ -5,15 +5,17 @@ "main": "src/server.ts", "scripts": { "build": "rimraf build && tsc -p ./tsconfig.build.json", - "docker-up-dev": "NODE_ENV=development ./run-docker.sh", - "dev": "NODE_ENV=development nodemon -r tsconfig-paths/register ./src/server.ts", - "start": "NODE_ENV=development TS_NODE_BASEURL=./build nodemon -r tsconfig-paths/register ./build/server.js", + "docker-up-dev": "rimraf .env && NODE_ENV=development ./run-docker.sh", + "docker-up-prod": "rimraf .env && NODE_ENV=production ./run-docker.sh", + "dev": "NODE_ENV=development nodemon -r tsconfig-paths/register ./src/index.ts", + "start": "NODE_ENV=development TS_NODE_BASEURL=./build nodemon -r tsconfig-paths/register ./build/index.js", "db:create": "pnpm run build && NODE_ENV=development npx sequelize-cli db:create", + "db:seed:all": "pnpm run build && NODE_ENV=development npx sequelize-cli db:seed:all", "db:migrate:up": "pnpm run build && NODE_ENV=development npx sequelize-cli db:migrate", "db:migrate:undo": "pnpm run build && NODE_ENV=development npx sequelize-cli db:migrate:undo", "test": "NODE_ENV=test TEST_MODEL=mock jest src --runInBand --detectOpenHandles", "test:watch": "NODE_ENV=test TEST_MODEL=real jest --runInBand --watch", - "test-single": "NODE_ENV=test TEST_MODEL=mock jest ./src/tests/server.test.ts --detectOpenHandles", + "test-single": "NODE_ENV=test TEST_MODEL=mock jest ./src/tests/app.test.ts --detectOpenHandles", "lint": "eslint ./src/**/*" }, "author": "Chinedu", @@ -40,6 +42,7 @@ "reflect-metadata": "^0.2.2", "safe-regex": "^2.1.1", "sequelize": "^6.37.3", + "socket.io": "^4.8.1", "zod": "^3.23.8" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b98fc15..9541992 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ dependencies: sequelize: specifier: ^6.37.3 version: 6.37.3(pg-hstore@2.3.4)(pg@8.12.0) + socket.io: + specifier: ^4.8.1 + version: 4.8.1 zod: specifier: ^3.23.8 version: 3.23.8 @@ -1103,6 +1106,10 @@ packages: '@sinonjs/commons': 3.0.1 dev: true + /@socket.io/component-emitter@3.1.2: + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + dev: false + /@tsconfig/node10@1.0.11: resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} dev: true @@ -1196,10 +1203,20 @@ packages: '@types/express': 5.0.0 dev: true + /@types/cookie@0.4.1: + resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} + dev: false + /@types/cookiejar@2.1.5: resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} dev: true + /@types/cors@2.8.17: + resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==} + dependencies: + '@types/node': 22.0.0 + dev: false + /@types/debug@4.1.12: resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} dependencies: @@ -1704,6 +1721,11 @@ packages: /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + /base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + dev: false + /bcrypt@5.1.1: resolution: {integrity: sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==} engines: {node: '>= 10.0.0'} @@ -2043,6 +2065,11 @@ packages: engines: {node: '>= 0.6'} dev: false + /cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + dev: false + /cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} dev: true @@ -2270,6 +2297,31 @@ packages: engines: {node: '>= 0.8'} dev: false + /engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + dev: false + + /engine.io@6.6.2: + resolution: {integrity: sha512-gmNvsYi9C8iErnZdVcJnvCpSKbWTt1E8+JZo8b+daLninywUWi5NQ5STSHZ9rFjFO7imNcvb8Pc5pe/wMR5xEw==} + engines: {node: '>=10.2.0'} + dependencies: + '@types/cookie': 0.4.1 + '@types/cors': 2.8.17 + '@types/node': 22.0.0 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.5 + debug: 4.3.5(supports-color@5.5.0) + engine.io-parser: 5.2.3 + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: false + /error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} dependencies: @@ -4788,6 +4840,44 @@ packages: engines: {node: '>=8'} dev: true + /socket.io-adapter@2.5.5: + resolution: {integrity: sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==} + dependencies: + debug: 4.3.5(supports-color@5.5.0) + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: false + + /socket.io-parser@4.2.4: + resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==} + engines: {node: '>=10.0.0'} + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.5(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + dev: false + + /socket.io@4.8.1: + resolution: {integrity: sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==} + engines: {node: '>=10.2.0'} + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.5 + debug: 4.3.5(supports-color@5.5.0) + engine.io: 6.6.2 + socket.io-adapter: 2.5.5 + socket.io-parser: 4.2.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: false + /source-map-support@0.5.13: resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} dependencies: @@ -5320,6 +5410,19 @@ packages: signal-exit: 3.0.7 dev: true + /ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: false + /xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} diff --git a/src/app.ts b/src/app.ts index 756ecf2..4acf92d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,7 +1,7 @@ import helmet from 'helmet'; -import http from 'node:http'; -import express from 'express'; +import { Server } from 'node:http'; import cookieParser from 'cookie-parser'; +import express, { Express } from 'express'; import { NOT_FOUND, OK } from 'http-status'; import { inject, injectable } from 'inversify'; @@ -23,16 +23,20 @@ import { import { TYPES } from 'di/types'; +import { SocketService, RedisService } from 'services'; import { AuthController, UserController } from 'controllers'; -import { RedisClient } from 'configs/redis.config'; @injectable() export class App { - public _express = express(); - private httpServer: ReturnType | undefined; private isInitialized: boolean = false; constructor( + @inject(TYPES.Server) + private server: Server, + @inject(TYPES.Express) + private express: Express, + @inject(TYPES.SocketService) + private socketService: SocketService, @inject(TYPES.AuthController) private authController: AuthController, @inject(TYPES.UserController) @@ -41,8 +45,8 @@ export class App { private rateLimitHandler: RateLimitHandler, @inject(TYPES.SessionHandler) private sessionHandler: SessionHandler, - @inject(TYPES.RedisClient) - private redisClient: RedisClient, + @inject(TYPES.RedisService) + private redisService: RedisService, ) {} public async initialize() { @@ -61,50 +65,55 @@ export class App { logger.log('----------------------------------------'); logger.log('Starting Server'); logger.log('----------------------------------------'); - this.httpServer = http.createServer(this._express); - this.httpServer.listen(serverConfig.port, () => { + this.server.listen(serverConfig.port, () => { logger.log('----------------------------------------'); logger.log(`Server started on ${serverConfig.host}:${serverConfig.port}`); logger.log('----------------------------------------'); + + // Initialize Socket.IO listeners + logger.log('----------------------------------------'); + logger.log('Initialize Socket.IO listeners'); + logger.log('----------------------------------------'); + this.socketService.setupListeners(); }); } public async shutdown(callback: (err?: Error) => void) { - await this.redisClient.close(); + await this.redisService.close(); await sequelize.close(); - this.httpServer?.close(callback); + this.server?.close(callback); } private setExpressSettings() { logger.log('----------------------------------------'); logger.log('Initializing API'); logger.log('----------------------------------------'); - this._express.use(helmet()); - this._express.use(cookieParser(cookiesConfig.secretKey)); - this._express.use(express.urlencoded({ extended: true })); - this._express.use(express.json()); - this._express.disable('x-powered-by'); + this.express.use(helmet()); + this.express.use(cookieParser(cookiesConfig.secretKey)); + this.express.use(express.urlencoded({ extended: true })); + this.express.use(express.json()); + this.express.disable('x-powered-by'); } private initializePreMiddlewares() { logger.log('----------------------------------------'); logger.log('Configuration Pre Middlewares'); logger.log('----------------------------------------'); - this._express.use(corsHandler); - this._express.use(this.sessionHandler.handler); - this._express.use(this.rateLimitHandler.handler); - this._express.use(loggerHandler); + this.express.use(corsHandler); + this.express.use(this.sessionHandler.handler); + this.express.use(this.rateLimitHandler.handler); + this.express.use(loggerHandler); } private initializeControllers() { logger.log('----------------------------------------'); logger.log('Define Routes & Controllers'); logger.log('----------------------------------------'); - this._express.get('/health-check', (_, res) => { + this.express.get('/health-check', (_, res) => { res.status(OK).json({ status: 'success', health: '100%' }); }); - defineRoutes([this.authController, this.userController], this._express); - this._express.use( + defineRoutes([this.authController, this.userController], this.express); + this.express.use( '*', catchAsync(async (req) => { throw new AppError( @@ -130,6 +139,6 @@ export class App { logger.log('----------------------------------------'); logger.log('Configuration Post Middlewares'); logger.log('----------------------------------------'); - this._express.use(globalErrorHandler); + this.express.use(globalErrorHandler); } } diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 8ee2bf6..af4a959 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -51,7 +51,7 @@ export class AuthController { secure: isProd, }) .status(OK) - .json({ ...result.user, accessToken: result.accessToken }); + .json({ user: result.user, accessToken: result.accessToken }); } @Route('post', '/login') diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index 5c19ee5..d62b7bd 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -3,9 +3,9 @@ import { injectable, inject } from 'inversify'; import { ACCEPTED, NO_CONTENT, OK } from 'http-status'; import { - QuerySchema, + UserQuerySchema, ParamsWithId, - UpdateSchema, + UserUpdateSchema, DeleteTypeSchema, } from 'validators'; import { TYPES } from 'di/types'; @@ -28,8 +28,8 @@ export class UserController { @Route('get', '/search') @AuthGuard() - @Validator({ query: QuerySchema }) - async query(req: Request<[], [], [], QuerySchema>, res: Response) { + @Validator({ query: UserQuerySchema }) + async query(req: Request<[], [], [], UserQuerySchema>, res: Response) { return res.status(OK).json(await this.service.getUsersByQuery(req.query)); } @@ -42,8 +42,14 @@ export class UserController { @Route('patch', '/:id') @AuthGuard() - @Validator({ body: UpdateSchema, params: ParamsWithId }) - async update(req: Request, res: Response) { + @Validator({ + body: UserUpdateSchema, + params: ParamsWithId, + }) + async update( + req: Request, + res: Response, + ) { return res.status(OK).json({ message: 'User details updated successfully', data: await this.service.update(req.params.id, req.body), diff --git a/src/db/models/user.model.ts b/src/db/models/user.model.ts index 1e9a109..15a03f3 100644 --- a/src/db/models/user.model.ts +++ b/src/db/models/user.model.ts @@ -1,6 +1,6 @@ import { - UUIDV4, Model, + UUIDV4, DataTypes, InferAttributes, CreationOptional, @@ -20,15 +20,11 @@ export class UserModel extends Model { declare lastName: string; declare password: string; declare firstName: string; - declare userType: '0' | '1' | '2'; declare id?: CreationOptional; declare createdAt?: CreationOptional; declare updatedAt?: CreationOptional; declare deletedAt?: CreationOptional; declare dateOfBirth?: CreationOptional; - /* static associate(models) { - // define association here - } */ getFullname() { return this?.firstName + ' ' + this?.lastName; @@ -50,14 +46,18 @@ export class UserModel extends Model { generateJWT(type: 'access' | 'refresh' | 'reset' | 'verify') { const { secretKey, accessExpiresIn, refreshExpiresIn, defaultExpiresIn } = jwtConfig; - return sign({ sub: this.id, email: this.email, type }, secretKey, { - expiresIn: - type === 'access' - ? accessExpiresIn - : type === 'refresh' - ? refreshExpiresIn - : defaultExpiresIn, - }); + return sign( + { sub: this.id, email: this.email, username: this.getFullname(), type }, + secretKey, + { + expiresIn: + type === 'access' + ? accessExpiresIn + : type === 'refresh' + ? refreshExpiresIn + : defaultExpiresIn, + }, + ); } } @@ -82,10 +82,6 @@ UserModel.init( allowNull: false, unique: true, }, - userType: { - allowNull: false, - type: DataTypes.ENUM('0', '1', '2'), - }, password: { type: DataTypes.STRING, allowNull: false, @@ -110,7 +106,16 @@ UserModel.init( type: DataTypes.DATE, }, }, - { sequelize, paranoid: true, freezeTableName: true, modelName: 'Users' }, + { + sequelize, + paranoid: true, + freezeTableName: true, + modelName: 'Users', + defaultScope: { + attributes: { exclude: ['password'] }, + }, + }, ); export type UserModelDto = InferAttributes; +// s diff --git a/src/di/container.ts b/src/di/container.ts index 4c86007..2f9e17e 100644 --- a/src/di/container.ts +++ b/src/di/container.ts @@ -1,8 +1,9 @@ import { interfaces, Container as InversifyContainer } from 'inversify'; import { + ServerModule, ModelsModule, ServicesModule, - ThirdpartyModule, + MiddlewareModule, ControllersModule, RepositoriesModule, } from './modules'; @@ -43,7 +44,8 @@ class ContainerManager { private initialize() { if (this.initialized) return; - this._container.load(ThirdpartyModule); + this._container.load(ServerModule); + this._container.load(MiddlewareModule); this._container.load(ModelsModule); this._container.load(RepositoriesModule); this._container.load(ServicesModule); @@ -58,14 +60,6 @@ class ContainerManager { ): interfaces.BindingToSyntax { return this._container.bind(serviceIdentifier); } - - // For testing purposes - public reset() { - this._container = new InversifyContainer({ - defaultScope: 'Singleton', - }); - this.initialized = false; - } } export const container = ContainerManager.getInstance(); diff --git a/src/di/modules/thirdparty.module.ts b/src/di/modules/middleware.module.ts similarity index 71% rename from src/di/modules/thirdparty.module.ts rename to src/di/modules/middleware.module.ts index 34db114..046928c 100644 --- a/src/di/modules/thirdparty.module.ts +++ b/src/di/modules/middleware.module.ts @@ -1,14 +1,12 @@ import { ContainerModule, interfaces } from 'inversify'; import { TYPES } from 'di/types'; -import { RedisClient } from 'configs/redis.config'; import { RateLimitHandler, SessionHandler, AuthHandler } from 'middlewares'; const initializeModule = (bind: interfaces.Bind) => { - bind(TYPES.RedisClient).to(RedisClient).inSingletonScope(); bind(TYPES.AuthHandler).to(AuthHandler).inSingletonScope(); bind(TYPES.SessionHandler).to(SessionHandler).inSingletonScope(); bind(TYPES.RateLimitHandler).to(RateLimitHandler).inSingletonScope(); }; -export const ThirdpartyModule = new ContainerModule(initializeModule); +export const MiddlewareModule = new ContainerModule(initializeModule); diff --git a/src/di/modules/server.module.ts b/src/di/modules/server.module.ts new file mode 100644 index 0000000..33dd071 --- /dev/null +++ b/src/di/modules/server.module.ts @@ -0,0 +1,20 @@ +import express, { Express } from 'express'; +import { ContainerModule, interfaces } from 'inversify'; +import { Server, createServer } from 'node:http'; + +import { TYPES } from 'di/types'; + +// Create Express app +const expressApp = express(); + +// Create the HTTP server instance +const httpServer: ReturnType | undefined = + createServer(expressApp); + +// Bind the HTTP server +const initializeModule = (bind: interfaces.Bind) => { + bind(TYPES.Express).toConstantValue(expressApp); + bind(TYPES.Server).toConstantValue(httpServer); +}; + +export const ServerModule = new ContainerModule(initializeModule); diff --git a/src/di/modules/services.module.ts b/src/di/modules/services.module.ts index b972e43..97c8695 100644 --- a/src/di/modules/services.module.ts +++ b/src/di/modules/services.module.ts @@ -1,9 +1,18 @@ import { ContainerModule, interfaces } from 'inversify'; import { TYPES } from 'di/types'; -import { AuthService, IAuthService, UserService, IUserService } from 'services'; +import { + AuthService, + IAuthService, + UserService, + IUserService, + RedisService, + SocketService, +} from 'services'; const initializeModule = (bind: interfaces.Bind) => { + bind(TYPES.RedisService).to(RedisService).inSingletonScope(); + bind(TYPES.SocketService).to(SocketService).inSingletonScope(); bind(TYPES.AuthService).to(AuthService).inSingletonScope(); bind(TYPES.UserService).to(UserService).inSingletonScope(); }; diff --git a/src/di/types.ts b/src/di/types.ts index 6c1c7bf..a1fb489 100644 --- a/src/di/types.ts +++ b/src/di/types.ts @@ -1,4 +1,8 @@ export const TYPES = { + // Server + Server: Symbol.for('Server'), + Express: Symbol.for('Express'), + // Models UserModel: Symbol.for('UserModel'), @@ -8,13 +12,14 @@ export const TYPES = { // Services AuthService: Symbol.for('AuthService'), UserService: Symbol.for('UserService'), + RedisService: Symbol.for('RedisService'), + SocketService: Symbol.for('SocketService'), // Controllers AuthController: Symbol.for('AuthController'), UserController: Symbol.for('UserController'), - // Thirdparty - RedisClient: Symbol.for('RedisClient'), + // Middleware AuthHandler: Symbol.for('AuthHandler'), SessionHandler: Symbol.for('SessionHandler'), RateLimitHandler: Symbol.for('RateLimitHandler'), diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..71d24d9 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,11 @@ +import 'reflect-metadata'; +import { container } from 'di/container'; +import { App } from 'app'; + +const app = container.get(App); +app + .initialize() + .then(() => app.start()) + .catch((err) => { + console.error('Failed to start the application:', err); + }); diff --git a/src/middlewares/authHandler.ts b/src/middlewares/authHandler.ts index 59c386d..a4e4613 100644 --- a/src/middlewares/authHandler.ts +++ b/src/middlewares/authHandler.ts @@ -5,8 +5,7 @@ import { Request, Response, NextFunction } from 'express'; import { UserModel } from 'db/models'; import { AppError, catchAsync } from 'utils'; import { TYPES } from 'di/types'; -import { RedisClient } from 'configs/redis.config'; -import { IAuthService } from 'services'; +import { IAuthService, RedisService } from 'services'; declare module 'express-session' { interface SessionData { @@ -26,7 +25,7 @@ export class AuthHandler { constructor( @inject(TYPES.AuthService) private authService: IAuthService, - @inject(TYPES.RedisClient) private redisClient: RedisClient, + @inject(TYPES.RedisService) private redisService: RedisService, ) { this.handler = this.handler.bind(this); } @@ -43,7 +42,7 @@ export class AuthHandler { await this.authService.validateJWT(accessToken); if (!error) { - const storedUser = (await this.redisClient + const storedUser = (await this.redisService .getClient() .get(decoded?.sub as string)) as string; diff --git a/src/middlewares/corsHandler.ts b/src/middlewares/corsHandler.ts index 59cf1b4..4dde145 100644 --- a/src/middlewares/corsHandler.ts +++ b/src/middlewares/corsHandler.ts @@ -7,11 +7,11 @@ export function corsHandler(req: Request, res: Response, next: NextFunction) { 'Origin, X-Requested-With, Content-Type, Accept, Authorization', ); res.header('Access-Control-Allow-Credentials', 'true'); + res.header('Access-Control-Allow-Methods', 'GET, PUT, POST, PATCH, DELETE'); if (req.method === 'OPTIONS') { - res.header('Access-Control-Allow-Methods', 'GET, PUT, POST, PATCH, DELETE'); - res.status(200).json({}); + res.sendStatus(200); // Send OK for preflight + } else { + next(); } - - next(); } diff --git a/src/middlewares/defineRoutes.ts b/src/middlewares/defineRoutes.ts index a4d353f..e77de5e 100644 --- a/src/middlewares/defineRoutes.ts +++ b/src/middlewares/defineRoutes.ts @@ -29,7 +29,9 @@ export function defineRoutes(controllers: IController[], app: Express) { for (let k = 0; k < routeNames.length; k++) { const handlers = routes.get(routeNames[k])?.map((item) => { - return catchAsync((...rest) => item.call(controller, ...rest)); + return catchAsync( + async (...rest) => await item.call(controller, ...rest), + ); }); if (handlers) { diff --git a/src/middlewares/rateLimitHandler.ts b/src/middlewares/rateLimitHandler.ts index 4d7ddb1..9495950 100644 --- a/src/middlewares/rateLimitHandler.ts +++ b/src/middlewares/rateLimitHandler.ts @@ -4,15 +4,18 @@ import { Request, Response, NextFunction } from 'express'; import { INTERNAL_SERVER_ERROR, TOO_MANY_REQUESTS } from 'http-status'; import { TYPES } from 'di/types'; -import { RedisClient } from 'configs/redis.config'; +import { RedisService } from 'services'; decorate(injectable(), RateLimiterRedis); @injectable() export class RateLimitHandler extends RateLimiterRedis { - constructor(@inject(TYPES.RedisClient) private redisClient: RedisClient) { + constructor( + @inject(TYPES.RedisService) + private redisService: RedisService, + ) { super({ - storeClient: redisClient.getClient({ + storeClient: redisService.getClient({ enableOfflineQueue: false, }), keyPrefix: 'rate-limit', @@ -37,7 +40,9 @@ export class RateLimitHandler extends RateLimiterRedis { } else { const secs = Math.round(rejRes.msBeforeNext / 1000) || 1; res.set('Retry-After', String(secs)); - res.status(TOO_MANY_REQUESTS).send('Too Many Requests'); + res + .status(TOO_MANY_REQUESTS) + .send({ status: 'error', message: 'Too Many Requests' }); } } } diff --git a/src/middlewares/sessionHandler.ts b/src/middlewares/sessionHandler.ts index 89cbb60..992af54 100644 --- a/src/middlewares/sessionHandler.ts +++ b/src/middlewares/sessionHandler.ts @@ -1,13 +1,13 @@ import { Redis } from 'ioredis'; import session from 'express-session'; import RedisStore from 'connect-redis'; - -import { isProd, SESSION_SECRET } from 'configs/env.config'; import { inject, injectable } from 'inversify'; -import { TYPES } from 'di/types'; -import { RedisClient } from 'configs/redis.config'; import { NextFunction, Request, Response } from 'express'; +import { TYPES } from 'di/types'; +import { RedisService } from 'services'; +import { isProd, SESSION_SECRET } from 'configs/env.config'; + export const sessionHandler = (client: Redis) => { // Initialize store. const redisStore = new RedisStore({ @@ -31,9 +31,9 @@ export const sessionHandler = (client: Redis) => { @injectable() export class SessionHandler extends RedisStore { - constructor(@inject(TYPES.RedisClient) private redisClient: RedisClient) { + constructor(@inject(TYPES.RedisService) private redisService: RedisService) { super({ - client: redisClient.getClient(), + client: redisService.getClient(), prefix: 'myapp:super-parakeet', }); diff --git a/src/repositories/base.repository.ts b/src/repositories/base.repository.ts index 8d7a84b..fed59df 100644 --- a/src/repositories/base.repository.ts +++ b/src/repositories/base.repository.ts @@ -5,6 +5,7 @@ import { ModelStatic, WhereOptions, WhereAttributeHashValue, + FindOptions, } from 'sequelize'; import { MakeNullishOptional } from 'sequelize/lib/utils'; @@ -22,12 +23,18 @@ export class BaseRepository { return await this.model.findAll({ where: query }); } - public async getById(id: string) { - return await this.model.findByPk(id); + public async getById( + id: string, + options?: Omit>, 'where'>, + ) { + return await this.model.findByPk(id, options); } - public async getOne(query: WhereOptions>) { - return await this.model.findOne({ where: query }); + public async getOne( + query: WhereOptions>, + options?: Omit>, 'where'>, + ) { + return await this.model.findOne({ where: query, ...options }); } public async updateById(id: string, payload: Partial) { diff --git a/src/server.ts b/src/server.ts deleted file mode 100644 index 4e47f44..0000000 --- a/src/server.ts +++ /dev/null @@ -1,9 +0,0 @@ -import 'reflect-metadata'; -import { container } from 'di/container'; -import { App } from 'app'; - -(async () => { - const app = container.get(App); - await app.initialize(); - app.start(); -})(); diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 01bc228..bb0747f 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -1,11 +1,13 @@ import { CONFLICT, + FORBIDDEN, BAD_REQUEST, + UNAUTHORIZED, UNPROCESSABLE_ENTITY, INTERNAL_SERVER_ERROR, - FORBIDDEN, - UNAUTHORIZED, } from 'http-status'; +import { Redis } from 'ioredis'; +import { Response } from 'express'; import { verify, decode, JwtPayload } from 'jsonwebtoken'; import { injectable, inject } from 'inversify'; import { CacheUpdate } from '@type-cacheable/core'; @@ -16,7 +18,6 @@ import { UserModelDto } from 'db/models'; import { AppError, exclude } from 'utils'; import { BaseService } from './base.service'; import { UserRepository } from 'repositories'; -import { RedisClient } from 'configs/redis.config'; import { cookiesConfig, jwtConfig } from 'configs/env.config'; import type { EmailSchema, @@ -25,8 +26,8 @@ import type { ResetPasswordSchema, RefreshTokenSchema, } from 'validators'; -import { Redis } from 'ioredis'; -import { Response } from 'express'; + +import { RedisService } from './redis.service'; const REDIS_BUFFER = 3 * 60; @@ -58,12 +59,12 @@ export class AuthService extends BaseService implements IAuthService { constructor( @inject(TYPES.UserRepository) private repo: UserRepository, - @inject(TYPES.RedisClient) - private redisClient: RedisClient, + @inject(TYPES.RedisService) + private redisService: RedisService, ) { super(); - this._redisClient = this.redisClient.getClient({ + this._redisClient = this.redisService.getClient({ enableOfflineQueue: true, }); @@ -74,11 +75,13 @@ export class AuthService extends BaseService implements IAuthService { this.on('user_failed_login', async () => {}); } - public async validateJWT(token: string) { + public async validateJWT(token?: string) { let response: { decoded: JwtPayload | null; error?: unknown; - } = { decoded: null }; + } = { decoded: null, error: new Error('No token provided') }; + + if (!token) return response; try { const decoded = verify(token, jwtConfig.secretKey) as JwtPayload; @@ -120,7 +123,12 @@ export class AuthService extends BaseService implements IAuthService { } public async authenticate(dto: LoginSchema) { - const user = await this.repo.getOne({ email: dto.email }); + const user = await this.repo.getOne( + { email: dto.email }, + { + attributes: { include: ['password'] }, + }, + ); if (user && (await user.isPasswordMatch(dto.password))) { const accessToken = user.generateJWT('access'); const refreshToken = user.generateJWT('refresh'); diff --git a/src/configs/redis.config.ts b/src/services/redis.service.ts similarity index 92% rename from src/configs/redis.config.ts rename to src/services/redis.service.ts index f1797dc..be53665 100644 --- a/src/configs/redis.config.ts +++ b/src/services/redis.service.ts @@ -1,10 +1,10 @@ import Redis, { RedisOptions } from 'ioredis'; -import { redisConfig } from './env.config'; import { injectable } from 'inversify'; +import { redisConfig } from 'configs/env.config'; @injectable() -export class RedisClient { +export class RedisService { private client?: Redis; /** diff --git a/src/services/socket.service.ts b/src/services/socket.service.ts new file mode 100644 index 0000000..c5aca0c --- /dev/null +++ b/src/services/socket.service.ts @@ -0,0 +1,109 @@ +import { injectable, inject } from 'inversify'; +import { Server as HttpServer } from 'node:http'; +import { Server as SocketIOServer } from 'socket.io'; + +import { TYPES } from 'di/types'; +import { AuthService } from './auth.service'; + +@injectable() +export class SocketService { + private io: SocketIOServer; + + constructor( + @inject(TYPES.Server) + httpServer: HttpServer, + @inject(TYPES.AuthService) + private authService: AuthService, + ) { + // Initialize Socket.IO with the provided HTTP server + this.io = new SocketIOServer(httpServer, { + cors: { + origin: '*', // Allow all origins (configure as needed for your environment) + methods: ['GET', 'POST'], + }, + }); + } + + public getIO(): SocketIOServer { + return this.io; + } + + public setupListeners(): void { + this.io.on('connection', async (socket) => { + logger.log('----------------------------------------'); + logger.log(`Socket client connected: ${socket.id}`); + logger.log('----------------------------------------'); + // Access request headers through socket.handshake.headers + const accessToken = socket.handshake.query.authorization as string; + + // Validate the token (if needed) + const { decoded, error } = + await this.authService.validateJWT(accessToken); + if (error) { + socket.disconnect(); // Disconnect if the token is invalid + } + + // Join a chat room based on the id + socket.on('joinRoom', async (chatId: string) => { + socket.join(chatId); + // find and update + // Broadcast to the room that a new user has joined + socket.broadcast.to(chatId).emit('userJoined', { + senderId: decoded?.sub, + sender: { + id: decoded?.sub || '', + firstName: decoded?.username.split(' ')[0] || '', + lastName: decoded?.username.split(' ')[1] || '', + }, + timestamp: new Date().toISOString(), + message: `${decoded?.username} has joined.`, + }); + }); + + // Leave a room + socket.on('leaveRoom', (chatId: string) => { + socket.leave(chatId); + socket.broadcast.to(chatId).emit('userLeft', { + senderId: decoded?.sub, + sender: { + id: decoded?.sub || '', + firstName: decoded?.username.split(' ')[0] || '', + lastName: decoded?.username.split(' ')[1] || '', + }, + timestamp: new Date().toISOString(), + message: `${decoded?.username} has left`, + }); + }); + + // Handle messages within a room + socket.on('message', async ({ chatId, message }) => { + const timestamp = new Date(); + // Emit message to all users in the room + this.io.to(chatId).emit('message', { + senderId: decoded?.sub, + sender: { + id: decoded?.sub || '', + firstName: decoded?.username.split(' ')[0] || '', + lastName: decoded?.username.split(' ')[1] || '', + }, + message, + timestamp: timestamp.toISOString(), + }); + }); + + // Handle messages within a room + socket.on('isTyping', ({ chatId, isTyping }) => { + // Emit to all users in the room except for the sender + socket.broadcast.to(chatId).emit('isUserTyping', { + message: isTyping ? `${decoded?.username} is typing...` : '', + }); + }); + + socket.on('disconnect', () => { + logger.log('----------------------------------------'); + logger.log(`Socket client disconnected: ${socket.id}`); + logger.log('----------------------------------------'); + }); + }); + } +} diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 3f90715..cecad4f 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -5,20 +5,20 @@ import { useAdapter } from '@type-cacheable/ioredis-adapter'; import { Cacheable, CacheClear, CacheUpdate } from '@type-cacheable/core'; import { TYPES } from 'di/types'; -import { UserModel, UserModelDto } from 'db/models'; import { AppError, exclude } from 'utils'; import { BaseService } from './base.service'; import { UserRepository } from 'repositories'; -import { RedisClient } from 'configs/redis.config'; -import { type UpdateSchema, type QuerySchema } from 'validators'; +import { RedisService } from './redis.service'; +import { UserModel, UserModelDto } from 'db/models'; +import type { UserUpdateSchema, UserQuerySchema } from 'validators'; export interface IUserService { getAllUsers(): Promise<{ data: UserModelDto[]; message: string }>; getUserById(id: string): Promise; getUsersByQuery( - query: QuerySchema, + query: UserQuerySchema, ): Promise<{ data: UserModelDto[]; message: string }>; - update(id: string, payload: UpdateSchema): Promise; + update(id: string, payload: UserUpdateSchema): Promise; softDeleteById(id: string): Promise; forceDeleteById(id: string): Promise; } @@ -28,12 +28,12 @@ export class UserService extends BaseService implements IUserService { constructor( @inject(TYPES.UserRepository) protected repo: UserRepository, - @inject(TYPES.RedisClient) - redisClient: RedisClient, + @inject(TYPES.RedisService) + redisService: RedisService, ) { super(); useAdapter( - redisClient.getClient({ + redisService.getClient({ enableOfflineQueue: true, }), false, @@ -65,7 +65,7 @@ export class UserService extends BaseService implements IUserService { @Cacheable({ cacheKey: (args) => qs.stringify(args[0]), }) - public async getUsersByQuery(query: QuerySchema) { + public async getUsersByQuery(query: UserQuerySchema) { const users = await this.repo.getAll(query); // run some formating and all need data manipulation return { @@ -86,7 +86,7 @@ export class UserService extends BaseService implements IUserService { cacheKey: (args, ctx, result) => result.id, cacheKeysToClear: () => ['users'], }) - public async update(id: string, payload: UpdateSchema) { + public async update(id: string, payload: UserUpdateSchema) { const [updatedRows] = await this.repo.updateById(id, payload); if (updatedRows) { diff --git a/src/tests/server.test.ts b/src/tests/app.test.ts similarity index 89% rename from src/tests/server.test.ts rename to src/tests/app.test.ts index c2d5547..ce84839 100644 --- a/src/tests/server.test.ts +++ b/src/tests/app.test.ts @@ -1,29 +1,30 @@ import 'reflect-metadata'; -import EventEmitter from 'node:events'; import request from 'supertest'; import { Express } from 'express'; +import EventEmitter from 'node:events'; import { NOT_FOUND, OK } from 'http-status'; import { container } from 'di/container'; import { App } from 'app'; +import { TYPES } from 'di/types'; // Fix EventEmitter inheritance issue for tests Object.getPrototypeOf(EventEmitter.prototype).constructor = Object; -describe('Server Start', () => { +describe('Testing App', () => { let app: App; let express: Express; beforeAll(async () => { app = container.get(App); await app.initialize(); - express = app._express; + express = container.get(TYPES.Express); }); afterAll(async () => { - await app?.shutdown(container.reset); + await app?.shutdown(() => {}); }); it('Starts and has the proper test environment', async () => { diff --git a/src/tests/models/user.model.test.ts b/src/tests/models/user.model.test.ts index b0999e9..5a9d826 100644 --- a/src/tests/models/user.model.test.ts +++ b/src/tests/models/user.model.test.ts @@ -8,7 +8,6 @@ import { TEST_MODEL } from 'configs/env.config'; describe('User Model Test', () => { let userModelMock: typeof UserModel; const user = { - userType: '1', lastName: 'Doe', password: 'ABC', firstName: 'John', diff --git a/src/tests/repositories/user.repository.test.ts b/src/tests/repositories/user.repository.test.ts index 90ed9d2..deb0e45 100644 --- a/src/tests/repositories/user.repository.test.ts +++ b/src/tests/repositories/user.repository.test.ts @@ -10,7 +10,6 @@ describe.only('User Repository Test', () => { let userModelMock: typeof UserModel; let userRepositoryMock: UserRepository; const user = { - userType: '1', lastName: 'Doe', password: 'ABC', firstName: 'John', diff --git a/src/utils/catchAsync.ts b/src/utils/catchAsync.ts index 15866a4..41852e6 100644 --- a/src/utils/catchAsync.ts +++ b/src/utils/catchAsync.ts @@ -2,9 +2,13 @@ import { Request, Response, NextFunction } from 'express'; export type RouteHandler = [req: Request, res: Response, next: NextFunction]; -export const catchAsync = (fn: (...rest: RouteHandler) => void) => { - const errorHandler = (...rest: RouteHandler) => { - Promise.resolve(fn(...rest)).catch(rest[2]); +export const catchAsync = (fn: (...rest: RouteHandler) => Promise) => { + const errorHandler = async (...rest: RouteHandler) => { + try { + await fn(...rest); // Await the function here + } catch (err) { + rest[2](err); // Call `next` with the error + } }; return errorHandler; }; diff --git a/src/validators/user.zod.schema.ts b/src/validators/user.zod.schema.ts index 3023b43..a713eab 100644 --- a/src/validators/user.zod.schema.ts +++ b/src/validators/user.zod.schema.ts @@ -3,10 +3,9 @@ import z from 'zod'; import { isEmpty } from 'utils/helper'; export const SignupSchema = z.object({ - lastName: z.string(), - firstName: z.string(), + lastName: z.string().min(1), + firstName: z.string().min(1), email: z.string().email(), - userType: z.enum(['0', '1', '2']), password: z .string() .min(6) @@ -59,20 +58,18 @@ export const ResetPasswordSchema = z.object({ export type ResetPasswordSchema = z.infer; -export const QuerySchema = z.object({ +export const UserQuerySchema = z.object({ lastName: z.string().optional(), firstName: z.string().optional(), email: z.string().email().optional(), - userType: z.enum(['0', '1', '2']).optional(), }); -export type QuerySchema = z.infer; +export type UserQuerySchema = z.infer; -export const UpdateSchema = z +export const UserUpdateSchema = z .object({ lastName: z.string().optional(), firstName: z.string().optional(), - // userType: z.enum(['0', '1', '2']).optional(), dateOfBirth: z.date().optional(), }) .partial() @@ -81,4 +78,4 @@ export const UpdateSchema = z path: ['firstName', 'lastName', 'dateOfBirth'], }); -export type UpdateSchema = z.infer; +export type UserUpdateSchema = z.infer;