@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.
Files changed (176) hide show
  1. package/README.md +53 -0
  2. package/dist/cli.js +534 -0
  3. package/package.json +37 -0
  4. package/templates/mongo/.env.example +32 -0
  5. package/templates/mongo/Dockerfile +64 -0
  6. package/templates/mongo/docker-compose.yml +35 -0
  7. package/templates/mongo/eslint.config.mjs +35 -0
  8. package/templates/mongo/nest-cli.json +8 -0
  9. package/templates/mongo/package.json +105 -0
  10. package/templates/mongo/src/app.module.ts +59 -0
  11. package/templates/mongo/src/common/decorators/public-api.decorator.ts +9 -0
  12. package/templates/mongo/src/common/decorators/raw-response.decorator.ts +4 -0
  13. package/templates/mongo/src/common/filters/http-exception.filter.spec.ts +95 -0
  14. package/templates/mongo/src/common/filters/http-exception.filter.ts +43 -0
  15. package/templates/mongo/src/common/filters/rpc-exception.filter.ts +18 -0
  16. package/templates/mongo/src/common/index.ts +5 -0
  17. package/templates/mongo/src/common/interceptors/timeout.interceptor.ts +32 -0
  18. package/templates/mongo/src/common/interceptors/transform.interceptor.spec.ts +52 -0
  19. package/templates/mongo/src/common/interceptors/transform.interceptor.ts +25 -0
  20. package/templates/mongo/src/common/middleware/correlation-id.middleware.spec.ts +69 -0
  21. package/templates/mongo/src/common/middleware/correlation-id.middleware.ts +26 -0
  22. package/templates/mongo/src/infrastructure/cache/inject-redis.decorator.ts +4 -0
  23. package/templates/mongo/src/infrastructure/cache/redis.module.ts +9 -0
  24. package/templates/mongo/src/infrastructure/cache/redis.service.spec.ts +174 -0
  25. package/templates/mongo/src/infrastructure/cache/redis.service.ts +121 -0
  26. package/templates/mongo/src/infrastructure/config/config.module.ts +36 -0
  27. package/templates/mongo/src/infrastructure/config/environment.validation.spec.ts +100 -0
  28. package/templates/mongo/src/infrastructure/config/environment.validation.ts +21 -0
  29. package/templates/mongo/src/infrastructure/database/mongodb.module.ts +17 -0
  30. package/templates/mongo/src/infrastructure/health/health.controller.ts +46 -0
  31. package/templates/mongo/src/infrastructure/health/health.module.ts +12 -0
  32. package/templates/mongo/src/infrastructure/health/redis.health-indicator.ts +20 -0
  33. package/templates/mongo/src/instrumentation.spec.ts +24 -0
  34. package/templates/mongo/src/instrumentation.ts +44 -0
  35. package/templates/mongo/src/main.ts +102 -0
  36. package/templates/mongo/src/modules/example/application/commands/create-item.command.ts +3 -0
  37. package/templates/mongo/src/modules/example/application/commands/create-item.handler.spec.ts +49 -0
  38. package/templates/mongo/src/modules/example/application/commands/create-item.handler.ts +20 -0
  39. package/templates/mongo/src/modules/example/application/commands/delete-item.command.ts +3 -0
  40. package/templates/mongo/src/modules/example/application/commands/delete-item.handler.ts +15 -0
  41. package/templates/mongo/src/modules/example/application/dtos/create-item.dto.ts +9 -0
  42. package/templates/mongo/src/modules/example/application/dtos/item.response.dto.ts +9 -0
  43. package/templates/mongo/src/modules/example/application/queries/get-item.handler.spec.ts +49 -0
  44. package/templates/mongo/src/modules/example/application/queries/get-item.handler.ts +16 -0
  45. package/templates/mongo/src/modules/example/application/queries/get-item.query.ts +3 -0
  46. package/templates/mongo/src/modules/example/application/queries/list-items.handler.ts +16 -0
  47. package/templates/mongo/src/modules/example/application/queries/list-items.query.ts +3 -0
  48. package/templates/mongo/src/modules/example/domain/item-name.value-object.spec.ts +49 -0
  49. package/templates/mongo/src/modules/example/domain/item-name.value-object.ts +18 -0
  50. package/templates/mongo/src/modules/example/domain/item.entity.spec.ts +48 -0
  51. package/templates/mongo/src/modules/example/domain/item.entity.ts +19 -0
  52. package/templates/mongo/src/modules/example/domain/item.repository.interface.ts +10 -0
  53. package/templates/mongo/src/modules/example/example.module.ts +31 -0
  54. package/templates/mongo/src/modules/example/infrastructure/.gitkeep +0 -0
  55. package/templates/mongo/src/modules/example/infrastructure/persistence/mongoose-item.repository.ts +42 -0
  56. package/templates/mongo/src/modules/example/infrastructure/persistence/schemas/item.schema.ts +15 -0
  57. package/templates/mongo/src/modules/example/presenter/item.controller.ts +52 -0
  58. package/templates/mongo/src/shared/base/aggregate-root.spec.ts +44 -0
  59. package/templates/mongo/src/shared/base/aggregate-root.ts +20 -0
  60. package/templates/mongo/src/shared/base/domain-event.ts +6 -0
  61. package/templates/mongo/src/shared/base/entity.spec.ts +36 -0
  62. package/templates/mongo/src/shared/base/entity.ts +13 -0
  63. package/templates/mongo/src/shared/base/index.ts +5 -0
  64. package/templates/mongo/src/shared/base/repository.interface.ts +6 -0
  65. package/templates/mongo/src/shared/base/value-object.spec.ts +39 -0
  66. package/templates/mongo/src/shared/base/value-object.ts +13 -0
  67. package/templates/mongo/src/shared/dto/pagination.dto.spec.ts +49 -0
  68. package/templates/mongo/src/shared/dto/pagination.dto.ts +37 -0
  69. package/templates/mongo/src/shared/dto/response.dto.ts +13 -0
  70. package/templates/mongo/src/shared/exceptions/app.exception.spec.ts +59 -0
  71. package/templates/mongo/src/shared/exceptions/app.exception.ts +19 -0
  72. package/templates/mongo/src/shared/exceptions/error-codes.ts +9 -0
  73. package/templates/mongo/src/shared/index.ts +7 -0
  74. package/templates/mongo/src/shared/logger/logger.module.ts +12 -0
  75. package/templates/mongo/src/shared/logger/logger.service.ts +48 -0
  76. package/templates/mongo/src/shared/logger/pino.config.ts +86 -0
  77. package/templates/mongo/src/shared/validation-options.ts +38 -0
  78. package/templates/mongo/src/shared/valueobjects/date.valueobject.spec.ts +40 -0
  79. package/templates/mongo/src/shared/valueobjects/date.valueobject.ts +14 -0
  80. package/templates/mongo/src/shared/valueobjects/id.valueobject.spec.ts +28 -0
  81. package/templates/mongo/src/shared/valueobjects/id.valueobject.ts +14 -0
  82. package/templates/mongo/src/shared/valueobjects/index.ts +4 -0
  83. package/templates/mongo/src/shared/valueobjects/number.valueobject.spec.ts +48 -0
  84. package/templates/mongo/src/shared/valueobjects/number.valueobject.ts +14 -0
  85. package/templates/mongo/src/shared/valueobjects/string.valueobject.spec.ts +37 -0
  86. package/templates/mongo/src/shared/valueobjects/string.valueobject.ts +14 -0
  87. package/templates/mongo/tsconfig.build.json +4 -0
  88. package/templates/mongo/tsconfig.json +23 -0
  89. package/templates/postgres/.env.example +32 -0
  90. package/templates/postgres/Dockerfile +64 -0
  91. package/templates/postgres/eslint.config.mjs +35 -0
  92. package/templates/postgres/nest-cli.json +8 -0
  93. package/templates/postgres/package.json +103 -0
  94. package/templates/postgres/prisma/schema.prisma +14 -0
  95. package/templates/postgres/prisma.config.ts +11 -0
  96. package/templates/postgres/src/app.module.ts +34 -0
  97. package/templates/postgres/src/common/decorators/public-api.decorator.ts +9 -0
  98. package/templates/postgres/src/common/decorators/raw-response.decorator.ts +4 -0
  99. package/templates/postgres/src/common/filters/http-exception.filter.spec.ts +95 -0
  100. package/templates/postgres/src/common/filters/http-exception.filter.ts +43 -0
  101. package/templates/postgres/src/common/filters/rpc-exception.filter.ts +18 -0
  102. package/templates/postgres/src/common/index.ts +5 -0
  103. package/templates/postgres/src/common/interceptors/timeout.interceptor.ts +32 -0
  104. package/templates/postgres/src/common/interceptors/transform.interceptor.spec.ts +52 -0
  105. package/templates/postgres/src/common/interceptors/transform.interceptor.ts +25 -0
  106. package/templates/postgres/src/common/middleware/correlation-id.middleware.spec.ts +69 -0
  107. package/templates/postgres/src/common/middleware/correlation-id.middleware.ts +26 -0
  108. package/templates/postgres/src/infrastructure/cache/inject-redis.decorator.ts +4 -0
  109. package/templates/postgres/src/infrastructure/cache/redis.module.ts +9 -0
  110. package/templates/postgres/src/infrastructure/cache/redis.service.spec.ts +174 -0
  111. package/templates/postgres/src/infrastructure/cache/redis.service.ts +121 -0
  112. package/templates/postgres/src/infrastructure/config/config.module.ts +36 -0
  113. package/templates/postgres/src/infrastructure/config/environment.validation.spec.ts +100 -0
  114. package/templates/postgres/src/infrastructure/config/environment.validation.ts +21 -0
  115. package/templates/postgres/src/infrastructure/database/inject-prisma.decorator.ts +4 -0
  116. package/templates/postgres/src/infrastructure/database/prisma.module.ts +9 -0
  117. package/templates/postgres/src/infrastructure/database/prisma.service.ts +21 -0
  118. package/templates/postgres/src/infrastructure/health/health.controller.ts +46 -0
  119. package/templates/postgres/src/infrastructure/health/health.module.ts +12 -0
  120. package/templates/postgres/src/infrastructure/health/prisma.health-indicator.ts +19 -0
  121. package/templates/postgres/src/infrastructure/health/redis.health-indicator.ts +20 -0
  122. package/templates/postgres/src/instrumentation.spec.ts +24 -0
  123. package/templates/postgres/src/instrumentation.ts +44 -0
  124. package/templates/postgres/src/main.ts +102 -0
  125. package/templates/postgres/src/modules/example/application/commands/create-item.command.ts +3 -0
  126. package/templates/postgres/src/modules/example/application/commands/create-item.handler.spec.ts +49 -0
  127. package/templates/postgres/src/modules/example/application/commands/create-item.handler.ts +20 -0
  128. package/templates/postgres/src/modules/example/application/commands/delete-item.command.ts +3 -0
  129. package/templates/postgres/src/modules/example/application/commands/delete-item.handler.ts +15 -0
  130. package/templates/postgres/src/modules/example/application/dtos/create-item.dto.ts +9 -0
  131. package/templates/postgres/src/modules/example/application/dtos/item.response.dto.ts +9 -0
  132. package/templates/postgres/src/modules/example/application/queries/get-item.handler.spec.ts +49 -0
  133. package/templates/postgres/src/modules/example/application/queries/get-item.handler.ts +16 -0
  134. package/templates/postgres/src/modules/example/application/queries/get-item.query.ts +3 -0
  135. package/templates/postgres/src/modules/example/application/queries/list-items.handler.ts +16 -0
  136. package/templates/postgres/src/modules/example/application/queries/list-items.query.ts +3 -0
  137. package/templates/postgres/src/modules/example/domain/item-name.value-object.spec.ts +49 -0
  138. package/templates/postgres/src/modules/example/domain/item-name.value-object.ts +18 -0
  139. package/templates/postgres/src/modules/example/domain/item.entity.spec.ts +48 -0
  140. package/templates/postgres/src/modules/example/domain/item.entity.ts +19 -0
  141. package/templates/postgres/src/modules/example/domain/item.repository.interface.ts +10 -0
  142. package/templates/postgres/src/modules/example/example.module.ts +26 -0
  143. package/templates/postgres/src/modules/example/infrastructure/.gitkeep +0 -0
  144. package/templates/postgres/src/modules/example/infrastructure/persistence/prisma-item.repository.ts +34 -0
  145. package/templates/postgres/src/modules/example/presenter/item.controller.ts +52 -0
  146. package/templates/postgres/src/shared/base/aggregate-root.spec.ts +44 -0
  147. package/templates/postgres/src/shared/base/aggregate-root.ts +20 -0
  148. package/templates/postgres/src/shared/base/domain-event.ts +6 -0
  149. package/templates/postgres/src/shared/base/entity.spec.ts +36 -0
  150. package/templates/postgres/src/shared/base/entity.ts +13 -0
  151. package/templates/postgres/src/shared/base/index.ts +5 -0
  152. package/templates/postgres/src/shared/base/repository.interface.ts +6 -0
  153. package/templates/postgres/src/shared/base/value-object.spec.ts +39 -0
  154. package/templates/postgres/src/shared/base/value-object.ts +13 -0
  155. package/templates/postgres/src/shared/dto/pagination.dto.spec.ts +49 -0
  156. package/templates/postgres/src/shared/dto/pagination.dto.ts +37 -0
  157. package/templates/postgres/src/shared/dto/response.dto.ts +13 -0
  158. package/templates/postgres/src/shared/exceptions/app.exception.spec.ts +59 -0
  159. package/templates/postgres/src/shared/exceptions/app.exception.ts +19 -0
  160. package/templates/postgres/src/shared/exceptions/error-codes.ts +9 -0
  161. package/templates/postgres/src/shared/index.ts +7 -0
  162. package/templates/postgres/src/shared/logger/logger.module.ts +12 -0
  163. package/templates/postgres/src/shared/logger/logger.service.ts +48 -0
  164. package/templates/postgres/src/shared/logger/pino.config.ts +86 -0
  165. package/templates/postgres/src/shared/validation-options.ts +38 -0
  166. package/templates/postgres/src/shared/valueobjects/date.valueobject.spec.ts +40 -0
  167. package/templates/postgres/src/shared/valueobjects/date.valueobject.ts +14 -0
  168. package/templates/postgres/src/shared/valueobjects/id.valueobject.spec.ts +28 -0
  169. package/templates/postgres/src/shared/valueobjects/id.valueobject.ts +14 -0
  170. package/templates/postgres/src/shared/valueobjects/index.ts +4 -0
  171. package/templates/postgres/src/shared/valueobjects/number.valueobject.spec.ts +48 -0
  172. package/templates/postgres/src/shared/valueobjects/number.valueobject.ts +14 -0
  173. package/templates/postgres/src/shared/valueobjects/string.valueobject.spec.ts +37 -0
  174. package/templates/postgres/src/shared/valueobjects/string.valueobject.ts +14 -0
  175. package/templates/postgres/tsconfig.build.json +4 -0
  176. package/templates/postgres/tsconfig.json +23 -0
