├── .husky ├── .gitignore └── pre-commit ├── .prettierignore ├── src ├── env.ts ├── entities │ ├── user.entity.ts │ ├── task.entity.ts │ └── activity.entity.ts ├── controllers │ ├── definations.ts │ ├── user.controller.ts │ ├── activity.controller.ts │ ├── auth.controller.ts │ └── task.controller.ts ├── repositories │ ├── activity.repository.ts │ ├── definations.ts │ ├── task.repository.ts │ └── user.repository.ts ├── events │ ├── definations.ts │ └── activity.event.ts ├── services │ ├── activity.service.ts │ ├── definations.ts │ ├── task.service.ts │ ├── user.service.ts │ ├── auth.service.test.ts │ └── auth.service.ts ├── configs.ts ├── app.ts └── index.ts ├── docs ├── architecture.jpg └── clean-architecture.jpg ├── .prettierrc.json ├── .vscode ├── settings.json └── launch.json ├── .dockerignore ├── tsconfig.json ├── .eslintrc.json ├── README.md ├── .gitignore └── package.json /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | public 4 | .vscode 5 | 6 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | 3 | dotenv.config(); 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | npx lint-staged 6 | -------------------------------------------------------------------------------- /docs/architecture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vtuanjs/typescript-solid-principles/HEAD/docs/architecture.jpg -------------------------------------------------------------------------------- /docs/clean-architecture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vtuanjs/typescript-solid-principles/HEAD/docs/clean-architecture.jpg -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "none", 4 | "singleQuote": true, 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /src/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity } from '@vtjs/common'; 2 | 3 | export default class User extends BaseEntity { 4 | email: string; 5 | password: string; 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 4 | "editor.formatOnSave": true, 5 | "eslint.alwaysShowStatus": true, 6 | "eslint.validate": ["javascript", "typescript"], 7 | } 8 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | /configs 3 | node_modules/ 4 | .dockerignore 5 | .gitignore 6 | docker_*.sh 7 | Dockerfile 8 | *.md 9 | **/test/ 10 | .env 11 | docker-compose.* 12 | deploy/ 13 | dist/ 14 | logs/ 15 | .eslintrc 16 | resources 17 | -------------------------------------------------------------------------------- /src/entities/task.entity.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity } from '@vtjs/common'; 2 | 3 | export default class Task extends BaseEntity { 4 | userId: string; 5 | title: string; 6 | description: string; 7 | startTime: string | Date; 8 | endTime: string | Date; 9 | deadline: string | Date; 10 | } 11 | -------------------------------------------------------------------------------- /src/controllers/definations.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from '@vtjs/core'; 2 | import User from '../entities/user.entity'; 3 | 4 | export type RequestWithUser = Request & { user: User }; 5 | 6 | export interface IAuthController { 7 | requiredAuth(req: Request, res: Response, next: NextFunction): Promise; 8 | } 9 | -------------------------------------------------------------------------------- /src/entities/activity.entity.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity } from '@vtjs/common'; 2 | 3 | export enum ActivityAction { 4 | CREATE_TASK = 'task.create', 5 | UPDATE_TASK = 'task.update', 6 | DELETE_TASK = 'task.delete', 7 | 8 | USER_LOGIN = 'user.login' 9 | } 10 | 11 | export default class Activity extends BaseEntity { 12 | userId: string; 13 | action: ActivityAction; 14 | refId: string; 15 | data: any; 16 | } 17 | -------------------------------------------------------------------------------- /src/repositories/activity.repository.ts: -------------------------------------------------------------------------------- 1 | import { Schema, BaseRepository, Mixed } from '@vtjs/mongoose'; 2 | import Activity from '../entities/activity.entity'; 3 | 4 | const activitySchema = new Schema( 5 | { 6 | userId: String, 7 | action: String, 8 | refId: String, 9 | data: Mixed 10 | }, 11 | { 12 | timestamps: true, 13 | versionKey: false, 14 | autoCreate: true 15 | } 16 | ); 17 | 18 | export default class ActivityRepository extends BaseRepository { 19 | constructor() { 20 | super('activity', activitySchema, 'activities'); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/repositories/definations.ts: -------------------------------------------------------------------------------- 1 | import { IBaseRepository, DeleteResponse } from '@vtjs/mongoose'; 2 | 3 | import User from '../entities/user.entity'; 4 | import Task from '../entities/task.entity'; 5 | import Activity from '../entities/activity.entity'; 6 | 7 | export interface IUserRepository extends IBaseRepository { 8 | // Example handling another function that is NOT found in the Base Repository 9 | deleteByEmail(email: string): Promise; 10 | } 11 | 12 | export interface ITaskRepository extends IBaseRepository {} 13 | 14 | export interface IActivityRepository extends IBaseRepository {} 15 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "skipFiles": ["/**"], 12 | "program": "${workspaceFolder}/src/index.ts", 13 | "preLaunchTask": "tsc: build - tsconfig.json", 14 | "sourceMaps": true, 15 | "smartStep": true, 16 | "outFiles": ["${workspaceFolder}/dist/**/*.js"] 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "moduleResolution": "node", 5 | "pretty": false, 6 | "sourceMap": true, 7 | "target": "es6", 8 | "outDir": "dist", 9 | "baseUrl": "src", 10 | "paths": {}, 11 | "allowJs": true, 12 | "noImplicitAny": false, 13 | "experimentalDecorators": true, 14 | "resolveJsonModule": true, 15 | "emitDecoratorMetadata": true, 16 | "esModuleInterop": true 17 | }, 18 | // Add more include file if you want. Like static file, images... 19 | // Example: public/**/* 20 | "include": ["src/index.ts", "src/**/*.json"], 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /src/events/definations.ts: -------------------------------------------------------------------------------- 1 | import { IntegrationEvent, IIntegrationEvent } from '@vtjs/rabbitmq'; 2 | import { ACTIVITY_CREATE_EVENT } from '../configs'; 3 | import { ActivityAction } from '../entities/activity.entity'; 4 | 5 | export class ActivityCreateEvent extends IntegrationEvent { 6 | constructor({ 7 | userId, 8 | action, 9 | refId, 10 | data 11 | }: { 12 | userId: string; 13 | action: ActivityAction; 14 | refId: string; 15 | data: any; 16 | }) { 17 | super({ 18 | name: ACTIVITY_CREATE_EVENT, 19 | data: { 20 | userId, 21 | action, 22 | refId, 23 | data 24 | } 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint", "prettier"], 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/eslint-recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "prettier" 10 | ], 11 | "rules": { 12 | "prettier/prettier": 1, 13 | "@typescript-eslint/no-explicit-any": "off", 14 | "@typescript-eslint/no-unused-vars": "off", 15 | "@typescript-eslint/explicit-module-boundary-types": "off", 16 | "@typescript-eslint/no-empty-interface": "off", 17 | "no-case-declarations": "off", 18 | "no-constant-condition": "off", 19 | "no-useless-escape": "off" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/events/activity.event.ts: -------------------------------------------------------------------------------- 1 | import { BaseEvent } from '@vtjs/rabbitmq'; 2 | import { ILogger } from '@vtjs/common'; 3 | import { ActivityCreateEvent } from './definations'; 4 | import { IActivityService } from '../services/definations'; 5 | 6 | export default class ActivityEvent extends BaseEvent { 7 | constructor(private activityService: IActivityService, private logger: ILogger) { 8 | super(); 9 | } 10 | 11 | async handle(event: ActivityCreateEvent, done: (arg?: Error) => void): Promise { 12 | try { 13 | await this.activityService.create(event.data); 14 | } catch (error) { 15 | this.logger.warn('Create activity error', error); 16 | } finally { 17 | done(); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NODEJS ARCHITECTURE WITH SOLID PRINCIPLES 2 | 3 | ## Running 4 | ### 1. Install Package 5 | - npm install 6 | - npm install -D @types/express @types/mongoose 7 | 8 | ### 2. Testing 9 | npm run test 10 | 11 | ### 3. Run in development mode 12 | npm run dev 13 | 14 | ### 4. Build 15 | npm run build 16 | 17 | ### 5. Run in production mode 18 | npm run start 19 | 20 | ## Architecture 21 | 22 |

23 | 24 |

25 | 26 |

27 | 28 |

29 | 30 | ## FLOW 31 | 32 | Table 33 | ``` 34 | User 35 | Task: User do task 36 | Activity: Logging user action from event 37 | ``` 38 | 39 | Guide: update soon at https://mysolution.dev -------------------------------------------------------------------------------- /src/services/activity.service.ts: -------------------------------------------------------------------------------- 1 | import { BaseService, ILogger, ServiceCache } from '@vtjs/common'; 2 | 3 | import { IActivityService } from './definations'; 4 | import Activity from '../entities/activity.entity'; 5 | import { IActivityRepository } from '../repositories/definations'; 6 | 7 | type ActivityServiceProp = { 8 | repo: IActivityRepository; 9 | logger: ILogger; 10 | }; 11 | 12 | export default class ActivityService extends BaseService implements IActivityService { 13 | // Declare repo type if you need handle another function 14 | // repo: IActivityRepository; 15 | constructor({ repo, logger }: ActivityServiceProp) { 16 | // Set cache = undefined if you don't need using cache 17 | super(repo, undefined, logger); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/repositories/task.repository.ts: -------------------------------------------------------------------------------- 1 | import { Schema, BaseRepository } from '@vtjs/mongoose'; 2 | import Task from '../entities/task.entity'; 3 | 4 | const taskSchema = new Schema( 5 | { 6 | userId: String, 7 | title: String, 8 | description: String, 9 | startTime: { 10 | type: Date, 11 | default: Date.now 12 | }, 13 | endTime: { 14 | type: Date, 15 | default: Date.now 16 | }, 17 | deadline: { 18 | type: Date, 19 | default: Date.now 20 | } 21 | }, 22 | { 23 | timestamps: true, 24 | versionKey: false, 25 | autoCreate: true 26 | } 27 | ); 28 | 29 | export default class TaskRepository extends BaseRepository { 30 | constructor() { 31 | super('task', taskSchema, 'tasks'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/controllers/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Request, Response, NextFunction, sendSuccessReponse } from '@vtjs/core'; 2 | 3 | import User from '../entities/user.entity'; 4 | import { IUserService } from '../services/definations'; 5 | 6 | export default class UserController extends Controller { 7 | constructor(private userService: IUserService) { 8 | super(); 9 | this.setupRouter(); 10 | } 11 | 12 | setupRouter(): void { 13 | this.router.post('/users', this.wrapTryCatch(this.create)); 14 | } 15 | 16 | async create(req: Request, res: Response, next: NextFunction) { 17 | const { email, password } = req.body as User; 18 | 19 | const user = await this.userService.createUser({ email, password }); 20 | sendSuccessReponse(user, res); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/services/definations.ts: -------------------------------------------------------------------------------- 1 | import { IBaseService } from '@vtjs/common'; 2 | 3 | import User from '../entities/user.entity'; 4 | import Task from '../entities/task.entity'; 5 | import Activity from '../entities/activity.entity'; 6 | 7 | export type JWTToken = { 8 | accessKey: string; 9 | exp: number; 10 | }; 11 | export interface IAuthService { 12 | login(email: string, password: string): Promise; 13 | verifyJWTToken(accessKey: string): Promise; 14 | } 15 | 16 | export interface IUserService extends IBaseService { 17 | createUser(user: Pick): Promise; 18 | } 19 | 20 | export interface ITaskService extends IBaseService { 21 | deleteTask(user: User, cond: Partial): Promise; 22 | updateTask(user: User, cond: Partial, task: Partial): Promise; 23 | } 24 | 25 | export interface IActivityService extends IBaseService {} 26 | -------------------------------------------------------------------------------- /src/repositories/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { Schema, BaseRepository, Repository, DeleteResponse } from '@vtjs/mongoose'; 2 | import { IUserRepository } from './definations'; 3 | import User from '../entities/user.entity'; 4 | 5 | const userSchema = new Schema( 6 | { 7 | email: { 8 | type: String, 9 | index: true, 10 | unique: true, 11 | lowercase: true, 12 | trim: true 13 | }, 14 | password: String 15 | }, 16 | { 17 | timestamps: true, 18 | versionKey: false, 19 | autoCreate: true 20 | } 21 | ); 22 | 23 | export default class UserRepository extends BaseRepository implements IUserRepository { 24 | constructor() { 25 | super('user', userSchema, 'users'); 26 | } 27 | 28 | // Example handling another function that is NOT found in the Base Repository 29 | @Repository() 30 | async deleteByEmail(email: string): Promise { 31 | return this.model.deleteOne({ email }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/controllers/activity.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Response, NextFunction, sendSuccessReponse } from '@vtjs/core'; 2 | import { NotFoundError } from '@vtjs/common'; 3 | 4 | import { IActivityService } from '../services/definations'; 5 | 6 | import { RequestWithUser, IAuthController } from './definations'; 7 | 8 | export default class ActivityController extends Controller { 9 | constructor(private activityService: IActivityService, private authController: IAuthController) { 10 | super(); 11 | this.setupRouter(); 12 | } 13 | 14 | setupRouter(): void { 15 | this.router.use('/activitys', this.authController.requiredAuth); 16 | this.router.get('/activitys', this.wrapTryCatch(this.getList)); 17 | this.router.get('/activitys/:id', this.wrapTryCatch(this.get)); 18 | } 19 | 20 | private async getList(req: RequestWithUser, res: Response, next: NextFunction) { 21 | const list = await this.activityService.findAll({ userId: req.user.id }); 22 | sendSuccessReponse(list, res); 23 | } 24 | 25 | private async get(req: RequestWithUser, res: Response, next: NextFunction) { 26 | const id = req.params.id; 27 | const activity = await this.activityService.findOne({ userId: req.user.id, id }); 28 | 29 | if (!activity) return next(new NotFoundError()); 30 | sendSuccessReponse(activity, res); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | deploy/deploy.sh 2 | deploy/docker-compose.env 3 | deploy/initdb.js 4 | 5 | public/ 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Typescript v1 declaration files 45 | typings/ 46 | 47 | # Optional npm cache directory 48 | .npm 49 | 50 | # Optional eslint cache 51 | .eslintcache 52 | 53 | # Optional REPL history 54 | .node_repl_history 55 | 56 | # Output of 'npm pack' 57 | *.tgz 58 | 59 | # Yarn Integrity file 60 | .yarn-integrity 61 | 62 | # dotenv environment variables file 63 | .env 64 | 65 | # next.js build output 66 | .next 67 | 68 | # JetBrains IDE 69 | .idea 70 | 71 | # Don't track transpiled files 72 | dist/ 73 | docker-compose.yml -------------------------------------------------------------------------------- /src/services/task.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseService, 3 | ILogger, 4 | ServiceCache, 5 | NotFoundError, 6 | PermissionDeniedError 7 | } from '@vtjs/common'; 8 | 9 | import { ITaskService } from './definations'; 10 | 11 | import User from '../entities/user.entity'; 12 | import Task from '../entities/task.entity'; 13 | 14 | import { ITaskRepository } from '../repositories/definations'; 15 | 16 | type TaskServiceProp = { 17 | repo: ITaskRepository; 18 | logger: ILogger; 19 | serviceCache: ServiceCache; 20 | }; 21 | 22 | export default class TaskService extends BaseService implements ITaskService { 23 | repo: ITaskRepository; 24 | constructor({ repo, logger, serviceCache }: TaskServiceProp) { 25 | super(repo, serviceCache, logger); 26 | } 27 | 28 | async deleteTask(user: User, cond: Partial): Promise { 29 | const findTask = await this.findOne(cond); 30 | if (!findTask) throw new NotFoundError(); 31 | 32 | if (findTask.userId !== user.id) throw new PermissionDeniedError(); 33 | return this.deleteById(findTask.id); 34 | } 35 | 36 | async updateTask(user: User, cond: Partial, task: Partial): Promise { 37 | const findTask = await this.findOne(cond); 38 | if (!findTask) throw new NotFoundError(); 39 | 40 | if (findTask.userId !== user.id) throw new PermissionDeniedError(); 41 | 42 | delete task.userId; 43 | return this.findOneAndUpdate({ id: findTask.id }, task); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/configs.ts: -------------------------------------------------------------------------------- 1 | import { name as appName, version as appVersion } from '../package.json'; 2 | export { name as appName, version as appVersion } from '../package.json'; 3 | 4 | export const BCRYPT_HASH_SALT = 10; 5 | 6 | export const MONGODB_CONNECTION_STRING = 7 | process.env.MONGODB_CONNECTION_STRING || 'mongodb://localhost:27017/typescriptSOLIDArchitecture'; 8 | export const MONGODB_USER = process.env.MONGODB_USER; 9 | export const MONGODB_PASSWORD = process.env.MONGODB_PASSWORD; 10 | 11 | export const REDIS_HOST = process.env.REDIS_HOST; 12 | export const REDIS_PORT = process.env.REDIS_PORT && +process.env.REDIS_PORT; 13 | export const REDIS_PASSWORD = process.env.REDIS_PASSWORD; 14 | 15 | export const SERVICE_CACHE_SECOND = 60 * 60 * 2; //2H 16 | 17 | // head -n 4096 /dev/urandom | openssl sha1 18 | export const JWT_SECRET_KEY = 19 | process.env.JWT_SECRET_KEY || 't8#58bd54647e37585902db6b6b1c1b9e4ef31357d6'; 20 | // Use JWt_EXPIRES_DAYS to calculate token's expiration time 21 | // When the token had expired, the user will be automatically logged out 22 | export const JWt_EXPIRES_DAYS = process.env.JWt_EXPIRES_DAYS ? +process.env.JWt_EXPIRES_DAYS : 1; 23 | export const JWt_EXPIRES_IN = `${JWt_EXPIRES_DAYS}d`; 24 | 25 | export const RABBITMQ_HOST = process.env.RABBITMQ_HOST; 26 | export const RABBITMQ_PORT = process.env.RABBITMQ_PORT && +process.env.RABBITMQ_PORT; 27 | export const RABBITMQ_USER = process.env.RABBITMQ_USER; 28 | export const RABBITMQ_PASSWORD = process.env.RABBITMQ_PASSWORD; 29 | 30 | export const ACTIVITY_CREATE_EVENT = `${appName}|activity.create`; 31 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { MainApplication } from '@vtjs/core'; 2 | import { IEventBus } from '@vtjs/rabbitmq'; 3 | import { ILogger } from '@vtjs/common'; 4 | 5 | import { IActivityService, IAuthService, ITaskService, IUserService } from './services/definations'; 6 | 7 | import AuthController from './controllers/auth.controller'; 8 | import ActivityController from './controllers/activity.controller'; 9 | import TaskController from './controllers/task.controller'; 10 | import UserController from './controllers/user.controller'; 11 | 12 | type ApplicationProp = { 13 | authService: IAuthService; 14 | activityService: IActivityService; 15 | taskService: ITaskService; 16 | userService: IUserService; 17 | eventBus: IEventBus; 18 | logger: ILogger; 19 | appName: string; 20 | appVersion: string; 21 | debug: boolean; 22 | }; 23 | 24 | export default class Application extends MainApplication { 25 | constructor({ 26 | authService, 27 | activityService, 28 | taskService, 29 | userService, 30 | eventBus, 31 | logger, 32 | appName, 33 | appVersion, 34 | debug 35 | }: ApplicationProp) { 36 | super(logger, { name: appName, version: appVersion, debug }); 37 | 38 | const authController = new AuthController(authService, eventBus, logger); 39 | const activityController = new ActivityController(activityService, authController); 40 | const taskController = new TaskController(taskService, authController, eventBus, logger); 41 | const userController = new UserController(userService); 42 | 43 | this.setupControllers(authController, activityController, taskController, userController); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/services/user.service.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcrypt'; 2 | 3 | import { BaseService, ILogger, ServiceCache, AlreadyExistsError } from '@vtjs/common'; 4 | 5 | import { IUserService } from './definations'; 6 | 7 | import { BCRYPT_HASH_SALT } from '../configs'; 8 | import User from '../entities/user.entity'; 9 | import { IUserRepository } from '../repositories/definations'; 10 | 11 | type UserServiceProp = { 12 | repo: IUserRepository; 13 | logger: ILogger; 14 | serviceCache: ServiceCache; 15 | }; 16 | 17 | export default class UserService extends BaseService implements IUserService { 18 | repo: IUserRepository; 19 | 20 | constructor({ repo, logger, serviceCache }: UserServiceProp) { 21 | super(repo, serviceCache, logger); 22 | } 23 | 24 | async createUser({ email, password }: Pick): Promise { 25 | const findUser = await this.findOne({ email }); 26 | if (findUser) throw new AlreadyExistsError(); 27 | 28 | const hashPassword = await bcrypt.hash(password, BCRYPT_HASH_SALT); 29 | return this.create({ 30 | email, 31 | password: hashPassword 32 | }); 33 | } 34 | 35 | // Example handling another function that is NOT found in the Base Repository 36 | async deleteUserByEmail(email: string): Promise { 37 | // Cache is build-in with all function in the Base Service 38 | // If you want handle it, please follow structure 39 | // Get Cache 40 | // const exampleHanleGetCache = await this.getCache({ email }); 41 | // Set Cache 42 | // this.setCache({ email }, user) 43 | 44 | const raw = await this.repo.deleteByEmail(email); 45 | 46 | if (raw.ok == 1) { 47 | // Handle delete cache because you not using function in Base Service 48 | this.deleteCache({ email }); 49 | return true; 50 | } 51 | return false; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-solid-architecture", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "tsc", 8 | "dev": "NODE_ENV=development nodemon --exec ts-node -r ./src/index.ts", 9 | "start": "node ./dist/src/index.js", 10 | "prod": "npm run build && npm run start", 11 | "test": "NODE_ENV=development env TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\" }' mocha -r ts-node/register '*/**/*.test.ts'", 12 | "format": "npx prettier --write .", 13 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx", 14 | "prepare": "husky install" 15 | }, 16 | "keywords": [], 17 | "author": "", 18 | "license": "ISC", 19 | "dependencies": { 20 | "@vtjs/auth": "^0.2.0", 21 | "@vtjs/cache": "^0.1.5", 22 | "@vtjs/common": "^0.1.9", 23 | "@vtjs/core": "^0.2.6", 24 | "@vtjs/mongoose": "^0.1.8", 25 | "@vtjs/rabbitmq": "^0.1.0", 26 | "axios-retry": "^3.1.9", 27 | "bcrypt": "^5.0.1", 28 | "dotenv": "^9.0.2" 29 | }, 30 | "devDependencies": { 31 | "@types/bcrypt": "^5.0.0", 32 | "@types/chai": "^4.2.18", 33 | "@types/express": "^4.17.11", 34 | "@types/mocha": "^8.2.2", 35 | "@types/mongoose": "^5.10.5", 36 | "@types/node": "^15.0.3", 37 | "@types/sinon": "^10.0.0", 38 | "@typescript-eslint/eslint-plugin": "^4.23.0", 39 | "@typescript-eslint/parser": "^4.23.0", 40 | "chai": "^4.3.4", 41 | "eslint": "^7.26.0", 42 | "eslint-config-prettier": "^8.3.0", 43 | "eslint-plugin-prettier": "^3.4.0", 44 | "husky": "^6.0.0", 45 | "lint-staged": "^11.0.0", 46 | "mocha": "^8.4.0", 47 | "prettier": "^2.3.0", 48 | "sinon": "^10.0.0", 49 | "ts-node": "^9.1.1", 50 | "typescript": "^4.2.4" 51 | }, 52 | "lint-staged": { 53 | "*.{js,ts,md}": [ 54 | "prettier --write", 55 | "git add" 56 | ], 57 | "*.{js,jsx,ts,tsx}": "eslint --cache --fix" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/services/auth.service.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, before } from 'mocha'; 2 | import { expect } from 'chai'; 3 | import { stub } from 'sinon'; 4 | 5 | import UserRepository from '../repositories/user.repository'; 6 | 7 | import AuthService from './auth.service'; 8 | import UserService from './user.service'; 9 | 10 | // Fake data 11 | const user = { 12 | id: '60a9f5909119ff4a7f7824bd', 13 | email: 'vantuan130393@gmail.com', 14 | password: '$2b$10$SQ7.jKTDx740GKQc0YcFo.VkMl1Q7oeDiAnYh0e27i3yvls.Y6dVu', 15 | createdAt: '2021-05-23T06:26:24.823Z', 16 | updatedAt: '2021-05-23T06:26:24.823Z' 17 | }; 18 | 19 | const loginObj = { 20 | email: 'vantuan130393@gmail.com', 21 | password: '123456' 22 | }; 23 | 24 | // Fake Repositlry 25 | const userRepository = new UserRepository(); 26 | stub(userRepository, 'findOne').resolves(user); 27 | 28 | // Initialization 29 | const userService = new UserService({ 30 | repo: userRepository, 31 | logger: console, 32 | serviceCache: null 33 | }); 34 | 35 | const authService = new AuthService({ 36 | logger: console, 37 | userService 38 | }); 39 | 40 | let accessToken; 41 | describe('User login', () => { 42 | it('should be return user info', (done) => { 43 | authService 44 | .login(loginObj.email, loginObj.password) 45 | .then((result) => { 46 | expect(result).has.ownProperty('id'); 47 | expect(result.email).to.eqls(user.email); 48 | expect(result).has.ownProperty('token'); 49 | accessToken = result.token.accessKey; 50 | done(); 51 | }) 52 | .catch((err) => done(err)); 53 | }); 54 | }); 55 | 56 | describe('Verify token', () => { 57 | it('should be verify token', (done) => { 58 | authService 59 | .verifyJWTToken(accessToken) 60 | .then((result) => { 61 | expect(result).has.ownProperty('id'); 62 | expect(result.email).to.eqls(user.email); 63 | done(); 64 | }) 65 | .catch((err) => done(err)); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { compare } from 'bcrypt'; 2 | 3 | import { ILogger, NotFoundError, ValidationError } from '@vtjs/common'; 4 | import { verifyJWT, generateJWt } from '@vtjs/auth'; 5 | 6 | import { IUserService, IAuthService, JWTToken } from './definations'; 7 | 8 | import User from '../entities/user.entity'; 9 | import { JWT_SECRET_KEY, JWt_EXPIRES_IN, JWt_EXPIRES_DAYS } from '../configs'; 10 | 11 | type AuthServiceProp = { 12 | logger: ILogger; 13 | userService: IUserService; 14 | }; 15 | 16 | type JWTDecoded = { 17 | id: string; 18 | exp: number; 19 | }; 20 | 21 | export default class AuthService implements IAuthService { 22 | logger: ILogger; 23 | userService: IUserService; 24 | 25 | constructor({ logger, userService }: AuthServiceProp) { 26 | this.logger = logger; 27 | this.userService = userService; 28 | } 29 | 30 | async login(email: string, password: string): Promise { 31 | const findUser = await this.userService.findOne({ email }); 32 | if (!findUser) throw new NotFoundError(); 33 | 34 | await compare(password, findUser.password).catch((e) => { 35 | throw new ValidationError(); 36 | }); 37 | 38 | delete findUser.password; 39 | const token = this.generateJWTToken(findUser.id); 40 | 41 | return { 42 | ...findUser, 43 | token 44 | }; 45 | } 46 | 47 | private generateJWTToken(id: string): JWTToken { 48 | const today = new Date(); 49 | const exp = new Date(today); 50 | exp.setDate(today.getDate() + JWt_EXPIRES_DAYS); 51 | const convertExpToNumber = Math.floor(exp.getTime() / 1000); 52 | 53 | const accessKey = generateJWt( 54 | { 55 | id 56 | }, 57 | { 58 | secretKey: JWT_SECRET_KEY, 59 | expiresIn: JWt_EXPIRES_IN 60 | } 61 | ); 62 | 63 | return { 64 | accessKey, 65 | exp: convertExpToNumber 66 | }; 67 | } 68 | 69 | async verifyJWTToken(accessKey: string): Promise { 70 | const decoded: JWTDecoded = verifyJWT({ 71 | token: accessKey, 72 | secretKey: JWT_SECRET_KEY 73 | }); 74 | 75 | if (!decoded?.id) throw new ValidationError(); 76 | const findUser = await this.userService.findOne({ id: decoded.id }); 77 | 78 | return findUser; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/controllers/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Request, Response, NextFunction, sendSuccessReponse } from '@vtjs/core'; 2 | import { UnauthorizedError, ValidationError, ILogger } from '@vtjs/common'; 3 | import { IEventBus } from '@vtjs/rabbitmq'; 4 | 5 | import { ActivityAction } from '../entities/activity.entity'; 6 | import User from '../entities/user.entity'; 7 | 8 | import { IAuthService } from '../services/definations'; 9 | import { ActivityCreateEvent } from '../events/definations'; 10 | 11 | import { RequestWithUser, IAuthController } from './definations'; 12 | 13 | export default class AuthController extends Controller implements IAuthController { 14 | constructor( 15 | private authService: IAuthService, 16 | private eventBus: IEventBus, 17 | private logger: ILogger 18 | ) { 19 | super(); 20 | this.requiredAuth = this.requiredAuth.bind(this); 21 | this.setupRouter(); 22 | } 23 | 24 | setupRouter(): void { 25 | this.router.post('/auth/login', this.wrapTryCatch(this.login)); 26 | } 27 | 28 | private async login(req: Request, res: Response, next: NextFunction) { 29 | const { email, password } = req.body as User; 30 | 31 | if (!email || !password) { 32 | return next(new ValidationError()); 33 | } 34 | 35 | const user = await this.authService.login(email, password); 36 | if (!user) { 37 | return next(new UnauthorizedError()); 38 | } 39 | 40 | this.eventBus 41 | .publish( 42 | new ActivityCreateEvent({ 43 | userId: user.id, 44 | action: ActivityAction.USER_LOGIN, 45 | refId: user.id, 46 | data: null 47 | }) 48 | ) 49 | .catch((e) => this.logger.error('Publish event error: ', e)); 50 | 51 | sendSuccessReponse(user, res); 52 | } 53 | 54 | async requiredAuth(req: RequestWithUser, res: Response, next: NextFunction): Promise { 55 | try { 56 | if (!req.headers['access-token']) { 57 | return next(new ValidationError('Missing token in header')); 58 | } 59 | 60 | const user = await this.authService.verifyJWTToken(req.headers['access-token'] as string); 61 | if (!user) { 62 | return next(new UnauthorizedError()); 63 | } 64 | 65 | req.user = user; 66 | next(); 67 | } catch (e) { 68 | next(e); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/controllers/task.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Response, NextFunction, sendSuccessReponse } from '@vtjs/core'; 2 | import { ServerError, NotFoundError, ILogger } from '@vtjs/common'; 3 | import { IEventBus } from '@vtjs/rabbitmq'; 4 | 5 | import { ActivityAction } from '../entities/activity.entity'; 6 | import Task from '../entities/task.entity'; 7 | import User from '../entities/user.entity'; 8 | 9 | import { ITaskService } from '../services/definations'; 10 | import { ActivityCreateEvent } from '../events/definations'; 11 | 12 | import { RequestWithUser, IAuthController } from './definations'; 13 | 14 | export default class TaskController extends Controller { 15 | constructor( 16 | private taskService: ITaskService, 17 | private authController: IAuthController, 18 | private eventBus: IEventBus, 19 | private logger: ILogger 20 | ) { 21 | super(); 22 | this.setupRouter(); 23 | } 24 | 25 | setupRouter(): void { 26 | this.router.use('/tasks', this.authController.requiredAuth); 27 | this.router.post('/tasks', this.wrapTryCatch(this.create)); 28 | this.router.get('/tasks', this.wrapTryCatch(this.getList)); 29 | this.router.get('/tasks/:id', this.wrapTryCatch(this.get)); 30 | this.router.delete('/tasks/:id', this.wrapTryCatch(this.delete)); 31 | this.router.put('/tasks/:id', this.wrapTryCatch(this.update)); 32 | } 33 | 34 | private async create(req: RequestWithUser, res: Response, next: NextFunction) { 35 | const task = req.body as Task; 36 | task.userId = req.user.id; 37 | 38 | const newTask = await this.taskService.create(task); 39 | this.publishEvent(req.user, ActivityAction.CREATE_TASK, task); 40 | sendSuccessReponse(newTask, res); 41 | } 42 | 43 | private async getList(req: RequestWithUser, res: Response, next: NextFunction) { 44 | const list = await this.taskService.findAll({ userId: req.user.id }); 45 | sendSuccessReponse(list, res); 46 | } 47 | 48 | private async get(req: RequestWithUser, res: Response, next: NextFunction) { 49 | const id = req.params.id; 50 | const task = await this.taskService.findOne({ userId: req.user.id, id }); 51 | 52 | if (!task) return next(new NotFoundError()); 53 | sendSuccessReponse(task, res); 54 | } 55 | 56 | private async delete(req: RequestWithUser, res: Response, next: NextFunction) { 57 | const id = req.params.id; 58 | const raw = await this.taskService.deleteTask(req.user, { id }); 59 | 60 | if (!raw) return next(new ServerError()); 61 | this.publishEvent(req.user, ActivityAction.DELETE_TASK); 62 | sendSuccessReponse(raw, res); 63 | } 64 | 65 | private async update(req: RequestWithUser, res: Response, next: NextFunction) { 66 | const id = req.params.id; 67 | const task = await this.taskService.updateTask(req.user, { id }, req.body); 68 | 69 | if (!task) return next(new ServerError()); 70 | this.publishEvent(req.user, ActivityAction.UPDATE_TASK, task); 71 | sendSuccessReponse(task, res); 72 | } 73 | 74 | private publishEvent(user: User, action: ActivityAction, task?: Task) { 75 | this.eventBus 76 | .publish( 77 | new ActivityCreateEvent({ 78 | userId: user.id, 79 | action: action, 80 | refId: user.id, 81 | data: task || null 82 | }) 83 | ) 84 | .catch((e) => this.logger.error('Publish event error: ', e)); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { MongoDB } from '@vtjs/mongoose'; 3 | import { RedisCache } from '@vtjs/cache'; 4 | import { Logger } from '@vtjs/common'; 5 | import { RabbitMQ } from '@vtjs/rabbitmq'; 6 | 7 | import './env'; 8 | import * as config from './configs'; 9 | 10 | import UserRepository from './repositories/user.repository'; 11 | import TaskRepository from './repositories/task.repository'; 12 | import ActivityRepository from './repositories/activity.repository'; 13 | 14 | import UserService from './services/user.service'; 15 | import AuthService from './services/auth.service'; 16 | import TaskService from './services/task.service'; 17 | import ActivityService from './services/activity.service'; 18 | 19 | import ActivityEvent from './events/activity.event'; 20 | 21 | import Application from './app'; 22 | 23 | const logger = new Logger({ service: config.appName }); 24 | 25 | const mongodb = new MongoDB( 26 | { 27 | connectionString: config.MONGODB_CONNECTION_STRING, 28 | user: config.MONGODB_USER, 29 | password: config.MONGODB_PASSWORD 30 | }, 31 | logger 32 | ); 33 | 34 | const redisCache = new RedisCache( 35 | { 36 | host: config.REDIS_HOST, 37 | port: config.REDIS_PORT, 38 | password: config.REDIS_PASSWORD 39 | }, 40 | logger 41 | ); 42 | 43 | // Event, Queue 44 | const eventEmitter = new EventEmitter(); 45 | const eventBus = new RabbitMQ({ 46 | config: { 47 | consumer: config.appName, 48 | exchange: 'example.eventBus', 49 | hostname: config.RABBITMQ_HOST, 50 | port: config.RABBITMQ_PORT, 51 | username: config.RABBITMQ_USER, 52 | password: config.RABBITMQ_PASSWORD 53 | }, 54 | logger, 55 | eventEmitter 56 | }); 57 | 58 | // Repository 59 | const userRepository = new UserRepository(); 60 | const taskRepository = new TaskRepository(); 61 | const activityRepository = new ActivityRepository(); 62 | 63 | // Service 64 | const userService = new UserService({ 65 | repo: userRepository, 66 | logger, 67 | serviceCache: { 68 | cache: redisCache, 69 | appName: config.appName, 70 | uniqueKey: 'user', 71 | second: config.SERVICE_CACHE_SECOND 72 | } 73 | }); 74 | const authService = new AuthService({ 75 | logger, 76 | userService 77 | }); 78 | const taskService = new TaskService({ 79 | repo: taskRepository, 80 | logger, 81 | serviceCache: { 82 | cache: redisCache, 83 | appName: config.appName, 84 | uniqueKey: 'task', 85 | second: config.SERVICE_CACHE_SECOND 86 | } 87 | }); 88 | const activityService = new ActivityService({ 89 | repo: activityRepository, 90 | logger 91 | }); 92 | 93 | const activityEvent = new ActivityEvent(activityService, logger); 94 | 95 | const app = new Application({ 96 | authService, 97 | activityService, 98 | taskService, 99 | userService, 100 | eventBus, 101 | logger, 102 | appName: config.appName, 103 | appVersion: config.appVersion, 104 | debug: true 105 | }); 106 | 107 | // MAIN CONTROLLER 108 | async function main() { 109 | await mongodb.connect(); 110 | await eventBus.connect(); 111 | 112 | await eventBus.subscribe(config.ACTIVITY_CREATE_EVENT, activityEvent.handle); 113 | 114 | app.showInfo(); 115 | app.start(); 116 | } 117 | 118 | main().catch((e) => { 119 | logger.error('Running app error: ', e); 120 | process.exit(1); 121 | }); 122 | 123 | process.on('beforeExit', async (code) => { 124 | logger.error(`Process beforeExit event with code ${code}`); 125 | process.exit(1); 126 | }); 127 | 128 | process.on('SIGTERM', async () => { 129 | logger.error(`Process ${process.pid} received a SIGTERM signal`); 130 | process.exit(0); 131 | }); 132 | 133 | process.on('SIGINT', async () => { 134 | logger.error(`Process ${process.pid} has been interrupted`); 135 | process.exit(0); 136 | }); 137 | --------------------------------------------------------------------------------