├── .env.example ├── .gitignore ├── .hygen.js ├── .hygen └── new │ ├── module │ ├── controller.ejs.t │ ├── dto │ │ ├── create.dto.ejs.t │ │ ├── get.dto.ejs.t │ │ ├── index.ejs.t │ │ ├── response.dto.ejs.t │ │ └── update.dto.ejs.t │ ├── entities │ │ └── entity.ejs.t │ ├── module.ejs.t │ ├── prompt.js │ ├── repository.ejs.t │ └── service.ejs.t │ └── queue │ ├── enums │ ├── index.ejs.t │ └── job-names.enum.ejs.t │ ├── index.ejs.t │ ├── prompt.js │ ├── queue.module.ejs.t │ ├── queue.processor.ejs.t │ └── queue.service.ejs.t ├── .prettierrc.js ├── README.md ├── docker-compose.yml ├── docker ├── Dockerfile ├── entrypoint.sh └── start_node.sh ├── eslint.config.mjs ├── libs ├── common │ ├── src │ │ ├── constants │ │ │ ├── aws.constant.ts │ │ │ ├── default-values.constant.ts │ │ │ ├── index.ts │ │ │ ├── jwt.constant.ts │ │ │ ├── responses.constant.ts │ │ │ ├── sort.constant.ts │ │ │ └── validation.constant.ts │ │ ├── decorators │ │ │ ├── controller.decorator.ts │ │ │ ├── index.ts │ │ │ ├── match.decorator.ts │ │ │ ├── public.decorator.ts │ │ │ ├── transform.decorator.ts │ │ │ └── user.decorator.ts │ │ ├── dtos │ │ │ ├── index.ts │ │ │ ├── order.dto.ts │ │ │ ├── pagination.dto.ts │ │ │ ├── responses │ │ │ │ ├── index.ts │ │ │ │ └── pagination-response.dto.ts │ │ │ └── search.dto.ts │ │ ├── enums │ │ │ ├── index.ts │ │ │ └── node-envs.enum.ts │ │ ├── filters │ │ │ ├── http-exception.filter.ts │ │ │ └── index.ts │ │ ├── guards │ │ │ ├── index.ts │ │ │ └── jwt-auth.guard.ts │ │ ├── index.ts │ │ ├── interceptors │ │ │ ├── index.ts │ │ │ ├── request-logger.interceptor.ts │ │ │ └── transform.interceptor.ts │ │ ├── pipes │ │ │ ├── index.ts │ │ │ └── validation.pipe.ts │ │ ├── transformers │ │ │ ├── index.ts │ │ │ └── password.transformer.ts │ │ ├── utils │ │ │ ├── app.utils.ts │ │ │ ├── calculate.ts │ │ │ ├── get-env.ts │ │ │ ├── index.ts │ │ │ └── pagination.ts │ │ └── validators │ │ │ ├── field-validator.decorator.ts │ │ │ ├── index.ts │ │ │ ├── match.decorator.ts │ │ │ ├── transform.decorator.ts │ │ │ └── validators.interface.ts │ └── tsconfig.lib.json ├── database │ ├── src │ │ ├── index.ts │ │ ├── postgres │ │ │ ├── base.repository.ts │ │ │ ├── config.ts │ │ │ ├── data-source.ts │ │ │ ├── db.enum.ts │ │ │ ├── db.interface.ts │ │ │ ├── entities │ │ │ │ ├── basket.entity.ts │ │ │ │ └── product.entity.ts │ │ │ ├── index.ts │ │ │ ├── migrations │ │ │ │ ├── 1724678162073-create-users-table.ts │ │ │ │ └── 1737960598226-add-users-columns.ts │ │ │ ├── postgres-config.service.ts │ │ │ └── postgres.module.ts │ │ └── redis │ │ │ ├── config.ts │ │ │ ├── constants.ts │ │ │ ├── index.ts │ │ │ ├── redis.module.ts │ │ │ └── redis.service.ts │ └── tsconfig.lib.json └── swagger │ ├── src │ ├── config.ts │ ├── index.ts │ ├── responses │ │ ├── bad-request.response.ts │ │ ├── created.response.ts │ │ ├── custom.response.ts │ │ ├── forbidden.response.ts │ │ ├── index.ts │ │ ├── not-found.response.ts │ │ ├── success.response.ts │ │ └── unauthorized.response.ts │ ├── swagger.decorator.ts │ └── swagger.type.ts │ └── tsconfig.lib.json ├── nest-cli.json ├── package-lock.json ├── package.json ├── public └── img │ └── swagger.png ├── src ├── infrastructure │ ├── aws │ │ ├── aws.module.ts │ │ ├── sns │ │ │ ├── sns.module.ts │ │ │ └── sns.service.ts │ │ └── sqs │ │ │ ├── sqs-config.service.ts │ │ │ ├── sqs.handler.ts │ │ │ └── sqs.module.ts │ ├── infrastructure.module.ts │ └── queues │ │ ├── index.ts │ │ ├── mail │ │ ├── enums │ │ │ ├── index.ts │ │ │ ├── mail-job-names.enum.ts │ │ │ └── mail-template-names.enum.ts │ │ ├── index.ts │ │ ├── mail.module.ts │ │ ├── mail.processor.ts │ │ └── mail.service.ts │ │ ├── notification │ │ ├── enums │ │ │ ├── index.ts │ │ │ └── notification-job-names.enum.ts │ │ ├── index.ts │ │ ├── notification.module.ts │ │ ├── notification.processor.ts │ │ └── notification.service.ts │ │ ├── queue.enum.ts │ │ ├── queue.module.ts │ │ └── queue.service.ts ├── main.ts └── modules │ ├── app │ ├── app.controller.ts │ └── app.module.ts │ ├── auth │ ├── auth.controller.ts │ ├── auth.module.ts │ ├── auth.service.ts │ ├── dto │ │ ├── auth-response.dto.ts │ │ ├── forgot-password.dto.ts │ │ ├── index.ts │ │ ├── login.dto.ts │ │ ├── register.dto.ts │ │ └── reset-password.dto.ts │ ├── jwt-config.service.ts │ └── strategies │ │ └── jwt.strategy.ts │ └── users │ ├── dto │ ├── create-user.dto.ts │ ├── get-users.dto.ts │ ├── index.ts │ └── user-response.dto.ts │ ├── entities │ └── user.entity.ts │ ├── users.controller.ts │ ├── users.module.ts │ ├── users.repository.ts │ └── users.service.ts ├── tsconfig.build.json └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | API_PORT=4000 2 | API_HOST=0.0.0.0 3 | NESTJS_API_URL=http://localhost:4000 4 | NODE_ENV=development 5 | 6 | # Bull Board 7 | BULL_ADMIN_USERNAME=admin 8 | BULL_ADMIN_PASSWORD=bull-admin-pass 9 | 10 | # JWT ******* 11 | JWT_SECRET=secret 12 | JWT_EXPIRATION=7d # Eg: "2 days", "10h", "7d". 13 | 14 | # Postgres ******* 15 | POSTGRES_HOST=localhost 16 | POSTGRES_PORT=5432 17 | POSTGRES_USER=postgres 18 | POSTGRES_PASSWORD=postgres 19 | POSTGRES_DATABASE=nestjs_architecture 20 | POSTGRES_LOGGING=false 21 | POSTGRES_SYNCHRONIZE=false 22 | POSTGRES_DROP_SCHEMA=false 23 | 24 | # Redis ******* 25 | REDIS_USERNAME=default 26 | REDIS_PASSWORD=user 27 | REDIS_HOST=localhost 28 | REDIS_PORT=6379 29 | 30 | # AWS ******* 31 | AWS_ACCESS_KEY_ID= 32 | AWS_SECRET_ACCESS_KEY= 33 | AWS_REGION= 34 | 35 | SQS_URL= 36 | SQS_WAIT_TIME_SECONDS=5 # 5 seconds 37 | SQS_POLLING_WAIT_TIME_MS=20 # 20 milliseconds 38 | SQS_VISIBILITY_TIMEOUT=30 # 30 seconds 39 | SQS_BATCH_POLLING=false 40 | SQS_AUTHENTICATION_ERROR_TIMEOUT=3600000 # 1 day 41 | 42 | SNS_ARN= 43 | 44 | # Pagination ******* 45 | USERS_PAGE_SIZE=50 46 | USERS_MAX_PAGE_SIZE=200 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # # compiled output 2 | dist 3 | node_modules 4 | .env 5 | postgres_data 6 | 7 | # # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | pnpm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | lerna-debug.log* 15 | 16 | # # OS 17 | .DS_Store 18 | 19 | # # Tests 20 | /coverage 21 | /.nyc_output 22 | 23 | # # IDEs and editors 24 | /.idea 25 | .project 26 | .classpath 27 | .c9/ 28 | *.launch 29 | .settings/ 30 | *.sublime-workspace 31 | 32 | # # IDE - VSCode 33 | .vscode/* 34 | !.vscode/settings.json 35 | !.vscode/tasks.json 36 | !.vscode/launch.json 37 | !.vscode/extensions.json -------------------------------------------------------------------------------- /.hygen.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable prettier/prettier */ 2 | module.exports = { 3 | templates: `${__dirname}/.hygen`, 4 | helpers: { 5 | /* ******* Helpers ******* */ 6 | dasherize(str) { 7 | return this.inflection.dasherize(str).toLowerCase() 8 | }, 9 | singularize(str) { 10 | // user -> users 11 | return this.inflection.singularize(str) 12 | }, 13 | pluralize(str) { 14 | // user -> users 15 | return this.inflection.pluralize(str) 16 | }, 17 | toDashCase(str) { 18 | // Convert any case to dash-case 19 | return str 20 | .replace(/([a-z])([A-Z])/g, '$1-$2') 21 | .replace(/_/g, '-') 22 | .toLowerCase() 23 | }, 24 | 25 | pascalSingularize(str) { 26 | // users -> User 27 | return this.changeCase.pascal(this.singularize(str)) 28 | }, 29 | pascalPluralize(str) { 30 | // user -> Users 31 | return this.changeCase.pascal(this.pluralize(str)) 32 | }, 33 | 34 | /* ******* ******* ******* New `Module` ******* ******* ******* */ 35 | /* ******* ******* ******* ******* ******* ******* ******* ******* */ 36 | 37 | instanceName(name) { 38 | return this.changeCase.camel(this.EntityName(name)) 39 | }, 40 | 41 | /* ******* Module, controller, service, repository names ******* */ 42 | ModuleName(name) { 43 | return `${this.pascalPluralize(name)}Module` 44 | }, 45 | ControllerName(name) { 46 | return `${this.pascalPluralize(name)}Controller` 47 | }, 48 | ServiceName(name) { 49 | return `${this.pascalPluralize(name)}Service` 50 | }, 51 | RepositoryName(name) { 52 | return `${this.pascalPluralize(name)}Repository` 53 | }, 54 | 55 | /* ******* Module, controller, service, repository folder/file names ******* */ 56 | moduleFolderName(name) { 57 | return `${this.pluralize(this.toDashCase(name))}` 58 | }, 59 | moduleFileName(name) { 60 | return `${this.moduleFolderName(name)}.module` 61 | }, 62 | controllerFileName(name) { 63 | return `${this.moduleFolderName(name)}.controller` 64 | }, 65 | repositoryFileName(name) { 66 | return `${this.moduleFolderName(name)}.repository` 67 | }, 68 | serviceFileName(name) { 69 | return `${this.moduleFolderName(name)}.service` 70 | }, 71 | 72 | /* ******* Entity and table names ******* */ 73 | entitiesFolderName() { 74 | return `entities` 75 | }, 76 | 77 | TableName(name) { 78 | name = name.replace(/-/g, '_') 79 | 80 | return this.inflection.pluralize( 81 | this.inflection 82 | .underscore(name) 83 | .toLowerCase() 84 | ) 85 | }, 86 | EntityName(name) { 87 | return this.pascalSingularize(name) 88 | }, 89 | 90 | entityFileName(name) { 91 | return `${this.toDashCase(this.singularize(name))}.entity` 92 | }, 93 | 94 | /* ******* Dtos ******* */ 95 | dtosFolderName() { 96 | return `dto` 97 | }, 98 | 99 | ResponseDtoName(name) { 100 | return `${this.pascalSingularize(name)}ResponseDto` 101 | }, 102 | CreateDtoName(name) { 103 | return `Create${this.pascalSingularize(name)}Dto` 104 | }, 105 | GetDtoName(name) { 106 | return `Get${this.pascalPluralize(name)}Dto` 107 | }, 108 | UpdateDtoName(name) { 109 | return `Update${this.pascalSingularize(name)}Dto` 110 | }, 111 | 112 | responseDtoFileName(name) { 113 | return `${this.singularize(name)}-response.dto` 114 | }, 115 | createDtoFileName(name) { 116 | return `create-${this.singularize(name)}.dto` 117 | }, 118 | getDtoFileName(name) { 119 | return `get-${this.pluralize(name)}.dto` 120 | }, 121 | updateDtoFileName(name) { 122 | return `update-${this.singularize(name)}.dto` 123 | }, 124 | 125 | /* ******* ******* ******* New `Queue` ******* ******* ******* */ 126 | /* ******* ******* ******* ******* ******* ******* ******* ******* */ 127 | 128 | QueueNameEnumKey(queueName) { 129 | queueName = queueName.replace(/-/g, '_') 130 | 131 | return this.inflection 132 | .underscore(queueName) 133 | .toLowerCase() 134 | }, 135 | 136 | queueJobNamesEnumFileName(queueName) { 137 | return `${this.queueFolderName(queueName)}-job-names.enum` 138 | }, 139 | QueueJobNamesEnumName(queueName) { 140 | return `${this.changeCase.pascal(queueName)}JobNames` 141 | }, 142 | 143 | queueParamName(queueName) { 144 | return `${this.changeCase.camel(queueName)}Queue` 145 | }, 146 | 147 | /* ******* Queue module, processor, service names ******* */ 148 | QueueModuleName(queueName) { 149 | return `${this.changeCase.pascal(queueName)}QueueModule` 150 | }, 151 | QueueProcessorName(queueName) { 152 | return `${this.changeCase.pascal(queueName)}QueueProcessor` 153 | }, 154 | QueueServiceName(queueName) { 155 | return `${this.changeCase.pascal(queueName)}QueueService` 156 | }, 157 | 158 | /* ******* Queue module, processor, service folder/file names ******* */ 159 | queueFolderName(queueName) { 160 | return this.toDashCase(queueName) 161 | }, 162 | 163 | queueModuleFileName(queueName) { 164 | return `${this.queueFolderName(queueName)}.module` 165 | }, 166 | queueProcessorFileName(queueName) { 167 | return `${this.queueFolderName(queueName)}.processor` 168 | }, 169 | queueServiceFileName(queueName) { 170 | return `${this.queueFolderName(queueName)}.service` 171 | }, 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /.hygen/new/module/controller.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "src/modules/<%= h.moduleFolderName(name) %>/<%= h.controllerFileName(name) %>.ts" 3 | unless_exists: true 4 | skip_if: <%= !resources.includes('Controller') %> 5 | --- 6 | <% 7 | ModuleName = h.ModuleName(name) 8 | 9 | ControllerName = h.ControllerName(name) 10 | ControllerPrefix = h.toDashCase(h.pascalPluralize(name)) 11 | 12 | ServiceName = h.ServiceName(name) 13 | serviceFileName = h.serviceFileName(name) 14 | serviceParamName = h.changeCase.camel(ServiceName) 15 | 16 | RepositoryName = h.RepositoryName(name) 17 | repositoryFileName = h.repositoryFileName(name) 18 | 19 | EntityName = h.EntityName(name) 20 | entityFileName = h.entityFileName(name) 21 | 22 | instanceName = h.instanceName(name) 23 | instanceId = instanceName + 'Id' 24 | 25 | dtosFolderName = h.dtosFolderName() 26 | ResponseDtoName = h.ResponseDtoName(name) 27 | CreateDtoName = h.CreateDtoName(name) 28 | GetDtoName = h.GetDtoName(name) 29 | UpdateDtoName = h.UpdateDtoName(name) 30 | 31 | createDtoParamName = h.changeCase.camel(CreateDtoName) 32 | updateDtoParamName = h.changeCase.camel(UpdateDtoName) 33 | 34 | %>import { Body, Delete, Get, Param, Post, Put, Query } from '@nestjs/common' 35 | 36 | import { Swagger } from '@app/swagger' 37 | import { EnhancedController, RequestUser, TransformResponse } from '@app/common' 38 | 39 | import { <%= CreateDtoName %>, <%= GetDtoName %>, <%= UpdateDtoName %>, <%= ResponseDtoName %> } from './<%= dtosFolderName %>' 40 | import { <%= ServiceName %> } from './<%= serviceFileName %>' 41 | 42 | @EnhancedController('<%= ControllerPrefix %>') 43 | @TransformResponse(<%= ResponseDtoName %>) 44 | export class <%= ControllerName %> { 45 | constructor(private readonly <%= serviceParamName %>: <%= ServiceName %>) {} 46 | 47 | @Swagger({ response: <%= ResponseDtoName %>, pagination: true }) 48 | @Get() 49 | index( 50 | @RequestUser('id') currentUserId: number, 51 | @Query() query: <%= GetDtoName %> 52 | ) { 53 | return this.<%= serviceParamName %>.index(currentUserId, query) 54 | } 55 | 56 | @Swagger({ response: <%= ResponseDtoName %>, 201: true, errorResponses: [400, 404] }) 57 | @Post() 58 | create( 59 | @RequestUser('id') currentUserId: number, 60 | @Param('id') <%= instanceId %>: number, 61 | @Body() <%= createDtoParamName %>: <%= CreateDtoName %> 62 | ) { 63 | return this.<%= serviceParamName %>.create(currentUserId, <%= instanceId %>, <%= createDtoParamName %>) 64 | } 65 | 66 | @Swagger({ response: <%= ResponseDtoName %>, errorResponses: [404] }) 67 | @Get(':id') 68 | find( 69 | @RequestUser('id') currentUserId: number, 70 | @Param('id') <%= instanceId %>: number 71 | ) { 72 | return this.<%= serviceParamName %>.find(currentUserId, <%= instanceId %>) 73 | } 74 | 75 | @Swagger({ response: <%= ResponseDtoName %>, errorResponses: [400, 404] }) 76 | @Put(':id') 77 | update( 78 | @RequestUser('id') currentUserId: number, 79 | @Param('id') <%= instanceId %>: number, 80 | @Body() <%= updateDtoParamName %>: <%= UpdateDtoName %> 81 | ) { 82 | return this.<%= serviceParamName %>.update(currentUserId, <%= instanceId %>, <%= updateDtoParamName %>) 83 | } 84 | 85 | @Swagger({ response: <%= ResponseDtoName %>, errorResponses: [404] }) 86 | @Delete(':id') 87 | delete( 88 | @RequestUser('id') currentUserId: number, 89 | @Param('id') <%= instanceId %>: number 90 | ) { 91 | return this.<%= serviceParamName %>.delete(currentUserId, <%= instanceId %>) 92 | } 93 | } 94 | 95 | -------------------------------------------------------------------------------- /.hygen/new/module/dto/create.dto.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "src/modules/<%= h.moduleFolderName(name) %>/<%= h.dtosFolderName(name) %>/<%= h.createDtoFileName(name) %>.ts" 3 | unless_exists: true 4 | skip_if: <%= !resources.includes('CreateDto') %> 5 | --- 6 | <% 7 | CreateDtoName = h.CreateDtoName(name); 8 | 9 | %>export class <%= CreateDtoName %> {} 10 | -------------------------------------------------------------------------------- /.hygen/new/module/dto/get.dto.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "src/modules/<%= h.moduleFolderName(name) %>/<%= h.dtosFolderName(name) %>/<%= h.getDtoFileName(name) %>.ts" 3 | unless_exists: true 4 | skip_if: <%= !resources.includes('CreateDto') %> 5 | --- 6 | <% 7 | GetDtoName = h.GetDtoName(name); 8 | 9 | %>import { IntersectionType } from '@nestjs/swagger' 10 | 11 | import { OrderDto, PaginationDto, SearchDto } from '@app/common' 12 | 13 | export class <%= GetDtoName %> extends IntersectionType( 14 | PaginationDto(), 15 | SearchDto, 16 | OrderDto() 17 | ) { 18 | userId?: number 19 | } 20 | -------------------------------------------------------------------------------- /.hygen/new/module/dto/index.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "src/modules/<%= h.moduleFolderName(name) %>/<%= h.dtosFolderName(name) %>/index.ts" 3 | unless_exists: true 4 | --- 5 | <% 6 | responseDtoFileName = h.responseDtoFileName(name) 7 | createDtoFileName = h.createDtoFileName(name) 8 | getDtoFileName = h.getDtoFileName(name) 9 | updateDtoFileName = h.updateDtoFileName(name) 10 | 11 | %>export * from './<%= createDtoFileName %>' 12 | export * from './<%= getDtoFileName %>' 13 | export * from './<%= updateDtoFileName %>' 14 | export * from './<%= responseDtoFileName %>' 15 | -------------------------------------------------------------------------------- /.hygen/new/module/dto/response.dto.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "src/modules/<%= h.moduleFolderName(name) %>/<%= h.dtosFolderName(name) %>/<%= h.responseDtoFileName(name) %>.ts" 3 | unless_exists: true 4 | skip_if: <%= !resources.includes('ResponseDto') %> 5 | --- 6 | <% 7 | ResponseDtoName = h.ResponseDtoName(name); 8 | 9 | %>import { ApiProperty } from '@nestjs/swagger' 10 | import { Exclude, Expose } from 'class-transformer' 11 | 12 | @Exclude() 13 | export class <%= ResponseDtoName %> { 14 | @Expose() 15 | @ApiProperty() 16 | id: number 17 | } 18 | -------------------------------------------------------------------------------- /.hygen/new/module/dto/update.dto.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "src/modules/<%= h.moduleFolderName(name) %>/<%= h.dtosFolderName(name) %>/<%= h.updateDtoFileName(name) %>.ts" 3 | unless_exists: true 4 | skip_if: <%= !resources.includes('UpdateDto') %> 5 | --- 6 | <% 7 | createDtoFileName = h.createDtoFileName(name) 8 | CreateDtoName = h.CreateDtoName(name) 9 | 10 | UpdateDtoName = h.UpdateDtoName(name) 11 | 12 | %>import { PickType } from '@nestjs/swagger' 13 | 14 | import { <%= CreateDtoName %> } from './<%= createDtoFileName %>' 15 | 16 | export class <%= UpdateDtoName %> extends PickType(<%= CreateDtoName %>, [] as const) {} 17 | -------------------------------------------------------------------------------- /.hygen/new/module/entities/entity.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "src/modules/<%= h.moduleFolderName(name) %>/<%= h.entitiesFolderName(name) %>/<%= h.entityFileName(name) %>.ts" 3 | unless_exists: true 4 | skip_if: <%= !resources.includes('Entity') %> 5 | --- 6 | <% 7 | EntityName = h.EntityName(name) 8 | 9 | TableName = h.TableName(name) 10 | 11 | %>import { Entity, PrimaryGeneratedColumn } from 'typeorm' 12 | 13 | import { DbTables } from '@app/database' 14 | 15 | @Entity(DbTables.<%= TableName %>) 16 | export class <%= EntityName %> { 17 | @PrimaryGeneratedColumn() 18 | id: number 19 | } 20 | -------------------------------------------------------------------------------- /.hygen/new/module/module.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "src/modules/<%= h.moduleFolderName(name) %>/<%= h.moduleFileName(name) %>.ts" 3 | unless_exists: true 4 | --- 5 | <% 6 | ModuleName = h.ModuleName(name) 7 | 8 | ControllerName = h.ControllerName(name) 9 | controllerFileName = h.controllerFileName(name) 10 | 11 | ServiceName = h.ServiceName(name) 12 | serviceFileName = h.serviceFileName(name) 13 | 14 | RepositoryName = h.RepositoryName(name) 15 | repositoryFileName = h.repositoryFileName(name) 16 | 17 | EntityName = h.EntityName(name) 18 | entitiesFolderName = h.entitiesFolderName(name) 19 | entityFileName = h.entityFileName(name) 20 | 21 | %>import { Module } from '@nestjs/common' 22 | import { TypeOrmModule } from '@nestjs/typeorm' 23 | 24 | import { <%= EntityName %> } from './<%= entitiesFolderName %>/<%= entityFileName %>' 25 | import { <%= ControllerName %> } from './<%= controllerFileName %>' 26 | import { <%= RepositoryName %> } from './<%= repositoryFileName %>' 27 | import { <%= ServiceName %> } from './<%= serviceFileName %>' 28 | 29 | @Module({ 30 | imports: [TypeOrmModule.forFeature([<%= EntityName %>])], 31 | controllers: [<%= ControllerName %>], 32 | providers: [<%= RepositoryName %>, <%= ServiceName %>], 33 | exports: [<%= ServiceName %>] 34 | }) 35 | export class <%= ModuleName %> {} 36 | -------------------------------------------------------------------------------- /.hygen/new/module/prompt.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable prettier/prettier */ 2 | module.exports = { 3 | prompt: ({ prompter }) => { 4 | return prompter 5 | .prompt([ 6 | { 7 | type: 'input', 8 | name: 'name', 9 | message: 'Module Name:', 10 | validate(value) { 11 | if (!value.length) { 12 | return 'Module must have a name.' 13 | } 14 | return true 15 | } 16 | }, 17 | { 18 | type: 'MultiSelect', 19 | name: 'resources', 20 | message: 'Resources:', 21 | initial: [ 22 | 'Entity', 23 | 'Controller', 24 | 'Service', 25 | 'Repository', 26 | 'ResponseDto', 27 | 'CreateDto', 28 | 'GetDto', 29 | 'UpdateDto' 30 | ], 31 | choices: [ 32 | { 33 | name: 'Entity', 34 | value: 'entity' 35 | }, 36 | { 37 | name: 'Controller', 38 | value: 'controller' 39 | }, 40 | { 41 | name: 'Service', 42 | value: 'service' 43 | }, 44 | { 45 | name: 'Repository', 46 | value: 'repository' 47 | }, 48 | { 49 | name: 'ResponseDto', 50 | value: 'response-dto' 51 | }, 52 | { 53 | name: 'CreateDto', 54 | value: 'create-dto' 55 | }, 56 | { 57 | name: 'GetDto', 58 | value: 'get-dto' 59 | }, 60 | { 61 | name: 'UpdateDto', 62 | value: 'update-dto' 63 | } 64 | ] 65 | } 66 | ]) 67 | .then((answer) => { 68 | return answer 69 | }) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /.hygen/new/module/repository.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "src/modules/<%= h.moduleFolderName(name) %>/<%= h.repositoryFileName(name) %>.ts" 3 | unless_exists: true 4 | skip_if: <%= !resources.includes('Repository') %> 5 | --- 6 | <% 7 | RepositoryName = h.RepositoryName(name) 8 | 9 | EntityName = h.EntityName(name) 10 | entitiesFolderName = h.entitiesFolderName(name) 11 | entityFileName = h.entityFileName(name) 12 | 13 | %>import { Injectable } from '@nestjs/common' 14 | import { DataSource } from 'typeorm' 15 | 16 | import { BaseRepository } from '@app/database' 17 | 18 | import { <%= EntityName %> } from './<%= entitiesFolderName %>/<%= entityFileName %>' 19 | 20 | @Injectable() 21 | export class <%= RepositoryName %> extends BaseRepository< <%= EntityName %> > { 22 | constructor(dataSource: DataSource) { 23 | super(dataSource, <%= EntityName %>) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.hygen/new/module/service.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "src/modules/<%= h.moduleFolderName(name) %>/<%= h.serviceFileName(name) %>.ts" 3 | unless_exists: true 4 | skip_if: <%= !resources.includes('Service') %> 5 | --- 6 | <% 7 | ServiceName = h.ServiceName(name) 8 | 9 | RepositoryName = h.RepositoryName(name) 10 | repositoryFileName = h.repositoryFileName(name) 11 | repositoryParamName = h.changeCase.camel(RepositoryName) 12 | 13 | EntityName = h.EntityName(name) 14 | entitiesFolderName = h.entitiesFolderName(name) 15 | entityFileName = h.entityFileName(name) 16 | 17 | instanceName = h.instanceName(name) 18 | instanceId = instanceName + 'Id' 19 | instanceNamePascal = h.changeCase.pascal(instanceName) 20 | 21 | dtosFolderName = h.dtosFolderName() 22 | CreateDtoName = h.CreateDtoName(name) 23 | GetDtoName = h.GetDtoName(name) 24 | UpdateDtoName = h.UpdateDtoName(name) 25 | 26 | createDtoParamName = h.changeCase.camel(CreateDtoName) 27 | getDtoParamName = h.changeCase.camel(GetDtoName) 28 | updateDtoParamName = h.changeCase.camel(UpdateDtoName) 29 | 30 | %>import { Injectable } from '@nestjs/common' 31 | import { InjectRepository } from '@nestjs/typeorm' 32 | import { Repository } from 'typeorm' 33 | 34 | import { NotFoundException, paginatedResponse, SUCCESS_RESPONSE } from '@app/common' 35 | 36 | import { <%= CreateDtoName %>, <%= GetDtoName %>, <%= UpdateDtoName %> } from './<%= dtosFolderName %>' 37 | import { <%= EntityName %> } from './<%= entitiesFolderName %>/<%= entityFileName %>' 38 | import { <%= RepositoryName %> } from './<%= repositoryFileName %>' 39 | 40 | @Injectable() 41 | export class <%= ServiceName %> { 42 | constructor( 43 | @InjectRepository(<%= EntityName %>) 44 | private readonly repo: Repository< <%= EntityName %> >, 45 | private readonly <%= repositoryParamName %>: <%= RepositoryName %> 46 | ) {} 47 | 48 | // ******* Controller Handlers ******* 49 | async index(currentUserId: number, query: <%= GetDtoName %>) { 50 | const { items, totalCount } = await this.getAndCount({ 51 | ...query, 52 | userId: currentUserId 53 | }) 54 | 55 | return paginatedResponse(items, totalCount, query.page, query.perPage) 56 | } 57 | 58 | async create(currentUserId: number, <%= instanceId %>: number, <%= createDtoParamName %>: <%= CreateDtoName %>) { 59 | const <%= instanceName %> = await this.create<%= instanceNamePascal %>({ ...<%= createDtoParamName %>, <%= instanceId %> }) 60 | 61 | return <%= instanceName %> 62 | } 63 | 64 | async find(currentUserId: number, <%= instanceId %>: number) { 65 | const <%= instanceName %> = await this.getById(<%= instanceId %>) 66 | 67 | if (!<%= instanceName %>) { 68 | throw new NotFoundException() 69 | } 70 | 71 | return <%= instanceName %> 72 | } 73 | 74 | async update(currentUserId: number, <%= instanceId %>: number, <%= updateDtoParamName %>: <%= UpdateDtoName %>) { 75 | const <%= instanceName %> = await this.getById(<%= instanceId %>) 76 | 77 | if (!<%= instanceName %>) { 78 | throw new NotFoundException() 79 | } 80 | 81 | if (!Object.keys(<%= updateDtoParamName %>).length) { 82 | return <%= instanceName %> 83 | } 84 | 85 | const updated<%= instanceNamePascal %> = await this.updateById(<%= instanceId %>, <%= updateDtoParamName %>) 86 | 87 | return updated<%= instanceNamePascal %> 88 | } 89 | 90 | async delete(currentUserId: number, <%= instanceId %>: number) { 91 | const <%= instanceName %> = await this.getById(<%= instanceId %>) 92 | 93 | if (!<%= instanceName %>) { 94 | throw new NotFoundException() 95 | } 96 | 97 | await this.deleteById(<%= instanceId %>) 98 | 99 | return SUCCESS_RESPONSE 100 | } 101 | 102 | // ******* ******* ******* ******* 103 | 104 | async create<%= instanceNamePascal %>(<%= createDtoParamName %>: <%= CreateDtoName %>): Promise< <%= EntityName %> > { 105 | return await this.<%= repositoryParamName %>.create(<%= createDtoParamName %>) 106 | } 107 | 108 | async getAndCount(<%= getDtoParamName %>: <%= GetDtoName %>) { 109 | const { page, perPage, order, searchText, userId } = <%= getDtoParamName %> 110 | 111 | const qb = this.repo.createQueryBuilder('<%= instanceName %>') 112 | 113 | if (searchText) { 114 | console.log('Search text:', searchText) 115 | } 116 | 117 | if (order) { 118 | for (const [key, orderType] of Object.entries(order)) { 119 | qb.orderBy(`<%= instanceName %>.${key}`, orderType) 120 | } 121 | } 122 | 123 | const [items, totalCount] = await qb 124 | .setParameter('userId', userId) 125 | .setParameter('searchPattern', `%${searchText}%`) 126 | .take(perPage) 127 | .skip((page - 1) * perPage) 128 | .getManyAndCount() 129 | 130 | return { items, totalCount } 131 | } 132 | 133 | async getById(<%= instanceId %>: number): Promise< <%= EntityName %> | null> { 134 | return await this.<%= repositoryParamName %>.findOne({ id: <%= instanceId %> }) 135 | } 136 | 137 | async updateById(<%= instanceId %>: number, <%= updateDtoParamName %>: <%= UpdateDtoName %>): Promise< <%= EntityName %> | null> { 138 | await this.<%= repositoryParamName %>.update({ id: <%= instanceId %> }, <%= updateDtoParamName %>) 139 | return await this.getById(<%= instanceId %>) 140 | } 141 | 142 | async deleteById(<%= instanceId %>: number) { 143 | await this.<%= repositoryParamName %>.delete({ id: <%= instanceId %> }) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /.hygen/new/queue/enums/index.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "src/infrastructure/queues/<%= h.queueFolderName(queueName) %>/enums/index.ts" 3 | unless_exists: true 4 | --- 5 | <% 6 | queueJobNamesEnumFileName = h.queueJobNamesEnumFileName(queueName) 7 | 8 | %>export * from './<%= queueJobNamesEnumFileName %>' 9 | -------------------------------------------------------------------------------- /.hygen/new/queue/enums/job-names.enum.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "src/infrastructure/queues/<%= h.queueFolderName(queueName) %>/enums/<%= h.queueJobNamesEnumFileName(queueName) %>.ts" 3 | unless_exists: true 4 | --- 5 | <% 6 | queueJobNamesEnumFileName = h.queueJobNamesEnumFileName(queueName) 7 | QueueJobNamesEnumName = h.QueueJobNamesEnumName(queueName) 8 | 9 | %>export enum <%= QueueJobNamesEnumName %> { 10 | exampleJob = 'exampleJob' 11 | } 12 | -------------------------------------------------------------------------------- /.hygen/new/queue/index.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "src/infrastructure/queues/<%= h.queueFolderName(queueName) %>/index.ts" 3 | unless_exists: true 4 | --- 5 | <% 6 | queueModuleFileName = h.queueModuleFileName(queueName) 7 | queueServiceFileName = h.queueServiceFileName(queueName) 8 | 9 | %>export * from './enums' 10 | export * from './<%= queueModuleFileName %>' 11 | export * from './<%= queueServiceFileName %>' 12 | -------------------------------------------------------------------------------- /.hygen/new/queue/prompt.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | prompt: ({ prompter }) => { 3 | return prompter 4 | .prompt([ 5 | { 6 | type: 'input', 7 | name: 'queueName', 8 | message: 'Queue Name:', 9 | validate(value) { 10 | if (!value.length) { 11 | return 'Queue must have a name.' 12 | } 13 | return true 14 | } 15 | } 16 | ]) 17 | .then((answer) => { 18 | return answer 19 | }) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.hygen/new/queue/queue.module.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "src/infrastructure/queues/<%= h.queueFolderName(queueName) %>/<%= h.queueModuleFileName(queueName) %>.ts" 3 | unless_exists: true 4 | --- 5 | <% 6 | QueueNameEnumKey = h.QueueNameEnumKey(queueName) 7 | 8 | QueueModuleName = h.QueueModuleName(queueName) 9 | QueueProcessorName = h.QueueProcessorName(queueName) 10 | QueueServiceName = h.QueueServiceName(queueName) 11 | 12 | queueProcessorFileName = h.queueProcessorFileName(queueName) 13 | queueServiceFileName = h.queueServiceFileName(queueName) 14 | 15 | %>import { BullMQAdapter } from '@bull-board/api/bullMQAdapter' 16 | import { BullBoardModule } from '@bull-board/nestjs' 17 | import { BullModule } from '@nestjs/bullmq' 18 | import { Module } from '@nestjs/common' 19 | 20 | import { QueueNames } from '../queue.enum' 21 | import { <%= QueueProcessorName %> } from './<%= queueProcessorFileName %>' 22 | import { <%= QueueServiceName %> } from './<%= queueServiceFileName %>' 23 | 24 | @Module({ 25 | imports: [ 26 | BullModule.registerQueue({ name: QueueNames.<%= QueueNameEnumKey %> }), 27 | BullBoardModule.forFeature({ name: QueueNames.<%= QueueNameEnumKey %>, adapter: BullMQAdapter }) 28 | ], 29 | providers: [<%= QueueProcessorName %>, <%= QueueServiceName %>], 30 | exports: [<%= QueueServiceName %>] 31 | }) 32 | export class <%= QueueModuleName %> {} 33 | -------------------------------------------------------------------------------- /.hygen/new/queue/queue.processor.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "src/infrastructure/queues/<%= h.queueFolderName(queueName) %>/<%= h.queueProcessorFileName(queueName) %>.ts" 3 | unless_exists: true 4 | --- 5 | <% 6 | QueueNameEnumKey = h.QueueNameEnumKey(queueName) 7 | QueueJobNamesEnumName = h.QueueJobNamesEnumName(queueName) 8 | 9 | QueueProcessorName = h.QueueProcessorName(queueName) 10 | 11 | %>import { Processor, WorkerHost } from '@nestjs/bullmq' 12 | import { Logger } from '@nestjs/common' 13 | import { Job } from 'bullmq' 14 | 15 | import { QueueNames } from '../queue.enum' 16 | import { <%= QueueJobNamesEnumName %> } from './enums' 17 | 18 | @Processor(QueueNames.<%= QueueNameEnumKey %>) 19 | export class <%= QueueProcessorName %> extends WorkerHost { 20 | private readonly logger = new Logger(<%= QueueProcessorName %>.name) 21 | 22 | async process(job: Job) { 23 | switch (job.name) { 24 | case <%= QueueJobNamesEnumName %>.exampleJob: 25 | return this.handleExampleJob(job) 26 | default: 27 | this.logger.warn(`⚠️ Job "${job.name}" is not handled.`) 28 | break 29 | } 30 | } 31 | 32 | private async handleExampleJob(job: Job) { 33 | this.logger.log(`✅ Handling "${<%= QueueJobNamesEnumName %>.exampleJob}" job with ID: ${job.id}`) 34 | this.logger.debug(`Job Data: ${JSON.stringify(job.data)}`) 35 | 36 | try { 37 | } catch (error) { 38 | this.logger.error(`❌ Error handling "${<%= QueueJobNamesEnumName %>.exampleJob}" job: ${error.message}`, error.stack) 39 | throw error 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.hygen/new/queue/queue.service.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "src/infrastructure/queues/<%= h.queueFolderName(queueName) %>/<%= h.queueServiceFileName(queueName) %>.ts" 3 | unless_exists: true 4 | --- 5 | <% 6 | QueueNameEnumKey = h.QueueNameEnumKey(queueName) 7 | QueueJobNamesEnumName = h.QueueJobNamesEnumName(queueName) 8 | 9 | QueueServiceName = h.QueueServiceName(queueName) 10 | 11 | queueParamName = h.queueParamName(queueName) 12 | 13 | %>import { InjectQueue } from '@nestjs/bullmq' 14 | import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common' 15 | import { Queue } from 'bullmq' 16 | import _ from 'lodash' 17 | 18 | import { QueueNames } from '../queue.enum' 19 | import { AbstractQueueService } from '../queue.service' 20 | import { <%= QueueJobNamesEnumName %> } from './enums' 21 | 22 | @Injectable() 23 | export class <%= QueueServiceName %> extends AbstractQueueService implements OnApplicationBootstrap { 24 | protected readonly logger: Logger = new Logger(_.upperFirst(_.camelCase(QueueNames.<%= QueueNameEnumKey %>))) 25 | 26 | private _queue: Queue 27 | 28 | get queue(): Queue { 29 | return this._queue 30 | } 31 | 32 | constructor(@InjectQueue(QueueNames.<%= QueueNameEnumKey %>) private readonly <%= queueParamName %>: Queue) { 33 | super() 34 | this._queue = this.<%= queueParamName %> 35 | } 36 | 37 | public async onApplicationBootstrap(): Promise { 38 | await this.checkConnection() 39 | await this.initEventListeners() 40 | } 41 | 42 | public async exampleJob(payload: Record) { 43 | return this.addJob(<%= QueueJobNamesEnumName %>.exampleJob, payload) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | tabWidth: 2, 4 | semi: false, 5 | singleQuote: true, 6 | trailingComma: "none" 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚀 NestJS Architecture! 2 | 3 | This project serves as a robust and scalable foundational architecture for building a modern NestJS application. It comes pre-configured with essential features and best practices, allowing developers to focus on building business logic rather than spending time on initial setup. The architecture is designed to be modular, maintainable, and easily extensible, making it ideal for both small-scale applications and large enterprise solutions. 4 | 5 | ### 🔍 Key features include: 6 | 7 | - 🧩 **Modular Structure:** Organized into feature-based modules, promoting separation of concerns and reusability across different parts of the application. 8 | - 🗄️ **Database Integration:** Seamless integration with PostgreSQL, including pre-configured TypeORM support for efficient database interaction, along with migration management to handle schema changes. 9 | - ⚡ **Redis Integration:** Pre-configured Redis support for caching and real-time data processing needs. 10 | - 🔐 **Authentication and Authorization:** Built-in JWT-based authentication module to handle user sessions securely, including registration, login, and user management APIs. 11 | - 📄 **API Documentation:** Auto-generated API documentation using NestJS DTOs. 12 | - 🛡️ **Validation and Error Handling:** Utilizes `class-validator` for easy validation through decorators, alongside custom exceptions with tailored messages and validation responses to ensure robust error handling. 13 | - 🐳 **Docker Support:** Ready-to-use Docker and Docker Compose configurations for containerized deployment, ensuring consistency across different environments. 14 | - ☁️ **AWS Integration:** Built-in support for AWS SNS and SQS, offering powerful tools for asynchronous communication and event-driven architectures. 15 | 16 | ## 🗂️ Project Structure 17 | 18 | - **`libs/` :** 19 | 20 | - This folder contains shared libraries generated using the NestJS CLI command `nest g library LIB_NAME`. These libraries can be reused across different modules in the application, promoting modularity and code reuse. 21 | 22 | - **`common/` :** 23 | 24 | - Contains shared utilities and components that are used across various modules in the application. 25 | 26 | - **`constants/`:** Contains constant values used throughout the application, such as configurations and settings 27 | 28 | - **`decorators/`:** Houses custom decorators that simplify and enhance the functionality of your code. 29 | 30 | - **`dtos/`:** Contains shared Data Transfer Objects (DTOs) used across multiple modules. 31 | 32 | - **`enums/`:** Stores enumerations that define a set of named constants, which can be used to represent a collection of related values in a type-safe manner. 33 | 34 | - **`exceptions/`:** Contains custom exceptions that extend the standard NestJS exceptions, providing more specific error handling across the application. 35 | 36 | - **`guards/`:** Includes custom guards that control access to routes based on certain conditions. 37 | 38 | - **`interceptors/`:** Houses custom interceptors that modify incoming requests or outgoing responses, such as logging, transformation, or error handling. 39 | 40 | - **`pipes/`:** Contains custom pipes that transform or validate data before it is processed by the controller. Pipes can be used for tasks like data validation, parsing, or formatting. 41 | 42 | - **`transformers/`:** Includes transformers that modify data between different layers of the application. 43 | 44 | - **`utils/`:** Stores utility functions and helpers that provide reusable functionality across the application, such as formatting dates, generating tokens, or handling common logic. 45 | 46 | - **`database/` :** 47 | 48 | - **`postgres/` :** 49 | 50 | - Contains configurations and setup for PostgreSQL integration, including TypeORM support and migration management. 51 | 52 | - **`entities/`:** Houses TypeORM entity definitions that map to database tables. These entities define the structure of your database and are used by TypeORM to perform CRUD operations. Each entity represents a model in your application and is directly tied to a specific database table. 53 | 54 | - **`migrations/`:** Contains migration files that handle changes to the database schema. Migrations can be generated using the command `npm run migrations:generate --name=MIGRATION_NAME`. 55 | 56 | - **`redis/` :** 57 | - Contains configuration and setup for Redis integration. Redis is used for caching and real-time data processing. This folder includes setup files to integrate Redis into the application, enhancing performance and enabling features like session management and caching. 58 | 59 | - **`swagger/`:** 60 | - **`responses/`**: Defines custom Swagger responses for different HTTP statuses. 61 | - `config.ts`: Contains Swagger configuration settings. 62 | - `index.ts`: Entry point for the Swagger configuration. 63 | - `swagger.decorator.ts`: A decorator to simplify Swagger documentation creation for routes. 64 | - `swagger.type.ts`: Contains all type definitions used in the Swagger configuration. 65 | - `tsconfig.lib.json`: TypeScript configuration specific to the Swagger library. 66 | 67 | - **`src/` :** 68 | 69 | The `src` folder is the core of the application. It contains the main application entry point and the organization of various features through modules. 70 | 71 | - **`main.ts:`** This file is the entry point of the NestJS application. It initializes the NestJS application by creating an instance of the `NestFactory` and setting up the application module. It is responsible for bootstrapping the entire application and configuring global settings, such as middlewares and exception filters. 72 | 73 | - **`modules/`:** Houses all the application modules, each encapsulating a specific feature or domain of the application. Modules in this folder adhere to the modular design principles of NestJS, promoting separation of concerns and code reusability. Each module typically includes its own set of controllers, services, and providers, organizing related functionality and business logic into cohesive units. 74 | 75 | - **`infrastructure/`:** Centralizes foundational modules and services that are globally required across the NestJS application. 76 | 77 | - **`infrastructure.module.ts`:** A centralized module for infrastructure services and modules. 78 | 79 | - **`aws/`:** Contains AWS-specific modules and services: 80 | 81 | - **`aws.module.ts`:** The AWS module aggregates and organizes services related to AWS integrations. 82 | - **`sns/`:** Handles SNS connection and provides `SnsService` for SNS communication. 83 | - **`sqs/`:** Manages SQS connection and polling. 84 | 85 | - **`queues/`:** Implements **BullMQ** integration in the NestJS application for managing job queues and processing tasks asynchronously. 86 | 87 | - **`Bull Board UI Integration`:** The package **@bull-board/nestjs** is used to render the Bull queue board UI at the `/api/v1/admin/bull-board` route. This UI allows for easy monitoring and management of queues, providing insights into the jobs being processed, their status, and other key metrics. (To access the Bull Board UI, you need to set `BULL_ADMIN_USERNAME` and `BULL_ADMIN_PASSWORD` environment variables in the .env file.) 88 | 89 | - **`queue.module.ts`:** The `QueueModule` is the central module for configuring and managing **BullMQ** queues within the application. It handles the integration with Redis and organizes queue-related modules for modular and scalable design. 90 | 91 | - **`queue.service.ts`:** This file defines an abstract class `AbstractQueueService` that provides a base implementation for managing queues using **BullMQ**. It serves as a reusable foundation for creating specific queue services in the application. 92 | 93 | - **`queue.enum.ts`:** Defines enumerations related to queues, such as queue names or job-related statuses, to maintain consistent references across the application. 94 | 95 | - **`index.ts`:** Re-exports entities and services from this folder to simplify imports elsewhere in the application. 96 | 97 | - **`notification/`:** 98 | This folder contains the implementation of a working example for integrating the application with a queue using **BullMQ**. It demonstrates how to produce and process jobs, making it a reference point for building queue-based communication in the application (for working examples you can check auth.controller). 99 | 100 | - **`docker/`:** 101 | 102 | - Contains configuration files and scripts related to Docker for containerizing the application. 103 | 104 | - **`Dockerfile:`** Defines the steps for building a Docker image for the application. It includes instructions to set up the environment, install dependencies, copy source files, and configure the application for running inside a Docker container. This file is used to create a consistent and reproducible build environment for the application. 105 | 106 | - **`docker-compose.yml` :** 107 | 108 | - Defines a multi-container Docker application setup. It includes services for the NestJS application (`app`), PostgreSQL database (`postgres`), and database migrations (`postgres_migrations`). The file manages container dependencies, environment configurations, and networking, facilitating easy deployment and orchestration of the application and its associated services. 109 | 110 | - **`.env.example`:** 111 | 112 | - Provides a template for environment variables required by the application. It includes placeholders for necessary configuration values such as database credentials, JWT secrets, and other settings. This file serves as a reference for creating the actual `.env` file with appropriate values for different environments (development, testing, production). 113 | 114 | - **`eslintrc.json.mjs` :** 115 | 116 | - Configures ESLint for linting JavaScript/TypeScript code. It defines rules and settings for code quality and style enforcement, ensuring consistent and error-free code across the project. 117 | 118 | - **`.prettierrc.js` :** 119 | - Configures Prettier for code formatting. It specifies formatting options such as indentation, line width, and quote style, ensuring that code adheres to a consistent format throughout the project. 120 | 121 | ## 📝 Note on `NOTE` 122 | 123 | Throughout the project, you will encounter `NOTE` comments that provide important context or instructions. These comments are intended to guide developers in understanding key parts of the code, configuration, and best practices. 124 | 125 | ## 💻 Prerequisites: 126 | 127 | Ensure you have the following tools installed in your PC: 128 | 129 | - NodeJS (along with npm) 130 | - NestJS 131 | - Postgres 132 | 133 | ## 🚀 Run project: 134 | 135 | 1. Clone the repository: 136 | 137 | ```sh 138 | git clone https://github.com/grishahovhanyan/nestjs-architecture.git 139 | ``` 140 | 141 | 2. Navigate to the project directory: 142 | 143 | ```sh 144 | cd nestjs-architecture 145 | ``` 146 | 147 | 3. Run the following command to install all dependencies: 148 | 149 | ```sh 150 | npm install 151 | ``` 152 | 153 | 4. Create a .env file from the provided .env.example file. 154 | 155 | ```sh 156 | cp .env.example .env 157 | ``` 158 | 159 | 5. To run migrations, use the following command: 160 | 161 | ```sh 162 | npm run migrations:run 163 | ``` 164 | 165 | 6. To run the development environment, use the following command: 166 | 167 | ```sh 168 | npm run start:dev 169 | ``` 170 | 171 | After starting the server, you can access the application at: http://localhost:PORT_FROM_ENV/swagger-ui/ 172 | 173 | ## 🐳 Run project with docker: 174 | 175 | 1. After clone go to the project directory and create a .env file from the provided .env.example file. 176 | 177 | ```sh 178 | cp .env.example .env 179 | ``` 180 | 181 | 2. Build a Docker image for project using the Dockerfile located in the "docker" directory. 182 | 183 | ```sh 184 | docker build . -f docker/Dockerfile 185 | ``` 186 | 187 | 3. Run a Docker container using the image created in the previous step. 188 | 189 | ```sh 190 | docker run --entrypoint /usr/src/app/docker/entrypoint.sh -it IMAGE_ID_FROM_PREVIOUS_STEP /usr/src/app/docker/start_node.sh 191 | ``` 192 | 193 | ## 🐳 Run project with docker compose: 194 | 195 | 1. After clone go to the project directory and create a .env file from the provided .env.example file. 196 | 197 | ```sh 198 | cp .env.example .env 199 | ``` 200 | 201 | 2. Build Docker images for a multi-container application defined in a Docker Compose file. 202 | 203 | ```sh 204 | docker compose up --build 205 | ``` 206 | 207 | 3. Run Docker containers based on the images created in the previous step. 208 | 209 | ```sh 210 | docker compose up 211 | ``` 212 | 213 | ## ✏️ API V1 Endpoints 214 | 215 | - **Healthcheck:** `GET /healthcheck/` 216 | 217 | - **Register:** `GET /auth/register/` 218 | - **Login:** `GET /auth/login/` 219 | 220 | - **Get current user:** `GET /users/me/` 221 | - **Get users:** `GET /users/` 222 | - **Get user by Id:** `GET /users/:id/` 223 | 224 | ![swagger](public/img/swagger.png) 225 | 226 | ## 🗂️ NPM Commands 227 | 228 | - **`npm run format`**: Formats TypeScript files in the `src`, `test`, and `libs` directories according to the rules defined in the `.prettierrc.js` configuration file. 229 | 230 | - **`npm run lint`**: Executes ESLint to check for code quality issues and ensure that the code adheres to the defined coding standards. 231 | 232 | - **`npm run lint`**: Runs ESLint and automatically fixes fixable issues in the code, ensuring it complies with the project's coding rules. 233 | 234 | - **`npm run build`**: Compiles TypeScript files into JavaScript and outputs the results into the `dist` folder, preparing the application for deployment. 235 | 236 | - **`npm run start`**: Starts the NestJS application in production mode using the compiled files from the `dist` folder. 237 | 238 | - **`npm run start:dev`**: Launches the application in development mode with live reloading enabled, automatically restarting the server when changes are detected. 239 | 240 | - **`npm run start:debug`**: Runs the application in debug mode with live reloading, allowing for debugging and step-by-step code execution. 241 | 242 | - **`npm run start:prod`**: Starts the compiled production-ready application using Node.js, running the code from the `dist/main` file. 243 | 244 | - **`npm run typeorm`**: Runs TypeORM CLI commands using `ts-node` to handle database-related tasks, such as running migrations, without needing to compile TypeScript code first. 245 | 246 | - **`npm run migrations:create`**: Creates a new migration file in the specified `migrations` directory, which can be used to define database schema changes. 247 | 248 | - **`npm run migrations:generate`**: Automatically generates a migration file based on the current state of the entities, capturing any schema changes that need to be applied to the database. 249 | 250 | - **`npm run migrations:run`**: Applies all pending migrations to the database, updating the schema according to the latest migration files. 251 | 252 | - **`npm run migrations:rollback`**: Reverts the last migration that was applied, rolling back the database schema to the previous state. 253 | 254 | ## 🗂️ NestJS CLI Commands 255 | 256 | - **`nest g module MODULE_NAME`**: Generates a new module in the application, creating a directory and file structure for the module. 257 | 258 | - **`nest g service SERVICE_NAME`**: Generates a new service in the specified module, providing a class with dependency injection. 259 | 260 | - **`nest g controller CONTROLLER_NAME`**: Generates a new controller in the specified module, enabling the creation of routes and request handlers. 261 | 262 | - **`nest g class CLASS_NAME`**: Generates a new class in the specified directory, which can be used for utility functions, constants, or other shared logic. 263 | 264 | - **`nest g library LIB_NAME`**: Generates a new library in the `libs/` folder, creating a reusable set of functionalities that can be shared across the application. 265 | 266 | ## 📝 Author 267 | 268 | - **Grisha Hovhanyan** - [github:grishahovhanyan](https://github.com/grishahovhanyan) 269 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | depends_on: 4 | postgres: 5 | condition: service_healthy 6 | redis: 7 | condition: service_healthy 8 | container_name: nest_app 9 | restart: always 10 | entrypoint: 'npm run start:debug' 11 | env_file: ./.env 12 | volumes: 13 | - .:/usr/src/app 14 | - /usr/src/app/node_modules 15 | build: 16 | context: . 17 | dockerfile: docker/Dockerfile 18 | ports: 19 | - '${API_PORT}:${API_PORT}' 20 | - '9229:9229' 21 | environment: 22 | POSTGRES_HOST: postgres 23 | POSTGRES_PORT: $POSTGRES_PORT 24 | POSTGRES_USER: $POSTGRES_USER 25 | POSTGRES_PASSWORD: $POSTGRES_PASSWORD 26 | POSTGRES_DATABASE: $POSTGRES_DATABASE 27 | REDIS_HOST: redis 28 | REDIS_PORT: $REDIS_PORT 29 | REDIS_USERNAME: $REDIS_USERNAME 30 | REDIS_PASSWORD: $REDIS_PASSWORD 31 | networks: 32 | - nest_app_network 33 | 34 | postgres: 35 | image: postgres 36 | container_name: nest_app_postgres 37 | environment: 38 | POSTGRES_USER: $POSTGRES_USER 39 | POSTGRES_PASSWORD: $POSTGRES_PASSWORD 40 | POSTGRES_DB: $POSTGRES_DATABASE 41 | ports: 42 | - '5433:$POSTGRES_PORT' 43 | networks: 44 | - nest_app_network 45 | volumes: 46 | - ./postgres_data:/var/lib/postgresql/data 47 | healthcheck: 48 | test: ['CMD-SHELL', 'pg_isready -U $POSTGRES_USER -h localhost -p $POSTGRES_PORT'] 49 | interval: 10s 50 | timeout: 5s 51 | retries: 5 52 | 53 | postgres_migrations: 54 | container_name: nest_app_postgres_migrations 55 | depends_on: 56 | postgres: 57 | condition: service_healthy 58 | build: 59 | context: . 60 | dockerfile: docker/Dockerfile 61 | command: npm run migrations:run 62 | env_file: 63 | - ./.env 64 | environment: 65 | POSTGRES_HOST: postgres 66 | POSTGRES_PORT: $POSTGRES_PORT 67 | POSTGRES_USER: $POSTGRES_USER 68 | POSTGRES_PASSWORD: $POSTGRES_PASSWORD 69 | POSTGRES_DATABASE: $POSTGRES_DATABASE 70 | networks: 71 | - nest_app_network 72 | 73 | redis: 74 | image: redis 75 | container_name: nest_app_redis 76 | environment: 77 | REDIS_USERNAME: $REDIS_USERNAME 78 | REDIS_PASSWORD: $REDIS_PASSWORD 79 | ports: 80 | - '6380:$REDIS_PORT' 81 | networks: 82 | - nest_app_network 83 | command: redis-server --requirepass $REDIS_PASSWORD 84 | healthcheck: 85 | test: ['CMD', 'redis-cli', '-h', 'localhost', '-a', '$REDIS_PASSWORD', 'ping'] 86 | interval: 10s 87 | timeout: 5s 88 | retries: 5 89 | 90 | networks: 91 | nest_app_network: 92 | driver: bridge 93 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.18-alpine AS base 2 | 3 | WORKDIR /usr/src/app 4 | COPY . /usr/src/app 5 | 6 | RUN apk update && apk add bash 7 | RUN chmod +x ./docker/entrypoint.sh 8 | RUN chmod +x ./docker/start_node.sh 9 | RUN npm install 10 | RUN npm install -g pm2 11 | RUN npx tsc 12 | RUN npm run build 13 | 14 | ENTRYPOINT ["/usr/src/app/docker/entrypoint.sh"] 15 | -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | exec "$@" 8 | -------------------------------------------------------------------------------- /docker/start_node.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo "run migrations" 3 | npm run migrations:run 4 | 5 | echo "start node" 6 | pm2-runtime start dist/main.js 7 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended' 2 | import typescriptEslintPlugin from '@typescript-eslint/eslint-plugin' 3 | import globals from 'globals' 4 | import tsParser from '@typescript-eslint/parser' 5 | import path from 'node:path' 6 | import { fileURLToPath } from 'node:url' 7 | import js from '@eslint/js' 8 | import { FlatCompat } from '@eslint/eslintrc' 9 | import simpleImportSort from 'eslint-plugin-simple-import-sort' 10 | 11 | const __filename = fileURLToPath(import.meta.url) 12 | const __dirname = path.dirname(__filename) 13 | const compat = new FlatCompat({ 14 | baseDirectory: __dirname, 15 | recommendedConfig: js.configs.recommended, 16 | allConfig: js.configs.all, 17 | }) 18 | 19 | export default [ 20 | { 21 | ignores: ['**/eslint.config.mjs', '**/.prettierrc.js', '.hygen.js', '.hygen/**', 'postgres_data/**', 'dist/**'], 22 | }, 23 | ...compat.extends( 24 | 'plugin:@typescript-eslint/recommended', 25 | 'plugin:prettier/recommended', 26 | ), 27 | { 28 | plugins: { 29 | '@typescript-eslint': typescriptEslintPlugin, 30 | 'simple-import-sort': simpleImportSort, 31 | }, 32 | languageOptions: { 33 | globals: { 34 | ...globals.node, 35 | ...globals.jest, 36 | }, 37 | parser: tsParser, 38 | ecmaVersion: 5, 39 | sourceType: 'module', 40 | parserOptions: { 41 | project: path.resolve(__dirname, 'tsconfig.json'), 42 | }, 43 | }, 44 | rules: { 45 | "prettier/prettier": ["error"], 46 | "@typescript-eslint/interface-name-prefix": "off", 47 | "@typescript-eslint/explicit-function-return-type": "off", 48 | "@typescript-eslint/explicit-module-boundary-types": "off", 49 | "@typescript-eslint/no-explicit-any": "off", 50 | "@typescript-eslint/no-unused-vars": ["error"], 51 | "@typescript-eslint/no-use-before-define": ["off"], 52 | "@typescript-eslint/no-empty-interface": [ 53 | "error", 54 | { 55 | "allowSingleExtends": true 56 | } 57 | ], 58 | "@typescript-eslint/no-inferrable-types": "off", 59 | "semi": [2, "never"], 60 | "object-shorthand": ["error", "always"], 61 | "quotes": ["error", "single", { 62 | "avoidEscape": true, 63 | "allowTemplateLiterals": true 64 | }], 65 | "no-unused-vars": "off", 66 | "object-curly-spacing": ["error", "always"], 67 | "array-bracket-spacing": ["error", "never"], 68 | "computed-property-spacing": ["error", "never"], 69 | "space-before-function-paren": [ 70 | "error", 71 | { 72 | "anonymous": "always", 73 | "named": "never", 74 | "asyncArrow": "always" 75 | } 76 | ], 77 | "no-multi-spaces": ["error"], 78 | "no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 0 }], 79 | "key-spacing": [ 80 | "error", 81 | { 82 | "mode": "strict", 83 | "afterColon": true, 84 | "beforeColon": false 85 | } 86 | ], 87 | "indent": [ 88 | "error", 89 | 2, 90 | { 91 | "FunctionDeclaration": { "parameters": "first" }, 92 | "ImportDeclaration": 1, 93 | "SwitchCase": 1, 94 | "ignoredNodes": ["PropertyDefinition"] 95 | } 96 | ], 97 | "no-nested-ternary": 2, 98 | "no-var": 2, 99 | "no-use-before-define": "off", 100 | "eol-last": ["error", "always"], 101 | "comma-spacing": ["error", { "before": false, "after": true }], 102 | "space-infix-ops": "error", 103 | "semi-spacing": ["error", { "before": false, "after": true }], 104 | "comma-dangle": ["error", "never"], 105 | "padded-blocks": ["error", "never"], 106 | "simple-import-sort/imports": [ 107 | "error", 108 | { 109 | "groups": [ 110 | ["^@?\\w", "^@", "^[^.]"], 111 | ["^@app/swagger", "^@app/common", "^@app/database", "^@modules/", "^@infra/"], 112 | ["^\\."], 113 | ] 114 | } 115 | ], 116 | "simple-import-sort/exports": "error", 117 | }, 118 | }, 119 | eslintPluginPrettierRecommended 120 | ] 121 | -------------------------------------------------------------------------------- /libs/common/src/constants/aws.constant.ts: -------------------------------------------------------------------------------- 1 | import { envService } from '../utils' 2 | 3 | const sqsWaitTimeSecondsValue = envService.getEnvNumber('SQS_WAIT_TIME_SECONDS', 20) 4 | const sqsVisibilityTimeoutValue = envService.getEnvNumber('SQS_VISIBILITY_TIMEOUT', 30) 5 | 6 | export const AWS_REGION = envService.getEnvString('AWS_REGION', 'us-east-1') 7 | 8 | export const SQS_URL = envService.getEnvString('SQS_URL') 9 | export const SQS_QUEUE_NAME = SQS_URL.split('/').pop() 10 | export const SQS_WAIT_TIME_SECONDS = 11 | sqsWaitTimeSecondsValue >= 0 && sqsWaitTimeSecondsValue <= 20 ? sqsWaitTimeSecondsValue : 20 12 | export const SQS_VISIBILITY_TIMEOUT = 13 | sqsVisibilityTimeoutValue >= 0 && sqsVisibilityTimeoutValue <= 43200 ? sqsVisibilityTimeoutValue : 30 14 | export const SQS_POLLING_WAIT_TIME_MS = envService.getEnvNumber('SQS_POLLING_WAIT_TIME_MS', 0) 15 | export const SQS_AUTHENTICATION_ERROR_TIMEOUT = envService.getEnvNumber('SQS_AUTHENTICATION_ERROR_TIMEOUT', 3600) 16 | export const SQS_BATCH_POLLING = envService.getEnvBoolean('SQS_BATCH_POLLING', false) 17 | 18 | export const SNS_ARN = envService.getEnvString('SNS_ARN') 19 | -------------------------------------------------------------------------------- /libs/common/src/constants/default-values.constant.ts: -------------------------------------------------------------------------------- 1 | export const RESET_PASSWORD_TOKEN_EXPIRE_TIME = 5 * 60000 // 5 minutes 2 | export const RESET_PASSWORD_REQUEST_TIME_LIMIT = 1 * 60000 // 1 minute 3 | -------------------------------------------------------------------------------- /libs/common/src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './aws.constant' 2 | export * from './default-values.constant' 3 | export * from './jwt.constant' 4 | export * from './responses.constant' 5 | export * from './sort.constant' 6 | export * from './validation.constant' 7 | -------------------------------------------------------------------------------- /libs/common/src/constants/jwt.constant.ts: -------------------------------------------------------------------------------- 1 | import { envService } from '../utils' 2 | 3 | export const JWT_SECRET = envService.getEnvString('JWT_SECRET', 'strong_secret_key') 4 | export const JWT_EXPIRATION = envService.getEnvString('JWT_EXPIRATION', '1d') 5 | -------------------------------------------------------------------------------- /libs/common/src/constants/responses.constant.ts: -------------------------------------------------------------------------------- 1 | export const VALIDATION_MESSAGES = { 2 | required: 'This field is required.', 3 | invalidBoolean: 'A valid boolean is required.', 4 | invalidInteger: 'A valid integer is required.', 5 | invalidNumber: 'A valid number is required.', 6 | invalidString: 'A valid string is required.', 7 | invalidArray: 'A valid array is required.', 8 | invalidEmail: 'A valid email is required.', 9 | invalidISOFormat: 'A valid iso date format is required.', 10 | invalidChoice: (valueUsed: string, validValues: string[] | number[]) => 11 | `${valueUsed || 'This field'} is not a valid choice. Use one of these values instead: [${validValues.join(', ')}.`, 12 | invalidDate: 'This field value must be greater then date now.', 13 | invalidFormat: (validFormat: string) => `This field has wrong format. Use this format instead: ${validFormat}.`, 14 | mustBeGreaterThan: (num: number) => `This filed must be greater than or equal to ${num}.`, 15 | mustBeLessThan: (num: number) => `This filed must be less than or equal to ${num}.`, 16 | lengthMustBeGreaterThan: (num: number) => `This field length must be at least ${num} characters long.`, 17 | lengthMustBeLessThan: (num: number) => `This filed length must be less than or equal to ${num} characters long.`, 18 | passwordMismatch: 'Password mismatch.' 19 | } 20 | 21 | export const ERROR_MESSAGES = { 22 | badRequest400: 'Bad request.', 23 | unauthorized401: 'Authentication credentials were not provided.', 24 | forbidden403: 'You do not have permission to perform this action.', 25 | notFound404: 'Not found.', 26 | userAlreadyExists: 'User with such email already exists.', 27 | invalidEmailPassword: 'Invalid email and/or password.', 28 | passwordResetTokenExpired: 'Password reset token has expired.', 29 | passwordResetRequestTooFrequent: 'You can only request a password reset once per minute.' 30 | } 31 | 32 | export const SUCCESS_RESPONSE = { message: 'success' } 33 | -------------------------------------------------------------------------------- /libs/common/src/constants/sort.constant.ts: -------------------------------------------------------------------------------- 1 | import { IOrderObject } from '@app/database' 2 | 3 | export enum SortDirections { 4 | ascending = 'ASC', 5 | descending = 'DESC' 6 | } 7 | 8 | export const DEFAULT_SORT_FIELDS = ['id'] 9 | 10 | export const USERS_SORT_FIELDS = ['id', 'registeredAt'] 11 | 12 | export function getSortOrderFromQuery(queryOrdering: string[], allowedSortFields: string[]): IOrderObject { 13 | const sortOrder = queryOrdering.reduce((orderObject, sortField) => { 14 | let sortDirection = SortDirections.ascending 15 | if (sortField.startsWith('-')) { 16 | sortDirection = SortDirections.descending 17 | sortField = sortField.slice(1) 18 | } 19 | 20 | if (allowedSortFields.includes(sortField)) { 21 | orderObject[sortField] = sortDirection 22 | } 23 | return orderObject 24 | }, {}) 25 | 26 | return sortOrder 27 | } 28 | 29 | export const getOrderDescription = (sortFields: string[] = DEFAULT_SORT_FIELDS) => ` 30 | Allowed fields: ${sortFields.join(', ')} 31 | 32 | Examples: 33 | ?ordering=-id (descending) 34 | ?ordering=createdAt (ascending) 35 | ?ordering=id,-createdAt` 36 | -------------------------------------------------------------------------------- /libs/common/src/constants/validation.constant.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi' 2 | 3 | /* 4 | ####### NOTE ####### 5 | The `envValidationSchema` object defines the validation rules for environment variables using the `Joi` library. 6 | Each environment variable listed is required for the application to run properly. This ensures that all necessary 7 | configuration values are present and valid before the application starts. 8 | 9 | Examples: If you plan to use the `AWSModule`, you should also include and validate the following environment variables: 10 | - `AWS_ACCESS_KEY_ID`: Your AWS access key ID. 11 | - `AWS_SECRET_ACCESS_KEY`: Your AWS secret access key. 12 | - `AWS_REGION`: The AWS region your resources are hosted in. 13 | - `SQS_URL`: The URL of the AWS SQS queue. 14 | - `SNS_ARN`: The ARN of the AWS SNS topic. 15 | */ 16 | export const envValidationSchema = Joi.object({ 17 | POSTGRES_HOST: Joi.string().required(), 18 | POSTGRES_PORT: Joi.number().required(), 19 | POSTGRES_USER: Joi.string().required(), 20 | POSTGRES_PASSWORD: Joi.string().required(), 21 | POSTGRES_DATABASE: Joi.string().required(), 22 | JWT_SECRET: Joi.string().required(), 23 | JWT_EXPIRATION: Joi.string().required() 24 | }) 25 | 26 | export const PASSWORD_MIN_LENGTH = 8 27 | export const PASSWORD_MAX_LENGTH = 20 28 | -------------------------------------------------------------------------------- /libs/common/src/decorators/controller.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, Controller } from '@nestjs/common' 2 | import { ApiBearerAuth, ApiTags } from '@nestjs/swagger' 3 | import { upperFirst } from 'lodash' 4 | 5 | import { SwaggerUnauthorized401 } from '@app/swagger' 6 | 7 | import { Public } from './public.decorator' 8 | 9 | export function EnhancedController(prefix: string, secured = true, swaggerTag = ''): ClassDecorator { 10 | const swaggerTagFromPrefix = !prefix.includes('/') 11 | ? upperFirst(prefix) 12 | : prefix 13 | .split('/') 14 | .map((item) => upperFirst(item)) 15 | .join('') 16 | 17 | const decorators: Array = [ 18 | ApiTags(swaggerTag || swaggerTagFromPrefix), 19 | Controller(prefix) 20 | ] 21 | 22 | if (secured) { 23 | decorators.push(ApiBearerAuth(), SwaggerUnauthorized401()) 24 | } else { 25 | decorators.push(Public()) 26 | } 27 | 28 | return applyDecorators(...decorators) 29 | } 30 | -------------------------------------------------------------------------------- /libs/common/src/decorators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controller.decorator' 2 | export * from './match.decorator' 3 | export * from './public.decorator' 4 | export * from './transform.decorator' 5 | export * from './user.decorator' 6 | -------------------------------------------------------------------------------- /libs/common/src/decorators/match.decorator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | registerDecorator, 3 | ValidationArguments, 4 | ValidationOptions, 5 | ValidatorConstraint, 6 | ValidatorConstraintInterface 7 | } from 'class-validator' 8 | 9 | export function Match(property: string, validationOptions?: ValidationOptions) { 10 | return (object: any, propertyName: string) => { 11 | registerDecorator({ 12 | target: object.constructor, 13 | propertyName, 14 | options: validationOptions, 15 | constraints: [property], 16 | validator: MatchConstraint 17 | }) 18 | } 19 | } 20 | 21 | @ValidatorConstraint({ name: 'match' }) 22 | export class MatchConstraint implements ValidatorConstraintInterface { 23 | validate(value: any, args: ValidationArguments) { 24 | const [relatedPropertyName] = args.constraints 25 | const relatedValue = (args.object as any)[relatedPropertyName] 26 | return value === relatedValue 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /libs/common/src/decorators/public.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common' 2 | 3 | export const IS_PUBLIC_KEY = 'isPublic' 4 | export const Public = () => SetMetadata(IS_PUBLIC_KEY, true) 5 | -------------------------------------------------------------------------------- /libs/common/src/decorators/transform.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, UseInterceptors } from '@nestjs/common' 2 | 3 | import { TransformInterceptor } from '@app/common' 4 | 5 | export function TransformResponse(dtoClass: new () => any) { 6 | return applyDecorators(UseInterceptors(new TransformInterceptor(dtoClass))) 7 | } 8 | -------------------------------------------------------------------------------- /libs/common/src/decorators/user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common' 2 | 3 | import { User } from '@modules/users/entities/user.entity' 4 | 5 | export const RequestUser = createParamDecorator((data: string, ctx: ExecutionContext) => { 6 | const request = ctx.switchToHttp().getRequest() 7 | const user: User = request.user 8 | 9 | return data ? user?.[data] : user 10 | }) 11 | -------------------------------------------------------------------------------- /libs/common/src/dtos/index.ts: -------------------------------------------------------------------------------- 1 | export * from './order.dto' 2 | export * from './pagination.dto' 3 | export * from './responses' 4 | export * from './search.dto' 5 | -------------------------------------------------------------------------------- /libs/common/src/dtos/order.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger' 2 | import { Transform } from 'class-transformer' 3 | import { IsOptional } from 'class-validator' 4 | 5 | import { getOrderDescription, getSortOrderFromQuery, SortDirections } from '../constants' 6 | 7 | export function OrderDto(sortFields?: string[]) { 8 | class DynamicOrderDto { 9 | @Transform(({ value }) => getSortOrderFromQuery(value?.split(',') ?? [], sortFields), { toClassOnly: true }) 10 | @IsOptional() 11 | @ApiPropertyOptional({ type: String, description: getOrderDescription(sortFields) }) 12 | order?: Record 13 | } 14 | 15 | return DynamicOrderDto 16 | } 17 | -------------------------------------------------------------------------------- /libs/common/src/dtos/pagination.dto.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer' 2 | 3 | import { DEFAULT_PAGE_SIZE, getPerPage, PageTypes } from '../utils' 4 | import { NumberFieldOptional } from '../validators' 5 | 6 | export function PaginationDto(pageType?: PageTypes) { 7 | class DynamicPaginationDto { 8 | @NumberFieldOptional({ positive: true }) 9 | page: number = 1 10 | 11 | @Transform(({ value }) => (pageType ? getPerPage(pageType, value) : DEFAULT_PAGE_SIZE), { toClassOnly: true }) 12 | @NumberFieldOptional({ positive: true }) 13 | perPage: number = DEFAULT_PAGE_SIZE 14 | } 15 | 16 | return DynamicPaginationDto 17 | } 18 | -------------------------------------------------------------------------------- /libs/common/src/dtos/responses/index.ts: -------------------------------------------------------------------------------- 1 | export * from './pagination-response.dto' 2 | -------------------------------------------------------------------------------- /libs/common/src/dtos/responses/pagination-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { Type } from 'class-transformer' 3 | 4 | export class PagesDto { 5 | @ApiProperty({ example: 3 }) 6 | next: number 7 | 8 | @ApiProperty({ example: 1 }) 9 | previous: number 10 | 11 | @ApiProperty({ example: 2 }) 12 | current: number 13 | 14 | @ApiProperty({ example: 5 }) 15 | numPages: number 16 | 17 | @ApiProperty({ example: 30 }) 18 | perPage: number 19 | } 20 | 21 | export class PaginationResponseDto> { 22 | @ApiProperty() 23 | @Type(() => PagesDto) 24 | pages: PagesDto 25 | 26 | @ApiProperty({ example: 30 }) 27 | count: number 28 | 29 | @ApiProperty() 30 | items: T[] 31 | } 32 | -------------------------------------------------------------------------------- /libs/common/src/dtos/search.dto.ts: -------------------------------------------------------------------------------- 1 | import { StringFieldOptional } from '../validators' 2 | 3 | export class SearchDto { 4 | @StringFieldOptional({ description: 'Text for searching' }) 5 | searchText?: string 6 | } 7 | -------------------------------------------------------------------------------- /libs/common/src/enums/index.ts: -------------------------------------------------------------------------------- 1 | export * from './node-envs.enum' 2 | -------------------------------------------------------------------------------- /libs/common/src/enums/node-envs.enum.ts: -------------------------------------------------------------------------------- 1 | export enum NodeEnvs { 2 | development = 'development', 3 | production = 'production', 4 | test = 'test' 5 | } 6 | -------------------------------------------------------------------------------- /libs/common/src/filters/http-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Logger } from '@nestjs/common' 2 | import { Response } from 'express' 3 | 4 | import { ERROR_MESSAGES } from '../constants' 5 | import { ValidationErrorPayload } from '../pipes/validation.pipe' 6 | 7 | // Define the type for possible exception payloads 8 | type ExceptionPayload = { message: string } | ValidationErrorPayload 9 | 10 | @Catch() 11 | export class HttpExceptionFilter implements ExceptionFilter { 12 | private readonly logger?: Logger 13 | 14 | constructor(options?: { logging: boolean }) { 15 | if (options?.logging) { 16 | // Initialize logger only if logging is enabled 17 | this.logger = new Logger(HttpExceptionFilter.name) 18 | } 19 | } 20 | 21 | catch(exception: unknown, host: ArgumentsHost) { 22 | const ctx = host.switchToHttp() 23 | const response = ctx.getResponse() 24 | 25 | let status = HttpStatus.INTERNAL_SERVER_ERROR 26 | let payload: ExceptionPayload = { message: 'Internal server error' } 27 | 28 | if (exception instanceof HttpException) { 29 | const exceptionResponse = exception.getResponse() 30 | status = exception.getStatus() 31 | 32 | if (typeof exceptionResponse === 'string') { 33 | payload = { message: exceptionResponse } 34 | } else if (typeof exceptionResponse === 'object') { 35 | payload = this.handleHttpException(exceptionResponse, status) 36 | } 37 | } else { 38 | this.logger?.error('[UnhandledException]', exception) 39 | } 40 | 41 | response.status(status).json(payload) 42 | } 43 | 44 | /** 45 | * Handles known HTTP exceptions and structures the response accordingly. 46 | */ 47 | private handleHttpException(exceptionResponse: Record, status: number): ExceptionPayload { 48 | this.logger?.error(`[HttpException] Status: ${status}`, exceptionResponse) 49 | 50 | switch (status) { 51 | case HttpStatus.BAD_REQUEST: 52 | return this.handleBadRequest(exceptionResponse) 53 | case HttpStatus.UNAUTHORIZED: 54 | return { message: ERROR_MESSAGES.unauthorized401 } 55 | case HttpStatus.FORBIDDEN: 56 | return { message: ERROR_MESSAGES.forbidden403 } 57 | case HttpStatus.NOT_FOUND: 58 | return { message: ERROR_MESSAGES.notFound404 } 59 | default: 60 | return { message: exceptionResponse.message || 'An unexpected error occurred.' } 61 | } 62 | } 63 | 64 | /** 65 | * Handles 400 Bad Request errors, including validation errors. 66 | */ 67 | private handleBadRequest(exceptionResponse: Record): ExceptionPayload { 68 | if (exceptionResponse.validationError) { 69 | return exceptionResponse 70 | } 71 | return { message: exceptionResponse.message || ERROR_MESSAGES.badRequest400 } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /libs/common/src/filters/index.ts: -------------------------------------------------------------------------------- 1 | export * from './http-exception.filter' 2 | -------------------------------------------------------------------------------- /libs/common/src/guards/index.ts: -------------------------------------------------------------------------------- 1 | export * from './jwt-auth.guard' 2 | -------------------------------------------------------------------------------- /libs/common/src/guards/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common' 2 | import { Reflector } from '@nestjs/core' 3 | import { AuthGuard } from '@nestjs/passport' 4 | 5 | import { IS_PUBLIC_KEY } from '@app/common' 6 | 7 | @Injectable() 8 | export class JwtAuthGuard extends AuthGuard('jwt') { 9 | constructor(private readonly reflector: Reflector) { 10 | super() 11 | } 12 | 13 | canActivate(context: ExecutionContext) { 14 | const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ 15 | context.getHandler(), 16 | context.getClass() 17 | ]) 18 | 19 | if (isPublic) { 20 | return true 21 | } 22 | 23 | return super.canActivate(context) 24 | } 25 | 26 | handleRequest(err: Error, user) { 27 | if (err || !user) { 28 | throw new UnauthorizedException() 29 | } 30 | 31 | return user 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /libs/common/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './constants' 2 | export * from './decorators' 3 | export * from './dtos' 4 | export * from './enums' 5 | export * from './filters' 6 | export * from './guards' 7 | export * from './interceptors' 8 | export * from './pipes' 9 | export * from './transformers' 10 | export * from './utils' 11 | -------------------------------------------------------------------------------- /libs/common/src/interceptors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './request-logger.interceptor' 2 | export * from './transform.interceptor' 3 | -------------------------------------------------------------------------------- /libs/common/src/interceptors/request-logger.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common' 2 | import { Observable } from 'rxjs' 3 | import { tap } from 'rxjs/operators' 4 | 5 | @Injectable() 6 | export class RequestLoggerInterceptor implements NestInterceptor { 7 | private readonly logger = new Logger('RequestLogger') 8 | 9 | intercept(context: ExecutionContext, next: CallHandler): Observable { 10 | const request = context.switchToHttp().getRequest() 11 | const response = context.switchToHttp().getResponse() 12 | 13 | const startTime = Date.now() 14 | 15 | return next.handle().pipe( 16 | tap(() => { 17 | const endTime = Date.now() 18 | const statusCode = response.statusCode 19 | const url = request.url 20 | 21 | this.logger.debug( 22 | `Request URL: ${url} | Response Code: ${statusCode} | Duration: ${(endTime - startTime) / 1000}s` 23 | ) 24 | }) 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /libs/common/src/interceptors/transform.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common' 2 | import { plainToInstance } from 'class-transformer' 3 | import { map, Observable } from 'rxjs' 4 | 5 | @Injectable() 6 | export class TransformInterceptor implements NestInterceptor { 7 | constructor(private readonly dtoClass: new () => any) {} 8 | 9 | intercept(context: ExecutionContext, next: CallHandler): Observable { 10 | return next.handle().pipe( 11 | map((data) => { 12 | if (data?.pages) { 13 | // Response with pagination 14 | return { ...data, items: plainToInstance(this.dtoClass, data.items) } 15 | } 16 | 17 | return plainToInstance(this.dtoClass, data) 18 | }) 19 | ) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /libs/common/src/pipes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './validation.pipe' 2 | -------------------------------------------------------------------------------- /libs/common/src/pipes/validation.pipe.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentMetadata, 3 | BadRequestException, 4 | Injectable, 5 | ValidationPipe as NestValidationPipe, 6 | ValidationPipeOptions 7 | } from '@nestjs/common' 8 | import { plainToInstance } from 'class-transformer' 9 | import { validate } from 'class-validator' 10 | 11 | import { VALIDATION_MESSAGES } from '@app/common' 12 | 13 | export type ValidationErrorPayload = Record 14 | 15 | const constraintsToMessages = { 16 | isDefined: VALIDATION_MESSAGES.required, 17 | isBoolean: VALIDATION_MESSAGES.invalidBoolean, 18 | isInt: VALIDATION_MESSAGES.invalidInteger, 19 | isNumber: VALIDATION_MESSAGES.invalidNumber, 20 | isString: VALIDATION_MESSAGES.invalidString, 21 | isEmail: VALIDATION_MESSAGES.invalidEmail 22 | } 23 | 24 | @Injectable() 25 | export class ValidationPipe extends NestValidationPipe { 26 | constructor(options?: ValidationPipeOptions) { 27 | super({ 28 | transform: true, // Ensure incoming requests are transformed 29 | whitelist: true, // Strip out non-whitelisted properties 30 | transformOptions: { enableImplicitConversion: true }, 31 | ...options 32 | }) 33 | } 34 | 35 | async transform(value: any, metadata: ArgumentMetadata) { 36 | if (!metadata.metatype || !this.toValidate(metadata)) { 37 | return value 38 | } 39 | 40 | // Use class-transformer to instantiate the DTO 41 | const objectToValidate = plainToInstance(metadata.metatype, value) 42 | 43 | // Use class-validator to validate the object 44 | const errors = await validate(objectToValidate) 45 | 46 | if (errors.length > 0) { 47 | const formattedErrors: ValidationErrorPayload = errors.reduce((obj, error) => { 48 | obj[error.property] = Object.entries(error.constraints || {}).map( 49 | ([key, value]) => constraintsToMessages[key] || value 50 | ) 51 | return obj 52 | }, {}) 53 | 54 | throw new BadRequestException({ ...formattedErrors, validationError: true }) 55 | } 56 | 57 | // Call super.transform() to apply NestJS transformation logic 58 | const transformedObject = await super.transform(value, metadata) 59 | 60 | // Strip undefined values from the final transformed object 61 | return Object.fromEntries(Object.entries(transformedObject).filter(([, v]) => v !== undefined)) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /libs/common/src/transformers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './password.transformer' 2 | -------------------------------------------------------------------------------- /libs/common/src/transformers/password.transformer.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from 'bcryptjs' 2 | import { ValueTransformer } from 'typeorm' 3 | 4 | export class HashService { 5 | static make(input: string): string { 6 | const salt = bcrypt.genSaltSync() 7 | return bcrypt.hashSync(input, salt) 8 | } 9 | 10 | static compare(input: string, hash: string): boolean { 11 | return bcrypt.compareSync(input, hash) 12 | } 13 | } 14 | 15 | export class PasswordTransformer implements ValueTransformer { 16 | to(value: string) { 17 | return HashService.make(value) 18 | } 19 | 20 | from(value: string) { 21 | return value 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /libs/common/src/utils/app.utils.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication, Logger } from '@nestjs/common' 2 | import { NestExpressApplication } from '@nestjs/platform-express' 3 | import { SwaggerModule } from '@nestjs/swagger' 4 | import compression from 'compression' 5 | import helmet from 'helmet' 6 | 7 | import { SWAGGER_CONFIGS, SWAGGER_OPTIONS } from '@app/swagger' 8 | import { HttpExceptionFilter, ValidationPipe } from '@app/common' 9 | 10 | import { envService } from './get-env' 11 | 12 | class AppUtilsService { 13 | private readonly logger: Logger = new Logger('App') 14 | 15 | public setupApp(app: NestExpressApplication, apiVersion: string = 'v1') { 16 | // Register a global validation pipe to validate incoming requests 17 | app.useGlobalPipes(new ValidationPipe()) 18 | 19 | // Register a global exception filter 20 | app.useGlobalFilters(new HttpExceptionFilter({ logging: !envService.isProductionEnv() })) 21 | 22 | // Set a global prefix for all routes in the API 23 | app.setGlobalPrefix(`api/${apiVersion}`) 24 | 25 | // Enable CORS 26 | const origins = envService.getOrigins() 27 | app.enableCors({ 28 | origin: origins, 29 | methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'], 30 | allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept'], 31 | maxAge: 3600 32 | }) 33 | 34 | this.logger.log(`🚦 Accepting request only from: ${origins}`) 35 | 36 | // Use Helmet to set secure HTTP headers to help protect against well-known vulnerabilities 37 | app.use(helmet()) 38 | // Trust proxy headers for accurate client IP address detection when behind a reverse proxy 39 | app.enable('trust proxy') 40 | // Set strong ETag generation for caching and optimizing responses 41 | app.set('etag', 'strong') 42 | // Enable compression to reduce the size of the response bodies and improve loading times 43 | app.use(compression()) 44 | 45 | // Setup Swagger 46 | this.setupSwagger(app) 47 | 48 | // Configure application gracefully shutdown 49 | app.enableShutdownHooks() 50 | this.killAppWithGrace(app) 51 | } 52 | 53 | public async gracefulShutdown(app: INestApplication, code: string): Promise { 54 | setTimeout(() => process.exit(1), 5000) 55 | this.logger.verbose(`Signal received with code '${code}'`) 56 | 57 | try { 58 | await app.close() 59 | 60 | this.logger.log('✅ Http server closed.') 61 | process.exit(0) 62 | } catch (error: unknown) { 63 | this.logger.error(`❌ Http server closed with error: ${error}`) 64 | process.exit(1) 65 | } 66 | } 67 | 68 | public killAppWithGrace(app: INestApplication): void { 69 | process.on('SIGINT', async () => { 70 | await this.gracefulShutdown(app, 'SIGINT') 71 | }) 72 | 73 | process.on('SIGTERM', async () => { 74 | await this.gracefulShutdown(app, 'SIGTERM') 75 | }) 76 | } 77 | 78 | public setupSwagger(app: INestApplication) { 79 | if (!envService.isTestEnv() && !envService.isProductionEnv()) { 80 | const { apiUrl } = envService.getApiUrl() 81 | 82 | const swaggerPath = 'swagger-ui' 83 | const swaggerDocument = SwaggerModule.createDocument(app, SWAGGER_CONFIGS) 84 | SwaggerModule.setup(swaggerPath, app, swaggerDocument, SWAGGER_OPTIONS) 85 | 86 | this.logger.log(`📑 Swagger is running on: ${apiUrl}/${swaggerPath}`) 87 | } 88 | } 89 | } 90 | 91 | export const appUtilsService = new AppUtilsService() 92 | -------------------------------------------------------------------------------- /libs/common/src/utils/calculate.ts: -------------------------------------------------------------------------------- 1 | export function calculateAge(birthDate: string | Date) { 2 | const birth = new Date(birthDate) 3 | const current = new Date() 4 | 5 | let age = current.getFullYear() - birth.getFullYear() 6 | if ( 7 | current.getMonth() < birth.getMonth() || 8 | (current.getMonth() === birth.getMonth() && current.getDate() < birth.getDate()) 9 | ) { 10 | age-- 11 | } 12 | 13 | return age 14 | } 15 | -------------------------------------------------------------------------------- /libs/common/src/utils/get-env.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config' 2 | 3 | import { NodeEnvs } from '@app/common' 4 | 5 | class EnvService { 6 | public getEnvString(key: string, defaultValue = ''): string { 7 | return process.env[key] || defaultValue 8 | } 9 | 10 | public getEnvNumber(key: string, defaultValue = 1) { 11 | return process.env[key] !== '' && !isNaN(+process.env[key]) ? +process.env[key] : defaultValue 12 | } 13 | 14 | public getEnvFloat(key: string, defaultValue = 1.0) { 15 | return parseFloat(process.env[key]) || defaultValue 16 | } 17 | 18 | public getEnvBoolean(key: string, defaultValue = true) { 19 | const value = process.env[key] 20 | return value !== 'true' && value !== 'false' ? defaultValue : value === 'true' 21 | } 22 | 23 | public getApiUrl(defaultPort: number = 5000) { 24 | const apiHost = this.getEnvString('API_HOST') 25 | const apiPort = this.getEnvNumber('API_PORT', defaultPort) 26 | const apiUrl = `${apiHost}:${apiPort}` 27 | 28 | return { apiHost, apiPort, apiUrl } 29 | } 30 | 31 | public getNodeEnv(): NodeEnvs { 32 | const processNodeEnv = this.getEnvString('NODE_ENV') 33 | return (Object.keys(NodeEnvs).includes(processNodeEnv) ? processNodeEnv : NodeEnvs.development) as NodeEnvs 34 | } 35 | 36 | public isTestEnv() { 37 | return this.getNodeEnv() === NodeEnvs.test 38 | } 39 | 40 | public isDevelopmentEnv() { 41 | return this.getNodeEnv() === NodeEnvs.development 42 | } 43 | 44 | public isProductionEnv() { 45 | return this.getNodeEnv() === NodeEnvs.production 46 | } 47 | 48 | public getOrigins(): string[] | string { 49 | const originArray = this.getEnvString('CORS_ORIGINS') 50 | .split(',') 51 | .map((item) => item.trim()) 52 | .filter(Boolean) 53 | return originArray.length ? originArray : '*' 54 | } 55 | } 56 | 57 | export const envService = new EnvService() 58 | -------------------------------------------------------------------------------- /libs/common/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app.utils' 2 | export * from './calculate' 3 | export * from './get-env' 4 | export * from './pagination' 5 | -------------------------------------------------------------------------------- /libs/common/src/utils/pagination.ts: -------------------------------------------------------------------------------- 1 | import { getSortOrderFromQuery } from '@app/common' 2 | import { IOrderObject } from '@app/database' 3 | 4 | import { envService } from './get-env' 5 | 6 | export enum PageTypes { 7 | users = 'users' 8 | } 9 | 10 | export const DEFAULT_PAGE_SIZE = 50 11 | export const MAX_PAGE_SIZE = 200 12 | 13 | export const DEFAULT_PAGE_SIZES = { 14 | [PageTypes.users]: envService.getEnvNumber('USERS_PAGE_SIZE', DEFAULT_PAGE_SIZE) 15 | } 16 | 17 | export const MAX_PAGE_SIZES = { 18 | [PageTypes.users]: envService.getEnvNumber('USERS_MAX_PAGE_SIZE', MAX_PAGE_SIZE) 19 | } 20 | 21 | export function getPerPage(type: string, querySize?: number) { 22 | const defaultSize = DEFAULT_PAGE_SIZES[type] ?? DEFAULT_PAGE_SIZE 23 | const maxSize = MAX_PAGE_SIZES[type] ?? MAX_PAGE_SIZE 24 | 25 | return +querySize && +querySize <= maxSize ? +querySize : defaultSize 26 | } 27 | 28 | export function getPagesForResponse(totalCount: number, page: number, perPage: number) { 29 | const numPages = Math.ceil(totalCount / perPage) 30 | 31 | return { 32 | next: page + 1 > numPages ? null : page + 1, 33 | previous: page - 1 < 1 ? null : page - 1, 34 | current: page, 35 | numPages, 36 | perPage 37 | } 38 | } 39 | 40 | export function getPaginationAndSortOrder( 41 | query: { page?: number; perPage?: number; ordering?: string }, 42 | pageSizeType: string, 43 | allowedSortFields: string[] = [] 44 | ): { page: number; perPage: number; order: IOrderObject } { 45 | const page = +query.page || 1 46 | const perPage = getPerPage(pageSizeType, +query.perPage) 47 | const order = getSortOrderFromQuery(query.ordering?.split(',') ?? [], allowedSortFields) 48 | 49 | return { page, perPage, order } 50 | } 51 | 52 | export function paginatedResponse(items, totalCount: number, page: number, perPage: number) { 53 | return { 54 | pages: getPagesForResponse(totalCount, page, perPage), 55 | count: items.length, 56 | items 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /libs/common/src/validators/field-validator.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common' 2 | import { ApiProperty, type ApiPropertyOptions } from '@nestjs/swagger' 3 | import { Type } from 'class-transformer' 4 | import { 5 | ArrayMinSize, 6 | ArrayUnique, 7 | IsArray, 8 | IsBoolean, 9 | IsDefined, 10 | IsEmail, 11 | IsEnum, 12 | IsInt, 13 | IsNumber, 14 | IsOptional, 15 | IsPositive, 16 | IsString, 17 | Max, 18 | MaxLength, 19 | Min, 20 | MinLength, 21 | NotEquals, 22 | ValidateIf, 23 | type ValidationOptions 24 | } from 'class-validator' 25 | 26 | import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, VALIDATION_MESSAGES } from '../constants' 27 | import { Match } from './match.decorator' 28 | import { ToBoolean } from './transform.decorator' 29 | import { 30 | IBooleanFieldOptions, 31 | IEnumFieldOptions, 32 | INumberFieldOptions, 33 | INumberIdsFieldOptions, 34 | IPasswordFieldOptions, 35 | IStringFieldOptions 36 | } from './validators.interface' 37 | 38 | export function IsUndefinable(options?: ValidationOptions): PropertyDecorator { 39 | return ValidateIf((_obj, value) => value !== undefined, options) 40 | } 41 | 42 | export function IsNullable(options?: ValidationOptions): PropertyDecorator { 43 | return ValidateIf((_obj, value) => value !== null, options) 44 | } 45 | 46 | export function NumberField(options: Omit & INumberFieldOptions = {}): PropertyDecorator { 47 | const { each } = options 48 | const decorators = [Type(() => Number)] 49 | 50 | if (options.swagger !== false) { 51 | decorators.push(ApiProperty({ type: Number, ...options } as ApiPropertyOptions)) 52 | } 53 | 54 | if (options.nullable) { 55 | decorators.push(IsNullable({ each })) 56 | } else { 57 | decorators.push(NotEquals(null, { each })) 58 | } 59 | 60 | if (options.int) { 61 | decorators.push(IsInt({ each })) 62 | } else { 63 | decorators.push(IsNumber({}, { each })) 64 | } 65 | 66 | if (options.positive) { 67 | decorators.push(IsPositive({ each })) 68 | } 69 | 70 | if (typeof options.min === 'number') { 71 | decorators.push(Min(options.min, { each, message: VALIDATION_MESSAGES.mustBeGreaterThan(options.min) })) 72 | } 73 | 74 | if (typeof options.max === 'number') { 75 | decorators.push(Max(options.max, { each, message: VALIDATION_MESSAGES.mustBeLessThan(options.max) })) 76 | } 77 | 78 | return applyDecorators(...decorators) 79 | } 80 | 81 | export function NumberFieldOptional( 82 | options: Omit & INumberFieldOptions = {} 83 | ): PropertyDecorator { 84 | return applyDecorators(IsUndefinable(options), NumberField({ required: false, nullable: true, ...options })) 85 | } 86 | 87 | export function NumberIdsField( 88 | options: Omit & INumberIdsFieldOptions = {} 89 | ): PropertyDecorator { 90 | return applyDecorators( 91 | options.optional ? IsOptional() : IsDefined(), 92 | IsArray(), 93 | ArrayMinSize(1), 94 | NumberField({ each: true, isArray: true, int: true, positive: true, example: [1, 2, 3], ...options }), 95 | ArrayUnique() 96 | ) 97 | } 98 | 99 | export function StringField(options: Omit & IStringFieldOptions = {}): PropertyDecorator { 100 | const decorators = [IsString({ each: options.each })] 101 | 102 | if (options.swagger !== false) { 103 | decorators.push(ApiProperty({ type: String, isArray: options.each, ...options } as ApiPropertyOptions)) 104 | } 105 | 106 | if (options.nullable) { 107 | decorators.push(IsNullable({ each: options.each })) 108 | } else { 109 | decorators.push(NotEquals(null, { each: options.each })) 110 | } 111 | 112 | const minLength = options.minLength ?? 1 113 | 114 | decorators.push( 115 | MinLength(minLength, { 116 | each: options.each, 117 | message: VALIDATION_MESSAGES.lengthMustBeGreaterThan(minLength) 118 | }) 119 | ) 120 | 121 | if (options.maxLength) { 122 | decorators.push( 123 | MaxLength(options.maxLength, { 124 | each: options.each, 125 | message: VALIDATION_MESSAGES.lengthMustBeLessThan(options.maxLength) 126 | }) 127 | ) 128 | } 129 | 130 | if (options.matchKey) { 131 | decorators.push(Match(options.matchKey, options.matchMessage ? { message: options.matchMessage } : {})) 132 | } 133 | 134 | return applyDecorators(...decorators) 135 | } 136 | 137 | export function StringFieldOptional( 138 | options: Omit & IStringFieldOptions = {} 139 | ): PropertyDecorator { 140 | return applyDecorators(IsUndefinable(), StringField({ required: false, nullable: true, ...options })) 141 | } 142 | 143 | export function PasswordField( 144 | options: Omit & IPasswordFieldOptions = {} 145 | ): PropertyDecorator { 146 | const decorators = [ 147 | StringField({ example: 'password', minLength: PASSWORD_MIN_LENGTH, maxLength: PASSWORD_MAX_LENGTH }) 148 | ] 149 | 150 | if (options.nullable) { 151 | decorators.push(IsNullable()) 152 | } else { 153 | decorators.push(NotEquals(null)) 154 | } 155 | 156 | return applyDecorators(...decorators) 157 | } 158 | 159 | export function BooleanField(options: Omit & IBooleanFieldOptions = {}): PropertyDecorator { 160 | const decorators = [ToBoolean(), IsBoolean()] 161 | 162 | if (options.swagger !== false) { 163 | decorators.push(ApiProperty({ type: Boolean, ...options } as ApiPropertyOptions)) 164 | } 165 | 166 | if (options.nullable) { 167 | decorators.push(IsNullable()) 168 | } else { 169 | decorators.push(NotEquals(null)) 170 | } 171 | 172 | return applyDecorators(...decorators) 173 | } 174 | 175 | export function BooleanFieldOptional( 176 | options: Omit & IBooleanFieldOptions = {} 177 | ): PropertyDecorator { 178 | return applyDecorators(IsUndefinable(), BooleanField({ required: false, nullable: true, ...options })) 179 | } 180 | 181 | export function EmailField(options: Omit & IStringFieldOptions = {}): PropertyDecorator { 182 | const decorators = [StringField({ ...options }), IsEmail()] 183 | 184 | if (options.swagger !== false) { 185 | decorators.push(ApiProperty({ type: String, example: 'example@gmail.com', ...options } as ApiPropertyOptions)) 186 | } 187 | 188 | if (options.nullable) { 189 | decorators.push(IsNullable()) 190 | } else { 191 | decorators.push(NotEquals(null)) 192 | } 193 | 194 | return applyDecorators(...decorators) 195 | } 196 | 197 | export function EmailFieldOptional( 198 | options: Omit & IStringFieldOptions = {} 199 | ): PropertyDecorator { 200 | return applyDecorators(IsUndefinable(), EmailField({ required: false, nullable: true, ...options })) 201 | } 202 | 203 | export function EnumField( 204 | getEnum: () => TEnum, 205 | options: Omit & IEnumFieldOptions = {} 206 | ): PropertyDecorator { 207 | const enumValue = getEnum() 208 | const decorators = [IsEnum(enumValue, { each: options.isArray })] 209 | 210 | if (options.swagger !== false) { 211 | decorators.push( 212 | ApiProperty({ 213 | enum: enumValue, 214 | isArray: options.isArray, 215 | ...options 216 | } as ApiPropertyOptions) 217 | ) 218 | } 219 | 220 | if (options.nullable) { 221 | decorators.push(IsNullable()) 222 | } else { 223 | decorators.push(NotEquals(null)) 224 | } 225 | 226 | return applyDecorators(...decorators) 227 | } 228 | 229 | export function EnumFieldOptional( 230 | getEnum: () => TEnum, 231 | options: Omit & IEnumFieldOptions = {} 232 | ): PropertyDecorator { 233 | return applyDecorators(IsUndefinable(), EnumField(getEnum, { nullable: true, required: false, ...options })) 234 | } 235 | -------------------------------------------------------------------------------- /libs/common/src/validators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './field-validator.decorator' 2 | export * from './match.decorator' 3 | export * from './transform.decorator' 4 | -------------------------------------------------------------------------------- /libs/common/src/validators/match.decorator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | registerDecorator, 3 | ValidationArguments, 4 | ValidationOptions, 5 | ValidatorConstraint, 6 | ValidatorConstraintInterface 7 | } from 'class-validator' 8 | 9 | export function Match(property: string, validationOptions?: ValidationOptions) { 10 | return (object: any, propertyName: string) => { 11 | registerDecorator({ 12 | target: object.constructor, 13 | propertyName, 14 | options: validationOptions, 15 | constraints: [property], 16 | validator: MatchConstraint 17 | }) 18 | } 19 | } 20 | 21 | @ValidatorConstraint({ name: 'match' }) 22 | export class MatchConstraint implements ValidatorConstraintInterface { 23 | validate(value: any, args: ValidationArguments) { 24 | const [relatedPropertyName] = args.constraints 25 | const relatedValue = (args.object as any)[relatedPropertyName] 26 | return value === relatedValue 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /libs/common/src/validators/transform.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer' 2 | import { map, trim } from 'lodash' 3 | 4 | /** 5 | * @description Trim spaces from start and end 6 | */ 7 | export function Trim(): PropertyDecorator { 8 | return Transform(({ value }) => { 9 | if (typeof value === 'string') { 10 | return trim(value).replaceAll(/\s\s+/g, ' ') 11 | } else if (Array.isArray(value)) { 12 | return map(value, (v) => (typeof v === 'string' ? trim(v).replaceAll(/\s\s+/g, ' ') : v)) 13 | } 14 | 15 | return value 16 | }) 17 | } 18 | 19 | /** 20 | * @description Convert string boolean value to boolean 21 | */ 22 | export function ToBoolean(): PropertyDecorator { 23 | return Transform( 24 | (params) => { 25 | switch (params.value) { 26 | case 'true': 27 | return true 28 | case 'false': 29 | return false 30 | default: 31 | return params.value 32 | } 33 | }, 34 | { toClassOnly: true } 35 | ) 36 | } 37 | 38 | /** 39 | * @description Convert string to integer 40 | */ 41 | export function ToInt(): PropertyDecorator { 42 | return Transform( 43 | ({ value }) => { 44 | if (typeof value === 'string') { 45 | return Number.parseInt(value, 10) 46 | } 47 | 48 | return value 49 | }, 50 | { toClassOnly: true } 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /libs/common/src/validators/validators.interface.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-object-type */ 2 | export interface IFieldOptions { 3 | each?: boolean 4 | swagger?: boolean 5 | nullable?: boolean 6 | } 7 | 8 | export interface INumberFieldOptions extends IFieldOptions { 9 | min?: number 10 | max?: number 11 | int?: boolean 12 | positive?: boolean 13 | } 14 | 15 | export interface INumberIdsFieldOptions extends INumberFieldOptions { 16 | optional?: boolean 17 | } 18 | 19 | export interface IStringFieldOptions extends IFieldOptions { 20 | minLength?: number 21 | maxLength?: number 22 | matchKey?: string 23 | matchMessage?: string 24 | } 25 | 26 | export interface IPasswordFieldOptions extends IStringFieldOptions { 27 | passwordConfirmField?: boolean 28 | } 29 | 30 | export interface IBooleanFieldOptions extends IFieldOptions {} 31 | export interface IEnumFieldOptions extends IFieldOptions {} 32 | -------------------------------------------------------------------------------- /libs/common/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "outDir": "../../dist/libs/common" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /libs/database/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './postgres' 2 | export * from './redis' 3 | -------------------------------------------------------------------------------- /libs/database/src/postgres/base.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { DataSource, DeepPartial, FindOptionsWhere, Repository } from 'typeorm' 3 | import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity' 4 | 5 | import { IFindAndCountInput, IFindAndCountOutput, IFindInput } from './db.interface' 6 | 7 | @Injectable() 8 | export class BaseRepository { 9 | constructor( 10 | private dataSource: DataSource, 11 | private entity: new () => T 12 | ) {} 13 | 14 | protected getRepository(): Repository { 15 | return this.dataSource.manager.getRepository(this.entity) 16 | } 17 | 18 | async findAndCount(input: IFindAndCountInput): Promise> { 19 | const { conditions, relations = [], take, skip, order } = input 20 | 21 | const [items, totalCount] = await this.getRepository().findAndCount({ 22 | where: conditions, 23 | relations, 24 | take, 25 | skip, 26 | order 27 | }) 28 | 29 | return { items, totalCount } 30 | } 31 | 32 | async create(createInput: DeepPartial): Promise { 33 | const instance = this.getRepository().create(createInput) 34 | await this.getRepository().save(instance) 35 | 36 | return instance 37 | } 38 | 39 | async bulkCreate(bulkCreateInput: DeepPartial[]) { 40 | return await this.getRepository().save(this.getRepository().create(bulkCreateInput)) 41 | } 42 | 43 | async findOne(conditions: FindOptionsWhere, findInput?: IFindInput): Promise { 44 | return await this.getRepository().findOne({ 45 | where: conditions, 46 | relations: findInput?.relations || [] 47 | }) 48 | } 49 | 50 | async find(conditions: FindOptionsWhere, findInput?: IFindInput): Promise { 51 | return await this.getRepository().find({ 52 | where: conditions, 53 | relations: findInput?.relations || [] 54 | }) 55 | } 56 | 57 | async update(conditions: FindOptionsWhere, updateInput: QueryDeepPartialEntity) { 58 | return await this.getRepository().update(conditions, updateInput) 59 | } 60 | 61 | async delete(conditions: FindOptionsWhere) { 62 | return await this.getRepository().delete(conditions) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /libs/database/src/postgres/config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config' 2 | import * as path from 'path' 3 | import { DataSourceOptions } from 'typeorm' 4 | 5 | const configService = new ConfigService() 6 | 7 | const entities = [path.join(process.cwd(), 'dist', 'src', 'modules/**/*.entity{.ts,.js}')] 8 | 9 | export const POSTGRES_CONFIGS: DataSourceOptions = { 10 | type: 'postgres', 11 | host: configService.get('POSTGRES_HOST'), 12 | port: +configService.get('POSTGRES_PORT'), 13 | username: configService.get('POSTGRES_USER'), 14 | password: `${configService.get('POSTGRES_PASSWORD')}`, 15 | database: configService.get('POSTGRES_DATABASE'), 16 | entities, 17 | logging: configService.get('POSTGRES_LOGGING') === 'true', 18 | synchronize: configService.get('POSTGRES_SYNCHRONIZE') === 'true', 19 | dropSchema: configService.get('POSTGRES_DROP_SCHEMA') === 'true' 20 | } 21 | -------------------------------------------------------------------------------- /libs/database/src/postgres/data-source.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import { DataSource } from 'typeorm' 3 | import 'tsconfig-paths/register' 4 | import 'dotenv/config' 5 | 6 | const entities = [path.join(process.cwd(), 'src', 'modules/**/entities/*.entity{.ts,.js}')] 7 | const migrations = [path.join(__dirname, 'migrations', '*{.ts,.js}')] 8 | 9 | export default new DataSource({ 10 | type: 'postgres', 11 | host: process.env.POSTGRES_HOST, 12 | port: +process.env.POSTGRES_PORT, 13 | username: process.env.POSTGRES_USER, 14 | password: `${process.env.POSTGRES_PASSWORD}`, 15 | database: process.env.POSTGRES_DATABASE, 16 | entities, 17 | migrations, 18 | logging: process.env.POSTGRES_LOGGING === 'true', 19 | synchronize: process.env.POSTGRES_SYNCHRONIZE === 'true', 20 | dropSchema: process.env.POSTGRES_DROP_SCHEMA === 'true' 21 | }) 22 | -------------------------------------------------------------------------------- /libs/database/src/postgres/db.enum.ts: -------------------------------------------------------------------------------- 1 | export enum DbTables { 2 | users = 'users', 3 | products = 'products', 4 | baskets = 'baskets' 5 | } 6 | 7 | /* 8 | ####### NOTE ####### 9 | This enum is used to centralize all database relationship keys. 10 | For example, instead of using strings like 'user', 'products', or 'basket' directly in the code, 11 | you should use DB_RELATIONS.user, DB_RELATIONS.products, and DB_RELATIONS.basket respectively. 12 | This ensures consistency and avoids hardcoding strings throughout the project. 13 | */ 14 | export enum DbRelations { 15 | user = 'user' 16 | } 17 | -------------------------------------------------------------------------------- /libs/database/src/postgres/db.interface.ts: -------------------------------------------------------------------------------- 1 | import { FindOptionsOrder, FindOptionsWhere } from 'typeorm' 2 | 3 | export interface IOrderObject { 4 | [key: string]: 'ASC' | 'DESC' 5 | } 6 | 7 | export interface IGetAndCountInput { 8 | page: number 9 | perPage: number 10 | order: IOrderObject 11 | } 12 | 13 | export interface IFindAndCountInput { 14 | conditions: FindOptionsWhere 15 | relations?: string[] 16 | take: number 17 | skip: number 18 | order?: FindOptionsOrder 19 | } 20 | 21 | export interface IFindAndCountOutput { 22 | items: T[] 23 | totalCount: number 24 | } 25 | 26 | export interface IFindInput { 27 | relations?: string[] 28 | } 29 | -------------------------------------------------------------------------------- /libs/database/src/postgres/entities/basket.entity.ts: -------------------------------------------------------------------------------- 1 | /* 2 | ####### NOTE ####### 3 | This entity is not used anywhere in the project, 4 | it is used only to demonstrate the types of entity relationships 5 | In real projects, it is recommended to save each entity 6 | in the `src/modules/MODULE_NAME/entities` folder for better organization. 7 | */ 8 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm' 9 | 10 | import { DbTables } from '../db.enum' 11 | 12 | @Entity(DbTables.baskets) 13 | export class Basket { 14 | @PrimaryGeneratedColumn() 15 | id: number 16 | 17 | @Column() 18 | userId: number 19 | 20 | /* 21 | ####### NOTE ####### 22 | OneToOne relationship between the current entity and the User entity 23 | */ 24 | // @OneToOne(() => User, (user) => user.basket) 25 | // @JoinColumn({ name: 'userId', referencedColumnName: 'id' }) 26 | // user: User 27 | } 28 | -------------------------------------------------------------------------------- /libs/database/src/postgres/entities/product.entity.ts: -------------------------------------------------------------------------------- 1 | /* 2 | ####### NOTE ####### 3 | This entity is not used anywhere in the project, 4 | it is used only to demonstrate the types of entity relationships 5 | In real projects, it is recommended to save each entity 6 | in the `src/modules/MODULE_NAME/entities` folder for better organization. 7 | */ 8 | import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm' 9 | 10 | import { DbTables } from '../db.enum' 11 | 12 | @Entity(DbTables.products) 13 | export class Product { 14 | @PrimaryGeneratedColumn() 15 | id: number 16 | 17 | @Column() 18 | createdBy: number 19 | 20 | @Column() 21 | name: string 22 | 23 | @Column() 24 | category: string 25 | 26 | @Column() 27 | price: number 28 | 29 | @Column() 30 | description: string 31 | 32 | @CreateDateColumn({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP(6)' }) 33 | createdAt: Date 34 | 35 | /* 36 | ####### NOTE ####### 37 | ManyToOne relationship between the current entity and the User entity 38 | */ 39 | // @ManyToOne(() => User, (user) => user.products, { onDelete: 'CASCADE' }) 40 | // @JoinColumn({ name: 'createdBy' }) 41 | // creator: User 42 | } 43 | -------------------------------------------------------------------------------- /libs/database/src/postgres/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base.repository' 2 | export * from './config' 3 | export * from './data-source' 4 | export * from './db.enum' 5 | export * from './db.interface' 6 | export * from './postgres.module' 7 | export * from './postgres-config.service' 8 | -------------------------------------------------------------------------------- /libs/database/src/postgres/migrations/1724678162073-create-users-table.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | 3 | export class CreateUsersTable1724678162073 implements MigrationInterface { 4 | name = 'CreateUsersTable1724678162073' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query( 8 | ` 9 | CREATE TABLE "users" ( 10 | "id" SERIAL NOT NULL, 11 | "firstName" character varying NOT NULL, 12 | "lastName" character varying NOT NULL, 13 | "email" character varying NOT NULL, 14 | "birthDate" character varying NOT NULL, 15 | "password" character varying NOT NULL, 16 | "registeredAt" TIMESTAMP NOT NULL DEFAULT ('now'::text)::timestamp(6) with time zone, 17 | CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id")) 18 | ` 19 | ) 20 | } 21 | 22 | public async down(queryRunner: QueryRunner): Promise { 23 | await queryRunner.query(`DROP TABLE "users"`) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /libs/database/src/postgres/migrations/1737960598226-add-users-columns.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | 3 | export class AddUsersColumns1737960598226 implements MigrationInterface { 4 | name = 'AddUsersColumns1737960598226' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query(`ALTER TABLE "users" ADD "token" character varying`) 8 | await queryRunner.query(`ALTER TABLE "users" ADD "tokenExpireDate" TIMESTAMP`) 9 | } 10 | 11 | public async down(queryRunner: QueryRunner): Promise { 12 | await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "tokenExpireDate"`) 13 | await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "token"`) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /libs/database/src/postgres/postgres-config.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm' 3 | 4 | import { POSTGRES_CONFIGS } from './config' 5 | 6 | @Injectable() 7 | export class PostgresConfigService implements TypeOrmOptionsFactory { 8 | createTypeOrmOptions(): TypeOrmModuleOptions { 9 | return POSTGRES_CONFIGS 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /libs/database/src/postgres/postgres.module.ts: -------------------------------------------------------------------------------- 1 | import { Logger, Module } from '@nestjs/common' 2 | import { TypeOrmModule } from '@nestjs/typeorm' 3 | import { DataSource } from 'typeorm' 4 | import { addTransactionalDataSource } from 'typeorm-transactional' 5 | 6 | import { PostgresConfigService } from './postgres-config.service' 7 | 8 | @Module({ 9 | imports: [ 10 | TypeOrmModule.forRootAsync({ 11 | useClass: PostgresConfigService, 12 | async dataSourceFactory(options) { 13 | const logger = new Logger('Database') 14 | 15 | if (!options) { 16 | throw new Error('❌ [Postgres database connection failed]: Invalid options passed') 17 | } 18 | 19 | try { 20 | const source = addTransactionalDataSource(new DataSource(options)) 21 | await source.initialize() 22 | logger.log(`🎯 Database initialized successfully.`) 23 | 24 | return source 25 | } catch (error) { 26 | logger.error(`❌ [Database connection error]: ${error.message}`) 27 | throw error 28 | } 29 | } 30 | }) 31 | ] 32 | }) 33 | export class PostgresModule {} 34 | -------------------------------------------------------------------------------- /libs/database/src/redis/config.ts: -------------------------------------------------------------------------------- 1 | import { envService } from '@app/common' 2 | 3 | const username = envService.getEnvString('REDIS_USERNAME', 'default') 4 | const password = envService.getEnvString('REDIS_PASSWORD', 'user') 5 | const host = envService.getEnvString('REDIS_HOST', 'localhost') 6 | const port = envService.getEnvNumber('REDIS_PORT', 6379) 7 | 8 | export const REDIS_CONFIG = { 9 | url: `redis://${username}:${password}@${host}:${port}` 10 | } 11 | -------------------------------------------------------------------------------- /libs/database/src/redis/constants.ts: -------------------------------------------------------------------------------- 1 | export const REDIS_CONFIGS_KEY = 'REDIS_CONFIGS' 2 | export const REDIS_CLIENT_KEY = 'REDIS_CLIENT' 3 | -------------------------------------------------------------------------------- /libs/database/src/redis/index.ts: -------------------------------------------------------------------------------- 1 | export * from './config' 2 | export * from './redis.module' 3 | export * from './redis.service' 4 | -------------------------------------------------------------------------------- /libs/database/src/redis/redis.module.ts: -------------------------------------------------------------------------------- 1 | import { Logger, Module } from '@nestjs/common' 2 | import { createClient, RedisClientOptions } from 'redis' 3 | 4 | import { REDIS_CONFIG } from './config' 5 | import { REDIS_CLIENT_KEY, REDIS_CONFIGS_KEY } from './constants' 6 | import { RedisService } from './redis.service' 7 | 8 | @Module({ 9 | providers: [ 10 | { 11 | provide: REDIS_CONFIGS_KEY, 12 | useValue: REDIS_CONFIG 13 | }, 14 | { 15 | inject: [REDIS_CONFIGS_KEY], 16 | provide: REDIS_CLIENT_KEY, 17 | useFactory: async (options: RedisClientOptions) => { 18 | const logger = new Logger('Redis') 19 | 20 | if (!options) { 21 | throw new Error('❌ [Redis connection failed]: Invalid options passed') 22 | } 23 | 24 | try { 25 | const client = createClient(options) 26 | await client.connect() 27 | 28 | logger.log('⚡ Redis connection initialized successfully.') 29 | 30 | return client 31 | } catch (error) { 32 | logger.error(`❌ [Redis connection error]: ${error.message}`) 33 | throw error 34 | } 35 | } 36 | }, 37 | RedisService 38 | ], 39 | exports: [REDIS_CLIENT_KEY, RedisService] 40 | }) 41 | export class RedisModule {} 42 | -------------------------------------------------------------------------------- /libs/database/src/redis/redis.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common' 2 | import { Redis, RedisKey } from 'ioredis' 3 | 4 | import { REDIS_CLIENT_KEY } from './constants' 5 | 6 | @Injectable() 7 | export class RedisService { 8 | constructor(@Inject(REDIS_CLIENT_KEY) private readonly redis: Redis) {} 9 | 10 | async get(key: RedisKey) { 11 | return await this.redis.get(key) 12 | } 13 | 14 | async set(key: RedisKey, value: string | number | Buffer) { 15 | return await this.redis.set(key, value) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /libs/database/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "outDir": "../../dist/libs/database" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /libs/swagger/src/config.ts: -------------------------------------------------------------------------------- 1 | import { DocumentBuilder, SwaggerCustomOptions } from '@nestjs/swagger' 2 | 3 | export const SWAGGER_CONFIGS = new DocumentBuilder() 4 | .setTitle('NestJS Architecture') 5 | .setDescription('NestJS Architecture') 6 | .setVersion('1.0') 7 | .addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT', in: 'header' }) 8 | .build() 9 | 10 | export const SWAGGER_OPTIONS: SwaggerCustomOptions = { 11 | swaggerOptions: { 12 | persistAuthorization: true, 13 | requestInterceptor: (req) => { 14 | req.credentials = 'include' 15 | return req 16 | } 17 | }, 18 | customSiteTitle: 'NestJS - Swagger' 19 | } 20 | -------------------------------------------------------------------------------- /libs/swagger/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './config' 2 | export * from './responses' 3 | export * from './swagger.decorator' 4 | -------------------------------------------------------------------------------- /libs/swagger/src/responses/bad-request.response.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common' 2 | import { ApiBadRequestResponse } from '@nestjs/swagger' 3 | 4 | import { ERROR_MESSAGES, VALIDATION_MESSAGES } from '@app/common' 5 | 6 | export function SwaggerBadRequest400() { 7 | return applyDecorators( 8 | ApiBadRequestResponse({ 9 | description: 'Bad Request', 10 | schema: { 11 | type: 'object', 12 | oneOf: [ 13 | { 14 | properties: { 15 | message: { 16 | example: `${ERROR_MESSAGES.userAlreadyExists} OR ${ERROR_MESSAGES.invalidEmailPassword} ...` 17 | } 18 | } 19 | }, 20 | { 21 | properties: { 22 | FIELD: { 23 | example: [VALIDATION_MESSAGES.required, VALIDATION_MESSAGES.invalidEmail, '...'] 24 | } 25 | } 26 | } 27 | ] 28 | } 29 | }) 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /libs/swagger/src/responses/created.response.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common' 2 | import { ApiCreatedResponse } from '@nestjs/swagger' 3 | 4 | export function SwaggerCreated201(options?: { description?: string }) { 5 | return applyDecorators( 6 | ApiCreatedResponse({ 7 | description: options?.description ?? 'Created', 8 | schema: { 9 | type: 'object', 10 | properties: { message: { example: 'created' } } 11 | } 12 | }) 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /libs/swagger/src/responses/custom.response.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, HttpStatus } from '@nestjs/common' 2 | import { ApiCreatedResponse, ApiExtraModels, ApiOkResponse, ApiResponse, getSchemaPath } from '@nestjs/swagger' 3 | 4 | import { PaginationResponseDto } from '@app/common' 5 | 6 | import { SwaggerOptions } from '../swagger.type' 7 | 8 | export function SwaggerCustomResponse(options: SwaggerOptions): MethodDecorator { 9 | const { response, pagination, isArray, description } = options 10 | 11 | const decorators = [] 12 | 13 | if (!pagination && !isArray) { 14 | decorators.push( 15 | options[201] 16 | ? ApiCreatedResponse({ type: response, description }) 17 | : ApiOkResponse({ type: response, description }) 18 | ) 19 | } else { 20 | const itemsSchema = { 21 | properties: { 22 | items: { 23 | type: 'array', 24 | items: { $ref: getSchemaPath(response) } 25 | } 26 | } 27 | } 28 | 29 | decorators.push( 30 | ...(pagination ? [ApiExtraModels(PaginationResponseDto)] : []), 31 | ApiExtraModels(response), 32 | ApiResponse({ 33 | status: HttpStatus.OK, 34 | description, 35 | schema: { 36 | allOf: [...(pagination ? [{ $ref: getSchemaPath(PaginationResponseDto) }] : []), itemsSchema] 37 | } 38 | }) 39 | ) 40 | } 41 | 42 | return applyDecorators(...decorators) 43 | } 44 | -------------------------------------------------------------------------------- /libs/swagger/src/responses/forbidden.response.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common' 2 | import { ApiForbiddenResponse } from '@nestjs/swagger' 3 | 4 | import { ERROR_MESSAGES } from '@app/common' 5 | 6 | export function SwaggerForbidden403() { 7 | return applyDecorators( 8 | ApiForbiddenResponse({ 9 | description: 'Forbidden', 10 | schema: { 11 | type: 'object', 12 | properties: { message: { example: ERROR_MESSAGES.forbidden403 } } 13 | } 14 | }) 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /libs/swagger/src/responses/index.ts: -------------------------------------------------------------------------------- 1 | export * from './bad-request.response' 2 | export * from './created.response' 3 | export * from './custom.response' 4 | export * from './forbidden.response' 5 | export * from './not-found.response' 6 | export * from './success.response' 7 | export * from './unauthorized.response' 8 | -------------------------------------------------------------------------------- /libs/swagger/src/responses/not-found.response.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common' 2 | import { ApiNotFoundResponse } from '@nestjs/swagger' 3 | 4 | import { ERROR_MESSAGES } from '@app/common' 5 | 6 | export function SwaggerNotFound404() { 7 | return applyDecorators( 8 | ApiNotFoundResponse({ 9 | description: 'Not Found', 10 | schema: { 11 | type: 'object', 12 | properties: { message: { example: ERROR_MESSAGES.notFound404 } } 13 | } 14 | }) 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /libs/swagger/src/responses/success.response.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common' 2 | import { ApiOkResponse } from '@nestjs/swagger' 3 | 4 | export function SwaggerSuccess200(options?: { description?: string }) { 5 | return applyDecorators( 6 | ApiOkResponse({ 7 | description: options?.description ?? 'Success', 8 | schema: { 9 | type: 'object', 10 | properties: { message: { example: 'success' } } 11 | } 12 | }) 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /libs/swagger/src/responses/unauthorized.response.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common' 2 | import { ApiUnauthorizedResponse } from '@nestjs/swagger' 3 | 4 | import { ERROR_MESSAGES } from '@app/common' 5 | 6 | export function SwaggerUnauthorized401() { 7 | return applyDecorators( 8 | ApiUnauthorizedResponse({ 9 | description: 'Unauthorized', 10 | schema: { 11 | type: 'object', 12 | properties: { message: { example: ERROR_MESSAGES.unauthorized401 } } 13 | } 14 | }) 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /libs/swagger/src/swagger.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common' 2 | import { ApiOperation } from '@nestjs/swagger' 3 | 4 | import { 5 | SwaggerBadRequest400, 6 | SwaggerCreated201, 7 | SwaggerCustomResponse, 8 | SwaggerForbidden403, 9 | SwaggerNotFound404, 10 | SwaggerSuccess200, 11 | SwaggerUnauthorized401 12 | } from './responses' 13 | import { SwaggerOptions } from './swagger.type' 14 | 15 | export function Swagger(options: SwaggerOptions): MethodDecorator { 16 | const { response, operation, description, errorResponses = [] } = options 17 | 18 | const decorators = [] 19 | 20 | if (operation) { 21 | decorators.push(ApiOperation({ summary: operation })) 22 | } 23 | 24 | if (response) { 25 | decorators.push(SwaggerCustomResponse(options)) 26 | } else { 27 | decorators.push(options[201] ? SwaggerCreated201({ description }) : SwaggerSuccess200({ description })) 28 | } 29 | 30 | if (errorResponses.includes(400) || options[400]) { 31 | decorators.push(SwaggerBadRequest400()) 32 | } 33 | 34 | if (errorResponses.includes(401) || options[401]) { 35 | decorators.push(SwaggerUnauthorized401()) 36 | } 37 | 38 | if (errorResponses.includes(403) || options[403]) { 39 | decorators.push(SwaggerForbidden403()) 40 | } 41 | 42 | if (errorResponses.includes(404) || options[404]) { 43 | decorators.push(SwaggerNotFound404()) 44 | } 45 | 46 | return applyDecorators(...decorators) 47 | } 48 | -------------------------------------------------------------------------------- /libs/swagger/src/swagger.type.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common' 2 | 3 | export interface SwaggerOptions { 4 | response?: Type 5 | pagination?: boolean 6 | isArray?: boolean 7 | operation?: string 8 | description?: string 9 | errorResponses?: number[] 10 | 201?: boolean 11 | 400?: boolean 12 | 401?: boolean 13 | 403?: boolean 14 | 404?: boolean 15 | } 16 | -------------------------------------------------------------------------------- /libs/swagger/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "outDir": "../../dist/libs/swagger" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "assets": ["templates/**/*"], 6 | "watchAssets": true, 7 | "projects": { 8 | "common": { 9 | "type": "library", 10 | "root": "libs/common", 11 | "entryFile": "index", 12 | "sourceRoot": "libs/common/src", 13 | "compilerOptions": { 14 | "tsConfigPath": "libs/common/tsconfig.lib.json" 15 | } 16 | }, 17 | "database": { 18 | "type": "library", 19 | "root": "libs/database", 20 | "entryFile": "index", 21 | "sourceRoot": "libs/database/src", 22 | "compilerOptions": { 23 | "tsConfigPath": "libs/database/tsconfig.lib.json" 24 | } 25 | }, 26 | "swagger": { 27 | "type": "library", 28 | "root": "libs/swagger", 29 | "entryFile": "index", 30 | "sourceRoot": "libs/swagger/src", 31 | "compilerOptions": { 32 | "tsConfigPath": "libs/swagger/tsconfig.lib.json" 33 | } 34 | } 35 | }, 36 | "compilerOptions": { 37 | "webpack": false, 38 | "tsConfigPath": "tsconfig.build.json", 39 | "watchAssets": true 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-architecture", 3 | "version": "1.7.3", 4 | "description": "Nestjs Architecture", 5 | "author": "Grisha Hovhanyan", 6 | "scripts": { 7 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"libs/**/*.ts\"", 8 | "lint": "eslint .", 9 | "lint:fix": "eslint . --fix", 10 | "prebuild": "rimraf dist", 11 | "build": "nest build", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "generate:module": "hygen new module", 17 | "generate:queue": "hygen new queue", 18 | "typeorm": "npx typeorm-ts-node-commonjs -d ./libs/database/src/postgres/data-source.ts", 19 | "migrations:create": "npx typeorm-ts-node-commonjs migration:create ./libs/database/src/postgres/migrations/$npm_config_name", 20 | "migrations:generate": "npm run typeorm -- migration:generate ./libs/database/src/postgres/migrations/$npm_config_name", 21 | "migrations:run": "npm run typeorm -- migration:run", 22 | "migrations:rollback": "npm run typeorm -- migration:revert" 23 | }, 24 | "dependencies": { 25 | "@aws-sdk/client-sns": "^3.744.0", 26 | "@bull-board/api": "^6.7.7", 27 | "@bull-board/express": "^6.7.7", 28 | "@bull-board/nestjs": "^6.7.7", 29 | "@nestjs/bullmq": "^11.0.2", 30 | "@nestjs/common": "^11.0.9", 31 | "@nestjs/config": "^4.0.0", 32 | "@nestjs/core": "^11.0.9", 33 | "@nestjs/jwt": "^11.0.0", 34 | "@nestjs/passport": "^11.0.5", 35 | "@nestjs/platform-express": "^11.0.9", 36 | "@nestjs/swagger": "^11.0.3", 37 | "@nestjs/typeorm": "^11.0.0", 38 | "@ssut/nestjs-sqs": "^3.0.1", 39 | "bcrypt": "^5.1.1", 40 | "bcryptjs": "^2.4.3", 41 | "bullmq": "^5.40.4", 42 | "class-transformer": "^0.5.1", 43 | "class-validator": "^0.14.1", 44 | "compression": "^1.8.0", 45 | "express-basic-auth": "^1.2.1", 46 | "helmet": "^8.0.0", 47 | "hygen": "^6.2.11", 48 | "ioredis": "^5.5.0", 49 | "joi": "^17.13.3", 50 | "jsonwebtoken": "^9.0.2", 51 | "passport": "^0.7.0", 52 | "passport-jwt": "^4.0.1", 53 | "pg": "^8.13.3", 54 | "redis": "^4.7.0", 55 | "reflect-metadata": "^0.2.2", 56 | "rimraf": "^6.0.1", 57 | "rxjs": "^7.8.1", 58 | "swagger-ui-express": "^5.0.1", 59 | "typeorm": "^0.3.20", 60 | "typeorm-transactional": "^0.5.0" 61 | }, 62 | "devDependencies": { 63 | "@eslint/eslintrc": "^3.2.0", 64 | "@eslint/js": "^9.20.0", 65 | "@nestjs/cli": "^11.0.2", 66 | "@nestjs/schematics": "^11.0.0", 67 | "@nestjs/testing": "^11.0.9", 68 | "@types/bcryptjs": "^2.4.6", 69 | "@types/compression": "^1.7.5", 70 | "@types/express": "^5.0.0", 71 | "@types/jsonwebtoken": "^9.0.8", 72 | "@types/lodash": "^4.17.15", 73 | "@types/node": "^22.13.2", 74 | "@types/passport-jwt": "^4.0.1", 75 | "@typescript-eslint/eslint-plugin": "^8.24.0", 76 | "@typescript-eslint/parser": "^8.24.0", 77 | "dotenv": "^16.4.7", 78 | "eslint": "^9.20.1", 79 | "eslint-config-prettier": "^10.0.1", 80 | "eslint-plugin-prettier": "^5.2.3", 81 | "eslint-plugin-simple-import-sort": "^12.1.1", 82 | "globals": "^15.15.0", 83 | "jest": "^29.7.0", 84 | "prettier": "^3.5.1", 85 | "source-map-support": "^0.5.21", 86 | "supertest": "^7.0.0", 87 | "ts-jest": "^29.2.5", 88 | "ts-loader": "^9.5.2", 89 | "ts-node": "^10.9.2", 90 | "tsconfig-paths": "^4.2.0", 91 | "typescript": "^5.7.3" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /public/img/swagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grishahovhanyan/nestjs-architecture/126303f18d741753c36216d24e7681d3e82f4d1a/public/img/swagger.png -------------------------------------------------------------------------------- /src/infrastructure/aws/aws.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | 3 | import { SnsModule } from './sns/sns.module' 4 | import { SqsModule } from './sqs/sqs.module' 5 | 6 | @Module({ 7 | imports: [SnsModule, SqsModule], 8 | exports: [SnsModule] 9 | }) 10 | export class AWSModule {} 11 | -------------------------------------------------------------------------------- /src/infrastructure/aws/sns/sns.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | 3 | import { SnsService } from './sns.service' 4 | 5 | @Module({ 6 | providers: [SnsService], 7 | exports: [SnsService] 8 | }) 9 | export class SnsModule {} 10 | -------------------------------------------------------------------------------- /src/infrastructure/aws/sns/sns.service.ts: -------------------------------------------------------------------------------- 1 | import { SNS } from '@aws-sdk/client-sns' 2 | import { Injectable } from '@nestjs/common' 3 | 4 | import { AWS_REGION, SNS_ARN } from '@app/common' 5 | 6 | @Injectable() 7 | export class SnsService { 8 | private readonly client: SNS 9 | 10 | constructor() { 11 | this.client = new SNS({ region: AWS_REGION }) 12 | } 13 | 14 | async publish(payload: Record) { 15 | const params = { 16 | TopicArn: SNS_ARN, 17 | Message: JSON.stringify(payload) 18 | } 19 | const response = await this.client.publish(params) 20 | 21 | return { 22 | MessageId: response.MessageId 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/infrastructure/aws/sqs/sqs-config.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common' 2 | import { SqsModuleOptionsFactory, SqsOptions } from '@ssut/nestjs-sqs/dist/sqs.types' 3 | 4 | import { 5 | SQS_AUTHENTICATION_ERROR_TIMEOUT, 6 | SQS_POLLING_WAIT_TIME_MS, 7 | SQS_QUEUE_NAME, 8 | SQS_URL, 9 | SQS_VISIBILITY_TIMEOUT, 10 | SQS_WAIT_TIME_SECONDS 11 | } from '@app/common' 12 | 13 | @Injectable() 14 | export class SqsConfigService implements SqsModuleOptionsFactory { 15 | private readonly logger = new Logger(SqsConfigService.name) 16 | 17 | createOptions(): SqsOptions { 18 | if (!SQS_URL || SQS_QUEUE_NAME) { 19 | this.logger.error('SQS configuration is invalid: SQS_URL is missing or SQS_QUEUE_NAME is incorrectly set.') 20 | 21 | return { 22 | consumers: [], 23 | producers: [] 24 | } 25 | } 26 | 27 | return { 28 | consumers: [ 29 | { 30 | queueUrl: SQS_URL, 31 | name: SQS_QUEUE_NAME, 32 | waitTimeSeconds: SQS_WAIT_TIME_SECONDS, 33 | visibilityTimeout: SQS_VISIBILITY_TIMEOUT, 34 | pollingWaitTimeMs: SQS_POLLING_WAIT_TIME_MS, 35 | authenticationErrorTimeout: SQS_AUTHENTICATION_ERROR_TIMEOUT 36 | } 37 | ], 38 | producers: [] 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/infrastructure/aws/sqs/sqs.handler.ts: -------------------------------------------------------------------------------- 1 | import { Message } from '@aws-sdk/client-sqs' 2 | import { Injectable, Logger } from '@nestjs/common' 3 | import { SqsConsumerEventHandler, SqsMessageHandler } from '@ssut/nestjs-sqs' 4 | 5 | import { SQS_BATCH_POLLING, SQS_QUEUE_NAME } from '@app/common' 6 | 7 | @Injectable() 8 | export class SqsHandler { 9 | private readonly logger = new Logger(SqsHandler.name) 10 | 11 | @SqsMessageHandler(SQS_QUEUE_NAME, SQS_BATCH_POLLING) 12 | async handleMessage(message: Message) { 13 | try { 14 | const parsedBody = JSON.parse(message.Body) as { Message: string } 15 | const sqsMessage = JSON.parse(parsedBody.Message) 16 | 17 | this.logger.log('SQS Message =>', sqsMessage) 18 | } catch (error) { 19 | this.logger.error('Failed to process message', error.stack) 20 | } 21 | } 22 | 23 | @SqsConsumerEventHandler(SQS_QUEUE_NAME, 'error') 24 | async onError(error: Error) { 25 | this.logger.warn(error.message) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/infrastructure/aws/sqs/sqs.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { SqsModule as NestSqsModule } from '@ssut/nestjs-sqs' 3 | 4 | import { SqsHandler } from './sqs.handler' 5 | import { SqsConfigService } from './sqs-config.service' 6 | 7 | @Module({ 8 | imports: [NestSqsModule.registerAsync({ useClass: SqsConfigService })], 9 | providers: [SqsHandler] 10 | }) 11 | export class SqsModule {} 12 | -------------------------------------------------------------------------------- /src/infrastructure/infrastructure.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common' 2 | import { ConfigModule } from '@nestjs/config' 3 | import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core' 4 | 5 | import { envValidationSchema, JwtAuthGuard, RequestLoggerInterceptor } from '@app/common' 6 | import { PostgresModule, RedisModule } from '@app/database' 7 | 8 | import { AWSModule } from './aws/aws.module' 9 | import { QueueModule } from './queues/queue.module' 10 | 11 | /* 12 | ####### NOTE ####### 13 | The `InfrastructureModule` centralizes foundational modules and services that are globally required across the NestJS application. 14 | It includes essential services and integrations such as `RedisModule`, `AWSModule`, and others that provide critical functionalities like caching, external service interactions, and cloud integrations. 15 | 16 | By making this module global, it eliminates the need to repeatedly import these modules in individual feature modules, 17 | ensuring that all necessary global dependencies (like configuration, guards, and interceptors) are available application-wide. 18 | 19 | You can also add other shared or infrastructural services here, such as logging, database connections, authentication mechanisms, etc. 20 | 21 | Any additional global services or modules can be added to this module as the application evolves. 22 | */ 23 | 24 | /* 25 | ####### NOTE ####### 26 | The `sharedFeatureModules` array includes all modules that: 27 | 1. Contain providers (e.g., services) which are required by other modules in the application (e.g., `SnsService` from `AWSModule`). 28 | 2. Must be included in both `imports` and `exports` of the `InfrastructureModule` to make their providers globally accessible. 29 | */ 30 | 31 | const sharedFeatureModules = [RedisModule, AWSModule, QueueModule] 32 | 33 | @Global() 34 | @Module({ 35 | imports: [ 36 | ConfigModule.forRoot({ 37 | envFilePath: '.env', 38 | isGlobal: true, 39 | validationSchema: envValidationSchema 40 | }), 41 | PostgresModule, 42 | ...sharedFeatureModules 43 | ], 44 | providers: [ 45 | { 46 | provide: APP_GUARD, 47 | useClass: JwtAuthGuard 48 | }, 49 | { 50 | provide: APP_INTERCEPTOR, 51 | useClass: RequestLoggerInterceptor 52 | } 53 | ], 54 | exports: [...sharedFeatureModules] 55 | }) 56 | export class InfrastructureModule {} 57 | -------------------------------------------------------------------------------- /src/infrastructure/queues/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mail' 2 | export * from './notification' 3 | -------------------------------------------------------------------------------- /src/infrastructure/queues/mail/enums/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mail-job-names.enum' 2 | export * from './mail-template-names.enum' 3 | -------------------------------------------------------------------------------- /src/infrastructure/queues/mail/enums/mail-job-names.enum.ts: -------------------------------------------------------------------------------- 1 | export enum MailJobNames { 2 | sendMail = 'sendMail' 3 | } 4 | -------------------------------------------------------------------------------- /src/infrastructure/queues/mail/enums/mail-template-names.enum.ts: -------------------------------------------------------------------------------- 1 | export enum MailTemplateNames { 2 | resetPassword = 'RESET_PASSWORD' 3 | } 4 | -------------------------------------------------------------------------------- /src/infrastructure/queues/mail/index.ts: -------------------------------------------------------------------------------- 1 | export * from './enums' 2 | export * from './mail.module' 3 | export * from './mail.service' 4 | -------------------------------------------------------------------------------- /src/infrastructure/queues/mail/mail.module.ts: -------------------------------------------------------------------------------- 1 | import { BullMQAdapter } from '@bull-board/api/bullMQAdapter' 2 | import { BullBoardModule } from '@bull-board/nestjs' 3 | import { BullModule } from '@nestjs/bullmq' 4 | import { Module } from '@nestjs/common' 5 | 6 | import { QueueNames } from '../queue.enum' 7 | import { MailQueueProcessor } from './mail.processor' 8 | import { MailQueueService } from './mail.service' 9 | 10 | @Module({ 11 | imports: [ 12 | BullModule.registerQueue({ name: QueueNames.mail }), 13 | BullBoardModule.forFeature({ name: QueueNames.mail, adapter: BullMQAdapter }) 14 | ], 15 | providers: [MailQueueProcessor, MailQueueService], 16 | exports: [MailQueueService] 17 | }) 18 | export class MailQueueModule {} 19 | -------------------------------------------------------------------------------- /src/infrastructure/queues/mail/mail.processor.ts: -------------------------------------------------------------------------------- 1 | import { Processor, WorkerHost } from '@nestjs/bullmq' 2 | import { Logger } from '@nestjs/common' 3 | import { Job } from 'bullmq' 4 | 5 | import { QueueNames } from '../queue.enum' 6 | import { MailJobNames } from './enums' 7 | 8 | @Processor(QueueNames.mail) 9 | export class MailQueueProcessor extends WorkerHost { 10 | private readonly logger = new Logger(MailQueueProcessor.name) 11 | 12 | async process(job: Job) { 13 | switch (job.name) { 14 | case MailJobNames.sendMail: 15 | return this.handleSendMail(job) 16 | default: 17 | this.logger.warn(`⚠️ Job "${job.name}" is not handled.`) 18 | break 19 | } 20 | } 21 | 22 | // TODO: implement a mail service 23 | private async handleSendMail(job: Job) { 24 | this.logger.log(`✅ Handling "${MailJobNames.sendMail}" job with ID: ${job.id}`) 25 | this.logger.debug(`Job Data: ${JSON.stringify(job.data)}`) 26 | 27 | try { 28 | } catch (error) { 29 | this.logger.error(`❌ Error handling "${MailJobNames.sendMail}" job: ${error.message}`, error.stack) 30 | throw error 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/infrastructure/queues/mail/mail.service.ts: -------------------------------------------------------------------------------- 1 | import { InjectQueue } from '@nestjs/bullmq' 2 | import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common' 3 | import { Queue } from 'bullmq' 4 | import _ from 'lodash' 5 | 6 | import { QueueNames } from '../queue.enum' 7 | import { AbstractQueueService } from '../queue.service' 8 | import { MailJobNames, MailTemplateNames } from './enums' 9 | 10 | @Injectable() 11 | export class MailQueueService extends AbstractQueueService implements OnApplicationBootstrap { 12 | protected readonly logger: Logger = new Logger(_.upperFirst(_.camelCase(QueueNames.mail))) 13 | 14 | private _queue: Queue 15 | 16 | get queue(): Queue { 17 | return this._queue 18 | } 19 | 20 | constructor(@InjectQueue(QueueNames.mail) private readonly mailQueue: Queue) { 21 | super() 22 | this._queue = this.mailQueue 23 | } 24 | 25 | public async onApplicationBootstrap(): Promise { 26 | await this.checkConnection() 27 | await this.initEventListeners() 28 | } 29 | 30 | public async sendResetPasswordMail(payload: Record) { 31 | return this.addJob(MailJobNames.sendMail, { 32 | template: MailTemplateNames.resetPassword, 33 | ...payload 34 | }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/infrastructure/queues/notification/enums/index.ts: -------------------------------------------------------------------------------- 1 | export * from './notification-job-names.enum' 2 | -------------------------------------------------------------------------------- /src/infrastructure/queues/notification/enums/notification-job-names.enum.ts: -------------------------------------------------------------------------------- 1 | export enum NotificationJobNames { 2 | registrationSuccess = 'registrationSuccess' 3 | } 4 | -------------------------------------------------------------------------------- /src/infrastructure/queues/notification/index.ts: -------------------------------------------------------------------------------- 1 | export * from './enums' 2 | export * from './notification.module' 3 | export * from './notification.service' 4 | -------------------------------------------------------------------------------- /src/infrastructure/queues/notification/notification.module.ts: -------------------------------------------------------------------------------- 1 | import { BullMQAdapter } from '@bull-board/api/bullMQAdapter' 2 | import { BullBoardModule } from '@bull-board/nestjs' 3 | import { BullModule } from '@nestjs/bullmq' 4 | import { Module } from '@nestjs/common' 5 | 6 | import { QueueNames } from '../queue.enum' 7 | import { NotificationQueueProcessor } from './notification.processor' 8 | import { NotificationQueueService } from './notification.service' 9 | 10 | @Module({ 11 | imports: [ 12 | BullModule.registerQueue({ name: QueueNames.notification }), 13 | BullBoardModule.forFeature({ name: QueueNames.notification, adapter: BullMQAdapter }) 14 | ], 15 | providers: [NotificationQueueProcessor, NotificationQueueService], 16 | exports: [NotificationQueueService] 17 | }) 18 | export class NotificationQueueModule {} 19 | -------------------------------------------------------------------------------- /src/infrastructure/queues/notification/notification.processor.ts: -------------------------------------------------------------------------------- 1 | import { Processor, WorkerHost } from '@nestjs/bullmq' 2 | import { Logger } from '@nestjs/common' 3 | import { Job } from 'bullmq' 4 | 5 | import { QueueNames } from '../queue.enum' 6 | import { NotificationJobNames } from './enums' 7 | 8 | @Processor(QueueNames.notification) 9 | export class NotificationQueueProcessor extends WorkerHost { 10 | private readonly logger = new Logger(NotificationQueueProcessor.name) 11 | 12 | async process(job: Job) { 13 | switch (job.name) { 14 | case NotificationJobNames.registrationSuccess: 15 | return this.handleRegistrationSuccess(job) 16 | default: 17 | this.logger.warn(`⚠️ Job "${job.name}" is not handled.`) 18 | break 19 | } 20 | } 21 | 22 | private async handleRegistrationSuccess(job: Job) { 23 | this.logger.log(`✅ Handling "${NotificationJobNames.registrationSuccess}" job with ID: ${job.id}`) 24 | this.logger.debug(`Job Data: ${JSON.stringify(job.data)}`) 25 | 26 | try { 27 | /* 28 | ####### NOTE ####### 29 | Add your business logic here for handling "registrationSuccess" 30 | */ 31 | } catch (error) { 32 | this.logger.error( 33 | `❌ Error handling "${NotificationJobNames.registrationSuccess}" job: ${error.message}`, 34 | error.stack 35 | ) 36 | throw error 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/infrastructure/queues/notification/notification.service.ts: -------------------------------------------------------------------------------- 1 | import { InjectQueue } from '@nestjs/bullmq' 2 | import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common' 3 | import { Queue } from 'bullmq' 4 | import _ from 'lodash' 5 | 6 | import { QueueNames } from '../queue.enum' 7 | import { AbstractQueueService } from '../queue.service' 8 | import { NotificationJobNames } from './enums' 9 | 10 | @Injectable() 11 | export class NotificationQueueService extends AbstractQueueService implements OnApplicationBootstrap { 12 | protected readonly logger: Logger = new Logger(_.upperFirst(_.camelCase(QueueNames.notification))) 13 | 14 | private _queue: Queue 15 | 16 | get queue(): Queue { 17 | return this._queue 18 | } 19 | 20 | constructor(@InjectQueue(QueueNames.notification) private readonly notificationQueue: Queue) { 21 | super() 22 | this._queue = this.notificationQueue 23 | } 24 | 25 | public async onApplicationBootstrap(): Promise { 26 | await this.checkConnection() 27 | await this.initEventListeners() 28 | } 29 | 30 | public async registrationSuccess(payload: { fullName: string; email: string }) { 31 | return this.addJob(NotificationJobNames.registrationSuccess, payload) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/infrastructure/queues/queue.enum.ts: -------------------------------------------------------------------------------- 1 | export enum QueueNames { 2 | notification = 'NOTIFICATION_QUEUE', 3 | mail = 'MAIL_QUEUE' 4 | } 5 | -------------------------------------------------------------------------------- /src/infrastructure/queues/queue.module.ts: -------------------------------------------------------------------------------- 1 | import { ExpressAdapter } from '@bull-board/express' 2 | import { BullBoardModule } from '@bull-board/nestjs' 3 | import { BullModule } from '@nestjs/bullmq' 4 | import { Module } from '@nestjs/common' 5 | import basicAuth from 'express-basic-auth' 6 | 7 | import { envService } from '@app/common' 8 | import { REDIS_CONFIG } from '@app/database' 9 | 10 | import { MailQueueModule, NotificationQueueModule } from './index' 11 | 12 | /* 13 | ####### NOTE ####### 14 | The `queueModules` array includes all queue-related modules in the application, such as `NotificationQueueModule`. 15 | These modules are included in both the `imports` and `exports` arrays of `QueueModule` 16 | to ensure their services/providers (e.g., `NotificationQueueService` from `NotificationQueueModule`) 17 | are globally available to other modules in the application. 18 | 19 | Add additional queue modules here as your application grows. 20 | */ 21 | const queueModules = [MailQueueModule, NotificationQueueModule] 22 | 23 | @Module({ 24 | imports: [ 25 | BullModule.forRoot({ 26 | connection: { url: REDIS_CONFIG.url } 27 | }), 28 | BullBoardModule.forRoot({ 29 | route: '/admin/bull-board', 30 | adapter: ExpressAdapter, 31 | middleware: [ 32 | basicAuth({ 33 | challenge: true, 34 | users: { 35 | [envService.getEnvString('BULL_ADMIN_USERNAME')]: envService.getEnvString('BULL_ADMIN_PASSWORD') 36 | } 37 | }) 38 | ] 39 | }), 40 | ...queueModules 41 | ], 42 | exports: [...queueModules] 43 | }) 44 | export class QueueModule {} 45 | -------------------------------------------------------------------------------- /src/infrastructure/queues/queue.service.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common' 2 | import { BulkJobOptions, Job, JobsOptions, Queue } from 'bullmq' 3 | 4 | export abstract class AbstractQueueService { 5 | protected readonly logger: Logger 6 | 7 | abstract get queue(): Queue 8 | 9 | constructor() {} 10 | 11 | /** 12 | * Checks the connection to the queue server. 13 | * 14 | * @returns A promise that resolves if the connection is successful or throws an error if not. 15 | * @throws If the connection fails, an error is thrown. 16 | */ 17 | protected async checkConnection(): Promise { 18 | const client = await this.queue.client 19 | 20 | if (client.status !== 'ready') { 21 | const errorMessage = `❌ Queue "${this.queue.name}" is not connected. Current status: [${client.status.toUpperCase()}]` 22 | this.logger.error(errorMessage) 23 | throw new Error(errorMessage) 24 | } 25 | 26 | this.logger.log(`📦 Queue "${this.queue.name}" is connected.`) 27 | } 28 | 29 | /** 30 | * Adds event listeners to the queue. 31 | */ 32 | protected async initEventListeners() { 33 | this.queue.on('error', (error: Error) => { 34 | this.logger.fatal(`Queue error: [${error.message}].`) 35 | }) 36 | } 37 | 38 | /** 39 | * Drains all jobs from the queue. 40 | * 41 | * @param delayed - Whether to include delayed jobs in the drain operation. Defaults to `false`. 42 | * @returns A promise that resolves when the queue is fully drained. 43 | */ 44 | protected async drain(delayed?: boolean): Promise { 45 | return this.queue.drain(delayed) 46 | } 47 | 48 | /** 49 | * Removes all jobs from the queue, effectively obliterating its contents. 50 | * 51 | * @param options - Options for the obliteration process, including a `force` flag. 52 | * @returns A promise that resolves when the obliteration is complete. 53 | */ 54 | protected async obliterate(options?: { force: boolean }): Promise { 55 | return this.queue.obliterate(options) 56 | } 57 | 58 | /** 59 | * Closes the queue instance associated with this queue adapter. 60 | * 61 | * @returns A promise that resolves once the queue is closed. 62 | */ 63 | protected closeQueue(): Promise { 64 | return this.queue.close() 65 | } 66 | 67 | /** 68 | * Adds a single job to the queue for processing. 69 | * 70 | * @param name - The name of the job, used to identify the job type. 71 | * @param data - The data payload to pass to the job processor. 72 | * @param options - Optional configuration for the job, such as delay or priority. 73 | * @returns A promise that resolves to the created job instance. 74 | */ 75 | protected async addJob(name: string, data: Data, options?: JobsOptions): Promise> { 76 | this.logger.log(`📤 Adding job "${name}" to queue "${this.queue.name}".`) 77 | return this.queue.add(name, data, options) 78 | } 79 | 80 | /** 81 | * Adds multiple jobs to the queue in bulk. 82 | * 83 | * @param jobs - An array of job definitions, each containing `name`, `data`, and optional `options`. 84 | * @returns A promise that resolves to an array of created job instances. 85 | */ 86 | protected async addBulk( 87 | jobs: Array<{ name: string; data: Data; options?: BulkJobOptions }> 88 | ): Promise>> { 89 | this.logger.log(`📤 Adding bulk jobs to queue "${this.queue.name}". Total jobs: ${jobs.length}.`) 90 | return this.queue.addBulk(jobs) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common' 2 | import { NestFactory } from '@nestjs/core' 3 | import { NestExpressApplication } from '@nestjs/platform-express' 4 | import { initializeTransactionalContext } from 'typeorm-transactional' 5 | 6 | import { appUtilsService, envService } from '@app/common' 7 | import { AppModule } from '@modules/app/app.module' 8 | 9 | const logger = new Logger('App') 10 | 11 | async function bootstrap() { 12 | initializeTransactionalContext() 13 | 14 | const app = await NestFactory.create(AppModule) 15 | 16 | // Setup application 17 | appUtilsService.setupApp(app) 18 | 19 | // Start application 20 | const { apiPort, apiUrl } = envService.getApiUrl() 21 | await app.listen(apiPort, () => logger.log(`🚀 Application is running on: ${apiUrl}`)) 22 | } 23 | 24 | bootstrap() 25 | 26 | process.on('uncaughtException', (err) => { 27 | logger.error(err, 'Uncaught exception detected') 28 | 29 | throw err 30 | }) 31 | -------------------------------------------------------------------------------- /src/modules/app/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Get } from '@nestjs/common' 2 | 3 | import { EnhancedController } from '@app/common' 4 | 5 | @EnhancedController('', false, 'App') 6 | export class AppController { 7 | @Get('healthcheck') 8 | index() { 9 | return 'Ok' 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | 3 | import { AuthModule } from '@modules/auth/auth.module' 4 | import { UsersModule } from '@modules/users/users.module' 5 | import { InfrastructureModule } from '@infra/infrastructure.module' 6 | 7 | import { AppController } from './app.controller' 8 | 9 | @Module({ 10 | imports: [InfrastructureModule, AuthModule, UsersModule], 11 | controllers: [AppController] 12 | }) 13 | export class AppModule {} 14 | -------------------------------------------------------------------------------- /src/modules/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, HttpCode, HttpStatus, Post } from '@nestjs/common' 2 | 3 | import { Swagger } from '@app/swagger' 4 | import { EnhancedController, TransformResponse } from '@app/common' 5 | import { UserResponseDto } from '@modules/users/dto' 6 | 7 | import { AuthService } from './auth.service' 8 | import { ForgotPasswordDto, LoginDto, LoginResponseDto, RegisterDto, ResetPasswordDto } from './dto' 9 | 10 | @EnhancedController('', false, 'Auth') 11 | export class AuthController { 12 | constructor(private readonly authService: AuthService) {} 13 | 14 | @Swagger({ response: UserResponseDto, 400: true }) 15 | @Post('register') 16 | @HttpCode(HttpStatus.OK) 17 | @TransformResponse(UserResponseDto) 18 | register(@Body() registerDto: RegisterDto) { 19 | return this.authService.register(registerDto) 20 | } 21 | 22 | @Swagger({ response: LoginResponseDto, 400: true }) 23 | @Post('login') 24 | @HttpCode(HttpStatus.OK) 25 | @TransformResponse(LoginResponseDto) 26 | login(@Body() loginDto: LoginDto) { 27 | return this.authService.login(loginDto) 28 | } 29 | 30 | @Post('forgot-password') 31 | @Swagger({ errorResponses: [400, 404] }) 32 | @HttpCode(HttpStatus.OK) 33 | forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) { 34 | return this.authService.forgotPassword(forgotPasswordDto) 35 | } 36 | 37 | @Post('reset-password') 38 | @Swagger({ errorResponses: [400, 404] }) 39 | @HttpCode(HttpStatus.OK) 40 | resetPassword(@Body() resetPasswordDto: ResetPasswordDto) { 41 | return this.authService.resetPassword(resetPasswordDto) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/modules/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { JwtModule } from '@nestjs/jwt' 3 | import { PassportModule } from '@nestjs/passport' 4 | 5 | import { UsersModule } from '@modules/users/users.module' 6 | 7 | import { AuthController } from './auth.controller' 8 | import { AuthService } from './auth.service' 9 | import { JwtConfigService } from './jwt-config.service' 10 | import { JwtStrategy } from './strategies/jwt.strategy' 11 | 12 | @Module({ 13 | imports: [ 14 | PassportModule, 15 | JwtModule.registerAsync({ 16 | useClass: JwtConfigService 17 | }), 18 | UsersModule 19 | ], 20 | controllers: [AuthController], 21 | providers: [AuthService, JwtStrategy] 22 | }) 23 | export class AuthModule {} 24 | -------------------------------------------------------------------------------- /src/modules/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common' 2 | import { JwtService } from '@nestjs/jwt' 3 | import { randomUUID } from 'crypto' 4 | 5 | import { 6 | ERROR_MESSAGES, 7 | HashService, 8 | RESET_PASSWORD_REQUEST_TIME_LIMIT, 9 | RESET_PASSWORD_TOKEN_EXPIRE_TIME, 10 | SUCCESS_RESPONSE 11 | } from '@app/common' 12 | import { User } from '@modules/users/entities/user.entity' 13 | import { UsersService } from '@modules/users/users.service' 14 | import { NotificationQueueService } from '@infra/queues' 15 | import { MailQueueService } from '@infra/queues/mail' 16 | 17 | import { ForgotPasswordDto, LoginDto, RegisterDto, ResetPasswordDto } from './dto' 18 | 19 | @Injectable() 20 | export class AuthService { 21 | constructor( 22 | private readonly jwtService: JwtService, 23 | private readonly mailQueueService: MailQueueService, 24 | private readonly notificationQueueService: NotificationQueueService, 25 | private readonly usersService: UsersService 26 | ) {} 27 | 28 | // ******* Controller Handlers ******* 29 | async register(registerDto: RegisterDto) { 30 | const user = await this.usersService.getByEmail(registerDto.email) 31 | 32 | if (user) { 33 | throw new BadRequestException(ERROR_MESSAGES.userAlreadyExists) 34 | } 35 | 36 | const createdUser = await this.usersService.create(registerDto) 37 | 38 | await this.notificationQueueService.registrationSuccess({ 39 | fullName: createdUser.fullName, 40 | email: createdUser.email 41 | }) 42 | 43 | return createdUser 44 | } 45 | 46 | async login(loginDto: LoginDto) { 47 | const user = await this.validateUser(loginDto.email, loginDto.password) 48 | 49 | return { 50 | accessToken: this.jwtService.sign({ userId: user.id }) 51 | } 52 | } 53 | 54 | async forgotPassword(forgotPasswordDto: ForgotPasswordDto) { 55 | const user = await this.usersService.getByEmail(forgotPasswordDto.email) 56 | 57 | if (!user) { 58 | throw new NotFoundException() 59 | } 60 | 61 | // Check if the user has requested a password reset within the last minute 62 | const timeDifference = new Date().getTime() - new Date(user.tokenExpireDate).getTime() 63 | 64 | if (user.tokenExpireDate && timeDifference < RESET_PASSWORD_REQUEST_TIME_LIMIT) { 65 | throw new BadRequestException(ERROR_MESSAGES.passwordResetRequestTooFrequent) 66 | } 67 | 68 | const token = randomUUID() 69 | const tokenExpireDate = new Date(new Date().getTime() + RESET_PASSWORD_TOKEN_EXPIRE_TIME) 70 | 71 | await this.mailQueueService.sendResetPasswordMail({ email: user.email, token }) 72 | 73 | await this.usersService.updateById(user.id, { token, tokenExpireDate }) 74 | 75 | return SUCCESS_RESPONSE 76 | } 77 | 78 | async resetPassword(resetPasswordDto: ResetPasswordDto) { 79 | const user = await this.usersService.getByToken(resetPasswordDto.token) 80 | 81 | if (!user) { 82 | throw new NotFoundException() 83 | } 84 | 85 | if (new Date() > user.tokenExpireDate) { 86 | throw new BadRequestException(ERROR_MESSAGES.passwordResetTokenExpired) 87 | } 88 | 89 | await this.usersService.updateById(user.id, { 90 | password: resetPasswordDto.password, 91 | token: null, 92 | tokenExpireDate: null 93 | }) 94 | 95 | return SUCCESS_RESPONSE 96 | } 97 | 98 | // ******* ******* ******* ******* 99 | 100 | async validateUser(email: string, pass: string): Promise { 101 | const user = await this.usersService.getByEmail(email) 102 | if (!user || !HashService.compare(pass, user.password)) { 103 | throw new BadRequestException(ERROR_MESSAGES.invalidEmailPassword) 104 | } 105 | 106 | return user 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/modules/auth/dto/auth-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { Exclude, Expose } from 'class-transformer' 3 | 4 | @Exclude() 5 | export class LoginResponseDto { 6 | @Expose() 7 | @ApiProperty() 8 | accessToken: string 9 | } 10 | -------------------------------------------------------------------------------- /src/modules/auth/dto/forgot-password.dto.ts: -------------------------------------------------------------------------------- 1 | import { EmailField } from '@app/common/validators' 2 | 3 | export class ForgotPasswordDto { 4 | @EmailField() 5 | email: string 6 | } 7 | -------------------------------------------------------------------------------- /src/modules/auth/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth-response.dto' 2 | export * from './forgot-password.dto' 3 | export * from './login.dto' 4 | export * from './register.dto' 5 | export * from './reset-password.dto' 6 | -------------------------------------------------------------------------------- /src/modules/auth/dto/login.dto.ts: -------------------------------------------------------------------------------- 1 | import { EmailField, StringField } from '@app/common/validators' 2 | 3 | export class LoginDto { 4 | @EmailField() 5 | email: string 6 | 7 | @StringField({ example: 'password' }) 8 | password: string 9 | } 10 | -------------------------------------------------------------------------------- /src/modules/auth/dto/register.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsDefined, IsString } from 'class-validator' 3 | 4 | import { VALIDATION_MESSAGES } from '@app/common' 5 | import { EmailField, PasswordField, StringField } from '@app/common/validators' 6 | 7 | export class RegisterDto { 8 | @StringField({ example: 'John' }) 9 | firstName: string 10 | 11 | @StringField({ example: 'Doe' }) 12 | lastName: string 13 | 14 | @EmailField() 15 | email: string 16 | 17 | @ApiProperty({ example: '2004-04-14' }) 18 | @IsString() 19 | @IsDefined() 20 | birthDate: string 21 | 22 | @PasswordField() 23 | password: string 24 | 25 | @StringField({ example: 'password', matchKey: 'password', matchMessage: VALIDATION_MESSAGES.passwordMismatch }) 26 | confirmPassword: string 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/auth/dto/reset-password.dto.ts: -------------------------------------------------------------------------------- 1 | import { VALIDATION_MESSAGES } from '@app/common' 2 | import { PasswordField, StringField } from '@app/common/validators' 3 | 4 | export class ResetPasswordDto { 5 | @StringField() 6 | readonly token: string 7 | 8 | @PasswordField() 9 | password: string 10 | 11 | @StringField({ example: 'password', matchKey: 'password', matchMessage: VALIDATION_MESSAGES.passwordMismatch }) 12 | confirmPassword: string 13 | } 14 | -------------------------------------------------------------------------------- /src/modules/auth/jwt-config.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { JwtModuleOptions, JwtOptionsFactory } from '@nestjs/jwt' 3 | 4 | import { JWT_EXPIRATION, JWT_SECRET } from '@app/common' 5 | 6 | @Injectable() 7 | export class JwtConfigService implements JwtOptionsFactory { 8 | createJwtOptions(): JwtModuleOptions { 9 | return { 10 | secret: JWT_SECRET, 11 | signOptions: { expiresIn: JWT_EXPIRATION } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/modules/auth/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { PassportStrategy } from '@nestjs/passport' 3 | import { ExtractJwt, Strategy } from 'passport-jwt' 4 | 5 | import { JWT_SECRET } from '@app/common' 6 | import { UsersService } from '@modules/users/users.service' 7 | 8 | @Injectable() 9 | export class JwtStrategy extends PassportStrategy(Strategy) { 10 | constructor(private readonly usersService: UsersService) { 11 | super({ 12 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 13 | ignoreExpiration: false, 14 | secretOrKey: JWT_SECRET 15 | }) 16 | } 17 | 18 | async validate(payload: { userId: number }) { 19 | return await this.usersService.getById(payload.userId) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/users/dto/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | export class CreateUserDto { 2 | firstName: string 3 | lastName: string 4 | email: string 5 | password: string 6 | } 7 | -------------------------------------------------------------------------------- /src/modules/users/dto/get-users.dto.ts: -------------------------------------------------------------------------------- 1 | import { IntersectionType } from '@nestjs/swagger' 2 | 3 | import { OrderDto, PageTypes, PaginationDto, SearchDto, USERS_SORT_FIELDS } from '@app/common' 4 | import { NumberFieldOptional, StringFieldOptional } from '@app/common/validators' 5 | 6 | export class GetUsersDto extends IntersectionType( 7 | PaginationDto(PageTypes.users), 8 | SearchDto, 9 | OrderDto(USERS_SORT_FIELDS) 10 | ) { 11 | @StringFieldOptional({ description: 'birthDate greater than equal (Must be in YYYY-DD-MM format)' }) 12 | birthDateGte?: string 13 | 14 | @StringFieldOptional({ description: 'birthDate less than equal (Must be in YYYY-DD-MM format)' }) 15 | birthDateLte?: string 16 | 17 | @NumberFieldOptional({ description: 'ageGte greater than equal' }) 18 | ageGte?: number 19 | 20 | @NumberFieldOptional({ description: 'ageLte less than equal' }) 21 | ageLte?: number 22 | 23 | userIdsToExclude?: number[] 24 | userIdsToInclude?: number[] 25 | } 26 | -------------------------------------------------------------------------------- /src/modules/users/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-user.dto' 2 | export * from './get-users.dto' 3 | export * from './user-response.dto' 4 | -------------------------------------------------------------------------------- /src/modules/users/dto/user-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { Exclude, Expose } from 'class-transformer' 3 | 4 | @Exclude() 5 | export class UserResponseDto { 6 | @Expose() 7 | @ApiProperty() 8 | id: number 9 | 10 | @Expose() 11 | @ApiProperty() 12 | firstName: string 13 | 14 | @Expose() 15 | @ApiProperty() 16 | lastName: string 17 | 18 | @Expose() 19 | @ApiProperty() 20 | fullName: string 21 | 22 | @Expose() 23 | @ApiProperty() 24 | email: string 25 | 26 | @Expose() 27 | @ApiProperty() 28 | birthDate: string 29 | 30 | @Expose() 31 | @ApiProperty() 32 | age: number 33 | 34 | @Expose() 35 | @ApiProperty() 36 | registeredAt: string 37 | } 38 | -------------------------------------------------------------------------------- /src/modules/users/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { Exclude, Expose } from 'class-transformer' 2 | import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm' 3 | 4 | import { calculateAge, PasswordTransformer } from '@app/common' 5 | import { DbTables } from '@app/database' 6 | 7 | @Entity(DbTables.users) 8 | export class User { 9 | @PrimaryGeneratedColumn() 10 | id: number 11 | 12 | @Column({ nullable: false }) 13 | firstName: string 14 | 15 | @Column({ nullable: false }) 16 | lastName: string 17 | 18 | @Column({ nullable: false }) 19 | email: string 20 | 21 | @Column({ nullable: false }) 22 | birthDate: string 23 | 24 | @Exclude() 25 | @Column({ 26 | transformer: new PasswordTransformer(), 27 | nullable: false 28 | }) 29 | password: string 30 | 31 | @Column({ nullable: true }) 32 | token: string 33 | 34 | @Column({ type: 'timestamp', nullable: true }) 35 | tokenExpireDate: Date 36 | 37 | @CreateDateColumn({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP(6)' }) 38 | registeredAt: Date 39 | 40 | /* 41 | ####### NOTE ####### 42 | OneToMany relationship between the current entity and the Product entity 43 | */ 44 | // @OneToMany(() => Product, (product) => product.creator) 45 | // products: Product[] 46 | 47 | /* 48 | ####### NOTE ####### 49 | OneToOne relationship between the current entity and the Basket entity 50 | */ 51 | // @OneToOne(() => Basket, (basket) => basket.user) 52 | // basket: Basket 53 | 54 | @Expose() 55 | get fullName(): string { 56 | return `${this.firstName} ${this.lastName}` 57 | } 58 | 59 | @Expose() 60 | get age(): number { 61 | return calculateAge(this.birthDate) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/modules/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { Get, Param, Query } from '@nestjs/common' 2 | 3 | import { Swagger } from '@app/swagger' 4 | import { EnhancedController, RequestUser, TransformResponse } from '@app/common' 5 | 6 | import { GetUsersDto, UserResponseDto } from './dto' 7 | import { UsersService } from './users.service' 8 | 9 | @EnhancedController('users') 10 | @TransformResponse(UserResponseDto) 11 | export class UsersController { 12 | constructor(private readonly usersService: UsersService) {} 13 | 14 | @Swagger({ response: UserResponseDto }) 15 | @Get('me') 16 | getMe(@RequestUser('id') currentUserId: number) { 17 | return this.usersService.getById(currentUserId) 18 | } 19 | 20 | @Swagger({ response: UserResponseDto, pagination: true }) 21 | @Get() 22 | index(@RequestUser('id') currentUserId: number, @Query() query: GetUsersDto) { 23 | return this.usersService.index(currentUserId, query) 24 | } 25 | 26 | @Swagger({ response: UserResponseDto }) 27 | @Get(':id') 28 | find(@Param('id') userId: number) { 29 | return this.usersService.find(userId) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/modules/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { TypeOrmModule } from '@nestjs/typeorm' 3 | 4 | import { User } from './entities/user.entity' 5 | import { UsersController } from './users.controller' 6 | import { UsersRepository } from './users.repository' 7 | import { UsersService } from './users.service' 8 | 9 | @Module({ 10 | imports: [TypeOrmModule.forFeature([User])], 11 | controllers: [UsersController], 12 | providers: [UsersRepository, UsersService], 13 | exports: [UsersService] 14 | }) 15 | export class UsersModule {} 16 | -------------------------------------------------------------------------------- /src/modules/users/users.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { DataSource } from 'typeorm' 3 | 4 | import { BaseRepository } from '@app/database' 5 | 6 | import { User } from './entities/user.entity' 7 | 8 | @Injectable() 9 | export class UsersRepository extends BaseRepository { 10 | constructor(dataSource: DataSource) { 11 | super(dataSource, User) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/modules/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common' 2 | import { InjectRepository } from '@nestjs/typeorm' 3 | import { Repository } from 'typeorm' 4 | 5 | import { paginatedResponse } from '@app/common' 6 | 7 | import { CreateUserDto, GetUsersDto } from './dto' 8 | import { User } from './entities/user.entity' 9 | import { UsersRepository } from './users.repository' 10 | 11 | @Injectable() 12 | export class UsersService { 13 | constructor( 14 | private readonly usersRepository: UsersRepository, 15 | @InjectRepository(User) 16 | private readonly repo: Repository // Can be used to create queryBuilder 17 | ) {} 18 | 19 | // ******* Controller Handlers ******* 20 | async index(currentUserId: number, query: GetUsersDto) { 21 | const { items, totalCount } = await this.getAndCount({ 22 | ...query, 23 | userIdsToExclude: [currentUserId] 24 | }) 25 | 26 | return paginatedResponse(items, totalCount, query.page, query.perPage) 27 | } 28 | 29 | async find(userId: number) { 30 | const user = await this.getById(userId) 31 | 32 | if (!user) { 33 | throw new NotFoundException() 34 | } 35 | 36 | return user 37 | } 38 | 39 | // ******* ******* ******* ******* 40 | 41 | async create(createUserInput: CreateUserDto): Promise { 42 | return await this.usersRepository.create(createUserInput) 43 | } 44 | 45 | async getAndCount(getUsersInput: GetUsersDto) { 46 | const { 47 | page, 48 | perPage, 49 | order, 50 | searchText, 51 | birthDateGte, 52 | birthDateLte, 53 | ageGte, 54 | ageLte, 55 | userIdsToExclude, 56 | userIdsToInclude 57 | } = getUsersInput 58 | 59 | const queryBuilder = this.repo.createQueryBuilder('user') 60 | 61 | if (searchText?.trim()) { 62 | const formattedSearchText = `%${searchText.trim()}%` 63 | queryBuilder.andWhere("CONCAT(user.firstName, ' ', user.lastName) ILIKE :searchText", { 64 | searchText: formattedSearchText 65 | }) 66 | } 67 | 68 | if (userIdsToExclude?.length) { 69 | queryBuilder.andWhere('user.id NOT IN (:...userIdsToExclude)', { userIdsToExclude }) 70 | } 71 | 72 | if (userIdsToInclude?.length) { 73 | queryBuilder.andWhere('user.id IN (:...userIdsToInclude)', { userIdsToInclude }) 74 | } 75 | 76 | if (birthDateGte) { 77 | queryBuilder.andWhere('user.birthDate >= :birthDateGte', { birthDateGte: new Date(birthDateGte) }) 78 | } 79 | 80 | if (birthDateLte) { 81 | queryBuilder.andWhere('user.birthDate <= :birthDateLte', { birthDateLte: new Date(birthDateLte) }) 82 | } 83 | 84 | if (ageGte) { 85 | const currentDate = new Date() 86 | const birthDateLteForAgeGte = new Date(currentDate.setFullYear(currentDate.getFullYear() - ageGte)) 87 | queryBuilder.andWhere('user.birthDate <= :birthDateLteForAgeGte', { birthDateLteForAgeGte }) 88 | } 89 | 90 | if (ageLte) { 91 | const currentDate = new Date() 92 | const birthDateGteForAgeLte = new Date(currentDate.setFullYear(currentDate.getFullYear() - ageLte - 1)) 93 | queryBuilder.andWhere('user.birthDate >= :birthDateGteForAgeLte', { birthDateGteForAgeLte }) 94 | } 95 | 96 | queryBuilder.take(perPage).skip((page - 1) * perPage) 97 | 98 | if (order) { 99 | Object.entries(order).forEach(([key, value]) => { 100 | queryBuilder.addOrderBy(`user.${key}`, value as 'ASC' | 'DESC') 101 | }) 102 | } 103 | 104 | const [items, totalCount] = await queryBuilder.getManyAndCount() 105 | 106 | return { items, totalCount } 107 | } 108 | 109 | async getById(userId: number): Promise { 110 | return await this.usersRepository.findOne({ id: userId }) 111 | } 112 | 113 | async getByEmail(email: string): Promise { 114 | return await this.usersRepository.findOne({ email }) 115 | } 116 | 117 | async getByToken(token: string): Promise { 118 | return await this.usersRepository.findOne({ token }) 119 | } 120 | 121 | async updateById(userid: number, updateUserDto: Partial): Promise { 122 | await this.usersRepository.update({ id: userid }, updateUserDto) 123 | return await this.getById(userid) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noImplicitAny": false 5 | }, 6 | "include": ["src/**/*.ts", "libs/**/*.ts"], 7 | "exclude": ["node_modules", "dist"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "ES2022", 5 | "outDir": "./dist", 6 | "rootDir": "./", 7 | "baseUrl": "./", 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "declaration": true, 11 | "resolveJsonModule": true, 12 | "moduleDetection": "force", 13 | "incremental": true, 14 | "sourceMap": true, 15 | "moduleResolution": "Node", 16 | "emitDecoratorMetadata": true, 17 | "experimentalDecorators": true, 18 | "allowSyntheticDefaultImports": true, 19 | "removeComments": true, 20 | "strictNullChecks": false, 21 | "noImplicitAny": false, 22 | "strictBindCallApply": false, 23 | "forceConsistentCasingInFileNames": false, 24 | "noFallthroughCasesInSwitch": false, 25 | "paths": { 26 | "@modules/*": ["src/modules/*"], 27 | "@infra/*": ["src/infrastructure/*"], 28 | "@app/common": ["libs/common/src"], 29 | "@app/common/*": ["libs/common/src/*"], 30 | "@app/database": ["libs/database/src"], 31 | "@app/database/*": ["libs/database/src/*"], 32 | "@app/swagger": ["libs/swagger/src"], 33 | "@app/swagger/*": ["libs/swagger/src/*"] 34 | } 35 | }, 36 | "include": ["src/**/*.ts", "libs/**/*.ts", ".hygen/**/*.js", ".hygen.js"], 37 | "exclude": ["node_modules", "dist"] 38 | } 39 | --------------------------------------------------------------------------------