@@ -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,6 @@
1
+ export abstract class DomainEvent {
2
+ readonly occurredAt: Date;
3
+ protected constructor() {
4
+ this.occurredAt = new Date();
5
+ }
6
+ }
@@ -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,5 @@
1
+ export { Entity } from './entity';
2
+ export { AggregateRoot } from './aggregate-root';
3
+ export { ValueObject } from './value-object';
4
+ export { DomainEvent } from './domain-event';
5
+ export { Repository } from './repository.interface';
@@ -0,0 +1,6 @@
1
+ export interface Repository<T, TId> {
2
+ findById(id: TId): Promise<T | null>;
3
+ findAll(): Promise<T[]>;
4
+ save(entity: T): Promise<void>;
5
+ delete(id: TId): Promise<void>;
6
+ }
@@ -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,7 @@
1
+ export * from './base';
2
+ export * from './valueobjects';
3
+ export * from './dto/pagination.dto';
4
+ export * from './dto/response.dto';
5
+ export * from './exceptions/app.exception';
6
+ export * from './exceptions/error-codes';
7
+ export * from './validation-options';
@@ -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
+ };
@@ -0,0 +1,40 @@
1
+ import { DateValueObject } from './date.valueobject';
2
+
3
+ describe('DateValueObject', () => {
4
+ it('should store and return a valid Date', () => {
5
+ const date = new Date('2024-01-15T10:00:00Z');
6
+ const vo = new DateValueObject(date);
7
+ expect(vo.value).toEqual(date);
8
+ });
9
+
10
+ it('should throw when constructed with an invalid Date (NaN time)', () => {
11
+ expect(() => new DateValueObject(new Date('invalid'))).toThrow(
12
+ 'Date value must be a valid Date',
13
+ );
14
+ });
15
+
16
+ it('should throw when constructed with a non-Date value', () => {
17
+ expect(() => new DateValueObject('2024-01-01' as any)).toThrow(
18
+ 'Date value must be a valid Date',
19
+ );
20
+ });
21
+
22
+ it('should throw when constructed with null', () => {
23
+ expect(() => new DateValueObject(null as any)).toThrow(
24
+ 'Date value must be a valid Date',
25
+ );
26
+ });
27
+
28
+ it('should be equal to another DateValueObject with the same date value', () => {
29
+ const date = new Date('2024-06-01T00:00:00Z');
30
+ const vo1 = new DateValueObject(date);
31
+ const vo2 = new DateValueObject(new Date('2024-06-01T00:00:00Z'));
32
+ expect(vo1.equals(vo2)).toBe(true);
33
+ });
34
+
35
+ it('should not be equal to a DateValueObject with a different date', () => {
36
+ const vo1 = new DateValueObject(new Date('2024-01-01'));
37
+ const vo2 = new DateValueObject(new Date('2024-12-31'));
38
+ expect(vo1.equals(vo2)).toBe(false);
39
+ });
40
+ });
@@ -0,0 +1,14 @@
1
+ import { ValueObject } from '../base/value-object';
2
+
3
+ export class DateValueObject extends ValueObject<{ value: Date }> {
4
+ constructor(value: Date) {
5
+ if (!(value instanceof Date) || isNaN(value.getTime())) {
6
+ throw new Error('Date value must be a valid Date');
7
+ }
8
+ super({ value });
9
+ }
10
+
11
+ get value(): Date {
12
+ return this.props.value;
13
+ }
14
+ }
@@ -0,0 +1,28 @@
1
+ import { IdValueObject } from './id.valueobject';
2
+
3
+ describe('IdValueObject', () => {
4
+ it('should store and return the id value', () => {
5
+ const id = new IdValueObject('abc-123');
6
+ expect(id.value).toBe('abc-123');
7
+ });
8
+
9
+ it('should throw when constructed with an empty string', () => {
10
+ expect(() => new IdValueObject('')).toThrow('ID must not be empty');
11
+ });
12
+
13
+ it('should throw when constructed with whitespace-only string', () => {
14
+ expect(() => new IdValueObject(' ')).toThrow('ID must not be empty');
15
+ });
16
+
17
+ it('should be equal to another IdValueObject with the same value', () => {
18
+ const id1 = new IdValueObject('abc-123');
19
+ const id2 = new IdValueObject('abc-123');
20
+ expect(id1.equals(id2)).toBe(true);
21
+ });
22
+
23
+ it('should not be equal to another IdValueObject with a different value', () => {
24
+ const id1 = new IdValueObject('abc-123');
25
+ const id2 = new IdValueObject('xyz-456');
26
+ expect(id1.equals(id2)).toBe(false);
27
+ });
28
+ });
@@ -0,0 +1,14 @@
1
+ import { ValueObject } from '../base/value-object';
2
+
3
+ export class IdValueObject extends ValueObject<{ value: string }> {
4
+ constructor(value: string) {
5
+ if (!value || value.trim().length === 0) {
6
+ throw new Error('ID must not be empty');
7
+ }
8
+ super({ value });
9
+ }
10
+
11
+ get value(): string {
12
+ return this.props.value;
13
+ }
14
+ }
@@ -0,0 +1,4 @@
1
+ export { IdValueObject } from './id.valueobject';
2
+ export { StringValueObject } from './string.valueobject';
3
+ export { NumberValueObject } from './number.valueobject';
4
+ export { DateValueObject } from './date.valueobject';