├── src ├── shared │ ├── utils │ │ ├── index.ts │ │ ├── promise.ts │ │ └── format.ts │ ├── cqrs │ │ ├── repository.base.ts │ │ ├── events │ │ │ ├── event.base.ts │ │ │ └── event-handler.base.ts │ │ ├── queries │ │ │ ├── query.base.ts │ │ │ └── query-handler.base.ts │ │ ├── commands │ │ │ ├── command-handler.base.ts │ │ │ └── command.base.ts │ │ ├── mappers │ │ │ ├── IMapper.ts │ │ │ └── mapper.base.ts │ │ ├── aggregate_root_base │ │ │ └── aggregate-root.base.ts │ │ ├── errors │ │ │ └── error.base.ts │ │ └── value-object.base.ts │ ├── exception │ │ ├── index.ts │ │ └── exception.resolver.ts │ ├── dtos │ │ ├── Query.dto.ts │ │ ├── Command.dto.ts │ │ ├── Pagination.dto.ts │ │ └── ObjectID.dto.ts │ ├── types │ │ ├── pagination.type.ts │ │ └── response-command.base.ts │ ├── models │ │ └── base.entity.ts │ ├── modules │ │ └── loggers │ │ │ ├── logger.module.ts │ │ │ └── logger.service.ts │ ├── decorators │ │ ├── custom.decorator.ts │ │ ├── auth.decorator.ts │ │ ├── frozen.decorator.ts │ │ ├── final.decorator.ts │ │ ├── mixin.decorators.ts │ │ └── swagger.decorator.ts │ └── guard.ts ├── infra │ ├── middleware │ │ ├── index.ts │ │ ├── unknown-exceptions.filter.ts │ │ └── http-exception.filter.ts │ ├── swagger │ │ ├── index.ts │ │ └── swagger.setup.ts │ ├── pipes │ │ └── validation.pipe.ts │ └── interceptors │ │ └── request-response.interceptor.ts ├── modules │ ├── article │ │ ├── dtos │ │ │ ├── ArticleResponse.dto.ts │ │ │ ├── CreateArticle.dto.ts │ │ │ └── ListArticle.dto.ts │ │ ├── controllers │ │ │ ├── index.ts │ │ │ ├── article-commands.controller.ts │ │ │ └── article-queries.controller.ts │ │ ├── cqrs │ │ │ ├── events │ │ │ │ ├── handlers │ │ │ │ │ ├── index.ts │ │ │ │ │ └── article-created.handler.ts │ │ │ │ └── impl │ │ │ │ │ └── article-created.event.ts │ │ │ ├── commands │ │ │ │ ├── handlers │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── saga.handler.ts │ │ │ │ │ └── create-article.handler.ts │ │ │ │ └── impl │ │ │ │ │ ├── saga.command.ts │ │ │ │ │ └── create-article.command.ts │ │ │ ├── queries │ │ │ │ ├── impl │ │ │ │ │ ├── find-single-article.query.ts │ │ │ │ │ └── find-many-article.query.ts │ │ │ │ └── handlers │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── find-single-article.handler.ts │ │ │ │ │ └── find-many-article.handler.ts │ │ │ └── sagas │ │ │ │ └── article.sagas.ts │ │ ├── domain │ │ │ ├── article.error.ts │ │ │ └── models │ │ │ │ ├── entities │ │ │ │ └── ArticleEntity.ts │ │ │ │ ├── schemas │ │ │ │ └── Article.schema.ts │ │ │ │ └── repositories │ │ │ │ └── Article.repository.ts │ │ ├── article.module.ts │ │ ├── mappers │ │ │ └── article.mapper.ts │ │ └── services │ │ │ └── article.service.ts │ ├── index.ts │ └── command │ │ ├── seeder.module.ts │ │ └── command.console.ts ├── app.service.ts ├── constants │ ├── error.constant.ts │ └── env.constant.ts ├── app.controller.ts ├── console.ts ├── config │ ├── database.module.ts │ └── config.module.ts ├── app.routes.ts ├── main.ts └── app.module.ts ├── .prettierrc ├── .commitlintrc.js ├── .husky ├── pre-commit └── commit-msg ├── tsconfig.build.json ├── .hintrc ├── nest-cli.json ├── .vscode └── settings.json ├── docker-compose.yml ├── .travis.yml ├── .github └── workflows │ ├── cd-dev.yaml │ └── ci.yaml ├── docker-compose-develop.yml ├── .env.example ├── Dockerfile ├── README.md ├── .gitignore ├── .eslintrc.js ├── tsconfig.json └── package.json /src/shared/utils/index.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/shared/cqrs/repository.base.ts: -------------------------------------------------------------------------------- 1 | export class BaseRepository {} 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /.commitlintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = {extends: ['@commitlint/config-conventional']}; 2 | -------------------------------------------------------------------------------- /src/shared/exception/index.ts: -------------------------------------------------------------------------------- 1 | export * from '@shared/exception/exception.resolver'; 2 | -------------------------------------------------------------------------------- /src/infra/middleware/index.ts: -------------------------------------------------------------------------------- 1 | export * from 'infra/middleware/http-exception.filter'; 2 | -------------------------------------------------------------------------------- /src/shared/dtos/Query.dto.ts: -------------------------------------------------------------------------------- 1 | export class BaseQueryDto { 2 | // todo: implement 3 | } 4 | -------------------------------------------------------------------------------- /src/shared/cqrs/events/event.base.ts: -------------------------------------------------------------------------------- 1 | export class BaseEvent { 2 | // todo: implement 3 | } 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /src/infra/swagger/index.ts: -------------------------------------------------------------------------------- 1 | export * from 'infra/swagger/swagger.setup'; 2 | export * from '@shared/decorators/swagger.decorator'; 3 | -------------------------------------------------------------------------------- /src/modules/article/dtos/ArticleResponse.dto.ts: -------------------------------------------------------------------------------- 1 | export class ArticleResponseDto { 2 | id: string; 3 | content: string; 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /.hintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "development" 4 | ], 5 | "hints": { 6 | "typescript-config/consistent-casing": "off" 7 | } 8 | } -------------------------------------------------------------------------------- /src/modules/article/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './article-queries.controller'; 2 | export * from './article-commands.controller'; 3 | -------------------------------------------------------------------------------- /src/shared/cqrs/queries/query.base.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Base class for regular queries 3 | */ 4 | export abstract class BaseQuery { 5 | // todo: implement 6 | } 7 | -------------------------------------------------------------------------------- /src/shared/types/pagination.type.ts: -------------------------------------------------------------------------------- 1 | export interface IPaginationMetadata { 2 | totalDocs: number; 3 | limit: number; 4 | page: number; 5 | totalPages: number; 6 | } 7 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return 'Hello World!'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/shared/models/base.entity.ts: -------------------------------------------------------------------------------- 1 | import { Document, ObjectId } from 'mongoose'; 2 | 3 | export type BaseDocument = BaseSchema & Document; 4 | 5 | export class BaseSchema { 6 | _id: ObjectId; 7 | } 8 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/modules/index.ts: -------------------------------------------------------------------------------- 1 | import { CommandModule } from '@modules/command/seeder.module'; 2 | import { ArticlesModule } from './article/article.module'; 3 | 4 | export const MODULES = [CommandModule, ArticlesModule]; 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Appender", 4 | "appenders", 5 | "cqrs", 6 | "dtos", 7 | "nestjs", 8 | "rawbody", 9 | "virtuals" 10 | ] 11 | } -------------------------------------------------------------------------------- /src/modules/article/cqrs/events/handlers/index.ts: -------------------------------------------------------------------------------- 1 | import { ArticleCreatedEventHandler } from './article-created.handler'; 2 | import { Provider } from '@nestjs/common'; 3 | 4 | export const EventHandlers: Provider[] = [ArticleCreatedEventHandler]; 5 | -------------------------------------------------------------------------------- /src/constants/error.constant.ts: -------------------------------------------------------------------------------- 1 | export const ErrorConstant = Object.freeze({ 2 | DEFAULT: { 3 | FAILED: 'FAILED', 4 | }, 5 | ARTICLE: { 6 | NOT_FOUND_ARTICLE: { 7 | MESSAGE: 'not found article', 8 | CODE: '0001', 9 | }, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /src/shared/types/response-command.base.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { AggregateID } from '../cqrs/aggregate_root_base/aggregate-root.base'; 3 | export class BaseResponseCommand { 4 | @ApiProperty({ type: String }) 5 | id: AggregateID; 6 | } 7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | cqrs: 4 | build: 5 | context: . 6 | dockerfile: ./Dockerfile 7 | container_name: cqrs 8 | env_file: 9 | - .env 10 | tty: true 11 | ports: 12 | - "${PORT}:${PORT}" 13 | restart: always -------------------------------------------------------------------------------- /src/modules/article/cqrs/commands/handlers/index.ts: -------------------------------------------------------------------------------- 1 | import { CreateArticleHandler } from './create-article.handler'; 2 | import { SagaHandler } from './saga.handler'; 3 | import { Provider } from '@nestjs/common'; 4 | 5 | export const CommandHandlers: Provider[] = [CreateArticleHandler, SagaHandler]; 6 | -------------------------------------------------------------------------------- /src/modules/article/cqrs/commands/impl/saga.command.ts: -------------------------------------------------------------------------------- 1 | import { CreateArticleDto } from '@modules/article/dtos/CreateArticle.dto'; 2 | import { ICommand } from '@nestjs/cqrs'; 3 | 4 | export class SagaCommand implements ICommand { 5 | constructor(public readonly articleDto: CreateArticleDto) {} 6 | } 7 | -------------------------------------------------------------------------------- /src/modules/command/seeder.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { CommandConsole } from '@modules/command/command.console'; 4 | 5 | @Module({ 6 | controllers: [], 7 | providers: [CommandConsole], 8 | exports: [], 9 | }) 10 | export class CommandModule {} 11 | -------------------------------------------------------------------------------- /src/shared/utils/promise.ts: -------------------------------------------------------------------------------- 1 | export async function delay(sec: number): Promise { 2 | return new Promise((resolve) => { 3 | setTimeout(resolve, sec * 1000); 4 | }); 5 | } 6 | 7 | export const sleep = (ms: number) => { 8 | return new Promise((resolve) => setTimeout(resolve, ms)); 9 | }; 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "16" 4 | services: 5 | - docker 6 | install: 7 | - npm install 8 | script: 9 | - npm test 10 | after_success: 11 | docker run -i --rm -v $(pwd):/app/source -v $(pwd)/.prettierrc:/app/.prettierrc hocptit/eslint-ci:0.0.1 "/app/source/{src,apps,libs,test}/**/*.ts"; 12 | -------------------------------------------------------------------------------- /src/modules/article/cqrs/queries/impl/find-single-article.query.ts: -------------------------------------------------------------------------------- 1 | import { IQuery } from '@nestjs/cqrs'; 2 | import { BaseQuery } from '@shared/cqrs/queries/query.base'; 3 | 4 | export class FindSingleArticleQuery extends BaseQuery implements IQuery { 5 | constructor(public readonly id: string) { 6 | super(); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/shared/dtos/Command.dto.ts: -------------------------------------------------------------------------------- 1 | // import { ApiProperty } from '@nestjs/swagger'; 2 | // import { CommandMetadata } from '@shared/cqrs/commands/command.base'; 3 | // import { IsOptional } from 'class-validator'; 4 | export class CommandDto { 5 | // @ApiProperty() 6 | // @IsOptional() 7 | // metadata?: CommandMetadata; 8 | } 9 | -------------------------------------------------------------------------------- /src/modules/article/dtos/CreateArticle.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { CommandDto } from '@shared/dtos/Command.dto'; 3 | import { IsString } from 'class-validator'; 4 | 5 | export class CreateArticleDto extends CommandDto { 6 | @ApiProperty() 7 | @IsString() 8 | content: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/modules/loggers/logger.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | 3 | import { LoggerService } from '@shared/modules/loggers/logger.service'; 4 | 5 | @Global() 6 | @Module({ 7 | imports: [], 8 | providers: [LoggerService], 9 | exports: [LoggerService], 10 | }) 11 | export class LoggingModule {} 12 | -------------------------------------------------------------------------------- /src/shared/decorators/custom.decorator.ts: -------------------------------------------------------------------------------- 1 | import rawbody from 'raw-body'; 2 | import { createParamDecorator } from '@nestjs/common'; 3 | 4 | export const PlainBody = createParamDecorator(async (data, context) => { 5 | const req = context.switchToHttp().getRequest(); 6 | return req?.readable ? (await rawbody(req)).toString().trim() : null; 7 | }); 8 | -------------------------------------------------------------------------------- /src/shared/utils/format.ts: -------------------------------------------------------------------------------- 1 | import { IResponse } from 'infra/interceptors/request-response.interceptor'; 2 | import moment from 'moment'; 3 | 4 | export function formatResponseSuccess(response: IResponse) { 5 | return response; 6 | } 7 | 8 | export function getUnixTimestamp(date = new Date()) { 9 | return moment(date).unix(); 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/article/cqrs/queries/handlers/index.ts: -------------------------------------------------------------------------------- 1 | import { FindSingleArticleHandler } from './find-single-article.handler'; 2 | import { FindManyArticlesQueryHandler } from './find-many-article.handler'; 3 | import { Provider } from '@nestjs/common'; 4 | 5 | export const QueryHandlers: Provider[] = [ 6 | FindSingleArticleHandler, 7 | FindManyArticlesQueryHandler, 8 | ]; 9 | -------------------------------------------------------------------------------- /.github/workflows/cd-dev.yaml: -------------------------------------------------------------------------------- 1 | name: deploy develop branch 2 | run-name: ${{ github.actor }} is deploying develop branch 3 | on: 4 | push: 5 | branches: 6 | - 'develop' 7 | jobs: 8 | deploy-dev: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | - run: | 14 | echo "Deploy script" 15 | -------------------------------------------------------------------------------- /src/shared/cqrs/events/event-handler.base.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LoggerService, 3 | LoggerPort, 4 | } from '@shared/modules/loggers/logger.service'; 5 | 6 | export class BaseEventHandler { 7 | protected logger: LoggerPort; 8 | constructor(protected loggerService: LoggerService, eventName: string) { 9 | this.logger = new LoggerPort(this.loggerService.getLogger(eventName)); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/command/command.console.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Command, Console } from 'nestjs-console'; 3 | 4 | @Console() 5 | @Injectable() 6 | export class CommandConsole { 7 | @Command({ 8 | command: 'command-data', 9 | description: 'command pool data', 10 | }) 11 | async handle(): Promise { 12 | console.log('command...'); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/shared/cqrs/queries/query-handler.base.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LoggerService, 3 | LoggerPort, 4 | } from '@shared/modules/loggers/logger.service'; 5 | 6 | export class BaseQueryHandler { 7 | protected logger: LoggerPort; 8 | constructor(protected loggerService: LoggerService, commandName: string) { 9 | this.logger = new LoggerPort(this.loggerService.getLogger(commandName)); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/shared/cqrs/commands/command-handler.base.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LoggerService, 3 | LoggerPort, 4 | } from '@shared/modules/loggers/logger.service'; 5 | 6 | export class BaseCommandHandler { 7 | protected logger: LoggerPort; 8 | constructor(protected loggerService: LoggerService, commandName: string) { 9 | this.logger = new LoggerPort(this.loggerService.getLogger(commandName)); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/article/cqrs/events/impl/article-created.event.ts: -------------------------------------------------------------------------------- 1 | import { IEvent } from '@nestjs/cqrs'; 2 | import { BaseEvent } from '@shared/cqrs/events/event.base'; 3 | import { CreateArticleDto } from '../../../dtos/CreateArticle.dto'; 4 | 5 | export class ArticleCreatedEvent extends BaseEvent implements IEvent { 6 | constructor(public readonly articleDto: CreateArticleDto) { 7 | super(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/modules/article/cqrs/queries/impl/find-many-article.query.ts: -------------------------------------------------------------------------------- 1 | import { ListArticleDto } from '@modules/article/dtos/ListArticle.dto'; 2 | import { IQuery } from '@nestjs/cqrs'; 3 | import { BaseQuery } from '@shared/cqrs/queries/query.base'; 4 | 5 | export class FindManyArticlesQuery extends BaseQuery implements IQuery { 6 | constructor(public listArticleDto: ListArticleDto) { 7 | super(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /docker-compose-develop.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | cqrs-server: 4 | container_name: cqrs-server 5 | image: cqrs-server 6 | env_file: 7 | - .env 8 | tty: true 9 | ports: 10 | - "${PORT}:${PORT}" 11 | restart: always 12 | # volumes: 13 | # - .:/app 14 | # - /app/node_modules 15 | networks: 16 | - cqrs-net 17 | 18 | networks: 19 | cqrs-net: 20 | external: true -------------------------------------------------------------------------------- /src/shared/decorators/auth.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 3 | 4 | export const PublicApi = () => SetMetadata('PUBLIC_KEY', true); 5 | export const CurrentUser = createParamDecorator( 6 | (data: any, ctx: ExecutionContext) => { 7 | const req = ctx.switchToHttp().getRequest(); 8 | return req.owner; 9 | }, 10 | ); 11 | -------------------------------------------------------------------------------- /src/modules/article/cqrs/commands/impl/create-article.command.ts: -------------------------------------------------------------------------------- 1 | import { CreateArticleDto } from '@modules/article/dtos/CreateArticle.dto'; 2 | import { ICommand } from '@nestjs/cqrs'; 3 | import { BaseCommand } from '@shared/cqrs/commands/command.base'; 4 | 5 | export class CreateArticleCommand extends BaseCommand implements ICommand { 6 | constructor(public readonly articleDto: CreateArticleDto) { 7 | super(articleDto); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/decorators/frozen.decorator.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | /** 3 | * Applies Object.freeze() to a class and it's prototype. 4 | * Does not freeze all the properties of a class created 5 | * using 'new' keyword, only static properties and prototype 6 | * of a class. 7 | */ 8 | export function Frozen(constructor: Function): void { 9 | Object.freeze(constructor); 10 | Object.freeze(constructor.prototype); 11 | } 12 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | 3 | PORT=5000 4 | #SWAGGER 5 | SWAGGER_PATH=/docs 6 | SWAGGER_IS_PUBLIC=true 7 | SWAGGER_HOST=http://localhost:5000 8 | SWAGGER_VERSION=1.0.0 9 | SWAGGER_TITLE='Document API' 10 | SWAGGER_DESC='Document API for dev' 11 | 12 | # Database 13 | MONGO_URI= 14 | 15 | # Log 16 | LOG_LEVEL=info 17 | 18 | 19 | #sentry, able set SENTRY_DSN=null, not error 20 | SENTRY_DSN= 21 | SENTRY_DSN_DEBUG=false 22 | SENTRY_DSN_ENABLED=false -------------------------------------------------------------------------------- /src/modules/article/domain/article.error.ts: -------------------------------------------------------------------------------- 1 | import { ErrorConstant } from '../../../constants/error.constant'; 2 | import { BaseError } from '@shared/cqrs/errors/error.base'; 3 | export class ENotFoundArticle extends BaseError { 4 | code: string; 5 | static code = ErrorConstant.ARTICLE.NOT_FOUND_ARTICLE.CODE; 6 | static readonly message = ErrorConstant.ARTICLE.NOT_FOUND_ARTICLE.MESSAGE; 7 | constructor() { 8 | super(ENotFoundArticle.message); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | 3 | import { ErrorConstant } from '@constants/error.constant'; 4 | 5 | import { AppService } from './app.service'; 6 | 7 | @Controller() 8 | export class AppController { 9 | constructor(private readonly appService: AppService) {} 10 | 11 | @Get() 12 | getHello(): string { 13 | return this.appService.getHello(); 14 | } 15 | @Get('/error') 16 | getConstant() { 17 | return ErrorConstant; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18 AS dist 2 | COPY package.json yarn.lock ./ 3 | 4 | RUN yarn install 5 | 6 | COPY . ./ 7 | 8 | RUN yarn build:prod 9 | 10 | FROM node:18 AS node_modules 11 | COPY package.json yarn.lock ./ 12 | 13 | RUN yarn install --prod 14 | 15 | FROM node:18 16 | 17 | ARG PORT=3000 18 | 19 | RUN mkdir -p /usr/src/app 20 | 21 | WORKDIR /usr/src/app 22 | 23 | COPY --from=dist dist /usr/src/app/dist 24 | COPY --from=node_modules node_modules /usr/src/app/node_modules 25 | 26 | COPY . /usr/src/app 27 | 28 | EXPOSE $PORT 29 | 30 | CMD [ "yarn", "start:prod" ] 31 | -------------------------------------------------------------------------------- /src/shared/decorators/final.decorator.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | 4 | /** 5 | * Prevents other classes extending a class marked by this decorator. 6 | */ 7 | export function Final( 8 | target: T, 9 | ): T { 10 | return class Final extends target { 11 | constructor(...args: any[]) { 12 | if (new.target !== Final) { 13 | throw new Error(`Cannot extend a final class "${target.name}"`); 14 | } 15 | super(...args); 16 | } 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /src/constants/env.constant.ts: -------------------------------------------------------------------------------- 1 | export enum EEnvKey { 2 | NODE_ENV = 'NODE_ENV', 3 | PORT = 'PORT', 4 | HOST_ADDRESS = 'HOST_ADDRESS', 5 | SWAGGER_PATH = 'SWAGGER_PATH', 6 | SWAGGER_IS_PUBLIC = 'SWAGGER_IS_PUBLIC', 7 | SWAGGER_HOST = 'SWAGGER_HOST', 8 | SWAGGER_VERSION = 'SWAGGER_VERSION', 9 | SWAGGER_TITLE = 'SWAGGER_TITLE', 10 | SWAGGER_DESC = 'SWAGGER_DESC', 11 | MONGO_URI = 'MONGO_URI', 12 | IS_WRITE_LOG = 'IS_WRITE_LOG', 13 | LOG_LEVEL = 'LOG_LEVEL', 14 | 15 | SENTRY_DSN = 'SENTRY_DSN', 16 | SENTRY_DSN_DEBUG = 'SENTRY_DSN_DEBUG', 17 | SENTRY_DSN_ENABLED = 'SENTRY_DSN_ENABLED', 18 | } 19 | -------------------------------------------------------------------------------- /src/shared/dtos/Pagination.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsInt, Min } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class PaginationDto { 5 | @Min(1) 6 | @IsInt() 7 | @ApiProperty({ required: true }) 8 | limit?: number; 9 | 10 | @Min(1) 11 | @IsInt() 12 | @ApiProperty({ required: true }) 13 | page?: number; 14 | } 15 | 16 | export const getPaginationOptions = ( 17 | { limit = 5, page = 1 }: PaginationDto, 18 | sort: { sortBy: string; direction: string }, 19 | ) => ({ 20 | sort: { [sort.sortBy]: sort.direction === 'asc' ? 1 : -1 }, 21 | limit, 22 | page, 23 | }); 24 | -------------------------------------------------------------------------------- /src/console.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | import { BootstrapConsole } from 'nestjs-console'; 3 | 4 | import { AppModule } from './app.module'; 5 | 6 | dotenv.config(); 7 | 8 | const bootstrap = new BootstrapConsole({ 9 | module: AppModule, 10 | useDecorators: true, 11 | contextOptions: { 12 | logger: false, 13 | }, 14 | }); 15 | bootstrap.init().then(async (app) => { 16 | try { 17 | await app.init(); 18 | await bootstrap.boot(); 19 | await app.close(); 20 | process.exit(0); 21 | } catch (e) { 22 | console.error(e); 23 | await app.close(); 24 | process.exit(1); 25 | } 26 | }); 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ## Documentation [read here](https://sotatek.notion.site/nest-cqrs-boilerplate-d3837f47619c44bbb4e680824aa9f665) 3 | ## Overview 4 | Project auto deploy in Heroku. 5 | ![CodeRabbit Pull Request Reviews](https://img.shields.io/coderabbit/prs/github/hocptit/nest-boilerplate-cqrs?utm_source=oss&utm_medium=github&utm_campaign=hocptit%2Fnest-boilerplate-cqrs&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews) 6 | The project applies the design pattern DDD (using CQRS). See details in the document (link above). 7 | 8 | 9 | ## Stay in touch 10 | 11 | - Author - nguyenthaihoc.dev@gmail.com 12 | 13 | -------------------------------------------------------------------------------- /src/config/database.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { MongooseModule } from '@nestjs/mongoose'; 4 | 5 | import { EEnvKey } from '@constants/env.constant'; 6 | 7 | @Module({ 8 | imports: [ 9 | MongooseModule.forRootAsync({ 10 | imports: [ConfigModule], 11 | inject: [ConfigService], 12 | useFactory: async (configService: ConfigService) => { 13 | const uri = configService.get(EEnvKey.MONGO_URI); 14 | return { 15 | uri, 16 | }; 17 | }, 18 | }), 19 | ], 20 | }) 21 | export class DatabaseModule {} 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | .env 38 | #yarn.lock 39 | package-lock.json 40 | /develop/bull-monitor/node_modules/ 41 | assets/nft_generate -------------------------------------------------------------------------------- /src/modules/article/dtos/ListArticle.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger'; 2 | import { BaseQuery } from '@shared/cqrs/queries/query.base'; 3 | import { SafeMongoIdTransform } from '@shared/dtos/ObjectID.dto'; 4 | import { Transform } from 'class-transformer'; 5 | import { IsMongoId, IsOptional, IsString } from 'class-validator'; 6 | export class ListArticleDto extends BaseQuery { 7 | @IsMongoId() 8 | @IsString() 9 | @ApiPropertyOptional() 10 | @IsOptional() 11 | @Transform((value) => SafeMongoIdTransform(value)) 12 | author: string; 13 | 14 | @ApiPropertyOptional() 15 | @IsString() 16 | @IsOptional() 17 | content: string; 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: validate-lint-when make pull request 2 | run-name: ${{ github.actor }} is validating eslint. 3 | 4 | on: 5 | pull_request: 6 | branches: 7 | - 'develop' 8 | - 'main' 9 | 10 | 11 | jobs: 12 | validate-lint: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | # - uses: docker 18 | - name: Using docker to check eslint 19 | run: | 20 | echo "Start lint...." 21 | docker run -i --rm -v $(pwd):/app/source -v $(pwd)/.prettierrc:/app/.prettierrc hocptit/eslint-ci:0.0.1 "/app/source/{src,apps,libs,test}/**/*.ts" 22 | echo "Lint done." -------------------------------------------------------------------------------- /src/app.routes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Application routes with its version 3 | */ 4 | 5 | // Root 6 | const articleRoot = 'article'; 7 | 8 | // Api Versions 9 | function v1(route) { 10 | return `/v1/${route}`; 11 | } 12 | export const routesV1 = { 13 | article: { 14 | root: v1(articleRoot), 15 | commands: { 16 | createArticle: { 17 | route: '', 18 | summary: 'Create Article', 19 | }, 20 | }, 21 | queries: { 22 | getArticleById: { 23 | route: ':id', 24 | summary: 'Get Article By Id', 25 | }, 26 | getAll: { 27 | route: '', 28 | summary: 'Get All Articles', 29 | }, 30 | }, 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /src/modules/article/cqrs/sagas/article.sagas.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ICommand, ofType, Saga } from '@nestjs/cqrs'; 3 | import { Observable } from 'rxjs'; 4 | import { delay, map } from 'rxjs/operators'; 5 | import { ArticleCreatedEvent } from '../events/impl/article-created.event'; 6 | import { SagaCommand } from '../commands/impl/saga.command'; 7 | 8 | @Injectable() 9 | export class ArticleSagas { 10 | @Saga() 11 | dragonKilled = (events$: Observable): Observable => { 12 | return events$.pipe( 13 | ofType(ArticleCreatedEvent), 14 | delay(1000), 15 | map((event) => { 16 | return new SagaCommand(event.articleDto); 17 | }), 18 | ); 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /src/modules/article/domain/models/entities/ArticleEntity.ts: -------------------------------------------------------------------------------- 1 | import { ArticleCreatedEvent } from '@modules/article/cqrs/events/impl/article-created.event'; 2 | import { BaseAggregateRoot } from '@shared/cqrs/aggregate_root_base/aggregate-root.base'; 3 | import { ArticleDocument, ArticleSchema } from '../schemas/Article.schema'; 4 | import { ObjectId } from 'mongoose'; 5 | import { CreateArticleDto } from '../../../dtos/CreateArticle.dto'; 6 | export class ArticleEntity extends BaseAggregateRoot< 7 | ArticleSchema, 8 | ArticleDocument 9 | > { 10 | constructor(id?: ObjectId, document?: ArticleDocument) { 11 | super(id, document); 12 | } 13 | createdArticle(dto: CreateArticleDto) { 14 | this.apply(new ArticleCreatedEvent(dto)); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/article/cqrs/commands/handlers/saga.handler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; 2 | // import { CreateArticleCommand } from '../impl/create-article.command'; 3 | // import ArticleRepository from '@models/repositories/Article.repository'; 4 | // import { ArticleEntity } from '@models/schemas/ArticleRoot'; 5 | import { SagaCommand } from '../impl/saga.command'; 6 | 7 | @CommandHandler(SagaCommand) 8 | export class SagaHandler implements ICommandHandler { 9 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 10 | // @ts-ignore 11 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 12 | async execute(_command: SagaCommand) { 13 | console.log('SagaHandler'); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/config/config.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import * as Joi from 'joi'; 4 | 5 | import { EEnvKey } from '@constants/env.constant'; 6 | 7 | @Global() 8 | @Module({ 9 | imports: [ 10 | ConfigModule.forRoot({ 11 | envFilePath: `.env`, 12 | validationSchema: Joi.object({ 13 | // todo: add validation 14 | [EEnvKey.NODE_ENV]: Joi.string().valid('development', 'production'), 15 | [EEnvKey.PORT]: Joi.number().default(3000), 16 | [EEnvKey.SWAGGER_PATH]: Joi.string(), 17 | }), 18 | load: [], 19 | }), 20 | ], 21 | providers: [ConfigService], 22 | exports: [ConfigService], 23 | }) 24 | export class ConfigurationModule {} 25 | -------------------------------------------------------------------------------- /src/modules/article/domain/models/schemas/Article.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | 3 | import { Prop } from 'infra/swagger'; 4 | import { BaseDocument, BaseSchema } from '@shared/models/base.entity'; 5 | 6 | export type ArticleDocument = ArticleSchema & BaseDocument; 7 | 8 | @Schema({ 9 | timestamps: { 10 | createdAt: 'created_at', 11 | updatedAt: 'updated_at', 12 | }, 13 | versionKey: false, 14 | virtuals: true, 15 | collection: 'article_schema', 16 | }) 17 | export class ArticleSchema extends BaseSchema { 18 | @Prop({ default: 'This is content' }) 19 | content: string; 20 | 21 | @Prop({ default: '' }) 22 | author: string; 23 | } 24 | 25 | export const ArticleSchemaInstance = 26 | SchemaFactory.createForClass(ArticleSchema); 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./src", 13 | "incremental": true, 14 | "allowJs": true, 15 | "resolveJsonModule": true, 16 | "paths": { 17 | "@config/*": ["config/*"], 18 | "@constants/*": ["constants/*"], 19 | "@models/*": ["models/*"], 20 | "@repo/*": ["repositories/*"], 21 | "@shared/*": ["shared/*"], 22 | "@modules/*": ["modules/*"] 23 | }, 24 | "esModuleInterop": true, 25 | }, 26 | "include": ["src/**/*"], 27 | "exclude": ["node_modules", "dist"] 28 | } 29 | -------------------------------------------------------------------------------- /src/shared/dtos/ObjectID.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Transform } from 'class-transformer'; 3 | import { IsMongoId, IsString } from 'class-validator'; 4 | import { Types } from 'mongoose'; 5 | import { BadRequestException } from '@shared/exception'; 6 | export const SafeMongoIdTransform = ({ value }) => { 7 | try { 8 | if ( 9 | Types.ObjectId.isValid(value) && 10 | new Types.ObjectId(value).toString() === value 11 | ) { 12 | return value; 13 | } 14 | throw new BadRequestException({ message: 'Id validation fail' }); 15 | } catch (error) { 16 | throw new BadRequestException({ message: error.message }); 17 | } 18 | }; 19 | export class ObjectIDDto { 20 | @IsMongoId() 21 | @IsString() 22 | @ApiProperty({ required: true }) 23 | @Transform((value) => SafeMongoIdTransform(value)) 24 | id: string; 25 | } 26 | -------------------------------------------------------------------------------- /src/shared/cqrs/mappers/IMapper.ts: -------------------------------------------------------------------------------- 1 | import { BaseDocument, BaseSchema } from '@shared/models/base.entity'; 2 | import { BaseAggregateRoot } from '../aggregate_root_base/aggregate-root.base'; 3 | 4 | export interface IMapper< 5 | Schema extends BaseSchema, 6 | TEntity extends BaseAggregateRoot, 7 | Document = Schema & BaseDocument, 8 | Response = any, 9 | > { 10 | // similar ORM 11 | toPersistence(entity: TEntity): Document; 12 | 13 | // Entity in DDD 14 | toDomain(record: Document): TEntity; 15 | 16 | // Response to client (if necessary) 17 | toResponse(entity: TEntity): Response; 18 | 19 | // similar ORM 20 | toPersistencies(entities: TEntity[]): Document[]; 21 | 22 | // Entity in DDD 23 | toDomains(records: Document[]): TEntity[]; 24 | 25 | // Response to client (if necessary) 26 | toResponses(entities: TEntity[]): Response[]; 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/article/domain/models/repositories/Article.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { Model } from 'mongoose'; 4 | 5 | import { ArticleDocument } from '../schemas/Article.schema'; 6 | import { BaseRepository } from '@shared/cqrs/repository.base'; 7 | import { ArticleMapper } from '../../../mappers/article.mapper'; 8 | import { ArticleSchema } from '../schemas/Article.schema'; 9 | 10 | @Injectable() 11 | export default class ArticleRepository extends BaseRepository { 12 | constructor( 13 | @InjectModel(ArticleSchema.name) 14 | public articleDocumentModel: Model, 15 | public mapper: ArticleMapper, 16 | ) { 17 | super(); 18 | } 19 | findArticle(id: string): Promise { 20 | return this.articleDocumentModel.findOne({ _id: id }).exec(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/shared/cqrs/aggregate_root_base/aggregate-root.base.ts: -------------------------------------------------------------------------------- 1 | import { AggregateRoot } from '@nestjs/cqrs'; 2 | import { BaseDocument } from '@shared/models/base.entity'; 3 | import { ObjectId } from 'mongoose'; 4 | import { BaseSchema } from '../../models/base.entity'; 5 | 6 | export type AggregateID = ObjectId; 7 | export class BaseAggregateRoot< 8 | Schema extends BaseSchema, 9 | TDocument = Schema & BaseDocument, 10 | > extends AggregateRoot { 11 | // todo: don't using public document, should use private field, apply get,set method for each field 12 | public document: TDocument; 13 | public id: AggregateID; 14 | constructor(id?: AggregateID | undefined, document?: TDocument | undefined) { 15 | super(); 16 | this.document = document; 17 | this.id = id; 18 | } 19 | 20 | setId(id: AggregateID) { 21 | this.id = id; 22 | return this; 23 | } 24 | 25 | setData(document: TDocument) { 26 | this.document = document; 27 | return this; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/modules/article/cqrs/events/handlers/article-created.handler.ts: -------------------------------------------------------------------------------- 1 | import { IEventHandler, EventsHandler } from '@nestjs/cqrs'; 2 | import { ArticleCreatedEvent } from '../impl/article-created.event'; 3 | import ArticleRepository from '@modules/article/domain/models/repositories/Article.repository'; 4 | import { BaseEventHandler } from '../../../../../shared/cqrs/events/event-handler.base'; 5 | import { LoggerService } from '@shared/modules/loggers/logger.service'; 6 | 7 | @EventsHandler(ArticleCreatedEvent) 8 | export class ArticleCreatedEventHandler 9 | extends BaseEventHandler 10 | implements IEventHandler 11 | { 12 | constructor( 13 | private readonly articleRepository: ArticleRepository, 14 | protected loggerService: LoggerService, 15 | ) { 16 | super(loggerService, ArticleCreatedEventHandler.name); 17 | } 18 | 19 | async handle(event: ArticleCreatedEvent) { 20 | console.log(event); 21 | this.logger.info('ArticleCreatedEventHandler'); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/shared/cqrs/errors/error.base.ts: -------------------------------------------------------------------------------- 1 | export interface SerializedError { 2 | message: string; 3 | code: string; 4 | stack?: string; 5 | cause?: string; 6 | metadata?: unknown; 7 | } 8 | 9 | /** 10 | * Base class for custom exceptions. 11 | * 12 | * @abstract 13 | * @class ExceptionBase 14 | * @extends {Error} 15 | */ 16 | export abstract class BaseError extends Error { 17 | abstract code: string; 18 | 19 | public readonly correlationId: string; 20 | 21 | /** 22 | * @param {string} message 23 | * @param {ObjectLiteral} [metadata={}] 24 | */ 25 | constructor( 26 | readonly message: string, 27 | readonly cause?: Error, 28 | readonly metadata?: unknown, 29 | ) { 30 | super(message); 31 | Error.captureStackTrace(this, this.constructor); 32 | } 33 | toJSON(): SerializedError { 34 | return { 35 | message: this.message, 36 | code: this.code, 37 | stack: this.stack, 38 | cause: JSON.stringify(this.cause), 39 | metadata: this.metadata, 40 | }; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/modules/article/controllers/article-commands.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, UsePipes, ValidationPipe } from '@nestjs/common'; 2 | import { ArticlesService } from '../services/article.service'; 3 | import { CreateArticleDto } from '../dtos/CreateArticle.dto'; 4 | import { routesV1 } from '../../../app.routes'; 5 | import { Controller, Post } from '@shared/decorators/mixin.decorators'; 6 | import { ApiOkResponsePayload, EApiOkResponsePayload } from 'infra/swagger'; 7 | import { BaseResponseCommand } from '../../../shared/types/response-command.base'; 8 | 9 | @Controller(routesV1.article.root) 10 | @UsePipes(new ValidationPipe()) 11 | export class ArticleCommandsController { 12 | constructor(private readonly ordersService: ArticlesService) {} 13 | 14 | @Post(routesV1.article.commands.createArticle.route, { 15 | summary: routesV1.article.commands.createArticle.summary, 16 | }) 17 | @ApiOkResponsePayload(BaseResponseCommand, EApiOkResponsePayload.OBJECT) 18 | async createArticle(@Body() articleDto: CreateArticleDto) { 19 | return this.ordersService.createArticle(articleDto); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/infra/swagger/swagger.setup.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 3 | 4 | import { EEnvKey } from '@constants/env.constant'; 5 | 6 | export function initSwagger(app, config: ConfigService) { 7 | const swaggerConfig = { 8 | isPublic: config.get(EEnvKey.SWAGGER_IS_PUBLIC) === 'true', 9 | title: config.get(EEnvKey.SWAGGER_TITLE), 10 | description: config.get(EEnvKey.SWAGGER_DESC), 11 | version: config.get(EEnvKey.SWAGGER_VERSION), 12 | server: config.get(EEnvKey.SWAGGER_HOST), 13 | }; 14 | if (!swaggerConfig.isPublic) return; 15 | 16 | const configSwagger = new DocumentBuilder() 17 | .setTitle(swaggerConfig.title) 18 | .setDescription(swaggerConfig.description) 19 | .setVersion(swaggerConfig.version) 20 | .addServer(swaggerConfig.server, 'Host') 21 | .setExternalDoc('Postman Collection', '/docs-json') 22 | .addBearerAuth() 23 | .build(); 24 | 25 | const document = SwaggerModule.createDocument(app, configSwagger); 26 | SwaggerModule.setup('/docs', app, document); 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/article/cqrs/queries/handlers/find-single-article.handler.ts: -------------------------------------------------------------------------------- 1 | import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; 2 | import { Types } from 'mongoose'; 3 | import { FindSingleArticleQuery } from '../impl/find-single-article.query'; 4 | import ArticleRepository from 'modules/article/domain/models/repositories/Article.repository'; 5 | import { Err, Ok, Result } from 'oxide.ts'; 6 | import { ENotFoundArticle } from '@modules/article/domain/article.error'; 7 | import { ArticleDocument } from '../../../domain/models/schemas/Article.schema'; 8 | 9 | @QueryHandler(FindSingleArticleQuery) 10 | export class FindSingleArticleHandler 11 | implements IQueryHandler 12 | { 13 | constructor(private readonly articleRepository: ArticleRepository) {} 14 | 15 | async execute( 16 | query: FindSingleArticleQuery, 17 | ): Promise> { 18 | const { id } = query; 19 | 20 | const article = await this.articleRepository.articleDocumentModel.findOne( 21 | new Types.ObjectId(id as any as string), 22 | ); 23 | if (!article) { 24 | return Err(new ENotFoundArticle()); 25 | } 26 | return Ok(article); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/shared/cqrs/commands/command.base.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from 'uuid'; 2 | import { IsNumber, IsOptional, IsString } from 'class-validator'; 3 | import { ApiProperty } from '@nestjs/swagger'; 4 | 5 | export type CommandProps = Omit & Partial; 6 | 7 | export class CommandMetadata { 8 | /** ID for correlation purposes (for commands that 9 | * arrive from other microservices,logs correlation, etc). */ 10 | @IsString() 11 | @ApiProperty() 12 | @IsOptional() 13 | readonly correlationId: string; 14 | 15 | /** 16 | * Time when the command occurred. Mostly for tracing purposes 17 | */ 18 | @IsNumber() 19 | @ApiProperty() 20 | @IsOptional() 21 | readonly timestamp: number; 22 | } 23 | 24 | export class BaseCommand { 25 | /** 26 | * Command id, in case if we want to save it 27 | * for auditing purposes and create a correlation/causation chain 28 | */ 29 | readonly id: string; 30 | 31 | readonly metadata: CommandMetadata; 32 | 33 | constructor(props: CommandProps) { 34 | this.id = props.id || v4(); 35 | this.metadata = { 36 | correlationId: props?.metadata?.correlationId, 37 | timestamp: props?.metadata?.timestamp || Date.now(), 38 | }; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/modules/article/cqrs/commands/handlers/create-article.handler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; 2 | import { CreateArticleCommand } from '../impl/create-article.command'; 3 | import ArticleRepository from 'modules/article/domain/models/repositories/Article.repository'; 4 | import { LoggerService } from '@shared/modules/loggers/logger.service'; 5 | import { BaseCommandHandler } from '@shared/cqrs/commands/command-handler.base'; 6 | import { ArticleDocument } from '../../../domain/models/schemas/Article.schema'; 7 | import { Ok, Result } from 'oxide.ts'; 8 | 9 | @CommandHandler(CreateArticleCommand) 10 | export class CreateArticleHandler 11 | extends BaseCommandHandler 12 | implements ICommandHandler 13 | { 14 | constructor( 15 | protected readonly articleRepository: ArticleRepository, 16 | protected loggerService: LoggerService, 17 | ) { 18 | super(loggerService, CreateArticleHandler.name); 19 | } 20 | async execute(command: CreateArticleCommand): Promise> { 21 | const { articleDto } = command; 22 | const articleCreated: ArticleDocument = 23 | await this.articleRepository.articleDocumentModel.create(articleDto); 24 | const articleEntity = 25 | this.articleRepository.mapper.toDomain(articleCreated); 26 | articleEntity.createdArticle(articleDto); 27 | articleEntity.commit(); 28 | return Ok(articleCreated._id); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/modules/article/controllers/article-queries.controller.ts: -------------------------------------------------------------------------------- 1 | import { Param, Query, UsePipes, ValidationPipe } from '@nestjs/common'; 2 | import { ArticlesService } from '../services/article.service'; 3 | import { ObjectIDDto } from '@shared/dtos/ObjectID.dto'; 4 | import { routesV1 } from 'app.routes'; 5 | import { Controller, Get, List } from '@shared/decorators/mixin.decorators'; 6 | import { ApiOkResponsePayload, EApiOkResponsePayload } from 'infra/swagger'; 7 | import { ArticleSchema } from '../domain/models/schemas/Article.schema'; 8 | import { ListArticleDto } from '../dtos/ListArticle.dto'; 9 | 10 | @Controller(routesV1.article.root) 11 | @UsePipes(new ValidationPipe()) 12 | export class ArticleQueriesController { 13 | constructor(private readonly articleService: ArticlesService) {} 14 | @List(routesV1.article.queries.getAll.route, { 15 | summary: routesV1.article.queries.getAll.summary, 16 | }) 17 | @ApiOkResponsePayload(ArticleSchema, EApiOkResponsePayload.ARRAY) 18 | async findArticles(@Query() listArticleDto: ListArticleDto) { 19 | return this.articleService.findAll(listArticleDto); 20 | } 21 | 22 | @Get(routesV1.article.queries.getArticleById.route, { 23 | summary: routesV1.article.queries.getArticleById.summary, 24 | }) 25 | @ApiOkResponsePayload(ArticleSchema, EApiOkResponsePayload.OBJECT) 26 | async findArticleById(@Param() params: ObjectIDDto) { 27 | return this.articleService.findById(params.id); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/infra/middleware/unknown-exceptions.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentsHost, 3 | Catch, 4 | ExceptionFilter, 5 | HttpStatus, 6 | } from '@nestjs/common'; 7 | 8 | import { IResponse } from 'infra/interceptors/request-response.interceptor'; 9 | import { LoggerService } from '@shared/modules/loggers/logger.service'; 10 | 11 | @Catch() 12 | export class UnknownExceptionsFilter implements ExceptionFilter { 13 | constructor(private readonly loggingService: LoggerService) {} 14 | 15 | private logger = this.loggingService.getLogger('internal-exception'); 16 | 17 | catch(exception: unknown, host: ArgumentsHost) { 18 | const ctx = host.switchToHttp(); 19 | const response = ctx.getResponse(); 20 | this.logger.error(exception); 21 | 22 | const defaultResponse: IResponse = { 23 | data: null, 24 | validatorErrors: [], 25 | statusCode: HttpStatus.INTERNAL_SERVER_ERROR, 26 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 27 | // @ts-ignore 28 | message: 29 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 30 | // @ts-ignore 31 | typeof exception === 'object' && exception?.message 32 | ? // eslint-disable-next-line @typescript-eslint/ban-ts-comment 33 | // @ts-ignore 34 | exception?.message 35 | : 'internal exception', 36 | success: false, 37 | }; 38 | response.status(defaultResponse.statusCode).json(defaultResponse); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/shared/guard.ts: -------------------------------------------------------------------------------- 1 | export class Guard { 2 | /** 3 | * Checks if value is empty. Accepts strings, numbers, booleans, objects and arrays. 4 | */ 5 | static isEmpty(value: unknown): boolean { 6 | if (typeof value === 'number' || typeof value === 'boolean') { 7 | return false; 8 | } 9 | if (typeof value === 'undefined' || value === null) { 10 | return true; 11 | } 12 | if (value instanceof Date) { 13 | return false; 14 | } 15 | if (value instanceof Object && !Object.keys(value).length) { 16 | return true; 17 | } 18 | if (Array.isArray(value)) { 19 | if (value.length === 0) { 20 | return true; 21 | } 22 | if (value.every((item) => Guard.isEmpty(item))) { 23 | return true; 24 | } 25 | } 26 | if (value === '') { 27 | return true; 28 | } 29 | 30 | return false; 31 | } 32 | 33 | /** 34 | * Checks length range of a provided number/string/array 35 | */ 36 | static lengthIsBetween( 37 | value: number | string | Array, 38 | min: number, 39 | max: number, 40 | ): boolean { 41 | if (Guard.isEmpty(value)) { 42 | throw new Error( 43 | 'Cannot check length of a value. Provided value is empty', 44 | ); 45 | } 46 | const valueLength = 47 | typeof value === 'number' 48 | ? Number(value).toString().length 49 | : value.length; 50 | if (valueLength >= min && valueLength <= max) { 51 | return true; 52 | } 53 | return false; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/modules/article/article.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, Provider } from '@nestjs/common'; 2 | import { CqrsModule } from '@nestjs/cqrs'; 3 | import { MongooseModule } from '@nestjs/mongoose'; 4 | import { CommandHandlers } from './cqrs/commands/handlers'; 5 | import { ArticleCommandsController } from './controllers'; 6 | import ArticleRepository from 'modules/article/domain/models/repositories/Article.repository'; 7 | import { ArticlesService } from './services/article.service'; 8 | import { QueryHandlers } from './cqrs/queries/handlers'; 9 | import { ArticleSchema } from '@modules/article/domain/models/schemas/Article.schema'; 10 | import { EventHandlers } from './cqrs/events/handlers/index'; 11 | import { ArticleSagas } from './cqrs/sagas/article.sagas'; 12 | import { ArticleQueriesController } from './controllers'; 13 | import { ArticleMapper } from './mappers/article.mapper'; 14 | import { ArticleSchemaInstance } from './domain/models/schemas/Article.schema'; 15 | 16 | const mappers: Provider[] = [ArticleMapper]; 17 | @Module({ 18 | imports: [ 19 | CqrsModule, 20 | MongooseModule.forFeature([ 21 | { 22 | name: ArticleSchema.name, 23 | // instance 24 | schema: ArticleSchemaInstance, 25 | }, 26 | ]), 27 | ], 28 | controllers: [ArticleCommandsController, ArticleQueriesController], 29 | providers: [ 30 | ArticleRepository, 31 | ArticlesService, 32 | ...mappers, 33 | ...CommandHandlers, 34 | ...QueryHandlers, 35 | ...EventHandlers, 36 | ArticleSagas, 37 | ], 38 | }) 39 | export class ArticlesModule {} 40 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { NestExpressApplication } from '@nestjs/platform-express'; 4 | import express from 'express'; 5 | 6 | import { EEnvKey } from '@constants/env.constant'; 7 | import { ResponseTransformInterceptor } from 'infra/interceptors/request-response.interceptor'; 8 | import { HttpExceptionFilter } from 'infra/middleware/http-exception.filter'; 9 | import { UnknownExceptionsFilter } from 'infra/middleware/unknown-exceptions.filter'; 10 | import { LoggerService } from '@shared/modules/loggers/logger.service'; 11 | import { BodyValidationPipe } from 'infra/pipes/validation.pipe'; 12 | import { initSwagger } from 'infra/swagger'; 13 | 14 | import { AppModule } from './app.module'; 15 | 16 | async function bootstrap() { 17 | const app = await NestFactory.create(AppModule); 18 | const configService = app.get(ConfigService); 19 | const loggingService = app.get(LoggerService); 20 | const logger = loggingService.getLogger('Main'); 21 | app.useGlobalInterceptors( 22 | new ResponseTransformInterceptor(loggingService, configService), 23 | ); 24 | app.useGlobalFilters(new UnknownExceptionsFilter(loggingService)); 25 | app.useGlobalFilters(new HttpExceptionFilter(loggingService)); 26 | 27 | app.useGlobalPipes(new BodyValidationPipe()); 28 | app.setGlobalPrefix('api'); 29 | app.enableCors(); 30 | initSwagger(app, configService); 31 | app.use('/assets', express.static('assets')); 32 | await app.listen(configService.get(EEnvKey.PORT) || 3000); 33 | logger.info(`Application is running on: ${await app.getUrl()}`); 34 | } 35 | bootstrap(); 36 | -------------------------------------------------------------------------------- /src/modules/article/cqrs/queries/handlers/find-many-article.handler.ts: -------------------------------------------------------------------------------- 1 | import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; 2 | import { FindManyArticlesQuery } from '../impl/find-many-article.query'; 3 | import ArticleRepository from 'modules/article/domain/models/repositories/Article.repository'; 4 | import { BaseQueryHandler } from '@shared/cqrs/queries/query-handler.base'; 5 | import { LoggerService } from '@shared/modules/loggers/logger.service'; 6 | import { ENotFoundArticle } from '../../../domain/article.error'; 7 | import { ArticleDocument } from '../../../domain/models/schemas/Article.schema'; 8 | import { Result, Ok } from 'oxide.ts'; 9 | import { FilterQuery } from 'mongoose'; 10 | 11 | @QueryHandler(FindManyArticlesQuery) 12 | export class FindManyArticlesQueryHandler 13 | extends BaseQueryHandler 14 | implements IQueryHandler 15 | { 16 | constructor( 17 | private readonly articleRepository: ArticleRepository, 18 | protected loggerService: LoggerService, 19 | ) { 20 | super(loggerService, FindManyArticlesQueryHandler.name); 21 | } 22 | 23 | async execute( 24 | query: FindManyArticlesQuery, 25 | ): Promise> { 26 | this.logger.info('FindManyArticlesHandler'); 27 | const conditions: FilterQuery = {}; 28 | if (query.listArticleDto.author) { 29 | conditions.author = query.listArticleDto.author; 30 | } 31 | if (query.listArticleDto.content) { 32 | conditions.$text = { $search: query.listArticleDto.content }; 33 | } 34 | const data = await this.articleRepository.articleDocumentModel.find( 35 | conditions, 36 | ); 37 | 38 | return Ok(data); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/infra/middleware/http-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentsHost, 3 | Catch, 4 | ExceptionFilter, 5 | HttpException, 6 | HttpStatus, 7 | } from '@nestjs/common'; 8 | import { Response } from 'express'; 9 | 10 | import * as exc from '@shared/exception'; 11 | import { IResponse } from 'infra/interceptors/request-response.interceptor'; 12 | import { LoggerService } from '@shared/modules/loggers/logger.service'; 13 | 14 | @Catch(HttpException) 15 | export class HttpExceptionFilter implements ExceptionFilter { 16 | constructor(private readonly loggingService: LoggerService) {} 17 | 18 | private logger = this.loggingService.getLogger('http-exception'); 19 | 20 | catch(exception: HttpException, host: ArgumentsHost) { 21 | const ctx = host.switchToHttp(); 22 | const response = ctx.getResponse(); 23 | let excResponse = exception.getResponse() as IResponse | any; 24 | if ( 25 | typeof excResponse !== 'object' || 26 | !excResponse.hasOwnProperty('success') 27 | ) { 28 | let newDataResponse: Record = 29 | typeof excResponse === 'object' 30 | ? excResponse 31 | : { message: excResponse }; 32 | newDataResponse = newDataResponse?.message; 33 | excResponse = new exc.BadRequestException({ 34 | statusCode: excResponse.statusCode 35 | ? excResponse.statusCode 36 | : HttpStatus.BAD_REQUEST, 37 | data: excResponse.data ? excResponse.data : null, 38 | validatorErrors: excResponse?.validatorErrors 39 | ? excResponse.validatorErrors 40 | : [], 41 | message: 42 | typeof newDataResponse === 'string' 43 | ? newDataResponse 44 | : 'unknown message', 45 | }).getResponse(); 46 | } 47 | response.status(excResponse.statusCode).json(excResponse); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/infra/pipes/validation.pipe.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus, ValidationPipe } from '@nestjs/common'; 2 | import { ValidationError } from 'class-validator'; 3 | 4 | import { BadRequestException } from '@shared/exception'; 5 | 6 | export class BodyValidationPipe extends ValidationPipe { 7 | constructor() { 8 | super({ 9 | transform: true, 10 | transformOptions: { enableImplicitConversion: true }, 11 | skipMissingProperties: false, 12 | exceptionFactory: (errs: [ValidationError]) => { 13 | return new BadRequestException({ 14 | message: `Validation errors on these fields: ${this.getMessageFromErrs( 15 | errs, 16 | )}`, 17 | statusCode: HttpStatus.BAD_REQUEST, 18 | validatorErrors: this.getPropertyAndConstraints(errs), 19 | }); 20 | }, 21 | }); 22 | } 23 | 24 | getMessageFromErrs(errs: ValidationError[], parent: string = null): string { 25 | return errs 26 | .map((e) => { 27 | const current = parent ? `${parent}.${e.property}` : `${e.property}`; //`${parent ? `${parent}.` : ''}${e.property}`; 28 | if (e.children && e.children.length > 0) { 29 | return `${this.getMessageFromErrs(e.children, current)}`; 30 | } 31 | return current; 32 | }) 33 | .join(', '); 34 | } 35 | 36 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 37 | getPropertyAndConstraints(errs: ValidationError[]): any[] { 38 | const details = []; 39 | errs.forEach((e) => { 40 | if (e.children && e.children.length > 0) { 41 | this.getPropertyAndConstraints(e.children).forEach((e) => 42 | details.push(e), 43 | ); 44 | } else { 45 | details.push({ 46 | property: e.property, 47 | constraints: Object.values(e.constraints), 48 | }); 49 | } 50 | }); 51 | return details; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/shared/cqrs/value-object.base.ts: -------------------------------------------------------------------------------- 1 | import { Guard } from '../guard'; 2 | import { BadRequestException } from '../exception'; 3 | 4 | /** 5 | * Domain Primitive is an object that contains only a single value 6 | */ 7 | export type Primitives = string | number | boolean; 8 | export interface DomainPrimitive { 9 | value: T; 10 | } 11 | 12 | type ValueObjectProps = T extends Primitives | Date ? DomainPrimitive : T; 13 | 14 | export abstract class ValueObject { 15 | protected readonly props: ValueObjectProps; 16 | 17 | protected constructor(props: ValueObjectProps) { 18 | this.checkIfEmpty(props); 19 | this.validate(props); 20 | this.props = props; 21 | } 22 | 23 | protected abstract validate(props: ValueObjectProps): void; 24 | 25 | static isValueObject(obj: unknown): obj is ValueObject { 26 | return obj instanceof ValueObject; 27 | } 28 | 29 | /** 30 | * Check if two Value Objects are equal. Checks structural equality. 31 | * @param vo ValueObject 32 | */ 33 | public equals(vo?: ValueObject): boolean { 34 | if (vo === null || vo === undefined) { 35 | return false; 36 | } 37 | return JSON.stringify(this) === JSON.stringify(vo); 38 | } 39 | 40 | /** 41 | * Unpack a value object to get its raw properties 42 | */ 43 | public unpack(): T { 44 | if (this.isDomainPrimitive(this.props)) { 45 | return this.props.value; 46 | } 47 | } 48 | 49 | private checkIfEmpty(props: ValueObjectProps): void { 50 | if ( 51 | Guard.isEmpty(props) || 52 | (this.isDomainPrimitive(props) && Guard.isEmpty(props.value)) 53 | ) { 54 | throw new BadRequestException({ message: 'Property cannot be empty' }); 55 | } 56 | } 57 | 58 | private isDomainPrimitive( 59 | obj: unknown, 60 | ): obj is DomainPrimitive { 61 | return !!Object.prototype.hasOwnProperty.call(obj, 'value'); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/modules/article/mappers/article.mapper.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ArticleEntity } from '../domain/models/entities/ArticleEntity'; 3 | import { 4 | ArticleDocument, 5 | ArticleSchema, 6 | } from '../domain/models/schemas/Article.schema'; 7 | import { ArticleResponseDto } from '../dtos/ArticleResponse.dto'; 8 | import { BaseMapper } from '@shared/cqrs/mappers/mapper.base'; 9 | import { EventPublisher } from '@nestjs/cqrs'; 10 | 11 | @Injectable() 12 | export class ArticleMapper extends BaseMapper< 13 | ArticleSchema, 14 | ArticleEntity, 15 | ArticleDocument, 16 | ArticleResponseDto 17 | > { 18 | constructor(protected readonly publisher: EventPublisher) { 19 | super(publisher); 20 | } 21 | 22 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 23 | // @ts-ignore 24 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 25 | toPersistencies(_entities: ArticleEntity[]): ArticleDocument[] { 26 | throw new Error('Method not implemented.'); 27 | } 28 | toDomains(records: ArticleDocument[]): ArticleEntity[] { 29 | return records.map((record) => this.toDomain(record)); 30 | } 31 | 32 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 33 | // @ts-ignore 34 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 35 | toResponses(_entities: ArticleEntity[]): ArticleResponseDto[] { 36 | throw new Error('Method not implemented.'); 37 | } 38 | 39 | toPersistence(entity: ArticleEntity): ArticleDocument { 40 | return entity.document; 41 | } 42 | 43 | toDomain(record: ArticleDocument): ArticleEntity { 44 | const entity = new ArticleEntity(record._id, record); 45 | return this.publisher.mergeObjectContext(entity); 46 | } 47 | 48 | toResponse(entity: ArticleEntity): ArticleResponseDto { 49 | const response = new ArticleResponseDto(); 50 | response.id = entity.document._id; 51 | response.content = entity.document.content; 52 | return response; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/shared/cqrs/mappers/mapper.base.ts: -------------------------------------------------------------------------------- 1 | import { BaseDocument } from '@shared/models/base.entity'; 2 | import { BaseAggregateRoot } from '../aggregate_root_base/aggregate-root.base'; 3 | import { IMapper } from './IMapper'; 4 | import { BaseSchema } from '../../models/base.entity'; 5 | import { EventPublisher } from '@nestjs/cqrs'; 6 | 7 | export class BaseMapper< 8 | Schema extends BaseSchema, 9 | TEntity extends BaseAggregateRoot, 10 | TDocument = Schema & BaseDocument, 11 | Response = any, 12 | > implements IMapper 13 | { 14 | constructor(protected readonly publisher: EventPublisher) {} 15 | 16 | toPersistence(entity: TEntity): TDocument { 17 | return entity.document; 18 | } 19 | 20 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 21 | // @ts-ignore 22 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 23 | toDomain(_record: TDocument): TEntity { 24 | throw new Error('Method not implemented.'); 25 | // return new DomainAggregate(record._id, record); 26 | } 27 | 28 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 29 | // @ts-ignore 30 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 31 | toResponse(_entity: TEntity): Response { 32 | throw new Error('Method not implemented.'); 33 | } 34 | toPersistencies(entities: TEntity[]): TDocument[] { 35 | return entities.map((entity) => entity.document); 36 | } 37 | 38 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 39 | // @ts-ignore 40 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 41 | toDomains(_records: TDocument[]): TEntity[] { 42 | throw new Error('Method not implemented.'); 43 | } 44 | 45 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 46 | // @ts-ignore 47 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 48 | toResponses(_entities: TEntity[]): Response[] { 49 | throw new Error('Method not implemented.'); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, Module } from '@nestjs/common'; 2 | import { MulterModule } from '@nestjs/platform-express'; 3 | import { ScheduleModule } from '@nestjs/schedule'; 4 | import { memoryStorage } from 'multer'; 5 | import { ConsoleModule } from 'nestjs-console'; 6 | import { SentryInterceptor, SentryModule } from '@ntegral/nestjs-sentry'; 7 | 8 | import { ConfigurationModule } from '@config/config.module'; 9 | import { DatabaseModule } from '@config/database.module'; 10 | 11 | import { LoggingModule } from '@shared/modules/loggers/logger.module'; 12 | 13 | import { AppController } from './app.controller'; 14 | import { AppService } from './app.service'; 15 | import { MODULES } from './modules'; 16 | import { ConfigModule, ConfigService } from '@nestjs/config'; 17 | import { APP_INTERCEPTOR } from '@nestjs/core'; 18 | 19 | @Module({ 20 | imports: [ 21 | ConfigurationModule, 22 | DatabaseModule, 23 | LoggingModule, 24 | ConsoleModule, 25 | SentryModule.forRootAsync({ 26 | imports: [ConfigModule], 27 | useFactory: async (config: ConfigService) => ({ 28 | debug: config.get('SENTRY_DSN_DEBUG') === 'true', 29 | dsn: config.get('SENTRY_DSN'), 30 | environment: 31 | config.get('NODE_ENV') === 'development' 32 | ? 'dev' 33 | : 'production', 34 | enabled: config.get('SENTRY_DSN_ENABLED') === 'true', 35 | logLevels: 36 | config.get('SENTRY_DSN_DEBUG') === 'true' 37 | ? ['debug'] 38 | : ['log'], 39 | }), 40 | inject: [ConfigService], 41 | }), 42 | MulterModule.register({ 43 | storage: memoryStorage(), 44 | }), 45 | ScheduleModule.forRoot(), 46 | ...MODULES, 47 | ], 48 | controllers: [AppController], 49 | providers: [ 50 | AppService, 51 | { 52 | provide: APP_INTERCEPTOR, 53 | useFactory: () => 54 | new SentryInterceptor({ 55 | filters: [ 56 | { 57 | type: HttpException, 58 | filter: (exception: HttpException) => 500 > exception.getStatus(), 59 | }, 60 | ], 61 | }), 62 | }, 63 | ], 64 | }) 65 | export class AppModule {} 66 | -------------------------------------------------------------------------------- /src/shared/modules/loggers/logger.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Appender, configure, getLogger, Layout, Logger } from 'log4js'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { EEnvKey } from '@constants/env.constant'; 5 | 6 | const layouts: Record = { 7 | console: { 8 | type: 'pattern', 9 | pattern: '%[%-6p %d [%c] | %m%]', 10 | }, 11 | dateFile: { 12 | type: 'pattern', 13 | pattern: '%-6p %d [%c] | %m', 14 | }, 15 | }; 16 | 17 | const appenders: Record = { 18 | console: { 19 | type: 'console', 20 | layout: layouts.console, 21 | }, 22 | dateFile: { 23 | type: 'dateFile', 24 | filename: 'logs/out.log', 25 | pattern: '-yyyy-MM-dd', 26 | layout: layouts.dateFile, 27 | }, 28 | dateFileAccess: { 29 | type: 'dateFile', 30 | filename: 'logs/out.log', 31 | pattern: '-yyyy-MM-dd', 32 | layout: layouts.access, 33 | }, 34 | multi: { 35 | type: 'multiFile', 36 | base: 'logs/', 37 | property: 'categoryName', 38 | extension: '.log', 39 | }, 40 | }; 41 | 42 | @Injectable() 43 | export class LoggerService { 44 | /** 45 | * config logging 46 | * @example 47 | * Import Logging module 48 | * constructor(protected loggingService: LoggingService) {} 49 | * logger = this.loggingService.getLogger('serviceA'); 50 | */ 51 | constructor(private configService: ConfigService) { 52 | const level = configService.get(EEnvKey.LOG_LEVEL); 53 | const isWriteLog = configService.get(EEnvKey.IS_WRITE_LOG) === 'true'; 54 | configure({ 55 | appenders: appenders, 56 | categories: { 57 | default: { 58 | appenders: isWriteLog ? ['console', 'dateFile'] : ['console'], 59 | level: level, 60 | enableCallStack: true, 61 | }, 62 | }, 63 | }); 64 | } 65 | 66 | getLogger = getLogger; 67 | 68 | logger = { 69 | default: getLogger('default'), 70 | }; 71 | } 72 | 73 | export class LoggerPort { 74 | constructor(protected logger: Logger) {} 75 | info(...args: any[]) { 76 | this.logger.info(args); 77 | } 78 | debug(...args: any[]) { 79 | this.logger.debug(args); 80 | } 81 | error(...args: any[]) { 82 | this.logger.error(args); 83 | } 84 | warn(...args: any[]) { 85 | this.logger.warn(args); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/shared/decorators/mixin.decorators.ts: -------------------------------------------------------------------------------- 1 | import { 2 | applyDecorators, 3 | Controller as NestController, 4 | Get as NestGet, 5 | Post as NestPost, 6 | Put as NestPut, 7 | Patch as NestPatch, 8 | Delete as NestDelete, 9 | } from '@nestjs/common'; 10 | import { PropOptions } from '@nestjs/mongoose'; 11 | import { 12 | ApiProperty, 13 | ApiPropertyOptions, 14 | ApiTags, 15 | ApiOperation, 16 | } from '@nestjs/swagger'; 17 | import { Prop as PropMongoose } from '@nestjs/mongoose'; 18 | import { OperationObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface'; 19 | 20 | export function Prop( 21 | optionProp?: PropOptions, 22 | optionApiProperty?: ApiPropertyOptions, 23 | ) { 24 | return applyDecorators( 25 | PropMongoose(optionProp), 26 | ApiProperty(optionApiProperty), 27 | ); 28 | } 29 | 30 | export function Controller(path: string) { 31 | return applyDecorators(NestController(path), ApiTags(path)); 32 | } 33 | 34 | export function Get( 35 | path?: string | string[], 36 | options?: Partial, 37 | ) { 38 | return applyDecorators( 39 | NestGet(path), 40 | ApiOperation(options ? options : { summary: 'Get a record' }), 41 | ); 42 | } 43 | 44 | export function List( 45 | path?: string | string[], 46 | options?: Partial, 47 | ) { 48 | return applyDecorators( 49 | NestGet(path), 50 | ApiOperation(options ? options : { summary: 'List records by condition' }), 51 | ); 52 | } 53 | 54 | export function Post( 55 | path?: string | string[], 56 | options?: Partial, 57 | ) { 58 | return applyDecorators( 59 | NestPost(path), 60 | ApiOperation(options ? options : { summary: 'Create record' }), 61 | ); 62 | } 63 | 64 | export function Put( 65 | path?: string | string[], 66 | options?: Partial, 67 | ) { 68 | return applyDecorators( 69 | NestPut(path), 70 | ApiOperation(options ? options : { summary: 'Edit record' }), 71 | ); 72 | } 73 | 74 | export function Patch( 75 | path?: string | string[], 76 | options?: Partial, 77 | ) { 78 | return applyDecorators( 79 | NestPatch(path), 80 | ApiOperation(options ? options : { summary: 'Edit record' }), 81 | ); 82 | } 83 | 84 | export function Delete( 85 | path?: string | string[], 86 | options?: Partial, 87 | ) { 88 | return applyDecorators( 89 | NestDelete(path), 90 | ApiOperation(options ? options : { summary: 'Delete record' }), 91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /src/modules/article/services/article.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { CommandBus, QueryBus } from '@nestjs/cqrs'; 3 | import { FindManyArticlesQuery } from '../cqrs/queries/impl/find-many-article.query'; 4 | import { CreateArticleDto } from '../dtos/CreateArticle.dto'; 5 | import { CreateArticleCommand } from '../cqrs/commands/impl/create-article.command'; 6 | import { ArticleDocument } from '@modules/article/domain/models/schemas/Article.schema'; 7 | import { Result, match } from 'oxide.ts'; 8 | import { ENotFoundArticle } from '../domain/article.error'; 9 | import { BadRequestException } from '@shared/exception'; 10 | import { FindSingleArticleQuery } from '../cqrs/queries/impl/find-single-article.query'; 11 | import { AggregateID } from '../../../shared/cqrs/aggregate_root_base/aggregate-root.base'; 12 | import { BaseResponseCommand } from '@shared/types/response-command.base'; 13 | import { ListArticleDto } from '../dtos/ListArticle.dto'; 14 | 15 | @Injectable() 16 | export class ArticlesService { 17 | constructor( 18 | private readonly commandBus: CommandBus, 19 | private readonly queryBus: QueryBus, 20 | ) {} 21 | 22 | async createArticle(dto: CreateArticleDto): Promise { 23 | const result: Result = await this.commandBus.execute( 24 | new CreateArticleCommand(dto), 25 | ); 26 | return match(result, { 27 | Ok: (id: AggregateID): BaseResponseCommand => { 28 | return { id }; 29 | }, 30 | Err: (error: Error) => { 31 | if (error instanceof ENotFoundArticle) 32 | throw new BadRequestException({ message: error.message }); 33 | throw error; 34 | }, 35 | }); 36 | } 37 | 38 | async findAll(listArticleDto: ListArticleDto): Promise { 39 | const result: Result = 40 | await this.queryBus.execute(new FindManyArticlesQuery(listArticleDto)); 41 | return match(result, { 42 | Ok: (article: ArticleDocument[]) => article, 43 | Err: (error: Error) => { 44 | if (error instanceof ENotFoundArticle) 45 | throw new BadRequestException({ message: error.message }); 46 | throw error; 47 | }, 48 | }); 49 | } 50 | 51 | async findById(id: string): Promise { 52 | const result: Result = await this.queryBus.execute( 53 | new FindSingleArticleQuery(id), 54 | ); 55 | return match(result, { 56 | Ok: (article: ArticleDocument) => article, 57 | Err: (error: Error) => { 58 | if (error instanceof ENotFoundArticle) 59 | throw new BadRequestException({ message: error.message }); 60 | throw error; 61 | }, 62 | }); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/infra/interceptors/request-response.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallHandler, 3 | ExecutionContext, 4 | HttpStatus, 5 | Injectable, 6 | NestInterceptor, 7 | } from '@nestjs/common'; 8 | import { Response } from 'express'; 9 | import { Observable } from 'rxjs'; 10 | import { map } from 'rxjs/operators'; 11 | import { LoggerService } from '@shared/modules/loggers/logger.service'; 12 | import { EEnvKey } from '@constants/env.constant'; 13 | import { ConfigService } from '@nestjs/config'; 14 | 15 | export const defaultResponse: IResponse<[]> = { 16 | success: true, 17 | statusCode: HttpStatus.OK, 18 | message: '', 19 | data: null, 20 | validatorErrors: [], 21 | }; 22 | 23 | export interface IResponse { 24 | statusCode?: HttpStatus; 25 | data?: T; 26 | _metadata?: { 27 | [key: string]: any; 28 | }; 29 | message?: string | null; 30 | success?: boolean; 31 | validatorErrors?: any[]; 32 | requestId?: string; 33 | } 34 | export function createResponse(data: any): IResponse { 35 | return { 36 | statusCode: data?.statusCode ? data.statusCode : HttpStatus.OK, 37 | data: data?.data || data || [], 38 | _metadata: data?._metadata 39 | ? { ...data._metadata, timestamp: new Date() } 40 | : { timestamp: new Date() }, 41 | success: true, 42 | message: data?.message ? data?.message : '', 43 | }; 44 | } 45 | @Injectable() 46 | export class ResponseTransformInterceptor 47 | implements NestInterceptor> 48 | { 49 | constructor( 50 | private readonly loggingService: LoggerService, 51 | private readonly configService: ConfigService, 52 | ) {} 53 | private logger = this.loggingService.getLogger('Request'); 54 | intercept( 55 | context: ExecutionContext, 56 | next: CallHandler, 57 | ): Observable> { 58 | const logLevel = this.configService.get(EEnvKey.LOG_LEVEL); 59 | if (logLevel === 'debug') { 60 | const request = context.switchToHttp().getRequest(); 61 | this.logger.info( 62 | request.headers, 63 | request.query, 64 | request.params, 65 | request.url, 66 | ); 67 | //todo: optimize logger body hidden password 68 | try { 69 | let body = request?.body; 70 | if (body && body instanceof Object) { 71 | body = JSON.parse(JSON.stringify(request?.body)); 72 | if (body?.password) { 73 | this.logger.info(`Hidden password`); 74 | delete body.password; 75 | } 76 | this.logger.info(request.url, `Body: `, body); 77 | } 78 | } catch (e) { 79 | throw e; 80 | } 81 | } 82 | return next.handle().pipe( 83 | map((data) => { 84 | const ctx = context.switchToHttp(); 85 | const response = ctx.getResponse(); 86 | const responseData = createResponse(data); 87 | response.status(responseData.statusCode); 88 | return createResponse(data); 89 | }), 90 | ); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/shared/exception/exception.resolver.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | 3 | import { 4 | defaultResponse, 5 | IResponse, 6 | } from 'infra/interceptors/request-response.interceptor'; 7 | 8 | export abstract class BaseException extends HttpException { 9 | protected constructor(partial: IResponse, statusCode: number) { 10 | const payload = { 11 | ...defaultResponse, 12 | statusCode: partial?.statusCode ? partial.statusCode : statusCode, 13 | message: '', 14 | ...partial, 15 | }; 16 | payload.success = payload.statusCode < 400; 17 | super(payload, statusCode); 18 | } 19 | } 20 | 21 | /** 22 | * response to client an error 23 | * @example 24 | * throw new exc.Exception({ 25 | message: 'Not found user id', 26 | }); 27 | */ 28 | export class Exception extends BaseException { 29 | constructor( 30 | payload: IResponse, 31 | statusCode: number = HttpStatus.INTERNAL_SERVER_ERROR, 32 | ) { 33 | super(payload, statusCode); 34 | } 35 | } 36 | 37 | export class BadRequestException extends BaseException { 38 | constructor(payload: IResponse) { 39 | super(payload, HttpStatus.BAD_REQUEST); 40 | } 41 | } 42 | 43 | export class BusinessException extends BaseException { 44 | constructor(payload: IResponse) { 45 | super(payload, HttpStatus.INTERNAL_SERVER_ERROR); 46 | } 47 | } 48 | 49 | export class Unauthorized extends BaseException { 50 | constructor(payload: IResponse) { 51 | super(payload, HttpStatus.UNAUTHORIZED); 52 | } 53 | } 54 | 55 | export class Forbidden extends BaseException { 56 | constructor(payload: IResponse) { 57 | super(payload, HttpStatus.FORBIDDEN); 58 | } 59 | } 60 | 61 | export class NotFound extends BaseException { 62 | constructor(payload: IResponse) { 63 | super(payload, HttpStatus.NOT_FOUND); 64 | } 65 | } 66 | 67 | export class MethodNotAllowed extends BaseException { 68 | constructor(payload: IResponse) { 69 | super(payload, HttpStatus.METHOD_NOT_ALLOWED); 70 | } 71 | } 72 | 73 | export class NotAcceptable extends BaseException { 74 | constructor(payload: IResponse) { 75 | super(payload, HttpStatus.NOT_ACCEPTABLE); 76 | } 77 | } 78 | 79 | export class Conflict extends BaseException { 80 | constructor(payload: IResponse) { 81 | super(payload, HttpStatus.CONFLICT); 82 | } 83 | } 84 | 85 | export class UnsupportedMediaType extends BaseException { 86 | constructor(payload: IResponse) { 87 | super(payload, HttpStatus.UNSUPPORTED_MEDIA_TYPE); 88 | } 89 | } 90 | 91 | export class TemporaryRedirect extends BaseException { 92 | constructor(payload: IResponse) { 93 | super(payload, HttpStatus.TEMPORARY_REDIRECT); 94 | } 95 | } 96 | 97 | export class PayloadTooLarge extends BaseException { 98 | constructor(payload: IResponse) { 99 | super(payload, HttpStatus.PAYLOAD_TOO_LARGE); 100 | } 101 | } 102 | 103 | export class FailedDependency extends BaseException { 104 | constructor(payload: IResponse) { 105 | super(payload, HttpStatus.FAILED_DEPENDENCY); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "boiler", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "nguyenthaihoc.dev@gmail.com", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\"", 11 | "start:ts": "nest start", 12 | "start": "node dist/main", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "postinstall": "husky install" 22 | }, 23 | "engines": { 24 | "node": "16.19.0", 25 | "npm": "8.19.3", 26 | "yarn": "1.22.19" 27 | }, 28 | "dependencies": { 29 | "@nestjs/cli": "^9.2.0", 30 | "@nestjs/common": "9.0.0", 31 | "@nestjs/config": "2.3.1", 32 | "@nestjs/core": "9.0.0", 33 | "@nestjs/cqrs": "9.0.3", 34 | "@nestjs/mapped-types": "1.2.2", 35 | "@nestjs/mongoose": "9.2.0", 36 | "@nestjs/platform-express": "9.0.0", 37 | "@nestjs/schedule": "2.2.0", 38 | "@nestjs/swagger": "6.2.1", 39 | "@ntegral/nestjs-sentry": "^4.0.0", 40 | "@sentry/hub": "^7.42.0", 41 | "@sentry/node": "^7.42.0", 42 | "class-transformer": "0.5.1", 43 | "class-validator": "0.14.0", 44 | "dotenv": "16.0.3", 45 | "fp-ts": "^2.13.1", 46 | "joi": "17.8.3", 47 | "log4js": "6.8.0", 48 | "moment": "2.29.4", 49 | "mongoose": "6.6.5", 50 | "morgan": "1.10.0", 51 | "nanoid": "3.3.1", 52 | "nestjs-console": "8.0.0", 53 | "nestjs-request-context": "^2.1.0", 54 | "oxide.ts": "^1.1.0", 55 | "reflect-metadata": "0.1.13", 56 | "rxjs": "7.2.0", 57 | "husky": "8.0.1" 58 | }, 59 | "lint-staged": { 60 | "*.ts": [ 61 | "npm run lint", 62 | "npm run format", 63 | "git add ." 64 | ] 65 | }, 66 | "devDependencies": { 67 | "@commitlint/cli": "17.1.2", 68 | "@commitlint/config-conventional": "17.1.0", 69 | "@nestjs/schematics": "9.0.0", 70 | "@nestjs/testing": "9.0.0", 71 | "@types/express": "4.17.13", 72 | "@types/jest": "29.2.4", 73 | "@types/node": "18.11.18", 74 | "@types/supertest": "2.0.11", 75 | "@typescript-eslint/eslint-plugin": "5.0.0", 76 | "@typescript-eslint/parser": "5.0.0", 77 | "eslint": "8.0.1", 78 | "eslint-config-prettier": "8.3.0", 79 | "eslint-plugin-prettier": "4.0.0", 80 | "jest": "29.3.1", 81 | "prettier": "2.3.2", 82 | "source-map-support": "0.5.20", 83 | "supertest": "6.1.3", 84 | "ts-jest": "29.0.3", 85 | "ts-loader": "9.2.3", 86 | "ts-node": "10.0.0", 87 | "tsconfig-paths": "4.1.1", 88 | "typescript": "4.7.4" 89 | }, 90 | "jest": { 91 | "moduleFileExtensions": [ 92 | "js", 93 | "json", 94 | "ts" 95 | ], 96 | "rootDir": "src", 97 | "testRegex": ".*\\.spec\\.ts$", 98 | "transform": { 99 | ".+\\.(t|j)s$": "ts-jest" 100 | }, 101 | "collectCoverageFrom": [ 102 | "**/*.(t|j)s" 103 | ], 104 | "coverageDirectory": "../coverage", 105 | "testEnvironment": "node" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/shared/decorators/swagger.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, HttpStatus, Type } from '@nestjs/common'; 2 | import { Prop as PropMongoose } from '@nestjs/mongoose'; 3 | import { PropOptions } from '@nestjs/mongoose/dist/decorators/prop.decorator'; 4 | import { 5 | ApiExtraModels, 6 | ApiOkResponse, 7 | ApiOperation, 8 | ApiProperty, 9 | getSchemaPath, 10 | } from '@nestjs/swagger'; 11 | import { ApiOperationOptions } from '@nestjs/swagger/dist/decorators/api-operation.decorator'; 12 | import { ApiPropertyOptions } from '@nestjs/swagger/dist/decorators/api-property.decorator'; 13 | import { IResponse } from 'infra/interceptors/request-response.interceptor'; 14 | import { IPaginationMetadata } from '@shared/types/pagination.type'; 15 | 16 | export * from '@nestjs/swagger'; 17 | 18 | export function enumToObj( 19 | enumVariable: Record, 20 | ): Record { 21 | const enumValues = Object.values(enumVariable); 22 | const hLen = enumValues.length / 2; 23 | const object = {}; 24 | for (let i = 0; i < hLen; i++) { 25 | object[enumValues[i]] = enumValues[hLen + i]; 26 | } 27 | return object; 28 | } 29 | 30 | export function enumProperty(options: ApiPropertyOptions): ApiPropertyOptions { 31 | const obj = enumToObj(options.enum); 32 | const enumValues = Object.values(obj); 33 | return { 34 | example: enumValues[0], 35 | ...options, 36 | enum: enumValues, 37 | description: (options.description ?? '') + ': ' + JSON.stringify(obj), 38 | }; 39 | } 40 | 41 | const createApiOperation = (defaultOptions: ApiOperationOptions) => { 42 | return (options?: ApiOperationOptions): MethodDecorator => 43 | ApiOperation({ 44 | ...defaultOptions, 45 | ...options, 46 | }); 47 | }; 48 | 49 | export const ApiEnumProperty = (options: ApiPropertyOptions) => 50 | ApiProperty(enumProperty(options)); 51 | export const ApiListOperation = createApiOperation({ 52 | summary: 'List all', 53 | }); 54 | export const ApiRetrieveOperation = createApiOperation({ 55 | summary: 'Get data 1 record', 56 | }); 57 | export const ApiCreateOperation = createApiOperation({ 58 | summary: 'Create new record', 59 | }); 60 | export const ApiUpdateOperation = createApiOperation({ 61 | summary: 'Edit record', 62 | }); 63 | export const ApiDeleteOperation = createApiOperation({ 64 | summary: 'Delete record', 65 | }); 66 | export const ApiBulkDeleteOperation = createApiOperation({ 67 | summary: 'Delete many record', 68 | }); 69 | 70 | export function Prop( 71 | optionProp?: PropOptions, 72 | optionApiProperty?: ApiPropertyOptions, 73 | ) { 74 | return applyDecorators( 75 | PropMongoose(optionProp), 76 | ApiProperty(optionApiProperty), 77 | ); 78 | } 79 | export enum EApiOkResponsePayload { 80 | ARRAY = 'array', 81 | OBJECT = 'object', 82 | } 83 | export const ApiOkResponsePayload = >( 84 | dto: DataDto, 85 | type: EApiOkResponsePayload = EApiOkResponsePayload.ARRAY, 86 | withPagination = false, 87 | ) => { 88 | const data = 89 | type === EApiOkResponsePayload.ARRAY 90 | ? { 91 | type: EApiOkResponsePayload.ARRAY, 92 | items: { $ref: getSchemaPath(dto) }, 93 | } 94 | : { 95 | type: EApiOkResponsePayload.OBJECT, 96 | properties: { 97 | data: { $ref: getSchemaPath(dto) }, 98 | }, 99 | }; 100 | 101 | const properties = 102 | type === EApiOkResponsePayload.ARRAY 103 | ? { 104 | properties: { 105 | data: data, 106 | }, 107 | } 108 | : { ...data }; 109 | 110 | return applyDecorators( 111 | ApiExtraModels( 112 | !withPagination ? ResponsePayload : ResponsePaginationPayload, 113 | dto, 114 | ), 115 | ApiOkResponse({ 116 | schema: { 117 | allOf: [ 118 | { 119 | $ref: getSchemaPath( 120 | !withPagination ? ResponsePayload : ResponsePaginationPayload, 121 | ), 122 | }, 123 | { 124 | ...properties, 125 | }, 126 | ], 127 | }, 128 | }), 129 | ); 130 | }; 131 | 132 | export class ResponsePayload implements IResponse { 133 | @ApiEnumProperty({ enum: HttpStatus, example: HttpStatus.OK }) 134 | statusCode?: HttpStatus; 135 | @ApiProperty() 136 | data?: T; 137 | @ApiProperty() 138 | _metadata?: { 139 | [key: string]: any; 140 | }; 141 | @ApiProperty({ 142 | description: 143 | 'If success = fail, it is message error, if success = true, it will null', 144 | }) 145 | message?: string | null; 146 | @ApiProperty({ description: 'Check is success' }) 147 | success?: boolean; 148 | @ApiProperty({ description: 'Validate error with input data' }) 149 | validatorErrors?: any[]; 150 | } 151 | 152 | export class PaginationMetadata implements IPaginationMetadata { 153 | @ApiProperty() 154 | totalDocs: number; 155 | @ApiProperty() 156 | limit: number; 157 | @ApiProperty() 158 | page: number; 159 | @ApiProperty() 160 | totalPages: number; 161 | } 162 | export class ResponsePaginationPayload extends ResponsePayload { 163 | @ApiProperty({ type: PaginationMetadata }) 164 | _metadata?: IPaginationMetadata; 165 | } 166 | --------------------------------------------------------------------------------