├── src ├── app.http ├── domain │ ├── shared │ │ ├── domain-events │ │ │ ├── domain-event.ts │ │ │ ├── domain-events-publisher.ts │ │ │ ├── domain-events-store.ts │ │ │ └── domain-events-subscriber.ts │ │ ├── base-entity.dto.ts │ │ ├── aggregate-root.dto.ts │ │ ├── base-entity.ts │ │ ├── shared.module.ts │ │ ├── aggregate-root.ts │ │ ├── base-repository.ts │ │ └── apply-filters.ts │ ├── skills │ │ ├── dto │ │ │ ├── create-skill.dto.ts │ │ │ ├── update-skill.dto.ts │ │ │ └── skill.dto.ts │ │ ├── entities │ │ │ └── skill.entity.ts │ │ ├── skills.repository.ts │ │ ├── skills.http │ │ ├── skills.module.ts │ │ ├── skills.controller.ts │ │ └── skills.service.ts │ ├── users │ │ ├── dto │ │ │ ├── user-skill.dto.ts │ │ │ ├── create-user-skill.dto.ts │ │ │ ├── update-user-skill.dto.ts │ │ │ ├── user.dto.ts │ │ │ ├── update-user.dto.ts │ │ │ └── create-user.dto.ts │ │ ├── events │ │ │ └── employee-added.event.ts │ │ ├── users.repository.ts │ │ ├── users.module.ts │ │ ├── entities │ │ │ ├── user-skill.entity.ts │ │ │ └── user.entity.ts │ │ ├── users.http │ │ ├── users.controller.ts │ │ └── users.service.ts │ └── notifications │ │ ├── notifications.repository.ts │ │ ├── entities │ │ └── notification.entity.ts │ │ ├── notifications.module.ts │ │ ├── dto │ │ └── create-notification.dto.ts │ │ └── notifications.service.ts ├── utils │ ├── is-object.ts │ ├── hash-password.ts │ ├── parse-json.pipe.ts │ └── generate-id.ts ├── application │ ├── event-handlers │ │ ├── index.ts │ │ └── employee-added │ │ │ └── notify-manager.handler.ts │ ├── application.module.ts │ └── background-event-handler.ts ├── integrations │ └── notifier │ │ ├── implementations │ │ ├── sms.service.ts │ │ └── email.service.ts │ │ ├── notifier.module.ts │ │ ├── dto │ │ └── send-notification.dto.ts │ │ └── notifier.service.ts ├── app.controller.ts ├── main.ts ├── app.module.ts └── custom.logger.ts ├── .prettierrc ├── tsconfig.build.json ├── .env.example ├── nest-cli.json ├── tsconfig.json ├── docker-compose.yml ├── .eslintrc.js ├── .gitignore ├── package.json └── README.md /src/app.http: -------------------------------------------------------------------------------- 1 | GET http://localhost:3000/app/test-logger -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /src/domain/shared/domain-events/domain-event.ts: -------------------------------------------------------------------------------- 1 | export interface DomainEvent { 2 | occurredOn: Date; 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/is-object.ts: -------------------------------------------------------------------------------- 1 | export function isObject(input: unknown): input is Record { 2 | return input && typeof input === 'object' && !Array.isArray(input); 3 | } 4 | -------------------------------------------------------------------------------- /src/domain/shared/base-entity.dto.ts: -------------------------------------------------------------------------------- 1 | import { Exclude, Expose } from 'class-transformer'; 2 | 3 | @Exclude() 4 | export class BaseEntityDto { 5 | @Expose() 6 | id: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/domain/skills/dto/create-skill.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export class CreateSkillDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | name: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/application/event-handlers/index.ts: -------------------------------------------------------------------------------- 1 | import { EmployeeAdded_NotifyManagerHandler } from './employee-added/notify-manager.handler'; 2 | 3 | export const EventHandlers = [EmployeeAdded_NotifyManagerHandler]; 4 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | DATABASE_URL=postgres://postgres:postgres@localhost:5432/nestjsfoldersdb 3 | REDIS_HOST=localhost 4 | REDIS_PORT=6379 5 | LOG_RULES="context=AppController;level=debug/level=warn" -------------------------------------------------------------------------------- /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/domain/skills/dto/update-skill.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/mapped-types'; 2 | import { CreateSkillDto } from './create-skill.dto'; 3 | 4 | export class UpdateSkillDto extends PartialType(CreateSkillDto) {} 5 | -------------------------------------------------------------------------------- /src/domain/users/dto/user-skill.dto.ts: -------------------------------------------------------------------------------- 1 | import { Exclude, Expose } from 'class-transformer'; 2 | 3 | @Exclude() 4 | export class UserSkillDto { 5 | @Expose() 6 | skillId: string; 7 | 8 | @Expose() 9 | score?: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/domain/skills/dto/skill.dto.ts: -------------------------------------------------------------------------------- 1 | import { Exclude, Expose } from 'class-transformer'; 2 | import { AggregateRootDto } from 'src/domain/shared/aggregate-root.dto'; 3 | 4 | @Exclude() 5 | export class SkillDto extends AggregateRootDto { 6 | @Expose() 7 | name: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/domain/skills/entities/skill.entity.ts: -------------------------------------------------------------------------------- 1 | import { AggregateRoot } from 'src/domain/shared/aggregate-root'; 2 | import { Column, Entity } from 'typeorm'; 3 | 4 | @Entity('skills') 5 | export class Skill extends AggregateRoot { 6 | @Column({ unique: true }) 7 | name: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/domain/users/dto/create-user-skill.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; 2 | 3 | export class CreateUserSkillDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | skillId: string; 7 | 8 | @IsInt() 9 | @Min(1) 10 | @Max(10) 11 | score: number; 12 | } 13 | -------------------------------------------------------------------------------- /src/domain/shared/aggregate-root.dto.ts: -------------------------------------------------------------------------------- 1 | import { Exclude, Expose } from 'class-transformer'; 2 | import { BaseEntityDto } from './base-entity.dto'; 3 | 4 | @Exclude() 5 | export class AggregateRootDto extends BaseEntityDto { 6 | @Expose() 7 | createdAt: Date; 8 | 9 | @Expose() 10 | updatedAt: Date; 11 | } 12 | -------------------------------------------------------------------------------- /src/integrations/notifier/implementations/sms.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class SmsService { 5 | async send(to: string, body: string): Promise { 6 | console.log(`Sending SMS to ${to} | Body: ${body}`); 7 | return Promise.resolve(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/integrations/notifier/implementations/email.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class EmailService { 5 | async send(to: string, subject: string, body: string): Promise { 6 | console.log(`Email to ${to} | Subject: ${subject} | Body: ${body}`); 7 | return Promise.resolve(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/domain/users/events/employee-added.event.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvent } from 'src/domain/shared/domain-events/domain-event'; 2 | 3 | export class EmployeeAddedEvent implements DomainEvent { 4 | public occurredOn: Date; 5 | 6 | constructor( 7 | public readonly managerId: string, 8 | public readonly employeeId: string, 9 | ) { 10 | this.occurredOn = new Date(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/hash-password.ts: -------------------------------------------------------------------------------- 1 | import { hash } from 'bcrypt'; 2 | 3 | export function hashPassword(password: string): Promise { 4 | // For most systems, use 10 rounds for a good balance between security and performance. 5 | // For more secure systems, consider 12 rounds. 6 | // For high-security applications, consider 14 or more, but always test the impact. 7 | return hash(password, 10); 8 | } 9 | -------------------------------------------------------------------------------- /src/domain/shared/base-entity.ts: -------------------------------------------------------------------------------- 1 | import { generateId } from 'src/utils/generate-id'; 2 | import { 3 | BaseEntity as TypeOrmBaseEntity, 4 | BeforeInsert, 5 | PrimaryColumn, 6 | } from 'typeorm'; 7 | 8 | export abstract class BaseEntity extends TypeOrmBaseEntity { 9 | @PrimaryColumn('bigint') 10 | id: string; 11 | 12 | @BeforeInsert() 13 | generateId() { 14 | this.id = generateId(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/domain/users/users.repository.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from 'typeorm'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { BaseRepository } from '../shared/base-repository'; 4 | import { User } from './entities/user.entity'; 5 | 6 | @Injectable() 7 | export class UsersRepository extends BaseRepository { 8 | constructor(dataSource: DataSource) { 9 | super(User, dataSource); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/domain/skills/skills.repository.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from 'typeorm'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { BaseRepository } from '../shared/base-repository'; 4 | import { Skill } from './entities/skill.entity'; 5 | 6 | @Injectable() 7 | export class SkillsRepository extends BaseRepository { 8 | constructor(dataSource: DataSource) { 9 | super(Skill, dataSource); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/domain/users/dto/update-user-skill.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from 'class-validator'; 2 | import { OmitType, PartialType } from '@nestjs/mapped-types'; 3 | import { CreateUserSkillDto } from './create-user-skill.dto'; 4 | 5 | export class UpdateUserSkillDto extends PartialType( 6 | OmitType(CreateUserSkillDto, ['skillId'] as const), 7 | ) { 8 | @IsString() 9 | @IsNotEmpty() 10 | skillId: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/parse-json.pipe.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class ParseJsonPipe implements PipeTransform { 5 | transform(value: string) { 6 | if (!value) return null; 7 | 8 | try { 9 | return JSON.parse(value); 10 | } catch (error) { 11 | throw new BadRequestException('Invalid JSON string'); 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/domain/notifications/notifications.repository.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from 'typeorm'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { BaseRepository } from '../shared/base-repository'; 4 | import { Notification } from './entities/notification.entity'; 5 | 6 | @Injectable() 7 | export class NotificationsRepository extends BaseRepository { 8 | constructor(dataSource: DataSource) { 9 | super(Notification, dataSource); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/domain/shared/domain-events/domain-events-publisher.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { EventBus } from '@nestjs/cqrs'; 3 | import { DomainEvent } from './domain-event'; 4 | 5 | @Injectable() 6 | export class DomainEventsPublisher { 7 | constructor(private readonly eventBus: EventBus) {} 8 | 9 | async publishEvents(events: DomainEvent[]) { 10 | events.forEach((event) => { 11 | this.eventBus.publish(event); 12 | }); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/domain/shared/shared.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { DomainEventsPublisher } from './domain-events/domain-events-publisher'; 3 | import { DomainEventsStore } from './domain-events/domain-events-store'; 4 | import { DomainEventsSubscriber } from './domain-events/domain-events-subscriber'; 5 | 6 | @Global() 7 | @Module({ 8 | providers: [DomainEventsPublisher, DomainEventsStore, DomainEventsSubscriber], 9 | }) 10 | export class SharedModule {} 11 | -------------------------------------------------------------------------------- /src/domain/skills/skills.http: -------------------------------------------------------------------------------- 1 | ### list 2 | GET http://localhost:3000/skills 3 | 4 | ### get 5 | GET http://localhost:3000/skills/165354695680 6 | 7 | ### create 8 | POST http://localhost:3000/skills 9 | Content-Type: application/json 10 | 11 | { 12 | "name": "Nest" 13 | } 14 | 15 | ### update 16 | PATCH http://localhost:3000/skills/3142773432320 17 | Content-Type: application/json 18 | 19 | { 20 | "name": "NestJS" 21 | } 22 | 23 | ### delete 24 | DELETE http://localhost:3000/skills/3010342477824 25 | -------------------------------------------------------------------------------- /src/integrations/notifier/notifier.module.ts: -------------------------------------------------------------------------------- 1 | import { BullModule } from '@nestjs/bull'; 2 | import { Module } from '@nestjs/common'; 3 | import { EmailService } from './implementations/email.service'; 4 | import { SmsService } from './implementations/sms.service'; 5 | import { NotifierService } from './notifier.service'; 6 | 7 | @Module({ 8 | imports: [BullModule.registerQueue({ name: 'notifications' })], 9 | providers: [EmailService, NotifierService, SmsService], 10 | exports: [NotifierService], 11 | }) 12 | export class NotifierModule {} 13 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Logger } from '@nestjs/common'; 2 | 3 | @Controller('app') 4 | export class AppController { 5 | private readonly logger = new Logger(AppController.name); 6 | 7 | @Get('test-logger') 8 | testLogger() { 9 | this.logger.verbose('Test verbose message'); 10 | this.logger.debug('Test debug message'); 11 | this.logger.log('Test log message'); 12 | this.logger.warn('Test warn message'); 13 | this.logger.error('Test error message'); 14 | return 'Logger test complete'; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/domain/skills/skills.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { Skill } from './entities/skill.entity'; 4 | import { SkillsController } from './skills.controller'; 5 | import { SkillsRepository } from './skills.repository'; 6 | import { SkillsService } from './skills.service'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([Skill])], 10 | controllers: [SkillsController], 11 | providers: [SkillsRepository, SkillsService], 12 | exports: [SkillsService], 13 | }) 14 | export class SkillsModule {} 15 | -------------------------------------------------------------------------------- /src/domain/users/dto/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { Exclude, Expose, Type } from 'class-transformer'; 2 | import { AggregateRootDto } from 'src/domain/shared/aggregate-root.dto'; 3 | import { UserSkillDto } from './user-skill.dto'; 4 | 5 | @Exclude() 6 | export class UserDto extends AggregateRootDto { 7 | @Expose() 8 | firstName: string; 9 | 10 | @Expose() 11 | lastName: string; 12 | 13 | @Expose() 14 | email: string; 15 | 16 | @Expose() 17 | phone: string; 18 | 19 | @Expose() 20 | managerId?: string; 21 | 22 | @Expose() 23 | @Type(() => UserSkillDto) 24 | skills: UserSkillDto[]; 25 | } 26 | -------------------------------------------------------------------------------- /src/domain/users/dto/update-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsArray, IsOptional, ValidateNested } from 'class-validator'; 3 | import { OmitType, PartialType } from '@nestjs/mapped-types'; 4 | import { CreateUserDto } from './create-user.dto'; 5 | import { UpdateUserSkillDto } from './update-user-skill.dto'; 6 | 7 | export class UpdateUserDto extends PartialType( 8 | OmitType(CreateUserDto, ['skills'] as const), 9 | ) { 10 | @IsOptional() 11 | @IsArray() 12 | @ValidateNested({ each: true }) 13 | @Type(() => UpdateUserSkillDto) 14 | skills?: UpdateUserSkillDto[]; 15 | } 16 | -------------------------------------------------------------------------------- /src/application/application.module.ts: -------------------------------------------------------------------------------- 1 | import { NotificationsModule } from 'src/domain/notifications/notifications.module'; 2 | import { UsersModule } from 'src/domain/users/users.module'; 3 | import { BullModule } from '@nestjs/bull'; 4 | import { Module } from '@nestjs/common'; 5 | import { EventHandlers } from './event-handlers'; 6 | 7 | @Module({ 8 | imports: [ 9 | // Domain modules 10 | NotificationsModule, 11 | UsersModule, 12 | 13 | // Queues 14 | BullModule.registerQueue({ name: 'employee-added' }), 15 | ], 16 | providers: [...EventHandlers], 17 | }) 18 | export class ApplicationModule {} 19 | -------------------------------------------------------------------------------- /src/domain/notifications/entities/notification.entity.ts: -------------------------------------------------------------------------------- 1 | import { AggregateRoot } from 'src/domain/shared/aggregate-root'; 2 | import { User } from 'src/domain/users/entities/user.entity'; 3 | import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'; 4 | 5 | @Entity('notifications') 6 | export class Notification extends AggregateRoot { 7 | @Column() 8 | @JoinColumn({ name: 'userId' }) 9 | @ManyToOne(() => User, { onDelete: 'CASCADE' }) 10 | userId: string; 11 | 12 | @Column() 13 | title: string; 14 | 15 | @Column() 16 | longMessage: string; 17 | 18 | @Column() 19 | shortMessage: string; 20 | } 21 | -------------------------------------------------------------------------------- /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": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/domain/shared/aggregate-root.ts: -------------------------------------------------------------------------------- 1 | import { CreateDateColumn, UpdateDateColumn } from 'typeorm'; 2 | import { BaseEntity } from './base-entity'; 3 | import { DomainEvent } from './domain-events/domain-event'; 4 | 5 | export abstract class AggregateRoot extends BaseEntity { 6 | @CreateDateColumn({ 7 | type: 'timestamp with time zone', 8 | default: () => 'CURRENT_TIMESTAMP', 9 | }) 10 | createdAt: Date; 11 | 12 | @UpdateDateColumn({ 13 | type: 'timestamp with time zone', 14 | default: () => 'CURRENT_TIMESTAMP', 15 | onUpdate: 'CURRENT_TIMESTAMP', 16 | }) 17 | updatedAt: Date; 18 | 19 | domainEvents: DomainEvent[] = []; 20 | } 21 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { AppModule } from './app.module'; 4 | import { CustomLogger } from './custom.logger'; 5 | 6 | async function bootstrap() { 7 | const app = await NestFactory.create(AppModule); 8 | 9 | app.useGlobalPipes( 10 | new ValidationPipe({ 11 | whitelist: true, 12 | forbidNonWhitelisted: true, 13 | transform: true, 14 | transformOptions: { 15 | enableImplicitConversion: true, 16 | }, 17 | }), 18 | ); 19 | 20 | app.useLogger(app.get(CustomLogger)); 21 | 22 | await app.listen(3000); 23 | } 24 | bootstrap(); 25 | -------------------------------------------------------------------------------- /src/domain/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { UserSkill } from './entities/user-skill.entity'; 4 | import { User } from './entities/user.entity'; 5 | import { UsersController } from './users.controller'; 6 | import { UsersRepository } from './users.repository'; 7 | import { UsersService } from './users.service'; 8 | 9 | @Module({ 10 | imports: [ 11 | TypeOrmModule.forFeature([User]), 12 | TypeOrmModule.forFeature([UserSkill]), 13 | ], 14 | controllers: [UsersController], 15 | providers: [UsersRepository, UsersService], 16 | exports: [UsersService], 17 | }) 18 | export class UsersModule {} 19 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:13 4 | container_name: nestjs-folders-postgres 5 | environment: 6 | POSTGRES_DB: nestjsfoldersdb 7 | POSTGRES_USER: postgres 8 | POSTGRES_PASSWORD: postgres 9 | ports: 10 | - '5432:5432' 11 | volumes: 12 | - postgres_data:/var/lib/postgresql/data 13 | networks: 14 | - nestjs-folders-network 15 | 16 | redis: 17 | image: redis:6.2 18 | container_name: nestjs-folders-redis 19 | ports: 20 | - '6379:6379' 21 | networks: 22 | - nestjs-folders-network 23 | 24 | volumes: 25 | postgres_data: 26 | 27 | networks: 28 | nestjs-folders-network: 29 | driver: bridge 30 | -------------------------------------------------------------------------------- /.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: ['plugin:@typescript-eslint/recommended'], 10 | root: true, 11 | env: { 12 | node: true, 13 | jest: true, 14 | }, 15 | ignorePatterns: ['.eslintrc.js'], 16 | rules: { 17 | '@typescript-eslint/interface-name-prefix': 'off', 18 | '@typescript-eslint/explicit-function-return-type': 'off', 19 | '@typescript-eslint/explicit-module-boundary-types': 'off', 20 | '@typescript-eslint/no-explicit-any': 'off', 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /src/domain/shared/domain-events/domain-events-store.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AggregateRoot } from '../aggregate-root'; 3 | 4 | @Injectable() 5 | export class DomainEventsStore { 6 | private entitiesWithEvents: AggregateRoot[] = []; 7 | 8 | collect(entity: AggregateRoot) { 9 | if (!(entity instanceof AggregateRoot)) { 10 | return; 11 | } 12 | this.entitiesWithEvents.push(entity); 13 | } 14 | 15 | get() { 16 | return this.entitiesWithEvents.flatMap((entity) => entity.domainEvents); 17 | } 18 | 19 | clear() { 20 | for (const entity of this.entitiesWithEvents) { 21 | entity.domainEvents = []; 22 | } 23 | this.entitiesWithEvents = []; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/domain/users/entities/user-skill.entity.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity } from 'src/domain/shared/base-entity'; 2 | import { Skill } from 'src/domain/skills/entities/skill.entity'; 3 | import { Column, Entity, JoinColumn, ManyToOne, Unique } from 'typeorm'; 4 | import { User } from './user.entity'; 5 | 6 | @Entity('user_skills') 7 | @Unique(['user', 'skillId']) 8 | export class UserSkill extends BaseEntity { 9 | @ManyToOne(() => User, (user) => user.skills, { onDelete: 'CASCADE' }) 10 | @JoinColumn({ name: 'userId' }) 11 | user: User; 12 | 13 | @Column({ type: 'bigint' }) 14 | @ManyToOne(() => Skill, { onDelete: 'CASCADE' }) 15 | @JoinColumn({ name: 'skillId' }) 16 | skillId: string; 17 | 18 | @Column({ type: 'int2' }) 19 | score: number; 20 | } 21 | -------------------------------------------------------------------------------- /src/domain/notifications/notifications.module.ts: -------------------------------------------------------------------------------- 1 | import { NotifierModule } from 'src/integrations/notifier/notifier.module'; 2 | import { Module } from '@nestjs/common'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { UsersModule } from '../users/users.module'; 5 | import { Notification } from './entities/notification.entity'; 6 | import { NotificationsRepository } from './notifications.repository'; 7 | import { NotificationsService } from './notifications.service'; 8 | 9 | @Module({ 10 | imports: [ 11 | UsersModule, 12 | NotifierModule, 13 | TypeOrmModule.forFeature([Notification]), 14 | ], 15 | providers: [NotificationsRepository, NotificationsService], 16 | exports: [NotificationsService], 17 | }) 18 | export class NotificationsModule {} 19 | -------------------------------------------------------------------------------- /src/domain/notifications/dto/create-notification.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString, validateOrReject } from 'class-validator'; 2 | 3 | export class CreateNotificationDto { 4 | constructor( 5 | userId: string, 6 | title: string, 7 | longMessage: string, 8 | shortMessage: string, 9 | ) { 10 | this.userId = userId; 11 | this.title = title; 12 | this.longMessage = longMessage; 13 | this.shortMessage = shortMessage; 14 | 15 | if (arguments.length) { 16 | validateOrReject(this); 17 | } 18 | } 19 | 20 | @IsString() 21 | @IsNotEmpty() 22 | title: string; 23 | 24 | @IsString() 25 | @IsNotEmpty() 26 | longMessage: string; 27 | 28 | @IsString() 29 | @IsNotEmpty() 30 | shortMessage: string; 31 | 32 | @IsString() 33 | @IsNotEmpty() 34 | userId: string; 35 | } 36 | -------------------------------------------------------------------------------- /src/utils/generate-id.ts: -------------------------------------------------------------------------------- 1 | import { Snowflake } from 'nodejs-snowflake'; 2 | 3 | const uid = new Snowflake(); 4 | 5 | export function generateId(): string { 6 | // Snowflake generates a unique ID as a BigInt. We cast it to a string for these reasons: 7 | 8 | // 1. BigInt can't be serialized directly in JSON (e.g., for API responses or JSON-based storage). 9 | // E.g., JSON.stringify(897976876987897n) will throw an error. 10 | 11 | // 2. Using strings for IDs ensures they work safely across different systems and databases 12 | // without issues like precision loss, especially with very large numbers. 13 | 14 | // 3. IDs aren't usually involved in math operations, so we don't require them as numbers in code, 15 | // and they are still stored as numbers in Postgres. No harm done. 16 | 17 | return uid.getUniqueID().toString(); 18 | } 19 | -------------------------------------------------------------------------------- /src/integrations/notifier/dto/send-notification.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsEmail, IsOptional, IsString, validateOrReject 3 | } from 'class-validator'; 4 | 5 | export class SendNotificationDto { 6 | constructor( 7 | title: string, 8 | longMessage: string, 9 | shortMessage: string, 10 | email: string, 11 | phone: string, 12 | ) { 13 | this.title = title; 14 | this.longMessage = longMessage; 15 | this.shortMessage = shortMessage; 16 | this.email = email; 17 | this.phone = phone; 18 | 19 | if (arguments.length) { 20 | validateOrReject(this); 21 | } 22 | } 23 | 24 | @IsString() 25 | title: string; 26 | 27 | @IsString() 28 | longMessage: string; 29 | 30 | @IsString() 31 | @IsOptional() 32 | shortMessage?: string; 33 | 34 | @IsEmail() 35 | @IsOptional() 36 | email?: string; 37 | 38 | @IsString() 39 | @IsOptional() 40 | phone?: string; 41 | } 42 | -------------------------------------------------------------------------------- /src/domain/users/dto/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { 3 | IsArray, 4 | IsEmail, 5 | IsNotEmpty, 6 | IsOptional, 7 | IsString, 8 | MinLength, 9 | ValidateNested, 10 | } from 'class-validator'; 11 | import { CreateUserSkillDto } from './create-user-skill.dto'; 12 | 13 | export class CreateUserDto { 14 | @IsString() 15 | @IsNotEmpty() 16 | firstName: string; 17 | 18 | @IsString() 19 | @IsNotEmpty() 20 | lastName: string; 21 | 22 | @IsEmail() 23 | @IsNotEmpty() 24 | email: string; 25 | 26 | @IsString() 27 | @IsNotEmpty() 28 | phone: string; 29 | 30 | @IsString() 31 | @MinLength(6) 32 | password: string; 33 | 34 | @IsString() 35 | @IsNotEmpty() 36 | @IsOptional() 37 | managerId?: string; 38 | 39 | @IsOptional() 40 | @IsArray() 41 | @ValidateNested({ each: true }) 42 | @Type(() => CreateUserSkillDto) 43 | skills?: CreateUserSkillDto[]; 44 | } 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /build 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | # dotenv environment variable files 39 | .env 40 | .env.development.local 41 | .env.test.local 42 | .env.production.local 43 | .env.local 44 | 45 | # temp directory 46 | .temp 47 | .tmp 48 | 49 | # Runtime data 50 | pids 51 | *.pid 52 | *.seed 53 | *.pid.lock 54 | 55 | # Diagnostic reports (https://nodejs.org/api/report.html) 56 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 57 | -------------------------------------------------------------------------------- /src/domain/users/users.http: -------------------------------------------------------------------------------- 1 | ### list 2 | GET http://localhost:3000/users 3 | # ?filter={"skills.score":{"$gt":7}} - filter by skills score greater than 7 4 | # ?filter={"managerId":{"$null":true}} - filter by managerId is null 5 | # ?filter={"managerId":"370466644992"} - filter by managerId 6 | # ?filter={"email":{"$startsWith":"john.doe1@"}} - filter by email starts with 7 | 8 | ### get 9 | GET http://localhost:3000/users/145301495808 10 | 11 | ### create 12 | POST http://localhost:3000/users 13 | Content-Type: application/json 14 | 15 | { 16 | "firstName": "John", 17 | "lastName": "Doe", 18 | "email": "john.doe3@acme.com", 19 | "phone": "1234567890", 20 | "password": "password", 21 | "skills": [{ 22 | "skillId": "3110057861120", 23 | "score": 10 24 | }] 25 | } 26 | 27 | ### update 28 | PATCH http://localhost:3000/users/6067654549504 29 | Content-Type: application/json 30 | 31 | { 32 | "managerId": null, 33 | "skills": [{ 34 | "skillId": "3110057861120", 35 | "score": 9 36 | }, { 37 | "skillId": "3142773432320", 38 | "score": 8 39 | }] 40 | } 41 | 42 | ### delete 43 | DELETE http://localhost:3000/users/165354695680 44 | -------------------------------------------------------------------------------- /src/domain/users/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { AggregateRoot } from 'src/domain/shared/aggregate-root'; 2 | import { hashPassword } from 'src/utils/hash-password'; 3 | import { 4 | AfterLoad, 5 | BeforeInsert, 6 | Column, 7 | Entity, 8 | JoinColumn, 9 | ManyToOne, 10 | OneToMany, 11 | } from 'typeorm'; 12 | import { UserSkill } from './user-skill.entity'; 13 | 14 | @Entity('users') 15 | export class User extends AggregateRoot { 16 | @Column() 17 | firstName: string; 18 | 19 | @Column() 20 | lastName: string; 21 | 22 | @Column({ unique: true }) 23 | email: string; 24 | 25 | @Column() 26 | phone: string; 27 | 28 | @Column() 29 | password: string; 30 | 31 | @Column({ nullable: true }) 32 | @JoinColumn({ name: 'managerId' }) 33 | @ManyToOne(() => User, null, { onDelete: 'SET NULL' }) 34 | managerId?: string; 35 | 36 | @OneToMany(() => UserSkill, (skill) => skill.user, { 37 | cascade: true, // Automatically save, update, and remove user skills 38 | eager: true, // Automatically load skills when user is loaded 39 | }) 40 | skills: UserSkill[]; 41 | 42 | @BeforeInsert() 43 | protected async beforeInsert() { 44 | this.password = await hashPassword(this.password); 45 | } 46 | 47 | @AfterLoad() 48 | protected afterLoad() { 49 | this.skills = this.skills ?? []; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/integrations/notifier/notifier.service.ts: -------------------------------------------------------------------------------- 1 | import { Job, Queue } from 'bull'; 2 | import { InjectQueue, Process, Processor } from '@nestjs/bull'; 3 | import { SendNotificationDto } from './dto/send-notification.dto'; 4 | import { EmailService } from './implementations/email.service'; 5 | import { SmsService } from './implementations/sms.service'; 6 | 7 | @Processor('notifications') 8 | export class NotifierService { 9 | constructor( 10 | private readonly email: EmailService, 11 | private readonly sms: SmsService, 12 | @InjectQueue('notifications') private readonly queue: Queue, 13 | ) {} 14 | 15 | async send(dto: SendNotificationDto): Promise { 16 | if (dto.email) { 17 | await this.queue.add('send-email', dto); 18 | } 19 | if (dto.phone) { 20 | await this.queue.add('send-sms', dto); 21 | } 22 | } 23 | 24 | @Process('send-email') 25 | protected async sendEmail(job: Job): Promise { 26 | const { email, title, shortMessage, longMessage } = job.data; 27 | await this.email.send(email, title, longMessage || shortMessage); 28 | } 29 | 30 | @Process('send-sms') 31 | protected async sendSms(job: Job): Promise { 32 | const { phone: phoneNumber, shortMessage, longMessage } = job.data; 33 | await this.sms.send( 34 | phoneNumber, 35 | shortMessage || longMessage.substring(0, 160), 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/domain/notifications/notifications.service.ts: -------------------------------------------------------------------------------- 1 | import { SendNotificationDto } from 'src/integrations/notifier/dto/send-notification.dto'; 2 | import { NotifierService as NotificationsSender } from 'src/integrations/notifier/notifier.service'; 3 | import { BadRequestException, Injectable } from '@nestjs/common'; 4 | import { UsersService } from '../users/users.service'; 5 | import { CreateNotificationDto } from './dto/create-notification.dto'; 6 | import { Notification } from './entities/notification.entity'; 7 | import { NotificationsRepository } from './notifications.repository'; 8 | 9 | @Injectable() 10 | export class NotificationsService { 11 | constructor( 12 | private readonly repository: NotificationsRepository, 13 | private readonly sender: NotificationsSender, 14 | private readonly users: UsersService, 15 | ) {} 16 | 17 | async create(dto: CreateNotificationDto): Promise { 18 | const user = await this.users.findOne({ id: dto.userId }); 19 | if (!user) { 20 | throw new BadRequestException(`Invalid user ID ${dto.userId}`); 21 | } 22 | 23 | const notification = await this.repository.save( 24 | this.repository.create(dto), 25 | ); 26 | 27 | await this.sender.send( 28 | new SendNotificationDto( 29 | dto.title, 30 | dto.longMessage, 31 | dto.shortMessage, 32 | user.email, 33 | user.phone, 34 | ), 35 | ); 36 | 37 | return notification; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/domain/shared/domain-events/domain-events-subscriber.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DataSource, 3 | EntitySubscriberInterface, 4 | InsertEvent, 5 | RemoveEvent, 6 | UpdateEvent, 7 | } from 'typeorm'; 8 | import { Injectable } from '@nestjs/common'; 9 | import { AggregateRoot } from '../aggregate-root'; 10 | import { DomainEventsPublisher } from './domain-events-publisher'; 11 | import { DomainEventsStore } from './domain-events-store'; 12 | 13 | @Injectable() 14 | export class DomainEventsSubscriber 15 | implements EntitySubscriberInterface 16 | { 17 | constructor( 18 | dataSource: DataSource, 19 | private readonly domainEvents: DomainEventsStore, 20 | private readonly publisher: DomainEventsPublisher, 21 | ) { 22 | dataSource.subscribers.push(this); 23 | } 24 | 25 | listenTo() { 26 | return AggregateRoot; 27 | } 28 | 29 | async afterInsert(event: InsertEvent) { 30 | this.domainEvents.collect(event.entity); 31 | } 32 | 33 | async afterUpdate(event: UpdateEvent) { 34 | this.domainEvents.collect(event.entity as AggregateRoot); 35 | } 36 | 37 | async afterRemove(event: RemoveEvent) { 38 | this.domainEvents.collect(event.entity); 39 | } 40 | 41 | async afterTransactionCommit() { 42 | await this.publisher.publishEvents(this.domainEvents.get()); 43 | this.domainEvents.clear(); 44 | } 45 | 46 | async afterTransactionRollback() { 47 | this.domainEvents.clear(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/application/event-handlers/employee-added/notify-manager.handler.ts: -------------------------------------------------------------------------------- 1 | import { Job } from 'bull'; 2 | import { 3 | BackgroundEventHandler, 4 | BackgroundEventsHandler, 5 | } from 'src/application/background-event-handler'; 6 | import { CreateNotificationDto } from 'src/domain/notifications/dto/create-notification.dto'; 7 | import { NotificationsService } from 'src/domain/notifications/notifications.service'; 8 | import { EmployeeAddedEvent } from 'src/domain/users/events/employee-added.event'; 9 | import { UsersService } from 'src/domain/users/users.service'; 10 | 11 | @BackgroundEventsHandler(EmployeeAddedEvent, 'employee-added', 'notify-manager') 12 | export class EmployeeAdded_NotifyManagerHandler extends BackgroundEventHandler { 13 | constructor( 14 | private readonly users: UsersService, 15 | private readonly notifications: NotificationsService, 16 | ) { 17 | super(); 18 | } 19 | 20 | async processJob(job: Job): Promise { 21 | const event = job.data; 22 | const employee = await this.users.findOne({ id: event.employeeId }); 23 | 24 | const notification = new CreateNotificationDto( 25 | event.managerId, 26 | 'New Employee Added', 27 | `A new employee, ${employee.firstName} ${employee.lastName}, has been added to your team. You can view their profile and assign them tasks in the app.`, 28 | `${employee.firstName} ${employee.lastName} has been added to your team.`, 29 | ); 30 | 31 | await this.notifications.create(notification); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/domain/shared/base-repository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DataSource, 3 | EntityTarget, 4 | ObjectLiteral, 5 | Repository, 6 | SelectQueryBuilder, 7 | } from 'typeorm'; 8 | import { applyFilters, Filter } from './apply-filters'; 9 | 10 | export abstract class BaseRepository< 11 | T extends ObjectLiteral, 12 | > extends Repository { 13 | constructor( 14 | readonly target: EntityTarget, 15 | dataSource: DataSource, 16 | ) { 17 | super(target, dataSource.manager); 18 | } 19 | 20 | async filterExists(filter: Filter): Promise { 21 | return this.getFilteredQueryBuilder(filter).getExists(); 22 | } 23 | 24 | async filterOne(filter: Filter): Promise { 25 | return (await this.getFilteredQueryBuilder(filter).getOne()) ?? null; 26 | } 27 | 28 | async filterAll(filter?: Filter): Promise { 29 | return this.getFilteredQueryBuilder(filter).getMany(); 30 | } 31 | 32 | private getFilteredQueryBuilder(filter?: Filter): SelectQueryBuilder { 33 | let qb = this.manager.createQueryBuilder(this.target, 'entity'); 34 | qb = this.addEagerRelations(qb); 35 | if (filter) qb = applyFilters(qb, this.target, 'entity', filter); 36 | return qb; 37 | } 38 | 39 | private addEagerRelations(qb: SelectQueryBuilder): SelectQueryBuilder { 40 | const metadata = this.manager.connection.getMetadata(this.target); 41 | metadata.relations 42 | .filter((relation) => relation.isEager) 43 | .forEach((relation) => { 44 | qb.leftJoinAndSelect( 45 | `entity.${relation.propertyName}`, 46 | relation.propertyName, 47 | ); 48 | }); 49 | return qb; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/domain/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { plainToClass } from 'class-transformer'; 2 | import { 3 | Body, 4 | Controller, 5 | Delete, 6 | Get, 7 | HttpCode, 8 | NotFoundException, 9 | Param, 10 | Patch, 11 | Post, 12 | Query, 13 | } from '@nestjs/common'; 14 | import { Filter } from '../shared/apply-filters'; 15 | import { CreateUserDto } from './dto/create-user.dto'; 16 | import { UpdateUserDto } from './dto/update-user.dto'; 17 | import { UserDto } from './dto/user.dto'; 18 | import { UsersService } from './users.service'; 19 | 20 | @Controller('users') 21 | export class UsersController { 22 | constructor(private readonly users: UsersService) {} 23 | 24 | @Get() 25 | async findAll(@Query('filter') filter?: Filter) { 26 | return this.users 27 | .findAll(filter) 28 | .then((users) => users.map((user) => plainToClass(UserDto, user))); 29 | } 30 | 31 | @Get(':id') 32 | async findOne(@Param('id') id: string) { 33 | const user = await this.users.findOne({ id }); 34 | if (!user) throw new NotFoundException(); 35 | return plainToClass(UserDto, user); 36 | } 37 | 38 | @Post() 39 | @HttpCode(201) 40 | async create(@Body() dto: CreateUserDto) { 41 | const user = await this.users.create(dto); 42 | return plainToClass(UserDto, user); 43 | } 44 | 45 | @Patch(':id') 46 | async update(@Param('id') id: string, @Body() dto: UpdateUserDto) { 47 | const user = await this.users.update(id, dto); 48 | if (!user) throw new NotFoundException(); 49 | return plainToClass(UserDto, user); 50 | } 51 | 52 | @Delete(':id') 53 | @HttpCode(204) 54 | async remove(@Param('id') id: string) { 55 | return this.users.remove(id); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/domain/skills/skills.controller.ts: -------------------------------------------------------------------------------- 1 | import { plainToClass } from 'class-transformer'; 2 | import { 3 | Body, 4 | Controller, 5 | Delete, 6 | Get, 7 | HttpCode, 8 | NotFoundException, 9 | Param, 10 | Patch, 11 | Post, 12 | Query, 13 | } from '@nestjs/common'; 14 | import { Filter } from '../shared/apply-filters'; 15 | import { CreateSkillDto } from './dto/create-skill.dto'; 16 | import { SkillDto } from './dto/skill.dto'; 17 | import { UpdateSkillDto } from './dto/update-skill.dto'; 18 | import { SkillsService } from './skills.service'; 19 | 20 | @Controller('skills') 21 | export class SkillsController { 22 | constructor(private readonly skills: SkillsService) {} 23 | 24 | @Get() 25 | async findAll(@Query('filter') filter?: Filter) { 26 | return this.skills 27 | .findAll(filter) 28 | .then((skills) => skills.map((skill) => plainToClass(SkillDto, skill))); 29 | } 30 | 31 | @Get(':id') 32 | async findOne(@Param('id') id: string) { 33 | const skill = await this.skills.findOne({ id }); 34 | if (!skill) throw new NotFoundException(); 35 | return plainToClass(SkillDto, skill); 36 | } 37 | 38 | @Post() 39 | @HttpCode(201) 40 | async create(@Body() dto: CreateSkillDto) { 41 | const skill = await this.skills.create(dto); 42 | return plainToClass(SkillDto, skill); 43 | } 44 | 45 | @Patch(':id') 46 | async update(@Param('id') id: string, @Body() dto: UpdateSkillDto) { 47 | const skill = await this.skills.update(id, dto); 48 | if (!skill) throw new NotFoundException(); 49 | return plainToClass(SkillDto, skill); 50 | } 51 | 52 | @Delete(':id') 53 | @HttpCode(204) 54 | async remove(@Param('id') id: string) { 55 | return this.skills.remove(id); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/domain/skills/skills.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable } from '@nestjs/common'; 2 | import { Filter } from '../shared/apply-filters'; 3 | import { CreateSkillDto } from './dto/create-skill.dto'; 4 | import { UpdateSkillDto } from './dto/update-skill.dto'; 5 | import { Skill } from './entities/skill.entity'; 6 | import { SkillsRepository } from './skills.repository'; 7 | 8 | @Injectable() 9 | export class SkillsService { 10 | constructor(private readonly repository: SkillsRepository) {} 11 | 12 | async exists(filter: Filter): Promise { 13 | return await this.repository.filterExists(filter); 14 | } 15 | 16 | async findOne(filter: Filter): Promise { 17 | return await this.repository.filterOne(filter); 18 | } 19 | 20 | async findAll(filter?: Filter): Promise { 21 | return await this.repository.filterAll(filter); 22 | } 23 | 24 | async create(dto: CreateSkillDto): Promise { 25 | const nameExists = await this.exists({ name: dto.name }); 26 | if (nameExists) { 27 | throw new BadRequestException(`Skill ${dto.name} already exists`); 28 | } 29 | const skill = this.repository.create(dto); 30 | return await this.repository.save(skill); 31 | } 32 | 33 | async update(id: string, dto: UpdateSkillDto): Promise { 34 | const skill = await this.findOne({ id }); 35 | if (!skill) return; 36 | if (dto.name && dto.name !== skill.name) { 37 | const nameExists = await this.exists({ name: dto.name }); 38 | if (nameExists) { 39 | throw new BadRequestException(`Skill ${dto.name} already exists`); 40 | } 41 | } 42 | Object.assign(skill, dto); 43 | return await this.repository.save(skill); 44 | } 45 | 46 | async remove(id: string): Promise { 47 | const skill = await this.findOne({ id }); 48 | if (!skill) return; 49 | await this.repository.remove(skill); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { LoggerModule } from 'nestjs-pino'; 2 | import { BullModule } from '@nestjs/bull'; 3 | import { Module } from '@nestjs/common'; 4 | import { ConfigModule, ConfigService } from '@nestjs/config'; 5 | import { CqrsModule } from '@nestjs/cqrs'; 6 | import { TypeOrmModule } from '@nestjs/typeorm'; 7 | import { AppController } from './app.controller'; 8 | import { ApplicationModule } from './application/application.module'; 9 | import { CustomLogger } from './custom.logger'; 10 | import { NotificationsModule } from './domain/notifications/notifications.module'; 11 | import { SharedModule } from './domain/shared/shared.module'; 12 | import { SkillsModule } from './domain/skills/skills.module'; 13 | import { UsersModule } from './domain/users/users.module'; 14 | 15 | @Module({ 16 | imports: [ 17 | ApplicationModule, 18 | 19 | // Domain modules 20 | SharedModule, 21 | NotificationsModule, 22 | SkillsModule, 23 | UsersModule, 24 | 25 | // Load environment variables 26 | ConfigModule.forRoot({ 27 | isGlobal: true, 28 | }), 29 | 30 | // TypeORM configuration 31 | TypeOrmModule.forRootAsync({ 32 | imports: [ConfigModule], 33 | useFactory: (configService: ConfigService) => ({ 34 | type: 'postgres', 35 | url: configService.get('DATABASE_URL'), 36 | autoLoadEntities: true, 37 | synchronize: configService.get('NODE_ENV') === 'development', 38 | logging: configService.get('NODE_ENV') === 'development', 39 | }), 40 | inject: [ConfigService], 41 | }), 42 | 43 | // Background processing 44 | BullModule.forRootAsync({ 45 | imports: [ConfigModule], 46 | useFactory: (configService: ConfigService) => ({ 47 | redis: { 48 | host: configService.get('REDIS_HOST'), 49 | port: configService.get('REDIS_PORT'), 50 | }, 51 | defaultJobOptions: { 52 | removeOnComplete: true, 53 | removeOnFail: 100, 54 | attempts: 3, 55 | backoff: { 56 | type: 'exponential', 57 | delay: 5000, 58 | }, 59 | }, 60 | }), 61 | inject: [ConfigService], 62 | }), 63 | 64 | // Other general modules 65 | CqrsModule.forRoot(), 66 | LoggerModule.forRoot({ pinoHttp: { level: 'trace' } }), 67 | ], 68 | providers: [CustomLogger], 69 | exports: [CustomLogger], 70 | controllers: [AppController], 71 | }) 72 | export class AppModule {} 73 | -------------------------------------------------------------------------------- /src/application/background-event-handler.ts: -------------------------------------------------------------------------------- 1 | import { Job, Queue } from 'bull'; 2 | import { getQueueToken, Process, Processor } from '@nestjs/bull'; 3 | import { Inject, Injectable, SetMetadata, Type } from '@nestjs/common'; 4 | import { ModuleRef } from '@nestjs/core'; 5 | import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; 6 | 7 | export const QUEUE_NAME_METADATA = 'QUEUE_NAME_METADATA'; 8 | export const JOB_NAME_METADATA = 'JOB_NAME_METADATA'; 9 | 10 | export function BackgroundEventsHandler( 11 | eventClass: Type, 12 | queueName: string, 13 | jobName: string, 14 | ) { 15 | return function (target: Type) { 16 | // Apply the @Processor decorator to the class 17 | Processor(queueName)(target); 18 | 19 | // Apply the @EventsHandler decorator to the class for the specified event 20 | EventsHandler(eventClass)(target); 21 | 22 | // Store metadata for queue and job names 23 | SetMetadata(QUEUE_NAME_METADATA, queueName)(target); 24 | SetMetadata(JOB_NAME_METADATA, jobName)(target); 25 | 26 | // Apply the @Process decorator to the processJob method 27 | const processJobMethod = target.prototype.processJob; 28 | if (processJobMethod) { 29 | Process(jobName)( 30 | target.prototype, 31 | 'processJob', 32 | Object.getOwnPropertyDescriptor(target.prototype, 'processJob'), 33 | ); 34 | } 35 | }; 36 | } 37 | 38 | @Injectable() 39 | export abstract class BackgroundEventHandler 40 | implements IEventHandler 41 | { 42 | @Inject() 43 | private readonly moduleRef: ModuleRef; 44 | 45 | protected queue: Queue; 46 | 47 | async handle(event: TEvent): Promise { 48 | const jobName = Reflect.getMetadata(JOB_NAME_METADATA, this.constructor); 49 | if (!jobName) { 50 | throw new Error( 51 | `Job name not found in metadata for ${this.constructor.name}`, 52 | ); 53 | } 54 | 55 | if (!this.queue) { 56 | const queueName = Reflect.getMetadata( 57 | QUEUE_NAME_METADATA, 58 | this.constructor, 59 | ); 60 | if (!queueName) { 61 | throw new Error( 62 | `Queue name not found in metadata for ${this.constructor.name}`, 63 | ); 64 | } 65 | 66 | // Retrieve the queue instance using ModuleRef 67 | const queueToken = getQueueToken(queueName); 68 | this.queue = this.moduleRef.get(queueToken, { 69 | strict: false, 70 | }); 71 | if (!this.queue) { 72 | throw new Error(`Queue '${queueName}' not found in the module context`); 73 | } 74 | } 75 | 76 | await this.queue.add(jobName, event); 77 | } 78 | 79 | abstract processJob(job: Job): Promise; 80 | } 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-folder-structure", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "cross-env NODE_ENV=development nest start --watch | pino-pretty", 13 | "start:debug": "cross-env NODE_ENV=development nest start --debug --watch | pino-pretty", 14 | "start:prod": "node dist/main", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "test:cov": "jest --coverage", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "jest --config ./test/jest-e2e.json" 21 | }, 22 | "dependencies": { 23 | "@nestjs/bull": "^10.2.1", 24 | "@nestjs/common": "^10.0.0", 25 | "@nestjs/config": "^3.2.3", 26 | "@nestjs/core": "^10.0.0", 27 | "@nestjs/cqrs": "^10.2.7", 28 | "@nestjs/mapped-types": "*", 29 | "@nestjs/platform-express": "^10.0.0", 30 | "@nestjs/typeorm": "^10.0.2", 31 | "bcrypt": "^5.1.1", 32 | "bull": "^4.16.1", 33 | "class-transformer": "^0.5.1", 34 | "class-validator": "^0.14.1", 35 | "cross-env": "^7.0.3", 36 | "nestjs-pino": "^4.1.0", 37 | "nodejs-snowflake": "^2.0.1", 38 | "pg": "^8.12.0", 39 | "pino-http": "^10.3.0", 40 | "reflect-metadata": "^0.2.0", 41 | "rxjs": "^7.8.1", 42 | "typeorm": "^0.3.20" 43 | }, 44 | "devDependencies": { 45 | "@nestjs/cli": "^10.0.0", 46 | "@nestjs/schematics": "^10.0.0", 47 | "@nestjs/testing": "^10.0.0", 48 | "@types/bcrypt": "^5.0.2", 49 | "@types/express": "^4.17.17", 50 | "@types/jest": "^29.5.2", 51 | "@types/node": "^20.3.1", 52 | "@types/supertest": "^6.0.0", 53 | "@typescript-eslint/eslint-plugin": "^6.0.0", 54 | "@typescript-eslint/parser": "^6.0.0", 55 | "eslint": "^8.42.0", 56 | "eslint-config-prettier": "^9.0.0", 57 | "eslint-plugin-prettier": "^5.0.0", 58 | "jest": "^29.5.0", 59 | "pino-pretty": "^11.3.0", 60 | "prettier": "^3.0.0", 61 | "source-map-support": "^0.5.21", 62 | "supertest": "^6.3.3", 63 | "ts-jest": "^29.1.0", 64 | "ts-loader": "^9.4.3", 65 | "ts-node": "^10.9.1", 66 | "tsconfig-paths": "^4.2.0", 67 | "typescript": "^5.1.3" 68 | }, 69 | "jest": { 70 | "moduleFileExtensions": [ 71 | "js", 72 | "json", 73 | "ts" 74 | ], 75 | "rootDir": "src", 76 | "testRegex": ".*\\.spec\\.ts$", 77 | "transform": { 78 | "^.+\\.(t|j)s$": "ts-jest" 79 | }, 80 | "collectCoverageFrom": [ 81 | "**/*.(t|j)s" 82 | ], 83 | "coverageDirectory": "../coverage", 84 | "testEnvironment": "node" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/custom.logger.ts: -------------------------------------------------------------------------------- 1 | import { InjectPinoLogger, PinoLogger } from 'nestjs-pino'; 2 | import { LoggerService } from '@nestjs/common'; 3 | 4 | export class CustomLogger implements LoggerService { 5 | private static contextRules: Record = {}; 6 | 7 | private readonly DEFAULT_CONTEXT = '*'; 8 | private readonly DEFAULT_LEVEL = 'info'; 9 | private readonly LOG_LEVEL_MAP: Record = { 10 | trace: 0, 11 | debug: 1, 12 | info: 2, 13 | warn: 3, 14 | error: 4, 15 | }; 16 | 17 | constructor( 18 | @InjectPinoLogger() 19 | private readonly logger: PinoLogger, 20 | ) { 21 | if (Object.keys(CustomLogger.contextRules).length === 0) { 22 | this.initializeContextRules(); 23 | } 24 | } 25 | 26 | verbose(message: string, context?: string) { 27 | if (this.shouldLog('trace', context)) { 28 | this.logger.trace({ context }, message); 29 | } 30 | } 31 | 32 | debug(message: string, context?: string) { 33 | if (this.shouldLog('debug', context)) { 34 | this.logger.debug({ context }, message); 35 | } 36 | } 37 | 38 | log(message: string, context?: string) { 39 | if (this.shouldLog('info', context)) { 40 | this.logger.info({ context }, message); 41 | } 42 | } 43 | 44 | warn(message: string, context?: string) { 45 | if (this.shouldLog('warn', context)) { 46 | this.logger.warn({ context }, message); 47 | } 48 | } 49 | 50 | error(message: string, trace?: string, context?: string) { 51 | if (this.shouldLog('error', context)) { 52 | this.logger.error({ context, trace }, message); 53 | } 54 | } 55 | 56 | private initializeContextRules() { 57 | const rules = process.env.LOG_RULES ?? ''; 58 | if (!rules) { 59 | CustomLogger.contextRules[this.DEFAULT_CONTEXT] = 60 | this.LOG_LEVEL_MAP[this.DEFAULT_LEVEL]; 61 | return; 62 | } 63 | 64 | const ruleEntries = rules.split('/'); 65 | for (const rule of ruleEntries) { 66 | let contextPart = this.DEFAULT_CONTEXT; 67 | let levelPart = this.DEFAULT_LEVEL; 68 | const parts = rule.split(';'); 69 | 70 | for (const part of parts) { 71 | if (part.startsWith('context=')) { 72 | contextPart = part.split('=')[1] || this.DEFAULT_CONTEXT; 73 | } else if (part.startsWith('level=')) { 74 | levelPart = part.split('=')[1] || this.DEFAULT_LEVEL; 75 | } 76 | } 77 | 78 | const contexts = contextPart.split(','); 79 | const numericLevel = 80 | this.LOG_LEVEL_MAP[levelPart.trim()] ?? 81 | this.LOG_LEVEL_MAP[this.DEFAULT_LEVEL]; 82 | 83 | for (const context of contexts) { 84 | CustomLogger.contextRules[context.trim()] = numericLevel; 85 | } 86 | } 87 | } 88 | 89 | private shouldLog(methodLevel: string, context: string): boolean { 90 | return this.LOG_LEVEL_MAP[methodLevel] >= this.getLogLevel(context); 91 | } 92 | 93 | private getLogLevel(context?: string): number { 94 | context = context ?? ''; 95 | const level = 96 | CustomLogger.contextRules[context] ?? 97 | CustomLogger.contextRules[this.DEFAULT_CONTEXT] ?? 98 | this.LOG_LEVEL_MAP[this.DEFAULT_LEVEL]; 99 | return level; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/domain/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable } from '@nestjs/common'; 2 | import { Filter } from '../shared/apply-filters'; 3 | import { CreateUserDto } from './dto/create-user.dto'; 4 | import { UpdateUserDto } from './dto/update-user.dto'; 5 | import { UserSkill } from './entities/user-skill.entity'; 6 | import { User } from './entities/user.entity'; 7 | import { EmployeeAddedEvent } from './events/employee-added.event'; 8 | import { UsersRepository } from './users.repository'; 9 | 10 | @Injectable() 11 | export class UsersService { 12 | constructor(private readonly repository: UsersRepository) {} 13 | 14 | async exists(filter: Filter): Promise { 15 | return await this.repository.filterExists(filter); 16 | } 17 | 18 | async findOne(filter: Filter): Promise { 19 | return await this.repository.filterOne(filter); 20 | } 21 | 22 | async findAll(filter?: Filter): Promise { 23 | return await this.repository.filterAll(filter); 24 | } 25 | 26 | async create(dto: CreateUserDto): Promise { 27 | // Email exists? 28 | const emailExists = await this.exists({ email: dto.email }); 29 | if (emailExists) { 30 | throw new BadRequestException(`Email ${dto.email} already in use`); 31 | } 32 | 33 | // Create a new user entity 34 | const user = this.repository.create(dto); 35 | 36 | // Handle optional managerId logic 37 | if (dto.managerId) { 38 | const manager = await this.findOne({ id: dto.managerId }); 39 | if (!manager) { 40 | throw new BadRequestException(`Manager not ${dto.managerId} found`); 41 | } 42 | user.domainEvents.push(new EmployeeAddedEvent(manager.id, user.id)); 43 | } 44 | 45 | // Handle skills (if any are provided) 46 | if (dto.skills && dto.skills.length > 0) { 47 | user.skills = dto.skills.map((skillDto) => 48 | this.repository.manager.create(UserSkill, skillDto), 49 | ); 50 | } 51 | 52 | return await this.repository.save(user); 53 | } 54 | 55 | async update(id: string, dto: UpdateUserDto): Promise { 56 | const user = await this.findOne({ id }); 57 | if (!user) return; 58 | 59 | // New email in use? 60 | if (dto.email && dto.email !== user.email) { 61 | const emailExists = await this.exists({ email: dto.email }); 62 | if (emailExists) { 63 | throw new BadRequestException(`Email ${dto.email} already in use`); 64 | } 65 | } 66 | 67 | // Handle optional managerId logic 68 | if (dto.managerId && dto.managerId !== user.managerId) { 69 | const manager = await this.findOne({ id: dto.managerId }); 70 | if (!manager) { 71 | throw new BadRequestException(`Manager not ${dto.managerId} found`); 72 | } 73 | user.domainEvents.push(new EmployeeAddedEvent(manager.id, user.id)); 74 | } 75 | 76 | // Deconstruct properties that we need to handle separately 77 | const { skills: skillDtos, ...rest } = dto; 78 | 79 | // Update skills if provided 80 | if (skillDtos) { 81 | user.skills = skillDtos.map((skillDto) => { 82 | const existing = user.skills.find( 83 | (s) => s.skillId === skillDto.skillId, 84 | ); 85 | return this.repository.manager.create(UserSkill, { 86 | ...existing, 87 | ...skillDto, 88 | id: existing?.id, 89 | }); 90 | }); 91 | } 92 | 93 | Object.assign(user, rest); 94 | return await this.repository.save(user); 95 | } 96 | 97 | async remove(id: string): Promise { 98 | const user = await this.findOne({ id }); 99 | if (!user) return; 100 | await this.repository.remove(user); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Este projeto é uma aplicação modular construída com NestJS, seguindo princípios de design limpo e alguns conceitos de Domain-Driven Design (DDD). A lógica de negócio principal está na pasta `domain`, enquanto as integrações com serviços externos, como notificações por e-mail e SMS, ficam na pasta `integrations`. A camada `application` coordena fluxos entre domínios diferentes ou domínios e integrações. Funções utilitárias reaproveitáveis estão na pasta `utils`. A ideia geral é manter a separação clara entre regras de negócio e serviços de suporte, garantindo flexibilidade e fácil manutenção no futuro. 2 | 3 | ## Pasta `domain` 4 | 5 | Aqui está toda a lógica central da aplicação, incluindo entidades, serviços e repositórios que gerenciam as regras de negócio. O objetivo é encapsular as **decisões** que são essenciais pro funcionamento do sistema, sem se preocupar com detalhes de implementação ou entrega de dados, que ficam nos módulos da pasta `integrations`. 6 | 7 | Essa separação faz com que os módulos do domínio sejam coesos e independentes de infraestrutura, o que facilita a manutenção. Por exemplo, o módulo `domain/notifications` decide quando e por que uma notificação deve ser enviada, mas a responsabilidade de realmente enviar a notificação é do módulo `integrations/notifier`. Assim, o sistema fica flexível e desacoplado de tecnologias externas. 8 | 9 | ### 5 módulos que poderiam ser adicionados em `domain` 10 | 11 | 1. **Orders**: Para gerenciar pedidos, com regras de criação, atualização e cancelamento. 12 | 2. **Payments**: Para processar e gerenciar transações financeiras. 13 | 3. **Products**: Para gerenciar produtos ou serviços oferecidos. 14 | 4. **Customer management**: Para lidar com informações e interações dos clientes. 15 | 5. **Inventory**: Para controlar estoque e operações de movimentação de inventário. 16 | 17 | ## Pasta `integrations` 18 | 19 | Esta pasta contém módulos que fazem integrações com serviços externos (como e-mails, SMS, gateways de pagamento) ou internos (como importação/exportação de CSV). A ideia é que esses módulos cuidem de funções de suporte, deixando a lógica de negócio desacoplada. 20 | 21 | Cada módulo dentro de `integrations` é pensado para funcionar de forma independente. Por exemplo, o `integrations/notifier` é responsável por enviar as notificações, mas as regras de quando isso deve acontecer estão no módulo de domínio correspondente. Isso facilita substituir ou adicionar novas integrações sem impactar o núcleo do sistema. 22 | 23 | ### 5 módulos de suporte que poderiam ser adicionados em `integrations` 24 | 25 | 1. **Payment gateway**: Integração com provedores de pagamento (ex: Stripe, PayPal). 26 | 2. **File storage**: Integração com serviços de armazenamento de arquivos (ex: AWS S3). 27 | 3. **Email provider**: Integração com provedores de e-mail (ex: SendGrid). 28 | 4. **SMS provider**: Integração com provedores de SMS (ex: Twilio). 29 | 5. **OAuth**: Integração com serviços de autenticação (ex: Google, Facebook). 30 | 31 | ## Pasta `application` 32 | 33 | A pasta `application` é onde ficam os módulos que orquestram e coordenam fluxos de trabalho, conectando módulos de domínio quanto e de integração. É uma camada que funciona como uma ponte entre diferentes partes do sistema, sem conter a lógica de negócio, mas garantindo que as ações certas sejam tomadas em resposta aos eventos que acontecem no domínio. 34 | 35 | A camada `application` cuida da execução dos casos de uso do sistema, coordenando a comunicação entre os módulos. Por exemplo, o módulo de domínio `users` pode emitir um evento como `EmployeeAdded`, e a camada `application` garante que esse evento gere uma notificação para o gerente, usando o módulo de integração `notifications`. Dessa forma, o fluxo de trabalho roda de forma organizada, mantendo bem separadas as responsabilidades de cada parte. 36 | 37 | ### 5 responsabilidades que a camada `application` poderia ter 38 | 39 | 1. **Processamento de eventos**: Gerenciar os eventos do domínio, coordenando como o sistema reage a esses eventos (ex: mandar notificações ou iniciar fluxos). 40 | 2. **Casos de Uso**: Implementar os casos de uso, juntando módulos de domínio e de integração para realizar uma função específica (ex: cadastrar um novo usuário e enviar um e-mail de boas-vindas). 41 | 3. **Coordenação de transações**: Orquestrar transações entre diferentes módulos do sistema (ex: processo de pagamento e atualização do status do pedido). 42 | 4. **Agendamento de tarefas**: Gerenciar tarefas assíncronas ou agendadas que envolvem ações entre os módulos (ex: disparar cobranças recorrentes para usuários via um gateway de pagamento). 43 | 5. **Serviços de aplicação**: Controlar serviços de alto nível que integram vários componentes do sistema, sem implementar a lógica de negócio (ex: um serviço que coordena a importação e exportação de dados entre arquivos CSV e o banco de dados). 44 | 45 | ## Pasta `utils` 46 | 47 | Aqui ficam funções utilitárias que podem ser usadas em diferentes partes da aplicação. Esses helpers não estão diretamente ligados à lógica de negócio ou a integrações específicas, mas ajudam a simplificar operações comuns, promovendo reutilização de código. 48 | 49 | Um exemplo seria a função `hashPassword`, que usa o pacote `bcrypt` para gerar hashes de senhas de forma segura. Ela pode ser usada em vários módulos que lidam com senhas, mantendo a consistência. 50 | 51 | ### 5 funções utilitárias que poderiam ser adicionadas em `utils` 52 | 53 | 1. **formatDate**: Para padronizar a formatação de datas. 54 | 2. **generateId**: Função para gerar IDs únicos. 55 | 3. **validateEmail**: Para validar e-mails. 56 | 4. **randomString**: Para gerar strings aleatórias, útil para senhas temporárias. 57 | 5. **capitalize**: Para capitalizar a primeira letra de uma string. 58 | 59 | ## Installation 60 | 61 | ```bash 62 | $ npm install 63 | ``` 64 | 65 | ## Running the app 66 | 67 | ```bash 68 | # create the containers 69 | $ docker-compose up -d 70 | 71 | # development 72 | $ npm run start 73 | 74 | # watch mode 75 | $ npm run start:dev 76 | 77 | # production mode 78 | $ npm run start:prod 79 | 80 | # cleanup containers and associated volumes 81 | $ docker-compose down -v 82 | ``` 83 | 84 | ## Test 85 | 86 | ```bash 87 | # unit tests 88 | $ npm run test 89 | 90 | # e2e tests 91 | $ npm run test:e2e 92 | 93 | # test coverage 94 | $ npm run test:cov 95 | ``` 96 | -------------------------------------------------------------------------------- /src/domain/shared/apply-filters.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from 'src/utils/is-object'; 2 | import { EntityTarget, SelectQueryBuilder } from 'typeorm'; 3 | 4 | export type LogicalOperator = '$or' | '$and' | '$not'; 5 | 6 | export type Operator = 7 | | '$eq' 8 | | '$ne' 9 | | '$gt' 10 | | '$gte' 11 | | '$lt' 12 | | '$lte' 13 | | '$in' 14 | | '$nin' 15 | | '$like' 16 | | '$ilike' 17 | | '$null' 18 | | '$between' 19 | | '$contains' 20 | | '$contained' 21 | | '$overlap' 22 | | '$startsWith' 23 | | '$endsWith'; 24 | 25 | type Primitive = string | number | boolean | null; 26 | 27 | export type Condition = { 28 | [field: string]: { [key in Operator]?: Primitive | Primitive[] } | Primitive; 29 | }; 30 | 31 | export interface LogicalFilter { 32 | $or?: Filter[]; 33 | $and?: Filter[]; 34 | $not?: Filter; 35 | } 36 | 37 | /** 38 | * The `filter` parameter can include logical operators (`$or`, `$and`, `$not`) as well as field-specific 39 | * comparison operators (e.g., `$eq`, `$ne`, `$in`, `$like`, etc.). 40 | * 41 | * ### Supported Logical Operators: 42 | * - `$or`: Combine multiple conditions where any one condition can be true. 43 | * - `$and`: Combine multiple conditions where all conditions must be true. 44 | * - `$not`: Negate a condition or group of conditions. 45 | * 46 | * ### Supported Field Comparison Operators: 47 | * - `$eq`: Equal to a value. 48 | * - `$ne`: Not equal to a value. 49 | * - `$gt`: Greater than a value. 50 | * - `$gte`: Greater than or equal to a value. 51 | * - `$lt`: Less than a value. 52 | * - `$lte`: Less than or equal to a value. 53 | * - `$in`: Value must be within an array of values. 54 | * - `$nin`: Value must not be within an array of values. 55 | * - `$like`: String matches a pattern (wildcard `%`). 56 | * - `$ilike`: Case-insensitive string matching (PostgreSQL). 57 | * - `$null`: Check if the value is NULL or NOT NULL. 58 | * - `$between`: Value must be between two values (inclusive). 59 | * - `$contains`: Array contains a specific value (PostgreSQL). 60 | * - `$contained`: Array is contained within another array (PostgreSQL). 61 | * - `$overlap`: Arrays overlap (share common elements). 62 | * - `$startsWith`: String starts with a given prefix. 63 | * - `$endsWith`: String ends with a given suffix. 64 | * 65 | * @example 66 | * // Basic Equality 67 | * const filter = { "name": { "$eq": "John" } }; 68 | * 69 | * // Not equal to 70 | * const filter = { "age": { "$ne": 30 } }; 71 | * 72 | * // Greater than 73 | * const filter = { "age": { "$gt": 25 } }; 74 | * 75 | * // Less than or equal to 76 | * const filter = { "salary": { "$lte": 50000 } }; 77 | * 78 | * // In an array 79 | * const filter = { "status": { "$in": ["active", "pending"] } }; 80 | * 81 | * // Not in an array 82 | * const filter = { "role": { "$nin": ["admin", "moderator"] } }; 83 | * 84 | * // LIKE pattern matching 85 | * const filter = { "name": { "$like": "%Smith%" } }; 86 | * 87 | * // Case-insensitive ILIKE matching (PostgreSQL) 88 | * const filter = { "email": { "$ilike": "%@gmail.com" } }; 89 | * 90 | * // NULL check 91 | * const filter = { "deletedAt": { "$null": true } }; // checks if `deletedAt` IS NULL 92 | * const filter = { "deletedAt": { "$null": false } }; // checks if `deletedAt` IS NOT NULL 93 | * 94 | * // Between two values (e.g., range filtering) 95 | * const filter = { "createdAt": { "$between": ["2023-01-01", "2023-12-31"] } }; 96 | * 97 | * // Array contains a value (PostgreSQL specific) 98 | * const filter = { "tags": { "$contains": "featured" } }; 99 | * 100 | * // Array is contained within another array (PostgreSQL specific) 101 | * const filter = { "tags": { "$contained": ["featured", "popular"] } }; 102 | * 103 | * // Arrays overlap (share any common elements, PostgreSQL specific) 104 | * const filter = { "tags": { "$overlap": ["new", "featured"] } }; 105 | * 106 | * // String starts with 107 | * const filter = { "username": { "$startsWith": "admin" } }; 108 | * 109 | * // String ends with 110 | * const filter = { "filename": { "$endsWith": ".jpg" } }; 111 | * 112 | * // Logical Operators 113 | * const filter = { 114 | * "$or": [ 115 | * { "age": { "$lt": 18 } }, 116 | * { "age": { "$gt": 60 } } 117 | * ] 118 | * }; 119 | * 120 | * const filter = { 121 | * "$and": [ 122 | * { "status": { "$eq": "active" } }, 123 | * { "age": { "$gte": 18 } } 124 | * ] 125 | * }; 126 | * 127 | * const filter = { 128 | * "$not": { "status": { "$eq": "inactive" } } 129 | * }; 130 | */ 131 | export type Filter = string | Condition | LogicalFilter; 132 | 133 | export interface Item { 134 | [key: string]: any; 135 | } 136 | 137 | /** 138 | * Applies filtering conditions to a TypeORM query builder based on a provided filter object. 139 | * 140 | * @param qb - TypeORM SelectQueryBuilder to apply the filters to. 141 | * @param target - The target entity class or table name being queried. 142 | * @param alias - The alias for the main entity being queried (default: "entity"). 143 | * @param filter - The filter object containing conditions and logical operators. 144 | * 145 | * @returns The modified SelectQueryBuilder with applied conditions. 146 | */ 147 | export function applyFilters( 148 | qb: SelectQueryBuilder, 149 | target: EntityTarget, 150 | alias: string, 151 | filter: Filter, 152 | ): SelectQueryBuilder { 153 | filter = parseFilter(filter); 154 | if (!isObject(filter)) return qb; 155 | 156 | Object.keys(filter).forEach((key) => { 157 | const value = filter[key as keyof Filter]; 158 | 159 | switch (key) { 160 | case '$or': 161 | const orConditions = (value as Filter[]).map((subFilter) => 162 | applyFilters(qb, target, alias, subFilter).getQuery(), 163 | ); 164 | qb.andWhere(`(${orConditions.join(' OR ')})`); 165 | break; 166 | 167 | case '$and': 168 | const andConditions = (value as Filter[]).map((subFilter) => 169 | applyFilters(qb, target, alias, subFilter).getQuery(), 170 | ); 171 | qb.andWhere(`(${andConditions.join(' AND ')})`); 172 | break; 173 | 174 | case '$not': 175 | const notCondition = applyFilters( 176 | qb, 177 | target, 178 | alias, 179 | value as Filter, 180 | ).getQuery(); 181 | qb.andWhere(`NOT (${notCondition})`); 182 | break; 183 | 184 | default: 185 | applyCondition(qb, target, alias, key, value); 186 | } 187 | }); 188 | 189 | return qb; 190 | } 191 | 192 | function parseFilter(filter: Filter): Filter | null { 193 | if (!filter) return null; 194 | if (typeof filter === 'string') { 195 | try { 196 | filter = JSON.parse(filter); 197 | } catch { 198 | return null; 199 | } 200 | } 201 | return filter; 202 | } 203 | 204 | function applyCondition( 205 | qb: SelectQueryBuilder, 206 | target: EntityTarget, 207 | alias: string, 208 | path: string, 209 | value: any, 210 | ): void { 211 | value = transformValue(value); 212 | if (path.includes('.')) { 213 | applyRelationCondition(qb, target, alias, path, value); 214 | } else { 215 | applySimpleCondition(qb, alias, path, value); 216 | } 217 | } 218 | 219 | function applyRelationCondition( 220 | qb: SelectQueryBuilder, 221 | target: EntityTarget, 222 | alias: string, 223 | path: string, 224 | value: any, 225 | ): void { 226 | const metadata = qb.connection.getMetadata(target); 227 | const [relation, field] = path.split('.'); 228 | 229 | // Check if relation exists in the metadata 230 | if (metadata.findRelationWithPropertyPath(relation)) { 231 | const relationAlias = `${alias}_${relation}`; 232 | Object.keys(value).forEach((operator: string) => { 233 | evaluateOperatorForNestedRelation( 234 | qb, 235 | target, 236 | alias, 237 | relation, 238 | relationAlias, 239 | field, 240 | operator as Operator, 241 | value[operator], 242 | ); 243 | }); 244 | } 245 | } 246 | 247 | function applySimpleCondition( 248 | qb: SelectQueryBuilder, 249 | alias: string, 250 | path: string, 251 | value: any, 252 | ): void { 253 | Object.keys(value).forEach((operator: string) => { 254 | evaluateOperator(qb, path, operator as Operator, value[operator], alias); 255 | }); 256 | } 257 | 258 | function transformValue(value: any): any { 259 | if (value === null) { 260 | return { $null: true }; 261 | } 262 | if (!(typeof value === 'object' && !Array.isArray(value))) { 263 | return { $eq: value }; 264 | } 265 | return value; 266 | } 267 | 268 | function evaluateOperator( 269 | qb: SelectQueryBuilder, 270 | field: string, 271 | operator: Operator, 272 | expectedValue: any, 273 | alias: string, 274 | ): void { 275 | const path = `${alias}.${field}`; 276 | const { condition, params } = generateSqlForOperator( 277 | field, 278 | path, 279 | operator, 280 | expectedValue, 281 | ); 282 | qb.andWhere(condition, params); 283 | } 284 | 285 | function evaluateOperatorForNestedRelation( 286 | qb: SelectQueryBuilder, 287 | target: EntityTarget, 288 | targetAlias: string, 289 | relation: string, 290 | relationAlias: string, 291 | field: string, 292 | operator: Operator, 293 | expectedValue: any, 294 | ): void { 295 | const path = `"${relationAlias}_sub"."${field}"`; 296 | const { condition, params } = generateSqlForOperator( 297 | field, 298 | path, 299 | operator, 300 | expectedValue, 301 | ); 302 | 303 | const targetMetadata = qb.connection.getMetadata(target); 304 | const relationMetadata = 305 | targetMetadata.findRelationWithPropertyPath(relation); 306 | const joinColumn = `${relationMetadata.inverseSidePropertyPath}Id`; 307 | 308 | qb.andWhere( 309 | `"${targetAlias}"."id" IN ( 310 | SELECT "${targetAlias}_sub"."id" 311 | FROM "${targetMetadata.givenTableName}" "${targetAlias}_sub" 312 | JOIN "${relationMetadata.inverseEntityMetadata.tableName}" "${relationAlias}_sub" 313 | ON "${relationAlias}_sub"."${joinColumn}" = "${targetAlias}_sub"."id" 314 | WHERE ${condition} 315 | )`, 316 | params, 317 | ); 318 | } 319 | 320 | function generateSqlForOperator( 321 | field: string, 322 | path: string, 323 | operator: Operator, 324 | expectedValue: any, 325 | ): { condition: string; params: Record } { 326 | let condition: string; 327 | let params: Record = { [field]: expectedValue }; 328 | 329 | switch (operator) { 330 | case '$eq': 331 | condition = `${path} = :${field}`; 332 | break; 333 | 334 | case '$ne': 335 | condition = `${path} != :${field}`; 336 | break; 337 | 338 | case '$gt': 339 | condition = `${path} > :${field}`; 340 | break; 341 | 342 | case '$gte': 343 | condition = `${path} >= :${field}`; 344 | break; 345 | 346 | case '$lt': 347 | condition = `${path} < :${field}`; 348 | break; 349 | 350 | case '$lte': 351 | condition = `${path} <= :${field}`; 352 | break; 353 | 354 | case '$in': 355 | if (!Array.isArray(expectedValue)) { 356 | throw new Error( 357 | `Operator $in expects an array but received: ${typeof expectedValue}`, 358 | ); 359 | } 360 | condition = `${path} IN (:...${field})`; 361 | break; 362 | 363 | case '$nin': 364 | if (!Array.isArray(expectedValue)) { 365 | throw new Error( 366 | `Operator $nin expects an array but received: ${typeof expectedValue}`, 367 | ); 368 | } 369 | condition = `${path} NOT IN (:...${field})`; 370 | break; 371 | 372 | case '$like': 373 | condition = `${path} LIKE :${field}`; 374 | break; 375 | 376 | case '$ilike': 377 | condition = `${path} ILIKE :${field}`; 378 | break; 379 | 380 | case '$null': 381 | if (expectedValue === true) { 382 | condition = `${path} IS NULL`; 383 | params = {}; 384 | } else { 385 | condition = `${path} IS NOT NULL`; 386 | params = {}; 387 | } 388 | break; 389 | 390 | case '$between': 391 | if (!Array.isArray(expectedValue) || expectedValue.length !== 2) { 392 | throw new Error( 393 | `Operator $between expects an array of two elements but received: ${typeof expectedValue}`, 394 | ); 395 | } 396 | condition = `${path} BETWEEN :start AND :end`; 397 | params = { start: expectedValue[0], end: expectedValue[1] }; 398 | break; 399 | 400 | case '$contains': 401 | condition = `${path} @> :${field}`; 402 | break; 403 | 404 | case '$contained': 405 | condition = `${path} <@ :${field}`; 406 | break; 407 | 408 | case '$overlap': 409 | condition = `${path} && :${field}`; 410 | break; 411 | 412 | case '$startsWith': 413 | condition = `${path} LIKE :${field}`; 414 | params = { [field]: `${expectedValue}%` }; 415 | break; 416 | 417 | case '$endsWith': 418 | condition = `${path} LIKE :${field}`; 419 | params = { [field]: `%${expectedValue}` }; 420 | break; 421 | 422 | default: 423 | throw new Error(`Unsupported operator: ${operator}`); 424 | } 425 | 426 | return { condition, params }; 427 | } 428 | --------------------------------------------------------------------------------