@tndhuy/create-app 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/dist/cli.js +534 -0
- package/package.json +37 -0
- package/templates/mongo/.env.example +32 -0
- package/templates/mongo/Dockerfile +64 -0
- package/templates/mongo/docker-compose.yml +35 -0
- package/templates/mongo/eslint.config.mjs +35 -0
- package/templates/mongo/nest-cli.json +8 -0
- package/templates/mongo/package.json +105 -0
- package/templates/mongo/src/app.module.ts +59 -0
- package/templates/mongo/src/common/decorators/public-api.decorator.ts +9 -0
- package/templates/mongo/src/common/decorators/raw-response.decorator.ts +4 -0
- package/templates/mongo/src/common/filters/http-exception.filter.spec.ts +95 -0
- package/templates/mongo/src/common/filters/http-exception.filter.ts +43 -0
- package/templates/mongo/src/common/filters/rpc-exception.filter.ts +18 -0
- package/templates/mongo/src/common/index.ts +5 -0
- package/templates/mongo/src/common/interceptors/timeout.interceptor.ts +32 -0
- package/templates/mongo/src/common/interceptors/transform.interceptor.spec.ts +52 -0
- package/templates/mongo/src/common/interceptors/transform.interceptor.ts +25 -0
- package/templates/mongo/src/common/middleware/correlation-id.middleware.spec.ts +69 -0
- package/templates/mongo/src/common/middleware/correlation-id.middleware.ts +26 -0
- package/templates/mongo/src/infrastructure/cache/inject-redis.decorator.ts +4 -0
- package/templates/mongo/src/infrastructure/cache/redis.module.ts +9 -0
- package/templates/mongo/src/infrastructure/cache/redis.service.spec.ts +174 -0
- package/templates/mongo/src/infrastructure/cache/redis.service.ts +121 -0
- package/templates/mongo/src/infrastructure/config/config.module.ts +36 -0
- package/templates/mongo/src/infrastructure/config/environment.validation.spec.ts +100 -0
- package/templates/mongo/src/infrastructure/config/environment.validation.ts +21 -0
- package/templates/mongo/src/infrastructure/database/mongodb.module.ts +17 -0
- package/templates/mongo/src/infrastructure/health/health.controller.ts +46 -0
- package/templates/mongo/src/infrastructure/health/health.module.ts +12 -0
- package/templates/mongo/src/infrastructure/health/redis.health-indicator.ts +20 -0
- package/templates/mongo/src/instrumentation.spec.ts +24 -0
- package/templates/mongo/src/instrumentation.ts +44 -0
- package/templates/mongo/src/main.ts +102 -0
- package/templates/mongo/src/modules/example/application/commands/create-item.command.ts +3 -0
- package/templates/mongo/src/modules/example/application/commands/create-item.handler.spec.ts +49 -0
- package/templates/mongo/src/modules/example/application/commands/create-item.handler.ts +20 -0
- package/templates/mongo/src/modules/example/application/commands/delete-item.command.ts +3 -0
- package/templates/mongo/src/modules/example/application/commands/delete-item.handler.ts +15 -0
- package/templates/mongo/src/modules/example/application/dtos/create-item.dto.ts +9 -0
- package/templates/mongo/src/modules/example/application/dtos/item.response.dto.ts +9 -0
- package/templates/mongo/src/modules/example/application/queries/get-item.handler.spec.ts +49 -0
- package/templates/mongo/src/modules/example/application/queries/get-item.handler.ts +16 -0
- package/templates/mongo/src/modules/example/application/queries/get-item.query.ts +3 -0
- package/templates/mongo/src/modules/example/application/queries/list-items.handler.ts +16 -0
- package/templates/mongo/src/modules/example/application/queries/list-items.query.ts +3 -0
- package/templates/mongo/src/modules/example/domain/item-name.value-object.spec.ts +49 -0
- package/templates/mongo/src/modules/example/domain/item-name.value-object.ts +18 -0
- package/templates/mongo/src/modules/example/domain/item.entity.spec.ts +48 -0
- package/templates/mongo/src/modules/example/domain/item.entity.ts +19 -0
- package/templates/mongo/src/modules/example/domain/item.repository.interface.ts +10 -0
- package/templates/mongo/src/modules/example/example.module.ts +31 -0
- package/templates/mongo/src/modules/example/infrastructure/.gitkeep +0 -0
- package/templates/mongo/src/modules/example/infrastructure/persistence/mongoose-item.repository.ts +42 -0
- package/templates/mongo/src/modules/example/infrastructure/persistence/schemas/item.schema.ts +15 -0
- package/templates/mongo/src/modules/example/presenter/item.controller.ts +52 -0
- package/templates/mongo/src/shared/base/aggregate-root.spec.ts +44 -0
- package/templates/mongo/src/shared/base/aggregate-root.ts +20 -0
- package/templates/mongo/src/shared/base/domain-event.ts +6 -0
- package/templates/mongo/src/shared/base/entity.spec.ts +36 -0
- package/templates/mongo/src/shared/base/entity.ts +13 -0
- package/templates/mongo/src/shared/base/index.ts +5 -0
- package/templates/mongo/src/shared/base/repository.interface.ts +6 -0
- package/templates/mongo/src/shared/base/value-object.spec.ts +39 -0
- package/templates/mongo/src/shared/base/value-object.ts +13 -0
- package/templates/mongo/src/shared/dto/pagination.dto.spec.ts +49 -0
- package/templates/mongo/src/shared/dto/pagination.dto.ts +37 -0
- package/templates/mongo/src/shared/dto/response.dto.ts +13 -0
- package/templates/mongo/src/shared/exceptions/app.exception.spec.ts +59 -0
- package/templates/mongo/src/shared/exceptions/app.exception.ts +19 -0
- package/templates/mongo/src/shared/exceptions/error-codes.ts +9 -0
- package/templates/mongo/src/shared/index.ts +7 -0
- package/templates/mongo/src/shared/logger/logger.module.ts +12 -0
- package/templates/mongo/src/shared/logger/logger.service.ts +48 -0
- package/templates/mongo/src/shared/logger/pino.config.ts +86 -0
- package/templates/mongo/src/shared/validation-options.ts +38 -0
- package/templates/mongo/src/shared/valueobjects/date.valueobject.spec.ts +40 -0
- package/templates/mongo/src/shared/valueobjects/date.valueobject.ts +14 -0
- package/templates/mongo/src/shared/valueobjects/id.valueobject.spec.ts +28 -0
- package/templates/mongo/src/shared/valueobjects/id.valueobject.ts +14 -0
- package/templates/mongo/src/shared/valueobjects/index.ts +4 -0
- package/templates/mongo/src/shared/valueobjects/number.valueobject.spec.ts +48 -0
- package/templates/mongo/src/shared/valueobjects/number.valueobject.ts +14 -0
- package/templates/mongo/src/shared/valueobjects/string.valueobject.spec.ts +37 -0
- package/templates/mongo/src/shared/valueobjects/string.valueobject.ts +14 -0
- package/templates/mongo/tsconfig.build.json +4 -0
- package/templates/mongo/tsconfig.json +23 -0
- package/templates/postgres/.env.example +32 -0
- package/templates/postgres/Dockerfile +64 -0
- package/templates/postgres/eslint.config.mjs +35 -0
- package/templates/postgres/nest-cli.json +8 -0
- package/templates/postgres/package.json +103 -0
- package/templates/postgres/prisma/schema.prisma +14 -0
- package/templates/postgres/prisma.config.ts +11 -0
- package/templates/postgres/src/app.module.ts +34 -0
- package/templates/postgres/src/common/decorators/public-api.decorator.ts +9 -0
- package/templates/postgres/src/common/decorators/raw-response.decorator.ts +4 -0
- package/templates/postgres/src/common/filters/http-exception.filter.spec.ts +95 -0
- package/templates/postgres/src/common/filters/http-exception.filter.ts +43 -0
- package/templates/postgres/src/common/filters/rpc-exception.filter.ts +18 -0
- package/templates/postgres/src/common/index.ts +5 -0
- package/templates/postgres/src/common/interceptors/timeout.interceptor.ts +32 -0
- package/templates/postgres/src/common/interceptors/transform.interceptor.spec.ts +52 -0
- package/templates/postgres/src/common/interceptors/transform.interceptor.ts +25 -0
- package/templates/postgres/src/common/middleware/correlation-id.middleware.spec.ts +69 -0
- package/templates/postgres/src/common/middleware/correlation-id.middleware.ts +26 -0
- package/templates/postgres/src/infrastructure/cache/inject-redis.decorator.ts +4 -0
- package/templates/postgres/src/infrastructure/cache/redis.module.ts +9 -0
- package/templates/postgres/src/infrastructure/cache/redis.service.spec.ts +174 -0
- package/templates/postgres/src/infrastructure/cache/redis.service.ts +121 -0
- package/templates/postgres/src/infrastructure/config/config.module.ts +36 -0
- package/templates/postgres/src/infrastructure/config/environment.validation.spec.ts +100 -0
- package/templates/postgres/src/infrastructure/config/environment.validation.ts +21 -0
- package/templates/postgres/src/infrastructure/database/inject-prisma.decorator.ts +4 -0
- package/templates/postgres/src/infrastructure/database/prisma.module.ts +9 -0
- package/templates/postgres/src/infrastructure/database/prisma.service.ts +21 -0
- package/templates/postgres/src/infrastructure/health/health.controller.ts +46 -0
- package/templates/postgres/src/infrastructure/health/health.module.ts +12 -0
- package/templates/postgres/src/infrastructure/health/prisma.health-indicator.ts +19 -0
- package/templates/postgres/src/infrastructure/health/redis.health-indicator.ts +20 -0
- package/templates/postgres/src/instrumentation.spec.ts +24 -0
- package/templates/postgres/src/instrumentation.ts +44 -0
- package/templates/postgres/src/main.ts +102 -0
- package/templates/postgres/src/modules/example/application/commands/create-item.command.ts +3 -0
- package/templates/postgres/src/modules/example/application/commands/create-item.handler.spec.ts +49 -0
- package/templates/postgres/src/modules/example/application/commands/create-item.handler.ts +20 -0
- package/templates/postgres/src/modules/example/application/commands/delete-item.command.ts +3 -0
- package/templates/postgres/src/modules/example/application/commands/delete-item.handler.ts +15 -0
- package/templates/postgres/src/modules/example/application/dtos/create-item.dto.ts +9 -0
- package/templates/postgres/src/modules/example/application/dtos/item.response.dto.ts +9 -0
- package/templates/postgres/src/modules/example/application/queries/get-item.handler.spec.ts +49 -0
- package/templates/postgres/src/modules/example/application/queries/get-item.handler.ts +16 -0
- package/templates/postgres/src/modules/example/application/queries/get-item.query.ts +3 -0
- package/templates/postgres/src/modules/example/application/queries/list-items.handler.ts +16 -0
- package/templates/postgres/src/modules/example/application/queries/list-items.query.ts +3 -0
- package/templates/postgres/src/modules/example/domain/item-name.value-object.spec.ts +49 -0
- package/templates/postgres/src/modules/example/domain/item-name.value-object.ts +18 -0
- package/templates/postgres/src/modules/example/domain/item.entity.spec.ts +48 -0
- package/templates/postgres/src/modules/example/domain/item.entity.ts +19 -0
- package/templates/postgres/src/modules/example/domain/item.repository.interface.ts +10 -0
- package/templates/postgres/src/modules/example/example.module.ts +26 -0
- package/templates/postgres/src/modules/example/infrastructure/.gitkeep +0 -0
- package/templates/postgres/src/modules/example/infrastructure/persistence/prisma-item.repository.ts +34 -0
- package/templates/postgres/src/modules/example/presenter/item.controller.ts +52 -0
- package/templates/postgres/src/shared/base/aggregate-root.spec.ts +44 -0
- package/templates/postgres/src/shared/base/aggregate-root.ts +20 -0
- package/templates/postgres/src/shared/base/domain-event.ts +6 -0
- package/templates/postgres/src/shared/base/entity.spec.ts +36 -0
- package/templates/postgres/src/shared/base/entity.ts +13 -0
- package/templates/postgres/src/shared/base/index.ts +5 -0
- package/templates/postgres/src/shared/base/repository.interface.ts +6 -0
- package/templates/postgres/src/shared/base/value-object.spec.ts +39 -0
- package/templates/postgres/src/shared/base/value-object.ts +13 -0
- package/templates/postgres/src/shared/dto/pagination.dto.spec.ts +49 -0
- package/templates/postgres/src/shared/dto/pagination.dto.ts +37 -0
- package/templates/postgres/src/shared/dto/response.dto.ts +13 -0
- package/templates/postgres/src/shared/exceptions/app.exception.spec.ts +59 -0
- package/templates/postgres/src/shared/exceptions/app.exception.ts +19 -0
- package/templates/postgres/src/shared/exceptions/error-codes.ts +9 -0
- package/templates/postgres/src/shared/index.ts +7 -0
- package/templates/postgres/src/shared/logger/logger.module.ts +12 -0
- package/templates/postgres/src/shared/logger/logger.service.ts +48 -0
- package/templates/postgres/src/shared/logger/pino.config.ts +86 -0
- package/templates/postgres/src/shared/validation-options.ts +38 -0
- package/templates/postgres/src/shared/valueobjects/date.valueobject.spec.ts +40 -0
- package/templates/postgres/src/shared/valueobjects/date.valueobject.ts +14 -0
- package/templates/postgres/src/shared/valueobjects/id.valueobject.spec.ts +28 -0
- package/templates/postgres/src/shared/valueobjects/id.valueobject.ts +14 -0
- package/templates/postgres/src/shared/valueobjects/index.ts +4 -0
- package/templates/postgres/src/shared/valueobjects/number.valueobject.spec.ts +48 -0
- package/templates/postgres/src/shared/valueobjects/number.valueobject.ts +14 -0
- package/templates/postgres/src/shared/valueobjects/string.valueobject.spec.ts +37 -0
- package/templates/postgres/src/shared/valueobjects/string.valueobject.ts +14 -0
- package/templates/postgres/tsconfig.build.json +4 -0
- package/templates/postgres/tsconfig.json +23 -0
package/templates/postgres/src/modules/example/infrastructure/persistence/prisma-item.repository.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { InjectPrisma } from '../../../../infrastructure/database/inject-prisma.decorator';
|
|
3
|
+
import { PrismaService } from '../../../../infrastructure/database/prisma.service';
|
|
4
|
+
import { IItemRepository } from '../../domain/item.repository.interface';
|
|
5
|
+
import { Item } from '../../domain/item.entity';
|
|
6
|
+
import { ItemName } from '../../domain/item-name.value-object';
|
|
7
|
+
|
|
8
|
+
@Injectable()
|
|
9
|
+
export class PrismaItemRepository implements IItemRepository {
|
|
10
|
+
constructor(@InjectPrisma() private readonly prisma: PrismaService) {}
|
|
11
|
+
|
|
12
|
+
async findById(id: string): Promise<Item | null> {
|
|
13
|
+
const record = await this.prisma.item.findUnique({ where: { id } });
|
|
14
|
+
if (!record) return null;
|
|
15
|
+
return Item.create(record.id, ItemName.create(record.name));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async findAll(): Promise<Item[]> {
|
|
19
|
+
const records = await this.prisma.item.findMany();
|
|
20
|
+
return records.map((r) => Item.create(r.id, ItemName.create(r.name)));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async save(item: Item): Promise<void> {
|
|
24
|
+
await this.prisma.item.upsert({
|
|
25
|
+
where: { id: item.id },
|
|
26
|
+
update: { name: item.name.value },
|
|
27
|
+
create: { id: item.id, name: item.name.value },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async delete(id: string): Promise<void> {
|
|
32
|
+
await this.prisma.item.delete({ where: { id } });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
|
|
2
|
+
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
|
|
3
|
+
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
|
4
|
+
import { CreateItemDto } from '../application/dtos/create-item.dto';
|
|
5
|
+
import { ItemResponseDto } from '../application/dtos/item.response.dto';
|
|
6
|
+
import { CreateItemCommand } from '../application/commands/create-item.command';
|
|
7
|
+
import { DeleteItemCommand } from '../application/commands/delete-item.command';
|
|
8
|
+
import { GetItemQuery } from '../application/queries/get-item.query';
|
|
9
|
+
import { ListItemsQuery } from '../application/queries/list-items.query';
|
|
10
|
+
import { PaginationDto } from '../../../shared';
|
|
11
|
+
|
|
12
|
+
@ApiTags('example')
|
|
13
|
+
@Controller('items')
|
|
14
|
+
export class ItemController {
|
|
15
|
+
constructor(
|
|
16
|
+
private readonly commandBus: CommandBus,
|
|
17
|
+
private readonly queryBus: QueryBus,
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
@Post()
|
|
21
|
+
@ApiOperation({ summary: 'Create a new item' })
|
|
22
|
+
@ApiResponse({ status: 201, description: 'Item created successfully', type: ItemResponseDto })
|
|
23
|
+
@ApiResponse({ status: 400, description: 'Validation failed' })
|
|
24
|
+
create(@Body() dto: CreateItemDto) {
|
|
25
|
+
return this.commandBus.execute(new CreateItemCommand(dto.name));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@Delete(':id')
|
|
29
|
+
@ApiOperation({ summary: 'Delete an item by ID' })
|
|
30
|
+
@ApiParam({ name: 'id', description: 'Item UUID' })
|
|
31
|
+
@ApiResponse({ status: 200, description: 'Item deleted successfully' })
|
|
32
|
+
@ApiResponse({ status: 404, description: 'Item not found' })
|
|
33
|
+
delete(@Param('id') id: string) {
|
|
34
|
+
return this.commandBus.execute(new DeleteItemCommand(id));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@Get(':id')
|
|
38
|
+
@ApiOperation({ summary: 'Get an item by ID' })
|
|
39
|
+
@ApiParam({ name: 'id', description: 'Item UUID' })
|
|
40
|
+
@ApiResponse({ status: 200, description: 'Item found', type: ItemResponseDto })
|
|
41
|
+
@ApiResponse({ status: 404, description: 'Item not found' })
|
|
42
|
+
findOne(@Param('id') id: string) {
|
|
43
|
+
return this.queryBus.execute(new GetItemQuery(id));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@Get()
|
|
47
|
+
@ApiOperation({ summary: 'List all items with pagination' })
|
|
48
|
+
@ApiResponse({ status: 200, description: 'List of items', type: [ItemResponseDto] })
|
|
49
|
+
findAll(@Query() pagination: PaginationDto) {
|
|
50
|
+
return this.queryBus.execute(new ListItemsQuery());
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { AggregateRoot } from './aggregate-root';
|
|
2
|
+
import { DomainEvent } from './domain-event';
|
|
3
|
+
|
|
4
|
+
class TestEvent extends DomainEvent {
|
|
5
|
+
constructor() {
|
|
6
|
+
super();
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
class TestAggregate extends AggregateRoot<string> {
|
|
11
|
+
constructor(id: string) {
|
|
12
|
+
super(id);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('AggregateRoot', () => {
|
|
17
|
+
it('should extend Entity and return the id', () => {
|
|
18
|
+
const aggregate = new TestAggregate('agg-1');
|
|
19
|
+
expect(aggregate.id).toBe('agg-1');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should collect a domain event via addDomainEvent', () => {
|
|
23
|
+
const aggregate = new TestAggregate('agg-1');
|
|
24
|
+
const event = new TestEvent();
|
|
25
|
+
aggregate.addDomainEvent(event);
|
|
26
|
+
const events = aggregate.pullDomainEvents();
|
|
27
|
+
expect(events).toHaveLength(1);
|
|
28
|
+
expect(events[0]).toBe(event);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should clear domain events after pullDomainEvents', () => {
|
|
32
|
+
const aggregate = new TestAggregate('agg-1');
|
|
33
|
+
aggregate.addDomainEvent(new TestEvent());
|
|
34
|
+
aggregate.pullDomainEvents();
|
|
35
|
+
const secondPull = aggregate.pullDomainEvents();
|
|
36
|
+
expect(secondPull).toHaveLength(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should support equals comparison via inherited Entity method', () => {
|
|
40
|
+
const a1 = new TestAggregate('agg-1');
|
|
41
|
+
const a2 = new TestAggregate('agg-1');
|
|
42
|
+
expect(a1.equals(a2)).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Entity } from './entity';
|
|
2
|
+
import { DomainEvent } from './domain-event';
|
|
3
|
+
|
|
4
|
+
export abstract class AggregateRoot<TId> extends Entity<TId> {
|
|
5
|
+
private readonly domainEvents: DomainEvent[] = [];
|
|
6
|
+
|
|
7
|
+
protected constructor(id: TId) {
|
|
8
|
+
super(id);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
addDomainEvent(event: DomainEvent): void {
|
|
12
|
+
this.domainEvents.push(event);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
pullDomainEvents(): DomainEvent[] {
|
|
16
|
+
const events = [...this.domainEvents];
|
|
17
|
+
this.domainEvents.length = 0;
|
|
18
|
+
return events;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Entity } from './entity';
|
|
2
|
+
|
|
3
|
+
class TestEntity extends Entity<string> {
|
|
4
|
+
constructor(id: string) {
|
|
5
|
+
super(id);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('Entity', () => {
|
|
10
|
+
it('should store and return the id', () => {
|
|
11
|
+
const entity = new TestEntity('abc');
|
|
12
|
+
expect(entity.id).toBe('abc');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should be equal to another entity with the same id', () => {
|
|
16
|
+
const e1 = new TestEntity('abc');
|
|
17
|
+
const e2 = new TestEntity('abc');
|
|
18
|
+
expect(e1.equals(e2)).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should not be equal to another entity with a different id', () => {
|
|
22
|
+
const e1 = new TestEntity('abc');
|
|
23
|
+
const e2 = new TestEntity('xyz');
|
|
24
|
+
expect(e1.equals(e2)).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should return false when comparing with undefined', () => {
|
|
28
|
+
const entity = new TestEntity('abc');
|
|
29
|
+
expect(entity.equals(undefined)).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should return true when comparing with itself (reference equality)', () => {
|
|
33
|
+
const entity = new TestEntity('abc');
|
|
34
|
+
expect(entity.equals(entity)).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export abstract class Entity<TId> {
|
|
2
|
+
readonly id: TId;
|
|
3
|
+
|
|
4
|
+
protected constructor(id: TId) {
|
|
5
|
+
this.id = id;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
equals(other?: Entity<TId>): boolean {
|
|
9
|
+
if (!other) return false;
|
|
10
|
+
if (other === this) return true;
|
|
11
|
+
return this.id === other.id;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ValueObject } from './value-object';
|
|
2
|
+
|
|
3
|
+
class TestVO extends ValueObject<{ value: string }> {
|
|
4
|
+
constructor(props: { value: string }) {
|
|
5
|
+
super(props);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('ValueObject', () => {
|
|
10
|
+
it('should be equal to another VO with the same props', () => {
|
|
11
|
+
const vo1 = new TestVO({ value: 'hello' });
|
|
12
|
+
const vo2 = new TestVO({ value: 'hello' });
|
|
13
|
+
expect(vo1.equals(vo2)).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should not be equal to a VO with different props', () => {
|
|
17
|
+
const vo1 = new TestVO({ value: 'hello' });
|
|
18
|
+
const vo2 = new TestVO({ value: 'world' });
|
|
19
|
+
expect(vo1.equals(vo2)).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should return false when comparing with undefined', () => {
|
|
23
|
+
const vo = new TestVO({ value: 'hello' });
|
|
24
|
+
expect(vo.equals(undefined)).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should return true when comparing with itself (reference equality)', () => {
|
|
28
|
+
const vo = new TestVO({ value: 'hello' });
|
|
29
|
+
expect(vo.equals(vo)).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should be immutable — mutation attempt does not change value', () => {
|
|
33
|
+
const vo = new TestVO({ value: 'original' });
|
|
34
|
+
expect(() => {
|
|
35
|
+
(vo as any).props.value = 'mutated';
|
|
36
|
+
}).toThrow();
|
|
37
|
+
expect(vo.equals(new TestVO({ value: 'original' }))).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export abstract class ValueObject<TProps> {
|
|
2
|
+
protected readonly props: TProps;
|
|
3
|
+
|
|
4
|
+
protected constructor(props: TProps) {
|
|
5
|
+
this.props = Object.freeze({ ...props });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
equals(other?: ValueObject<TProps>): boolean {
|
|
9
|
+
if (!other) return false;
|
|
10
|
+
if (other === this) return true;
|
|
11
|
+
return JSON.stringify(this.props) === JSON.stringify(other.props);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { plainToInstance } from 'class-transformer';
|
|
2
|
+
import { validate } from 'class-validator';
|
|
3
|
+
import { PaginationDto } from './pagination.dto';
|
|
4
|
+
|
|
5
|
+
async function validateDto(plain: Record<string, unknown>) {
|
|
6
|
+
const dto = plainToInstance(PaginationDto, plain);
|
|
7
|
+
const errors = await validate(dto);
|
|
8
|
+
return { dto, errors };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('PaginationDto', () => {
|
|
12
|
+
it('should have defaults: page=1, limit=20, order=asc', async () => {
|
|
13
|
+
const { dto } = await validateDto({});
|
|
14
|
+
expect(dto.page).toBe(1);
|
|
15
|
+
expect(dto.limit).toBe(20);
|
|
16
|
+
expect(dto.order).toBe('asc');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should pass with valid page=2, limit=50, sort=name, order=desc', async () => {
|
|
20
|
+
const { errors } = await validateDto({ page: 2, limit: 50, sort: 'name', order: 'desc' });
|
|
21
|
+
expect(errors).toHaveLength(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should fail when page < 1', async () => {
|
|
25
|
+
const { errors } = await validateDto({ page: 0 });
|
|
26
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
27
|
+
const pageError = errors.find((e) => e.property === 'page');
|
|
28
|
+
expect(pageError).toBeDefined();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should fail when limit > 100', async () => {
|
|
32
|
+
const { errors } = await validateDto({ limit: 101 });
|
|
33
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
34
|
+
const limitError = errors.find((e) => e.property === 'limit');
|
|
35
|
+
expect(limitError).toBeDefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should fail when order is not asc or desc', async () => {
|
|
39
|
+
const { errors } = await validateDto({ order: 'random' });
|
|
40
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
41
|
+
const orderError = errors.find((e) => e.property === 'order');
|
|
42
|
+
expect(orderError).toBeDefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should fail when limit < 1', async () => {
|
|
46
|
+
const { errors } = await validateDto({ limit: 0 });
|
|
47
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ApiPropertyOptional } from '@nestjs/swagger';
|
|
2
|
+
import { Type } from 'class-transformer';
|
|
3
|
+
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
|
4
|
+
|
|
5
|
+
export class PaginationDto {
|
|
6
|
+
@ApiPropertyOptional({ description: 'Page number (1-based)', default: 1, minimum: 1 })
|
|
7
|
+
@IsOptional()
|
|
8
|
+
@Type(() => Number)
|
|
9
|
+
@IsInt()
|
|
10
|
+
@Min(1)
|
|
11
|
+
page?: number = 1;
|
|
12
|
+
|
|
13
|
+
@ApiPropertyOptional({ description: 'Items per page', default: 20, minimum: 1, maximum: 100 })
|
|
14
|
+
@IsOptional()
|
|
15
|
+
@Type(() => Number)
|
|
16
|
+
@IsInt()
|
|
17
|
+
@Min(1)
|
|
18
|
+
@Max(100)
|
|
19
|
+
limit?: number = 20;
|
|
20
|
+
|
|
21
|
+
@ApiPropertyOptional({ description: 'Sort field' })
|
|
22
|
+
@IsOptional()
|
|
23
|
+
@IsString()
|
|
24
|
+
sort?: string;
|
|
25
|
+
|
|
26
|
+
@ApiPropertyOptional({ description: 'Sort direction', enum: ['asc', 'desc'], default: 'asc' })
|
|
27
|
+
@IsOptional()
|
|
28
|
+
@IsIn(['asc', 'desc'])
|
|
29
|
+
order?: 'asc' | 'desc' = 'asc';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PaginationMeta {
|
|
33
|
+
total: number;
|
|
34
|
+
page: number;
|
|
35
|
+
limit: number;
|
|
36
|
+
totalPages: number;
|
|
37
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { PaginationMeta } from './pagination.dto';
|
|
2
|
+
|
|
3
|
+
export interface ResponseEnvelope<T = unknown> {
|
|
4
|
+
success: boolean;
|
|
5
|
+
data?: T;
|
|
6
|
+
meta?: PaginationMeta;
|
|
7
|
+
error?: {
|
|
8
|
+
code: string;
|
|
9
|
+
message: string;
|
|
10
|
+
statusCode: number;
|
|
11
|
+
details?: unknown;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { HttpException } from '@nestjs/common';
|
|
2
|
+
import { AppException } from './app.exception';
|
|
3
|
+
import { ErrorCodes } from './error-codes';
|
|
4
|
+
|
|
5
|
+
describe('AppException', () => {
|
|
6
|
+
it('should set code, message, statusCode from payload', () => {
|
|
7
|
+
const exception = new AppException({
|
|
8
|
+
code: ErrorCodes.NOT_FOUND,
|
|
9
|
+
message: 'Item not found',
|
|
10
|
+
statusCode: 404,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
expect(exception.code).toBe('NOT_FOUND');
|
|
14
|
+
expect(exception.message).toBe('Item not found');
|
|
15
|
+
expect(exception.getStatus()).toBe(404);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should set details from payload', () => {
|
|
19
|
+
const details = [{ field: 'email', constraints: { isEmail: 'must be email' } }];
|
|
20
|
+
const exception = new AppException({
|
|
21
|
+
code: ErrorCodes.VALIDATION_FAILED,
|
|
22
|
+
message: 'Validation failed',
|
|
23
|
+
statusCode: 400,
|
|
24
|
+
details,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(exception.details).toEqual(details);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should default details to undefined when not provided', () => {
|
|
31
|
+
const exception = new AppException({
|
|
32
|
+
code: ErrorCodes.INTERNAL_ERROR,
|
|
33
|
+
message: 'Something went wrong',
|
|
34
|
+
statusCode: 500,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(exception.details).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should be instanceof HttpException', () => {
|
|
41
|
+
const exception = new AppException({
|
|
42
|
+
code: ErrorCodes.UNAUTHORIZED,
|
|
43
|
+
message: 'Unauthorized',
|
|
44
|
+
statusCode: 401,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(exception).toBeInstanceOf(HttpException);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should return the correct statusCode via getStatus()', () => {
|
|
51
|
+
const exception = new AppException({
|
|
52
|
+
code: ErrorCodes.FORBIDDEN,
|
|
53
|
+
message: 'Forbidden',
|
|
54
|
+
statusCode: 403,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(exception.getStatus()).toBe(403);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { HttpException } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
export interface AppExceptionPayload {
|
|
4
|
+
code: string;
|
|
5
|
+
message: string;
|
|
6
|
+
statusCode: number;
|
|
7
|
+
details?: unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class AppException extends HttpException {
|
|
11
|
+
public readonly code: string;
|
|
12
|
+
public readonly details?: unknown;
|
|
13
|
+
|
|
14
|
+
constructor(payload: AppExceptionPayload) {
|
|
15
|
+
super(payload, payload.statusCode);
|
|
16
|
+
this.code = payload.code;
|
|
17
|
+
this.details = payload.details;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const ErrorCodes = {
|
|
2
|
+
VALIDATION_FAILED: 'VALIDATION_FAILED',
|
|
3
|
+
NOT_FOUND: 'NOT_FOUND',
|
|
4
|
+
UNAUTHORIZED: 'UNAUTHORIZED',
|
|
5
|
+
FORBIDDEN: 'FORBIDDEN',
|
|
6
|
+
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
|
7
|
+
} as const;
|
|
8
|
+
|
|
9
|
+
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Global, Module } from '@nestjs/common';
|
|
2
|
+
import { LoggerModule as PinoLoggerModule } from 'nestjs-pino';
|
|
3
|
+
import { pinoConfig } from './pino.config';
|
|
4
|
+
import { LoggerService } from './logger.service';
|
|
5
|
+
|
|
6
|
+
@Global()
|
|
7
|
+
@Module({
|
|
8
|
+
imports: [PinoLoggerModule.forRoot(pinoConfig)],
|
|
9
|
+
providers: [LoggerService],
|
|
10
|
+
exports: [PinoLoggerModule, LoggerService],
|
|
11
|
+
})
|
|
12
|
+
export class LoggerModule {}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
|
|
3
|
+
|
|
4
|
+
@Injectable()
|
|
5
|
+
export class LoggerService {
|
|
6
|
+
private context: string = LoggerService.name;
|
|
7
|
+
|
|
8
|
+
constructor(
|
|
9
|
+
@InjectPinoLogger(LoggerService.name)
|
|
10
|
+
private readonly logger: PinoLogger,
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
log(message: string, contextOrMetadata?: string | Record<string, unknown>): void {
|
|
14
|
+
const metadata = this.normalizeMetadata(contextOrMetadata);
|
|
15
|
+
this.logger.info(metadata, message);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
error(message: string, traceOrMetadata?: string | Record<string, unknown>, context?: string): void {
|
|
19
|
+
let metadata: Record<string, unknown>;
|
|
20
|
+
if (typeof traceOrMetadata === 'string') {
|
|
21
|
+
metadata = { trace: traceOrMetadata, context: context || this.context };
|
|
22
|
+
} else {
|
|
23
|
+
metadata = this.normalizeMetadata(traceOrMetadata);
|
|
24
|
+
}
|
|
25
|
+
this.logger.error(metadata, message);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
warn(message: string, contextOrMetadata?: string | Record<string, unknown>): void {
|
|
29
|
+
const metadata = this.normalizeMetadata(contextOrMetadata);
|
|
30
|
+
this.logger.warn(metadata, message);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
debug(message: string, contextOrMetadata?: string | Record<string, unknown>): void {
|
|
34
|
+
const metadata = this.normalizeMetadata(contextOrMetadata);
|
|
35
|
+
this.logger.debug(metadata, message);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
setContext(context: string): this {
|
|
39
|
+
this.context = context;
|
|
40
|
+
return this;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private normalizeMetadata(contextOrMetadata?: string | Record<string, unknown>): Record<string, unknown> {
|
|
44
|
+
if (!contextOrMetadata) return { context: this.context };
|
|
45
|
+
if (typeof contextOrMetadata === 'string') return { context: contextOrMetadata };
|
|
46
|
+
return { context: this.context, ...contextOrMetadata };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import type { Params } from 'nestjs-pino';
|
|
3
|
+
import { trace, context, isSpanContextValid } from '@opentelemetry/api';
|
|
4
|
+
|
|
5
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
6
|
+
const LOG_DIR = join(process.cwd(), 'logs');
|
|
7
|
+
const LOG_MAX_SIZE = '10M';
|
|
8
|
+
|
|
9
|
+
export const pinoConfig: Params = {
|
|
10
|
+
pinoHttp: {
|
|
11
|
+
level: isDev ? 'debug' : 'info',
|
|
12
|
+
// Inject Trace context into every log line
|
|
13
|
+
mixin() {
|
|
14
|
+
const activeSpan = trace.getSpan(context.active());
|
|
15
|
+
if (activeSpan) {
|
|
16
|
+
const spanContext = activeSpan.spanContext();
|
|
17
|
+
if (isSpanContextValid(spanContext)) {
|
|
18
|
+
return {
|
|
19
|
+
traceId: spanContext.traceId,
|
|
20
|
+
spanId: spanContext.spanId,
|
|
21
|
+
traceFlags: `0${spanContext.traceFlags.toString(16)}`,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return {};
|
|
26
|
+
},
|
|
27
|
+
// Standardize on X-Request-Id as the primary req.id
|
|
28
|
+
genReqId: (req) => (req.headers['x-request-id'] as string) || (req.headers['x-correlation-id'] as string),
|
|
29
|
+
transport: {
|
|
30
|
+
targets: [
|
|
31
|
+
// Daily rotating file for production-like environments
|
|
32
|
+
{
|
|
33
|
+
target: 'pino-roll',
|
|
34
|
+
options: {
|
|
35
|
+
file: join(LOG_DIR, 'application'),
|
|
36
|
+
frequency: 'daily',
|
|
37
|
+
size: LOG_MAX_SIZE,
|
|
38
|
+
mkdir: true,
|
|
39
|
+
extension: '.log',
|
|
40
|
+
},
|
|
41
|
+
level: 'info',
|
|
42
|
+
},
|
|
43
|
+
// Dedicated error log
|
|
44
|
+
{
|
|
45
|
+
target: 'pino/file',
|
|
46
|
+
options: {
|
|
47
|
+
destination: join(LOG_DIR, 'error.log'),
|
|
48
|
+
mkdir: true,
|
|
49
|
+
},
|
|
50
|
+
level: 'error',
|
|
51
|
+
},
|
|
52
|
+
// Console output (pretty in Dev, JSON in Prod)
|
|
53
|
+
...(isDev
|
|
54
|
+
? [
|
|
55
|
+
{
|
|
56
|
+
target: 'pino-pretty',
|
|
57
|
+
options: {
|
|
58
|
+
colorize: true,
|
|
59
|
+
translateTime: 'SYS:yyyy-mm-dd HH:MM:ss',
|
|
60
|
+
ignore: 'pid,hostname',
|
|
61
|
+
messageKey: 'message',
|
|
62
|
+
},
|
|
63
|
+
level: 'debug',
|
|
64
|
+
},
|
|
65
|
+
]
|
|
66
|
+
: [
|
|
67
|
+
{
|
|
68
|
+
target: 'pino/file',
|
|
69
|
+
options: { destination: 1 },
|
|
70
|
+
level: 'info',
|
|
71
|
+
},
|
|
72
|
+
]),
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
messageKey: 'message',
|
|
76
|
+
serializers: {
|
|
77
|
+
req: (req: { method: string; url: string }) => ({
|
|
78
|
+
method: req.method,
|
|
79
|
+
url: req.url,
|
|
80
|
+
}),
|
|
81
|
+
res: (res: { statusCode: number }) => ({
|
|
82
|
+
statusCode: res.statusCode,
|
|
83
|
+
}),
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ValidationError, ValidationPipeOptions } from '@nestjs/common';
|
|
2
|
+
import { AppException } from './exceptions/app.exception';
|
|
3
|
+
import { ErrorCodes } from './exceptions/error-codes';
|
|
4
|
+
|
|
5
|
+
function formatErrors(errors: ValidationError[]): { field: string; constraints: Record<string, string> }[] {
|
|
6
|
+
return errors.flatMap((error) => {
|
|
7
|
+
if (error.children && error.children.length > 0) {
|
|
8
|
+
return formatErrors(error.children).map((child) => ({
|
|
9
|
+
...child,
|
|
10
|
+
field: `${error.property}.${child.field}`,
|
|
11
|
+
}));
|
|
12
|
+
}
|
|
13
|
+
return [
|
|
14
|
+
{
|
|
15
|
+
field: error.property,
|
|
16
|
+
constraints: (error.constraints ?? {}) as Record<string, string>,
|
|
17
|
+
},
|
|
18
|
+
];
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const validationOptions: ValidationPipeOptions = {
|
|
23
|
+
whitelist: true,
|
|
24
|
+
forbidNonWhitelisted: true,
|
|
25
|
+
transform: true,
|
|
26
|
+
transformOptions: {
|
|
27
|
+
enableImplicitConversion: true,
|
|
28
|
+
},
|
|
29
|
+
exceptionFactory: (errors: ValidationError[]) => {
|
|
30
|
+
const formattedErrors = formatErrors(errors);
|
|
31
|
+
return new AppException({
|
|
32
|
+
code: ErrorCodes.VALIDATION_FAILED,
|
|
33
|
+
message: 'Validation failed',
|
|
34
|
+
statusCode: 400,
|
|
35
|
+
details: formattedErrors,
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
};
|