nestjs-ddd-cli 2.2.1 → 3.2.1
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 +247 -408
- package/ddd.schema.json +111 -0
- package/dist/commands/aggregate-validator.d.ts +9 -0
- package/dist/commands/aggregate-validator.js +953 -0
- package/dist/commands/aggregate-validator.js.map +1 -0
- package/dist/commands/ai-assist.d.ts +8 -0
- package/dist/commands/ai-assist.js +337 -0
- package/dist/commands/ai-assist.js.map +1 -0
- package/dist/commands/api-contracts.d.ts +9 -0
- package/dist/commands/api-contracts.js +1368 -0
- package/dist/commands/api-contracts.js.map +1 -0
- package/dist/commands/api-docs.d.ts +8 -0
- package/dist/commands/api-docs.js +408 -0
- package/dist/commands/api-docs.js.map +1 -0
- package/dist/commands/api-versioning.d.ts +11 -0
- package/dist/commands/api-versioning.js +643 -0
- package/dist/commands/api-versioning.js.map +1 -0
- package/dist/commands/audit-logging.d.ts +9 -0
- package/dist/commands/audit-logging.js +1129 -0
- package/dist/commands/audit-logging.js.map +1 -0
- package/dist/commands/batch-generate.d.ts +10 -0
- package/dist/commands/batch-generate.js +405 -0
- package/dist/commands/batch-generate.js.map +1 -0
- package/dist/commands/caching-strategies.d.ts +9 -0
- package/dist/commands/caching-strategies.js +874 -0
- package/dist/commands/caching-strategies.js.map +1 -0
- package/dist/commands/code-analyzer.d.ts +42 -0
- package/dist/commands/code-analyzer.js +474 -0
- package/dist/commands/code-analyzer.js.map +1 -0
- package/dist/commands/database-seeding.d.ts +6 -0
- package/dist/commands/database-seeding.js +621 -0
- package/dist/commands/database-seeding.js.map +1 -0
- package/dist/commands/db-optimization.d.ts +7 -0
- package/dist/commands/db-optimization.js +687 -0
- package/dist/commands/db-optimization.js.map +1 -0
- package/dist/commands/dependency-graph.d.ts +6 -0
- package/dist/commands/dependency-graph.js +329 -0
- package/dist/commands/dependency-graph.js.map +1 -0
- package/dist/commands/doctor-enhanced.d.ts +22 -0
- package/dist/commands/doctor-enhanced.js +543 -0
- package/dist/commands/doctor-enhanced.js.map +1 -0
- package/dist/commands/doctor.d.ts +4 -0
- package/dist/commands/doctor.js +151 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/env-manager.d.ts +6 -0
- package/dist/commands/env-manager.js +419 -0
- package/dist/commands/env-manager.js.map +1 -0
- package/dist/commands/event-sourcing-full.d.ts +10 -0
- package/dist/commands/event-sourcing-full.js +1107 -0
- package/dist/commands/event-sourcing-full.js.map +1 -0
- package/dist/commands/feature-flags.d.ts +9 -0
- package/dist/commands/feature-flags.js +824 -0
- package/dist/commands/feature-flags.js.map +1 -0
- package/dist/commands/filter-dsl.d.ts +10 -0
- package/dist/commands/filter-dsl.js +1407 -0
- package/dist/commands/filter-dsl.js.map +1 -0
- package/dist/commands/generate-all.js +485 -32
- package/dist/commands/generate-all.js.map +1 -1
- package/dist/commands/generate-deployment.d.ts +8 -0
- package/dist/commands/generate-deployment.js +746 -0
- package/dist/commands/generate-deployment.js.map +1 -0
- package/dist/commands/generate-domain-service.d.ts +14 -0
- package/dist/commands/generate-domain-service.js +796 -0
- package/dist/commands/generate-domain-service.js.map +1 -0
- package/dist/commands/generate-entity.js +82 -24
- package/dist/commands/generate-entity.js.map +1 -1
- package/dist/commands/generate-from-schema.d.ts +56 -0
- package/dist/commands/generate-from-schema.js +222 -0
- package/dist/commands/generate-from-schema.js.map +1 -0
- package/dist/commands/generate-orchestrator.d.ts +14 -0
- package/dist/commands/generate-orchestrator.js +887 -0
- package/dist/commands/generate-orchestrator.js.map +1 -0
- package/dist/commands/generate-repository.d.ts +14 -0
- package/dist/commands/generate-repository.js +1019 -0
- package/dist/commands/generate-repository.js.map +1 -0
- package/dist/commands/generate-shared.d.ts +4 -0
- package/dist/commands/generate-shared.js +388 -0
- package/dist/commands/generate-shared.js.map +1 -0
- package/dist/commands/generate-value-object.d.ts +32 -0
- package/dist/commands/generate-value-object.js +700 -0
- package/dist/commands/generate-value-object.js.map +1 -0
- package/dist/commands/graphql-subscriptions.d.ts +6 -0
- package/dist/commands/graphql-subscriptions.js +607 -0
- package/dist/commands/graphql-subscriptions.js.map +1 -0
- package/dist/commands/graphql-types.d.ts +5 -0
- package/dist/commands/graphql-types.js +423 -0
- package/dist/commands/graphql-types.js.map +1 -0
- package/dist/commands/health-probes-advanced.d.ts +6 -0
- package/dist/commands/health-probes-advanced.js +655 -0
- package/dist/commands/health-probes-advanced.js.map +1 -0
- package/dist/commands/i18n-setup.d.ts +10 -0
- package/dist/commands/i18n-setup.js +677 -0
- package/dist/commands/i18n-setup.js.map +1 -0
- package/dist/commands/init-config.d.ts +6 -0
- package/dist/commands/init-config.js +370 -0
- package/dist/commands/init-config.js.map +1 -0
- package/dist/commands/init-project.js +56 -6
- package/dist/commands/init-project.js.map +1 -1
- package/dist/commands/interactive-scaffold.d.ts +5 -0
- package/dist/commands/interactive-scaffold.js +271 -0
- package/dist/commands/interactive-scaffold.js.map +1 -0
- package/dist/commands/metrics-prometheus.d.ts +6 -0
- package/dist/commands/metrics-prometheus.js +681 -0
- package/dist/commands/metrics-prometheus.js.map +1 -0
- package/dist/commands/migration-engine.d.ts +6 -0
- package/dist/commands/migration-engine.js +446 -0
- package/dist/commands/migration-engine.js.map +1 -0
- package/dist/commands/migration.d.ts +12 -0
- package/dist/commands/migration.js +484 -0
- package/dist/commands/migration.js.map +1 -0
- package/dist/commands/monorepo.d.ts +8 -0
- package/dist/commands/monorepo.js +483 -0
- package/dist/commands/monorepo.js.map +1 -0
- package/dist/commands/multi-database.d.ts +5 -0
- package/dist/commands/multi-database.js +439 -0
- package/dist/commands/multi-database.js.map +1 -0
- package/dist/commands/observability-tracing.d.ts +10 -0
- package/dist/commands/observability-tracing.js +740 -0
- package/dist/commands/observability-tracing.js.map +1 -0
- package/dist/commands/openapi-export.d.ts +8 -0
- package/dist/commands/openapi-export.js +359 -0
- package/dist/commands/openapi-export.js.map +1 -0
- package/dist/commands/perf-analyzer.d.ts +8 -0
- package/dist/commands/perf-analyzer.js +423 -0
- package/dist/commands/perf-analyzer.js.map +1 -0
- package/dist/commands/rate-limiting.d.ts +10 -0
- package/dist/commands/rate-limiting.js +953 -0
- package/dist/commands/rate-limiting.js.map +1 -0
- package/dist/commands/recipe-plugin.d.ts +56 -0
- package/dist/commands/recipe-plugin.js +315 -0
- package/dist/commands/recipe-plugin.js.map +1 -0
- package/dist/commands/recipe.d.ts +6 -0
- package/dist/commands/recipe.js +3941 -0
- package/dist/commands/recipe.js.map +1 -0
- package/dist/commands/recipes/elasticsearch.recipe.d.ts +1 -0
- package/dist/commands/recipes/elasticsearch.recipe.js +761 -0
- package/dist/commands/recipes/elasticsearch.recipe.js.map +1 -0
- package/dist/commands/recipes/event-sourcing.recipe.d.ts +1 -0
- package/dist/commands/recipes/event-sourcing.recipe.js +889 -0
- package/dist/commands/recipes/event-sourcing.recipe.js.map +1 -0
- package/dist/commands/recipes/index.d.ts +7 -0
- package/dist/commands/recipes/index.js +24 -0
- package/dist/commands/recipes/index.js.map +1 -0
- package/dist/commands/recipes/message-queue.recipe.d.ts +1 -0
- package/dist/commands/recipes/message-queue.recipe.js +706 -0
- package/dist/commands/recipes/message-queue.recipe.js.map +1 -0
- package/dist/commands/recipes/middleware.recipe.d.ts +1 -0
- package/dist/commands/recipes/middleware.recipe.js +383 -0
- package/dist/commands/recipes/middleware.recipe.js.map +1 -0
- package/dist/commands/recipes/multi-tenancy.recipe.d.ts +1 -0
- package/dist/commands/recipes/multi-tenancy.recipe.js +520 -0
- package/dist/commands/recipes/multi-tenancy.recipe.js.map +1 -0
- package/dist/commands/recipes/oauth2.recipe.d.ts +1 -0
- package/dist/commands/recipes/oauth2.recipe.js +472 -0
- package/dist/commands/recipes/oauth2.recipe.js.map +1 -0
- package/dist/commands/recipes/websocket.recipe.d.ts +1 -0
- package/dist/commands/recipes/websocket.recipe.js +453 -0
- package/dist/commands/recipes/websocket.recipe.js.map +1 -0
- package/dist/commands/resilience-patterns.d.ts +13 -0
- package/dist/commands/resilience-patterns.js +1029 -0
- package/dist/commands/resilience-patterns.js.map +1 -0
- package/dist/commands/security-patterns.d.ts +11 -0
- package/dist/commands/security-patterns.js +2233 -0
- package/dist/commands/security-patterns.js.map +1 -0
- package/dist/commands/template-debug.d.ts +27 -0
- package/dist/commands/template-debug.js +388 -0
- package/dist/commands/template-debug.js.map +1 -0
- package/dist/commands/test-factory-full.d.ts +9 -0
- package/dist/commands/test-factory-full.js +1570 -0
- package/dist/commands/test-factory-full.js.map +1 -0
- package/dist/commands/test-scaffold.d.ts +7 -0
- package/dist/commands/test-scaffold.js +621 -0
- package/dist/commands/test-scaffold.js.map +1 -0
- package/dist/index.js +1088 -0
- package/dist/index.js.map +1 -1
- package/dist/templates/ai-context/CLAUDE.md.hbs +158 -0
- package/dist/templates/ai-context/conventions.md.hbs +154 -0
- package/dist/templates/command/create-command.hbs +6 -14
- package/dist/templates/command/delete-command.hbs +19 -0
- package/dist/templates/command/update-command.hbs +24 -0
- package/dist/templates/controller/controller.hbs +64 -17
- package/dist/templates/dto/create-dto.hbs +29 -5
- package/dist/templates/dto/filter-dto.hbs +52 -0
- package/dist/templates/dto/filter-query.dto.hbs +148 -0
- package/dist/templates/dto/paginated-response.dto.hbs +29 -0
- package/dist/templates/dto/pagination-query.dto.hbs +30 -0
- package/dist/templates/dto/response-dto.hbs +38 -0
- package/dist/templates/dto/update-dto.hbs +11 -0
- package/dist/templates/entity/entity.hbs +32 -1
- package/dist/templates/event/domain-event.hbs +33 -7
- package/dist/templates/event/event-handler.hbs +40 -0
- package/dist/templates/exception/base-exceptions.hbs +69 -0
- package/dist/templates/exception/entity-not-found.exception.hbs +7 -0
- package/dist/templates/mapper/mapper.hbs +49 -24
- package/dist/templates/module/module.hbs +34 -10
- package/dist/templates/orm-entity/orm-entity.hbs +63 -12
- package/dist/templates/prisma/prisma-mapper.hbs +71 -0
- package/dist/templates/prisma/prisma-repository.hbs +114 -0
- package/dist/templates/prisma/prisma-schema.hbs +20 -0
- package/dist/templates/prisma/prisma-service.hbs +51 -0
- package/dist/templates/query/get-all.query.hbs +50 -0
- package/dist/templates/query/get-by-id.query.hbs +31 -0
- package/dist/templates/repository/repository.hbs +55 -13
- package/dist/templates/resolver/graphql-input.hbs +54 -0
- package/dist/templates/resolver/graphql-type.hbs +58 -0
- package/dist/templates/resolver/pagination-args.hbs +33 -0
- package/dist/templates/resolver/resolver.hbs +62 -0
- package/dist/templates/shared/prisma-query-builder.util.hbs +189 -0
- package/dist/templates/shared/query-builder.util.hbs +218 -0
- package/dist/templates/test/controller.spec.hbs +124 -0
- package/dist/templates/test/repository.spec.hbs +158 -0
- package/dist/templates/test/usecase.spec.hbs +116 -0
- package/dist/templates/usecase/create-usecase.hbs +19 -7
- package/dist/templates/usecase/delete-usecase.hbs +17 -0
- package/dist/templates/usecase/update-usecase.hbs +31 -0
- package/dist/utils/config.utils.d.ts +45 -0
- package/dist/utils/config.utils.js +211 -0
- package/dist/utils/config.utils.js.map +1 -0
- package/dist/utils/error.utils.d.ts +145 -0
- package/dist/utils/error.utils.js +422 -0
- package/dist/utils/error.utils.js.map +1 -0
- package/dist/utils/field.utils.d.ts +54 -0
- package/dist/utils/field.utils.js +389 -0
- package/dist/utils/field.utils.js.map +1 -0
- package/dist/utils/file.utils.d.ts +19 -8
- package/dist/utils/file.utils.js +135 -4
- package/dist/utils/file.utils.js.map +1 -1
- package/dist/utils/idempotency.utils.d.ts +123 -0
- package/dist/utils/idempotency.utils.js +444 -0
- package/dist/utils/idempotency.utils.js.map +1 -0
- package/dist/utils/naming.utils.js +24 -5
- package/dist/utils/naming.utils.js.map +1 -1
- package/dist/utils/performance.utils.d.ts +37 -0
- package/dist/utils/performance.utils.js +158 -0
- package/dist/utils/performance.utils.js.map +1 -0
- package/dist/utils/relation.utils.d.ts +92 -0
- package/dist/utils/relation.utils.js +388 -0
- package/dist/utils/relation.utils.js.map +1 -0
- package/dist/utils/rollback.utils.d.ts +49 -0
- package/dist/utils/rollback.utils.js +306 -0
- package/dist/utils/rollback.utils.js.map +1 -0
- package/dist/utils/schema.utils.d.ts +123 -0
- package/dist/utils/schema.utils.js +419 -0
- package/dist/utils/schema.utils.js.map +1 -0
- package/dist/utils/security.utils.d.ts +57 -0
- package/dist/utils/security.utils.js +315 -0
- package/dist/utils/security.utils.js.map +1 -0
- package/dist/utils/template-engine.utils.d.ts +80 -0
- package/dist/utils/template-engine.utils.js +463 -0
- package/dist/utils/template-engine.utils.js.map +1 -0
- package/dist/utils/validation-registry.utils.d.ts +160 -0
- package/dist/utils/validation-registry.utils.js +526 -0
- package/dist/utils/validation-registry.utils.js.map +1 -0
- package/package.json +3 -1
|
@@ -0,0 +1,3941 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.applyRecipe = applyRecipe;
|
|
40
|
+
exports.listRecipes = listRecipes;
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
43
|
+
const file_utils_1 = require("../utils/file.utils");
|
|
44
|
+
const dependency_utils_1 = require("../utils/dependency.utils");
|
|
45
|
+
// Import recipe implementations from separate files
|
|
46
|
+
const middleware_recipe_1 = require("./recipes/middleware.recipe");
|
|
47
|
+
const websocket_recipe_1 = require("./recipes/websocket.recipe");
|
|
48
|
+
const multi_tenancy_recipe_1 = require("./recipes/multi-tenancy.recipe");
|
|
49
|
+
const oauth2_recipe_1 = require("./recipes/oauth2.recipe");
|
|
50
|
+
const message_queue_recipe_1 = require("./recipes/message-queue.recipe");
|
|
51
|
+
const elasticsearch_recipe_1 = require("./recipes/elasticsearch.recipe");
|
|
52
|
+
const event_sourcing_recipe_1 = require("./recipes/event-sourcing.recipe");
|
|
53
|
+
const AVAILABLE_RECIPES = {
|
|
54
|
+
'auth-jwt': {
|
|
55
|
+
name: 'JWT Authentication',
|
|
56
|
+
description: 'JWT-based authentication with guards and decorators',
|
|
57
|
+
dependencies: ['@nestjs/jwt', '@nestjs/passport', 'passport', 'passport-jwt', 'bcrypt'],
|
|
58
|
+
devDependencies: ['@types/passport-jwt', '@types/bcrypt'],
|
|
59
|
+
},
|
|
60
|
+
'pagination': {
|
|
61
|
+
name: 'Pagination Utilities',
|
|
62
|
+
description: 'Shared pagination DTOs and utilities',
|
|
63
|
+
dependencies: [],
|
|
64
|
+
devDependencies: [],
|
|
65
|
+
},
|
|
66
|
+
'soft-delete': {
|
|
67
|
+
name: 'Soft Delete',
|
|
68
|
+
description: 'Soft delete base class and repository mixin',
|
|
69
|
+
dependencies: [],
|
|
70
|
+
devDependencies: [],
|
|
71
|
+
},
|
|
72
|
+
'audit-log': {
|
|
73
|
+
name: 'Audit Logging',
|
|
74
|
+
description: 'Track entity changes with audit log',
|
|
75
|
+
dependencies: [],
|
|
76
|
+
devDependencies: [],
|
|
77
|
+
},
|
|
78
|
+
'caching': {
|
|
79
|
+
name: 'Redis Caching',
|
|
80
|
+
description: 'Redis-based caching with decorators',
|
|
81
|
+
dependencies: ['@nestjs/cache-manager', 'cache-manager', 'cache-manager-redis-store', 'redis'],
|
|
82
|
+
devDependencies: ['@types/cache-manager-redis-store'],
|
|
83
|
+
},
|
|
84
|
+
'file-upload': {
|
|
85
|
+
name: 'File Upload',
|
|
86
|
+
description: 'File upload service with local/S3 storage support',
|
|
87
|
+
dependencies: ['@nestjs/platform-express', 'multer', '@aws-sdk/client-s3', '@aws-sdk/s3-request-presigner', 'uuid'],
|
|
88
|
+
devDependencies: ['@types/multer', '@types/uuid'],
|
|
89
|
+
},
|
|
90
|
+
'notifications': {
|
|
91
|
+
name: 'Notifications',
|
|
92
|
+
description: 'Multi-channel notification system (email, push, SMS)',
|
|
93
|
+
dependencies: ['@nestjs/bull', 'bull', 'nodemailer', 'handlebars'],
|
|
94
|
+
devDependencies: ['@types/nodemailer'],
|
|
95
|
+
},
|
|
96
|
+
'webhooks': {
|
|
97
|
+
name: 'Webhooks',
|
|
98
|
+
description: 'Webhook management with retry and signature verification',
|
|
99
|
+
dependencies: ['axios', 'crypto'],
|
|
100
|
+
devDependencies: [],
|
|
101
|
+
},
|
|
102
|
+
'filtering': {
|
|
103
|
+
name: 'Advanced Filtering',
|
|
104
|
+
description: 'Query builder utilities with operators (eq, gt, lt, like, in, between)',
|
|
105
|
+
dependencies: [],
|
|
106
|
+
devDependencies: [],
|
|
107
|
+
},
|
|
108
|
+
'rate-limiting': {
|
|
109
|
+
name: 'Rate Limiting',
|
|
110
|
+
description: 'Throttling with decorators, Redis-based rate limiting, and IP tracking',
|
|
111
|
+
dependencies: ['@nestjs/throttler', 'ioredis'],
|
|
112
|
+
devDependencies: [],
|
|
113
|
+
},
|
|
114
|
+
'health': {
|
|
115
|
+
name: 'Health Checks & Monitoring',
|
|
116
|
+
description: 'Health endpoints, structured logging, and monitoring utilities',
|
|
117
|
+
dependencies: ['@nestjs/terminus', 'pino', 'pino-pretty', 'nestjs-pino'],
|
|
118
|
+
devDependencies: [],
|
|
119
|
+
},
|
|
120
|
+
'api-versioning': {
|
|
121
|
+
name: 'API Versioning',
|
|
122
|
+
description: 'URL/Header-based versioning with deprecation support and migration helpers',
|
|
123
|
+
dependencies: [],
|
|
124
|
+
devDependencies: [],
|
|
125
|
+
},
|
|
126
|
+
'test-factories': {
|
|
127
|
+
name: 'Test Factories & Fixtures',
|
|
128
|
+
description: 'Faker-based factories, test data builders, database seeders, and mock utilities',
|
|
129
|
+
dependencies: ['@faker-js/faker'],
|
|
130
|
+
devDependencies: [],
|
|
131
|
+
},
|
|
132
|
+
'middleware': {
|
|
133
|
+
name: 'Middleware, Guards & Interceptors',
|
|
134
|
+
description: 'Common middleware, guards, and interceptors for NestJS applications',
|
|
135
|
+
dependencies: [],
|
|
136
|
+
devDependencies: [],
|
|
137
|
+
},
|
|
138
|
+
'websocket': {
|
|
139
|
+
name: 'WebSocket & Real-Time',
|
|
140
|
+
description: 'Socket.IO gateway, rooms, presence tracking, and real-time events',
|
|
141
|
+
dependencies: ['@nestjs/websockets', '@nestjs/platform-socket.io', 'socket.io'],
|
|
142
|
+
devDependencies: [],
|
|
143
|
+
},
|
|
144
|
+
'multi-tenancy': {
|
|
145
|
+
name: 'Multi-Tenancy',
|
|
146
|
+
description: 'SaaS multi-tenant architecture with tenant isolation and scoped repositories',
|
|
147
|
+
dependencies: [],
|
|
148
|
+
devDependencies: [],
|
|
149
|
+
},
|
|
150
|
+
'oauth2': {
|
|
151
|
+
name: 'OAuth2 & Social Login',
|
|
152
|
+
description: 'OAuth2/OIDC with Google, GitHub, and enterprise SSO support',
|
|
153
|
+
dependencies: ['@nestjs/passport', 'passport-google-oauth20', 'passport-github2', 'openid-client'],
|
|
154
|
+
devDependencies: ['@types/passport-google-oauth20', '@types/passport-github2'],
|
|
155
|
+
},
|
|
156
|
+
'message-queue': {
|
|
157
|
+
name: 'Message Queue (RabbitMQ)',
|
|
158
|
+
description: 'RabbitMQ integration with producers, consumers, dead letters, and retry patterns',
|
|
159
|
+
dependencies: ['@nestjs/microservices', 'amqplib', 'amqp-connection-manager'],
|
|
160
|
+
devDependencies: ['@types/amqplib'],
|
|
161
|
+
},
|
|
162
|
+
'elasticsearch': {
|
|
163
|
+
name: 'Elasticsearch Search',
|
|
164
|
+
description: 'Full-text search with Elasticsearch, indexing, and autocomplete',
|
|
165
|
+
dependencies: ['@nestjs/elasticsearch', '@elastic/elasticsearch'],
|
|
166
|
+
devDependencies: [],
|
|
167
|
+
},
|
|
168
|
+
'event-sourcing': {
|
|
169
|
+
name: 'Event Sourcing',
|
|
170
|
+
description: 'Event store, projections, snapshots, and event replay capabilities',
|
|
171
|
+
dependencies: ['@nestjs/cqrs'],
|
|
172
|
+
devDependencies: [],
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
async function applyRecipe(recipeName, options) {
|
|
176
|
+
const recipe = AVAILABLE_RECIPES[recipeName];
|
|
177
|
+
if (!recipe) {
|
|
178
|
+
console.log(chalk_1.default.red(`Unknown recipe: ${recipeName}`));
|
|
179
|
+
console.log(chalk_1.default.yellow('\nAvailable recipes:'));
|
|
180
|
+
Object.entries(AVAILABLE_RECIPES).forEach(([key, value]) => {
|
|
181
|
+
console.log(chalk_1.default.cyan(` ${key.padEnd(15)} - ${value.description}`));
|
|
182
|
+
});
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
console.log(chalk_1.default.blue(`\n🧪 Applying recipe: ${recipe.name}`));
|
|
186
|
+
const basePath = options.path || process.cwd();
|
|
187
|
+
// Install dependencies if requested
|
|
188
|
+
if (options.installDeps && recipe.dependencies.length > 0) {
|
|
189
|
+
console.log(chalk_1.default.cyan(' Installing dependencies...'));
|
|
190
|
+
await (0, dependency_utils_1.installDependencies)(basePath, recipe.dependencies);
|
|
191
|
+
if (recipe.devDependencies.length > 0) {
|
|
192
|
+
await (0, dependency_utils_1.installDependencies)(basePath, recipe.devDependencies, true);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Apply the specific recipe
|
|
196
|
+
switch (recipeName) {
|
|
197
|
+
case 'auth-jwt':
|
|
198
|
+
await applyAuthJwtRecipe(basePath);
|
|
199
|
+
break;
|
|
200
|
+
case 'pagination':
|
|
201
|
+
await applyPaginationRecipe(basePath);
|
|
202
|
+
break;
|
|
203
|
+
case 'soft-delete':
|
|
204
|
+
await applySoftDeleteRecipe(basePath);
|
|
205
|
+
break;
|
|
206
|
+
case 'audit-log':
|
|
207
|
+
await applyAuditLogRecipe(basePath);
|
|
208
|
+
break;
|
|
209
|
+
case 'caching':
|
|
210
|
+
await applyCachingRecipe(basePath);
|
|
211
|
+
break;
|
|
212
|
+
case 'file-upload':
|
|
213
|
+
await applyFileUploadRecipe(basePath);
|
|
214
|
+
break;
|
|
215
|
+
case 'notifications':
|
|
216
|
+
await applyNotificationsRecipe(basePath);
|
|
217
|
+
break;
|
|
218
|
+
case 'webhooks':
|
|
219
|
+
await applyWebhooksRecipe(basePath);
|
|
220
|
+
break;
|
|
221
|
+
case 'filtering':
|
|
222
|
+
await applyFilteringRecipe(basePath);
|
|
223
|
+
break;
|
|
224
|
+
case 'rate-limiting':
|
|
225
|
+
await applyRateLimitingRecipe(basePath);
|
|
226
|
+
break;
|
|
227
|
+
case 'health':
|
|
228
|
+
await applyHealthRecipe(basePath);
|
|
229
|
+
break;
|
|
230
|
+
case 'api-versioning':
|
|
231
|
+
await applyApiVersioningRecipe(basePath);
|
|
232
|
+
break;
|
|
233
|
+
case 'test-factories':
|
|
234
|
+
await applyTestFactoriesRecipe(basePath);
|
|
235
|
+
break;
|
|
236
|
+
case 'middleware':
|
|
237
|
+
await (0, middleware_recipe_1.applyMiddlewareRecipe)(basePath);
|
|
238
|
+
break;
|
|
239
|
+
case 'websocket':
|
|
240
|
+
await (0, websocket_recipe_1.applyWebSocketRecipe)(basePath);
|
|
241
|
+
break;
|
|
242
|
+
case 'multi-tenancy':
|
|
243
|
+
await (0, multi_tenancy_recipe_1.applyMultiTenancyRecipe)(basePath);
|
|
244
|
+
break;
|
|
245
|
+
case 'oauth2':
|
|
246
|
+
await (0, oauth2_recipe_1.applyOAuth2Recipe)(basePath);
|
|
247
|
+
break;
|
|
248
|
+
case 'message-queue':
|
|
249
|
+
await (0, message_queue_recipe_1.applyMessageQueueRecipe)(basePath);
|
|
250
|
+
break;
|
|
251
|
+
case 'elasticsearch':
|
|
252
|
+
await (0, elasticsearch_recipe_1.applyElasticsearchRecipe)(basePath);
|
|
253
|
+
break;
|
|
254
|
+
case 'event-sourcing':
|
|
255
|
+
await (0, event_sourcing_recipe_1.applyEventSourcingRecipe)(basePath);
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
console.log(chalk_1.default.green(`\n✅ Recipe '${recipe.name}' applied successfully!`));
|
|
259
|
+
if (recipe.dependencies.length > 0 && !options.installDeps) {
|
|
260
|
+
console.log(chalk_1.default.yellow('\n📦 Required dependencies (run with --install-deps to auto-install):'));
|
|
261
|
+
console.log(chalk_1.default.cyan(` npm install ${recipe.dependencies.join(' ')}`));
|
|
262
|
+
if (recipe.devDependencies.length > 0) {
|
|
263
|
+
console.log(chalk_1.default.cyan(` npm install -D ${recipe.devDependencies.join(' ')}`));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async function applyAuthJwtRecipe(basePath) {
|
|
268
|
+
const sharedPath = path.join(basePath, 'src/shared');
|
|
269
|
+
const authPath = path.join(sharedPath, 'auth');
|
|
270
|
+
await (0, file_utils_1.ensureDir)(authPath);
|
|
271
|
+
await (0, file_utils_1.ensureDir)(path.join(authPath, 'guards'));
|
|
272
|
+
await (0, file_utils_1.ensureDir)(path.join(authPath, 'decorators'));
|
|
273
|
+
await (0, file_utils_1.ensureDir)(path.join(authPath, 'strategies'));
|
|
274
|
+
// JWT Strategy
|
|
275
|
+
const jwtStrategyContent = `import { Injectable, UnauthorizedException } from "@nestjs/common";
|
|
276
|
+
import { PassportStrategy } from "@nestjs/passport";
|
|
277
|
+
import { ExtractJwt, Strategy } from "passport-jwt";
|
|
278
|
+
import { ConfigService } from "@nestjs/config";
|
|
279
|
+
|
|
280
|
+
export interface JwtPayload {
|
|
281
|
+
sub: string;
|
|
282
|
+
email: string;
|
|
283
|
+
roles?: string[];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
@Injectable()
|
|
287
|
+
export class JwtStrategy extends PassportStrategy(Strategy) {
|
|
288
|
+
constructor(private configService: ConfigService) {
|
|
289
|
+
super({
|
|
290
|
+
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
|
291
|
+
ignoreExpiration: false,
|
|
292
|
+
secretOrKey: configService.get<string>("JWT_SECRET"),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async validate(payload: JwtPayload) {
|
|
297
|
+
if (!payload.sub) {
|
|
298
|
+
throw new UnauthorizedException();
|
|
299
|
+
}
|
|
300
|
+
return { userId: payload.sub, email: payload.email, roles: payload.roles };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
`;
|
|
304
|
+
await (0, file_utils_1.writeFile)(path.join(authPath, 'strategies/jwt.strategy.ts'), jwtStrategyContent);
|
|
305
|
+
// Auth Guard
|
|
306
|
+
const authGuardContent = `import { Injectable, ExecutionContext, UnauthorizedException } from "@nestjs/common";
|
|
307
|
+
import { AuthGuard } from "@nestjs/passport";
|
|
308
|
+
import { Reflector } from "@nestjs/core";
|
|
309
|
+
import { IS_PUBLIC_KEY } from "../decorators/public.decorator";
|
|
310
|
+
|
|
311
|
+
@Injectable()
|
|
312
|
+
export class JwtAuthGuard extends AuthGuard("jwt") {
|
|
313
|
+
constructor(private reflector: Reflector) {
|
|
314
|
+
super();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
canActivate(context: ExecutionContext) {
|
|
318
|
+
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
|
319
|
+
context.getHandler(),
|
|
320
|
+
context.getClass(),
|
|
321
|
+
]);
|
|
322
|
+
|
|
323
|
+
if (isPublic) {
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return super.canActivate(context);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
handleRequest(err: any, user: any, info: any) {
|
|
331
|
+
if (err || !user) {
|
|
332
|
+
throw err || new UnauthorizedException();
|
|
333
|
+
}
|
|
334
|
+
return user;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
`;
|
|
338
|
+
await (0, file_utils_1.writeFile)(path.join(authPath, 'guards/jwt-auth.guard.ts'), authGuardContent);
|
|
339
|
+
// Roles Guard
|
|
340
|
+
const rolesGuardContent = `import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
|
|
341
|
+
import { Reflector } from "@nestjs/core";
|
|
342
|
+
import { ROLES_KEY } from "../decorators/roles.decorator";
|
|
343
|
+
|
|
344
|
+
@Injectable()
|
|
345
|
+
export class RolesGuard implements CanActivate {
|
|
346
|
+
constructor(private reflector: Reflector) {}
|
|
347
|
+
|
|
348
|
+
canActivate(context: ExecutionContext): boolean {
|
|
349
|
+
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
|
350
|
+
context.getHandler(),
|
|
351
|
+
context.getClass(),
|
|
352
|
+
]);
|
|
353
|
+
|
|
354
|
+
if (!requiredRoles) {
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const { user } = context.switchToHttp().getRequest();
|
|
359
|
+
return requiredRoles.some((role) => user.roles?.includes(role));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
`;
|
|
363
|
+
await (0, file_utils_1.writeFile)(path.join(authPath, 'guards/roles.guard.ts'), rolesGuardContent);
|
|
364
|
+
// Decorators
|
|
365
|
+
const publicDecoratorContent = `import { SetMetadata } from "@nestjs/common";
|
|
366
|
+
|
|
367
|
+
export const IS_PUBLIC_KEY = "isPublic";
|
|
368
|
+
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
|
369
|
+
`;
|
|
370
|
+
await (0, file_utils_1.writeFile)(path.join(authPath, 'decorators/public.decorator.ts'), publicDecoratorContent);
|
|
371
|
+
const rolesDecoratorContent = `import { SetMetadata } from "@nestjs/common";
|
|
372
|
+
|
|
373
|
+
export const ROLES_KEY = "roles";
|
|
374
|
+
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
|
375
|
+
`;
|
|
376
|
+
await (0, file_utils_1.writeFile)(path.join(authPath, 'decorators/roles.decorator.ts'), rolesDecoratorContent);
|
|
377
|
+
const currentUserDecoratorContent = `import { createParamDecorator, ExecutionContext } from "@nestjs/common";
|
|
378
|
+
|
|
379
|
+
export interface CurrentUserData {
|
|
380
|
+
userId: string;
|
|
381
|
+
email: string;
|
|
382
|
+
roles?: string[];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export const CurrentUser = createParamDecorator(
|
|
386
|
+
(data: keyof CurrentUserData | undefined, ctx: ExecutionContext) => {
|
|
387
|
+
const request = ctx.switchToHttp().getRequest();
|
|
388
|
+
const user = request.user as CurrentUserData;
|
|
389
|
+
|
|
390
|
+
return data ? user?.[data] : user;
|
|
391
|
+
}
|
|
392
|
+
);
|
|
393
|
+
`;
|
|
394
|
+
await (0, file_utils_1.writeFile)(path.join(authPath, 'decorators/current-user.decorator.ts'), currentUserDecoratorContent);
|
|
395
|
+
// Index exports
|
|
396
|
+
const indexContent = `// Guards
|
|
397
|
+
export * from "./guards/jwt-auth.guard";
|
|
398
|
+
export * from "./guards/roles.guard";
|
|
399
|
+
|
|
400
|
+
// Decorators
|
|
401
|
+
export * from "./decorators/public.decorator";
|
|
402
|
+
export * from "./decorators/roles.decorator";
|
|
403
|
+
export * from "./decorators/current-user.decorator";
|
|
404
|
+
|
|
405
|
+
// Strategies
|
|
406
|
+
export * from "./strategies/jwt.strategy";
|
|
407
|
+
`;
|
|
408
|
+
await (0, file_utils_1.writeFile)(path.join(authPath, 'index.ts'), indexContent);
|
|
409
|
+
console.log(chalk_1.default.green(' ✓ JWT Strategy'));
|
|
410
|
+
console.log(chalk_1.default.green(' ✓ Auth Guards (JWT, Roles)'));
|
|
411
|
+
console.log(chalk_1.default.green(' ✓ Decorators (Public, Roles, CurrentUser)'));
|
|
412
|
+
}
|
|
413
|
+
async function applyPaginationRecipe(basePath) {
|
|
414
|
+
const sharedPath = path.join(basePath, 'src/shared');
|
|
415
|
+
const paginationPath = path.join(sharedPath, 'pagination');
|
|
416
|
+
await (0, file_utils_1.ensureDir)(paginationPath);
|
|
417
|
+
const paginationDtoContent = `import { ApiPropertyOptional } from "@nestjs/swagger";
|
|
418
|
+
import { IsOptional, IsInt, Min, Max, IsString, IsIn } from "class-validator";
|
|
419
|
+
import { Type } from "class-transformer";
|
|
420
|
+
|
|
421
|
+
export class PaginationQueryDto {
|
|
422
|
+
@ApiPropertyOptional({ description: "Page number", default: 1, minimum: 1 })
|
|
423
|
+
@IsOptional()
|
|
424
|
+
@Type(() => Number)
|
|
425
|
+
@IsInt()
|
|
426
|
+
@Min(1)
|
|
427
|
+
page?: number = 1;
|
|
428
|
+
|
|
429
|
+
@ApiPropertyOptional({ description: "Items per page", default: 10, minimum: 1, maximum: 100 })
|
|
430
|
+
@IsOptional()
|
|
431
|
+
@Type(() => Number)
|
|
432
|
+
@IsInt()
|
|
433
|
+
@Min(1)
|
|
434
|
+
@Max(100)
|
|
435
|
+
limit?: number = 10;
|
|
436
|
+
|
|
437
|
+
@ApiPropertyOptional({ description: "Field to sort by", default: "createdAt" })
|
|
438
|
+
@IsOptional()
|
|
439
|
+
@IsString()
|
|
440
|
+
sortBy?: string = "createdAt";
|
|
441
|
+
|
|
442
|
+
@ApiPropertyOptional({ description: "Sort order", enum: ["ASC", "DESC"], default: "DESC" })
|
|
443
|
+
@IsOptional()
|
|
444
|
+
@IsIn(["ASC", "DESC"])
|
|
445
|
+
sortOrder?: "ASC" | "DESC" = "DESC";
|
|
446
|
+
|
|
447
|
+
get skip(): number {
|
|
448
|
+
return ((this.page || 1) - 1) * (this.limit || 10);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
get take(): number {
|
|
452
|
+
return this.limit || 10;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export class PaginationMeta {
|
|
457
|
+
total: number;
|
|
458
|
+
page: number;
|
|
459
|
+
limit: number;
|
|
460
|
+
totalPages: number;
|
|
461
|
+
hasNextPage: boolean;
|
|
462
|
+
hasPreviousPage: boolean;
|
|
463
|
+
|
|
464
|
+
constructor(total: number, page: number, limit: number) {
|
|
465
|
+
this.total = total;
|
|
466
|
+
this.page = page;
|
|
467
|
+
this.limit = limit;
|
|
468
|
+
this.totalPages = Math.ceil(total / limit);
|
|
469
|
+
this.hasNextPage = page < this.totalPages;
|
|
470
|
+
this.hasPreviousPage = page > 1;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export class PaginatedResponseDto<T> {
|
|
475
|
+
items: T[];
|
|
476
|
+
meta: PaginationMeta;
|
|
477
|
+
|
|
478
|
+
constructor(items: T[], total: number, page: number, limit: number) {
|
|
479
|
+
this.items = items;
|
|
480
|
+
this.meta = new PaginationMeta(total, page, limit);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
`;
|
|
484
|
+
await (0, file_utils_1.writeFile)(path.join(paginationPath, 'pagination.dto.ts'), paginationDtoContent);
|
|
485
|
+
const indexContent = `export * from "./pagination.dto";
|
|
486
|
+
`;
|
|
487
|
+
await (0, file_utils_1.writeFile)(path.join(paginationPath, 'index.ts'), indexContent);
|
|
488
|
+
console.log(chalk_1.default.green(' ✓ PaginationQueryDto'));
|
|
489
|
+
console.log(chalk_1.default.green(' ✓ PaginationMeta'));
|
|
490
|
+
console.log(chalk_1.default.green(' ✓ PaginatedResponseDto'));
|
|
491
|
+
}
|
|
492
|
+
async function applySoftDeleteRecipe(basePath) {
|
|
493
|
+
const sharedPath = path.join(basePath, 'src/shared');
|
|
494
|
+
const basePath2 = path.join(sharedPath, 'base');
|
|
495
|
+
await (0, file_utils_1.ensureDir)(basePath2);
|
|
496
|
+
const baseEntityContent = `import {
|
|
497
|
+
PrimaryGeneratedColumn,
|
|
498
|
+
CreateDateColumn,
|
|
499
|
+
UpdateDateColumn,
|
|
500
|
+
DeleteDateColumn,
|
|
501
|
+
Column,
|
|
502
|
+
} from "typeorm";
|
|
503
|
+
|
|
504
|
+
export abstract class BaseOrmEntity {
|
|
505
|
+
@PrimaryGeneratedColumn("uuid")
|
|
506
|
+
id: string;
|
|
507
|
+
|
|
508
|
+
@Column({ name: "is_active", default: true })
|
|
509
|
+
isActive: boolean;
|
|
510
|
+
|
|
511
|
+
@CreateDateColumn({ name: "created_at" })
|
|
512
|
+
createdAt: Date;
|
|
513
|
+
|
|
514
|
+
@UpdateDateColumn({ name: "updated_at" })
|
|
515
|
+
updatedAt: Date;
|
|
516
|
+
|
|
517
|
+
@DeleteDateColumn({ name: "deleted_at" })
|
|
518
|
+
deletedAt?: Date;
|
|
519
|
+
}
|
|
520
|
+
`;
|
|
521
|
+
await (0, file_utils_1.writeFile)(path.join(basePath2, 'base-orm.entity.ts'), baseEntityContent);
|
|
522
|
+
const baseRepoContent = `import { Repository, FindOptionsWhere, DeepPartial } from "typeorm";
|
|
523
|
+
import { BaseOrmEntity } from "./base-orm.entity";
|
|
524
|
+
|
|
525
|
+
export abstract class BaseRepository<T extends BaseOrmEntity> {
|
|
526
|
+
constructor(protected readonly repository: Repository<T>) {}
|
|
527
|
+
|
|
528
|
+
async findById(id: string): Promise<T | null> {
|
|
529
|
+
return this.repository.findOne({
|
|
530
|
+
where: { id, deletedAt: null } as FindOptionsWhere<T>,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async findAll(): Promise<T[]> {
|
|
535
|
+
return this.repository.find({
|
|
536
|
+
where: { deletedAt: null } as FindOptionsWhere<T>,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async create(data: DeepPartial<T>): Promise<T> {
|
|
541
|
+
const entity = this.repository.create(data);
|
|
542
|
+
return this.repository.save(entity);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async update(id: string, data: DeepPartial<T>): Promise<T | null> {
|
|
546
|
+
await this.repository.update(id, data as any);
|
|
547
|
+
return this.findById(id);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async softDelete(id: string): Promise<void> {
|
|
551
|
+
await this.repository.update(id, {
|
|
552
|
+
deletedAt: new Date(),
|
|
553
|
+
isActive: false,
|
|
554
|
+
} as any);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async hardDelete(id: string): Promise<void> {
|
|
558
|
+
await this.repository.delete(id);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async restore(id: string): Promise<void> {
|
|
562
|
+
await this.repository.update(id, {
|
|
563
|
+
deletedAt: null,
|
|
564
|
+
isActive: true,
|
|
565
|
+
} as any);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async exists(id: string): Promise<boolean> {
|
|
569
|
+
const count = await this.repository.count({
|
|
570
|
+
where: { id, deletedAt: null } as FindOptionsWhere<T>,
|
|
571
|
+
});
|
|
572
|
+
return count > 0;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
`;
|
|
576
|
+
await (0, file_utils_1.writeFile)(path.join(basePath2, 'base.repository.ts'), baseRepoContent);
|
|
577
|
+
const indexContent = `export * from "./base-orm.entity";
|
|
578
|
+
export * from "./base.repository";
|
|
579
|
+
`;
|
|
580
|
+
await (0, file_utils_1.writeFile)(path.join(basePath2, 'index.ts'), indexContent);
|
|
581
|
+
console.log(chalk_1.default.green(' ✓ BaseOrmEntity with soft delete'));
|
|
582
|
+
console.log(chalk_1.default.green(' ✓ BaseRepository with CRUD + soft delete'));
|
|
583
|
+
}
|
|
584
|
+
async function applyAuditLogRecipe(basePath) {
|
|
585
|
+
const sharedPath = path.join(basePath, 'src/shared');
|
|
586
|
+
const auditPath = path.join(sharedPath, 'audit');
|
|
587
|
+
await (0, file_utils_1.ensureDir)(auditPath);
|
|
588
|
+
const auditEntityContent = `import {
|
|
589
|
+
Entity,
|
|
590
|
+
PrimaryGeneratedColumn,
|
|
591
|
+
Column,
|
|
592
|
+
CreateDateColumn,
|
|
593
|
+
} from "typeorm";
|
|
594
|
+
|
|
595
|
+
export type AuditAction = "CREATE" | "UPDATE" | "DELETE" | "RESTORE";
|
|
596
|
+
|
|
597
|
+
@Entity("audit_logs")
|
|
598
|
+
export class AuditLogEntity {
|
|
599
|
+
@PrimaryGeneratedColumn("uuid")
|
|
600
|
+
id: string;
|
|
601
|
+
|
|
602
|
+
@Column()
|
|
603
|
+
entityName: string;
|
|
604
|
+
|
|
605
|
+
@Column()
|
|
606
|
+
entityId: string;
|
|
607
|
+
|
|
608
|
+
@Column()
|
|
609
|
+
action: AuditAction;
|
|
610
|
+
|
|
611
|
+
@Column({ type: "jsonb", nullable: true })
|
|
612
|
+
oldValues?: Record<string, any>;
|
|
613
|
+
|
|
614
|
+
@Column({ type: "jsonb", nullable: true })
|
|
615
|
+
newValues?: Record<string, any>;
|
|
616
|
+
|
|
617
|
+
@Column({ nullable: true })
|
|
618
|
+
userId?: string;
|
|
619
|
+
|
|
620
|
+
@Column({ nullable: true })
|
|
621
|
+
userEmail?: string;
|
|
622
|
+
|
|
623
|
+
@CreateDateColumn()
|
|
624
|
+
createdAt: Date;
|
|
625
|
+
}
|
|
626
|
+
`;
|
|
627
|
+
await (0, file_utils_1.writeFile)(path.join(auditPath, 'audit-log.entity.ts'), auditEntityContent);
|
|
628
|
+
const auditServiceContent = `import { Injectable } from "@nestjs/common";
|
|
629
|
+
import { InjectRepository } from "@nestjs/typeorm";
|
|
630
|
+
import { Repository } from "typeorm";
|
|
631
|
+
import { AuditLogEntity, AuditAction } from "./audit-log.entity";
|
|
632
|
+
|
|
633
|
+
export interface AuditContext {
|
|
634
|
+
userId?: string;
|
|
635
|
+
userEmail?: string;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
@Injectable()
|
|
639
|
+
export class AuditService {
|
|
640
|
+
constructor(
|
|
641
|
+
@InjectRepository(AuditLogEntity)
|
|
642
|
+
private readonly auditRepository: Repository<AuditLogEntity>
|
|
643
|
+
) {}
|
|
644
|
+
|
|
645
|
+
async log(
|
|
646
|
+
entityName: string,
|
|
647
|
+
entityId: string,
|
|
648
|
+
action: AuditAction,
|
|
649
|
+
oldValues: Record<string, any> | null,
|
|
650
|
+
newValues: Record<string, any> | null,
|
|
651
|
+
context?: AuditContext
|
|
652
|
+
): Promise<void> {
|
|
653
|
+
const auditLog = this.auditRepository.create({
|
|
654
|
+
entityName,
|
|
655
|
+
entityId,
|
|
656
|
+
action,
|
|
657
|
+
oldValues: oldValues || undefined,
|
|
658
|
+
newValues: newValues || undefined,
|
|
659
|
+
userId: context?.userId,
|
|
660
|
+
userEmail: context?.userEmail,
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
await this.auditRepository.save(auditLog);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async getAuditHistory(entityName: string, entityId: string): Promise<AuditLogEntity[]> {
|
|
667
|
+
return this.auditRepository.find({
|
|
668
|
+
where: { entityName, entityId },
|
|
669
|
+
order: { createdAt: "DESC" },
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
`;
|
|
674
|
+
await (0, file_utils_1.writeFile)(path.join(auditPath, 'audit.service.ts'), auditServiceContent);
|
|
675
|
+
const indexContent = `export * from "./audit-log.entity";
|
|
676
|
+
export * from "./audit.service";
|
|
677
|
+
`;
|
|
678
|
+
await (0, file_utils_1.writeFile)(path.join(auditPath, 'index.ts'), indexContent);
|
|
679
|
+
console.log(chalk_1.default.green(' ✓ AuditLogEntity'));
|
|
680
|
+
console.log(chalk_1.default.green(' ✓ AuditService'));
|
|
681
|
+
}
|
|
682
|
+
async function applyCachingRecipe(basePath) {
|
|
683
|
+
const sharedPath = path.join(basePath, 'src/shared');
|
|
684
|
+
const cachePath = path.join(sharedPath, 'cache');
|
|
685
|
+
await (0, file_utils_1.ensureDir)(cachePath);
|
|
686
|
+
const cacheDecoratorContent = `import { SetMetadata } from "@nestjs/common";
|
|
687
|
+
|
|
688
|
+
export const CACHE_KEY = "cache_key";
|
|
689
|
+
export const CACHE_TTL = "cache_ttl";
|
|
690
|
+
|
|
691
|
+
export interface CacheOptions {
|
|
692
|
+
key?: string;
|
|
693
|
+
ttl?: number; // seconds
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
export const Cacheable = (options: CacheOptions = {}) => {
|
|
697
|
+
return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
|
|
698
|
+
SetMetadata(CACHE_KEY, options.key || \`\${target.constructor.name}:\${propertyKey}\`)(
|
|
699
|
+
target,
|
|
700
|
+
propertyKey,
|
|
701
|
+
descriptor
|
|
702
|
+
);
|
|
703
|
+
SetMetadata(CACHE_TTL, options.ttl || 300)(target, propertyKey, descriptor);
|
|
704
|
+
};
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
export const CacheInvalidate = (keys: string[]) => SetMetadata("cache_invalidate", keys);
|
|
708
|
+
`;
|
|
709
|
+
await (0, file_utils_1.writeFile)(path.join(cachePath, 'cache.decorator.ts'), cacheDecoratorContent);
|
|
710
|
+
const cacheInterceptorContent = `import {
|
|
711
|
+
Injectable,
|
|
712
|
+
NestInterceptor,
|
|
713
|
+
ExecutionContext,
|
|
714
|
+
CallHandler,
|
|
715
|
+
} from "@nestjs/common";
|
|
716
|
+
import { Reflector } from "@nestjs/core";
|
|
717
|
+
import { Observable, of } from "rxjs";
|
|
718
|
+
import { tap } from "rxjs/operators";
|
|
719
|
+
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
|
720
|
+
import { Inject } from "@nestjs/common";
|
|
721
|
+
import { Cache } from "cache-manager";
|
|
722
|
+
import { CACHE_KEY, CACHE_TTL } from "./cache.decorator";
|
|
723
|
+
|
|
724
|
+
@Injectable()
|
|
725
|
+
export class CacheInterceptor implements NestInterceptor {
|
|
726
|
+
constructor(
|
|
727
|
+
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
|
728
|
+
private reflector: Reflector
|
|
729
|
+
) {}
|
|
730
|
+
|
|
731
|
+
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
|
|
732
|
+
const cacheKey = this.reflector.get<string>(CACHE_KEY, context.getHandler());
|
|
733
|
+
const cacheTtl = this.reflector.get<number>(CACHE_TTL, context.getHandler());
|
|
734
|
+
|
|
735
|
+
if (!cacheKey) {
|
|
736
|
+
return next.handle();
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const request = context.switchToHttp().getRequest();
|
|
740
|
+
const fullKey = \`\${cacheKey}:\${JSON.stringify(request.params)}:\${JSON.stringify(request.query)}\`;
|
|
741
|
+
|
|
742
|
+
const cachedData = await this.cacheManager.get(fullKey);
|
|
743
|
+
|
|
744
|
+
if (cachedData) {
|
|
745
|
+
return of(cachedData);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
return next.handle().pipe(
|
|
749
|
+
tap(async (data) => {
|
|
750
|
+
await this.cacheManager.set(fullKey, data, cacheTtl * 1000);
|
|
751
|
+
})
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
`;
|
|
756
|
+
await (0, file_utils_1.writeFile)(path.join(cachePath, 'cache.interceptor.ts'), cacheInterceptorContent);
|
|
757
|
+
const indexContent = `export * from "./cache.decorator";
|
|
758
|
+
export * from "./cache.interceptor";
|
|
759
|
+
`;
|
|
760
|
+
await (0, file_utils_1.writeFile)(path.join(cachePath, 'index.ts'), indexContent);
|
|
761
|
+
console.log(chalk_1.default.green(' ✓ Cache decorators (Cacheable, CacheInvalidate)'));
|
|
762
|
+
console.log(chalk_1.default.green(' ✓ CacheInterceptor'));
|
|
763
|
+
}
|
|
764
|
+
async function applyFileUploadRecipe(basePath) {
|
|
765
|
+
const sharedPath = path.join(basePath, 'src/shared');
|
|
766
|
+
const uploadPath = path.join(sharedPath, 'upload');
|
|
767
|
+
await (0, file_utils_1.ensureDir)(uploadPath);
|
|
768
|
+
await (0, file_utils_1.ensureDir)(path.join(uploadPath, 'strategies'));
|
|
769
|
+
// Storage interface
|
|
770
|
+
const storageInterfaceContent = `export interface StorageProvider {
|
|
771
|
+
upload(file: Express.Multer.File, path?: string): Promise<UploadResult>;
|
|
772
|
+
delete(fileKey: string): Promise<void>;
|
|
773
|
+
getSignedUrl(fileKey: string, expiresIn?: number): Promise<string>;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
export interface UploadResult {
|
|
777
|
+
key: string;
|
|
778
|
+
url: string;
|
|
779
|
+
size: number;
|
|
780
|
+
mimeType: string;
|
|
781
|
+
originalName: string;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
export interface UploadOptions {
|
|
785
|
+
maxSize?: number; // bytes
|
|
786
|
+
allowedMimeTypes?: string[];
|
|
787
|
+
path?: string;
|
|
788
|
+
}
|
|
789
|
+
`;
|
|
790
|
+
await (0, file_utils_1.writeFile)(path.join(uploadPath, 'storage.interface.ts'), storageInterfaceContent);
|
|
791
|
+
// Local storage strategy
|
|
792
|
+
const localStorageContent = `import { Injectable } from "@nestjs/common";
|
|
793
|
+
import { StorageProvider, UploadResult } from "../storage.interface";
|
|
794
|
+
import * as fs from "fs/promises";
|
|
795
|
+
import * as path from "path";
|
|
796
|
+
import { v4 as uuid } from "uuid";
|
|
797
|
+
|
|
798
|
+
@Injectable()
|
|
799
|
+
export class LocalStorageStrategy implements StorageProvider {
|
|
800
|
+
private readonly uploadDir: string;
|
|
801
|
+
private readonly baseUrl: string;
|
|
802
|
+
|
|
803
|
+
constructor() {
|
|
804
|
+
this.uploadDir = process.env.UPLOAD_DIR || "./uploads";
|
|
805
|
+
this.baseUrl = process.env.UPLOAD_BASE_URL || "http://localhost:3000/uploads";
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
async upload(file: Express.Multer.File, filePath?: string): Promise<UploadResult> {
|
|
809
|
+
const ext = path.extname(file.originalname);
|
|
810
|
+
const fileName = \`\${uuid()}\${ext}\`;
|
|
811
|
+
const relativePath = filePath ? \`\${filePath}/\${fileName}\` : fileName;
|
|
812
|
+
const fullPath = path.join(this.uploadDir, relativePath);
|
|
813
|
+
|
|
814
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
815
|
+
await fs.writeFile(fullPath, file.buffer);
|
|
816
|
+
|
|
817
|
+
return {
|
|
818
|
+
key: relativePath,
|
|
819
|
+
url: \`\${this.baseUrl}/\${relativePath}\`,
|
|
820
|
+
size: file.size,
|
|
821
|
+
mimeType: file.mimetype,
|
|
822
|
+
originalName: file.originalname,
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
async delete(fileKey: string): Promise<void> {
|
|
827
|
+
const fullPath = path.join(this.uploadDir, fileKey);
|
|
828
|
+
await fs.unlink(fullPath);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async getSignedUrl(fileKey: string, _expiresIn?: number): Promise<string> {
|
|
832
|
+
// Local storage doesn't need signed URLs
|
|
833
|
+
return \`\${this.baseUrl}/\${fileKey}\`;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
`;
|
|
837
|
+
await (0, file_utils_1.writeFile)(path.join(uploadPath, 'strategies/local.strategy.ts'), localStorageContent);
|
|
838
|
+
// S3 storage strategy
|
|
839
|
+
const s3StorageContent = `import { Injectable } from "@nestjs/common";
|
|
840
|
+
import { StorageProvider, UploadResult } from "../storage.interface";
|
|
841
|
+
import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
|
|
842
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
843
|
+
import { v4 as uuid } from "uuid";
|
|
844
|
+
import * as path from "path";
|
|
845
|
+
|
|
846
|
+
@Injectable()
|
|
847
|
+
export class S3StorageStrategy implements StorageProvider {
|
|
848
|
+
private readonly s3Client: S3Client;
|
|
849
|
+
private readonly bucket: string;
|
|
850
|
+
private readonly region: string;
|
|
851
|
+
|
|
852
|
+
constructor() {
|
|
853
|
+
this.region = process.env.AWS_REGION || "us-east-1";
|
|
854
|
+
this.bucket = process.env.AWS_S3_BUCKET || "uploads";
|
|
855
|
+
|
|
856
|
+
this.s3Client = new S3Client({
|
|
857
|
+
region: this.region,
|
|
858
|
+
credentials: {
|
|
859
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
|
|
860
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
|
|
861
|
+
},
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
async upload(file: Express.Multer.File, filePath?: string): Promise<UploadResult> {
|
|
866
|
+
const ext = path.extname(file.originalname);
|
|
867
|
+
const fileName = \`\${uuid()}\${ext}\`;
|
|
868
|
+
const key = filePath ? \`\${filePath}/\${fileName}\` : fileName;
|
|
869
|
+
|
|
870
|
+
await this.s3Client.send(
|
|
871
|
+
new PutObjectCommand({
|
|
872
|
+
Bucket: this.bucket,
|
|
873
|
+
Key: key,
|
|
874
|
+
Body: file.buffer,
|
|
875
|
+
ContentType: file.mimetype,
|
|
876
|
+
Metadata: {
|
|
877
|
+
originalName: file.originalname,
|
|
878
|
+
},
|
|
879
|
+
})
|
|
880
|
+
);
|
|
881
|
+
|
|
882
|
+
return {
|
|
883
|
+
key,
|
|
884
|
+
url: \`https://\${this.bucket}.s3.\${this.region}.amazonaws.com/\${key}\`,
|
|
885
|
+
size: file.size,
|
|
886
|
+
mimeType: file.mimetype,
|
|
887
|
+
originalName: file.originalname,
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
async delete(fileKey: string): Promise<void> {
|
|
892
|
+
await this.s3Client.send(
|
|
893
|
+
new DeleteObjectCommand({
|
|
894
|
+
Bucket: this.bucket,
|
|
895
|
+
Key: fileKey,
|
|
896
|
+
})
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
async getSignedUrl(fileKey: string, expiresIn: number = 3600): Promise<string> {
|
|
901
|
+
const command = new GetObjectCommand({
|
|
902
|
+
Bucket: this.bucket,
|
|
903
|
+
Key: fileKey,
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
return getSignedUrl(this.s3Client, command, { expiresIn });
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
`;
|
|
910
|
+
await (0, file_utils_1.writeFile)(path.join(uploadPath, 'strategies/s3.strategy.ts'), s3StorageContent);
|
|
911
|
+
// Upload service
|
|
912
|
+
const uploadServiceContent = `import { Injectable, BadRequestException } from "@nestjs/common";
|
|
913
|
+
import { StorageProvider, UploadResult, UploadOptions } from "./storage.interface";
|
|
914
|
+
import { LocalStorageStrategy } from "./strategies/local.strategy";
|
|
915
|
+
import { S3StorageStrategy } from "./strategies/s3.strategy";
|
|
916
|
+
|
|
917
|
+
@Injectable()
|
|
918
|
+
export class UploadService {
|
|
919
|
+
private readonly storageProvider: StorageProvider;
|
|
920
|
+
|
|
921
|
+
constructor() {
|
|
922
|
+
const storageType = process.env.STORAGE_TYPE || "local";
|
|
923
|
+
this.storageProvider =
|
|
924
|
+
storageType === "s3" ? new S3StorageStrategy() : new LocalStorageStrategy();
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
async uploadFile(
|
|
928
|
+
file: Express.Multer.File,
|
|
929
|
+
options: UploadOptions = {}
|
|
930
|
+
): Promise<UploadResult> {
|
|
931
|
+
this.validateFile(file, options);
|
|
932
|
+
return this.storageProvider.upload(file, options.path);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
async uploadFiles(
|
|
936
|
+
files: Express.Multer.File[],
|
|
937
|
+
options: UploadOptions = {}
|
|
938
|
+
): Promise<UploadResult[]> {
|
|
939
|
+
return Promise.all(files.map((file) => this.uploadFile(file, options)));
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
async deleteFile(fileKey: string): Promise<void> {
|
|
943
|
+
return this.storageProvider.delete(fileKey);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
async getSignedUrl(fileKey: string, expiresIn?: number): Promise<string> {
|
|
947
|
+
return this.storageProvider.getSignedUrl(fileKey, expiresIn);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
private validateFile(file: Express.Multer.File, options: UploadOptions): void {
|
|
951
|
+
if (options.maxSize && file.size > options.maxSize) {
|
|
952
|
+
throw new BadRequestException(
|
|
953
|
+
\`File size exceeds maximum allowed size of \${options.maxSize} bytes\`
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
if (
|
|
958
|
+
options.allowedMimeTypes &&
|
|
959
|
+
!options.allowedMimeTypes.includes(file.mimetype)
|
|
960
|
+
) {
|
|
961
|
+
throw new BadRequestException(
|
|
962
|
+
\`File type \${file.mimetype} is not allowed. Allowed types: \${options.allowedMimeTypes.join(", ")}\`
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
`;
|
|
968
|
+
await (0, file_utils_1.writeFile)(path.join(uploadPath, 'upload.service.ts'), uploadServiceContent);
|
|
969
|
+
// Index exports
|
|
970
|
+
const indexContent = `export * from "./storage.interface";
|
|
971
|
+
export * from "./upload.service";
|
|
972
|
+
export * from "./strategies/local.strategy";
|
|
973
|
+
export * from "./strategies/s3.strategy";
|
|
974
|
+
`;
|
|
975
|
+
await (0, file_utils_1.writeFile)(path.join(uploadPath, 'index.ts'), indexContent);
|
|
976
|
+
console.log(chalk_1.default.green(' ✓ StorageProvider interface'));
|
|
977
|
+
console.log(chalk_1.default.green(' ✓ LocalStorageStrategy'));
|
|
978
|
+
console.log(chalk_1.default.green(' ✓ S3StorageStrategy'));
|
|
979
|
+
console.log(chalk_1.default.green(' ✓ UploadService'));
|
|
980
|
+
}
|
|
981
|
+
async function applyNotificationsRecipe(basePath) {
|
|
982
|
+
const sharedPath = path.join(basePath, 'src/shared');
|
|
983
|
+
const notifPath = path.join(sharedPath, 'notifications');
|
|
984
|
+
await (0, file_utils_1.ensureDir)(notifPath);
|
|
985
|
+
await (0, file_utils_1.ensureDir)(path.join(notifPath, 'channels'));
|
|
986
|
+
await (0, file_utils_1.ensureDir)(path.join(notifPath, 'templates'));
|
|
987
|
+
// Notification interface
|
|
988
|
+
const notifInterfaceContent = `export interface NotificationChannel {
|
|
989
|
+
send(notification: Notification): Promise<void>;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
export interface Notification {
|
|
993
|
+
recipient: string;
|
|
994
|
+
subject: string;
|
|
995
|
+
content: string;
|
|
996
|
+
template?: string;
|
|
997
|
+
data?: Record<string, any>;
|
|
998
|
+
metadata?: Record<string, any>;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
export type NotificationType = "email" | "push" | "sms" | "in-app";
|
|
1002
|
+
|
|
1003
|
+
export interface NotificationOptions {
|
|
1004
|
+
channels: NotificationType[];
|
|
1005
|
+
priority?: "low" | "normal" | "high";
|
|
1006
|
+
scheduledAt?: Date;
|
|
1007
|
+
}
|
|
1008
|
+
`;
|
|
1009
|
+
await (0, file_utils_1.writeFile)(path.join(notifPath, 'notification.interface.ts'), notifInterfaceContent);
|
|
1010
|
+
// Email channel
|
|
1011
|
+
const emailChannelContent = `import { Injectable } from "@nestjs/common";
|
|
1012
|
+
import { NotificationChannel, Notification } from "../notification.interface";
|
|
1013
|
+
import * as nodemailer from "nodemailer";
|
|
1014
|
+
import * as Handlebars from "handlebars";
|
|
1015
|
+
import * as fs from "fs/promises";
|
|
1016
|
+
import * as path from "path";
|
|
1017
|
+
|
|
1018
|
+
@Injectable()
|
|
1019
|
+
export class EmailChannel implements NotificationChannel {
|
|
1020
|
+
private transporter: nodemailer.Transporter;
|
|
1021
|
+
private templateCache: Map<string, HandlebarsTemplateDelegate> = new Map();
|
|
1022
|
+
|
|
1023
|
+
constructor() {
|
|
1024
|
+
this.transporter = nodemailer.createTransport({
|
|
1025
|
+
host: process.env.SMTP_HOST,
|
|
1026
|
+
port: parseInt(process.env.SMTP_PORT || "587"),
|
|
1027
|
+
secure: process.env.SMTP_SECURE === "true",
|
|
1028
|
+
auth: {
|
|
1029
|
+
user: process.env.SMTP_USER,
|
|
1030
|
+
pass: process.env.SMTP_PASS,
|
|
1031
|
+
},
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
async send(notification: Notification): Promise<void> {
|
|
1036
|
+
let html = notification.content;
|
|
1037
|
+
|
|
1038
|
+
if (notification.template) {
|
|
1039
|
+
const template = await this.loadTemplate(notification.template);
|
|
1040
|
+
html = template(notification.data || {});
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
await this.transporter.sendMail({
|
|
1044
|
+
from: process.env.SMTP_FROM || "noreply@example.com",
|
|
1045
|
+
to: notification.recipient,
|
|
1046
|
+
subject: notification.subject,
|
|
1047
|
+
html,
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
private async loadTemplate(templateName: string): Promise<HandlebarsTemplateDelegate> {
|
|
1052
|
+
if (this.templateCache.has(templateName)) {
|
|
1053
|
+
return this.templateCache.get(templateName)!;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
const templatePath = path.join(
|
|
1057
|
+
process.cwd(),
|
|
1058
|
+
"src/shared/notifications/templates",
|
|
1059
|
+
\`\${templateName}.hbs\`
|
|
1060
|
+
);
|
|
1061
|
+
|
|
1062
|
+
const templateContent = await fs.readFile(templatePath, "utf-8");
|
|
1063
|
+
const compiled = Handlebars.compile(templateContent);
|
|
1064
|
+
this.templateCache.set(templateName, compiled);
|
|
1065
|
+
|
|
1066
|
+
return compiled;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
`;
|
|
1070
|
+
await (0, file_utils_1.writeFile)(path.join(notifPath, 'channels/email.channel.ts'), emailChannelContent);
|
|
1071
|
+
// In-app channel
|
|
1072
|
+
const inAppChannelContent = `import { Injectable } from "@nestjs/common";
|
|
1073
|
+
import { NotificationChannel, Notification } from "../notification.interface";
|
|
1074
|
+
import { EventEmitter2 } from "@nestjs/event-emitter";
|
|
1075
|
+
|
|
1076
|
+
@Injectable()
|
|
1077
|
+
export class InAppChannel implements NotificationChannel {
|
|
1078
|
+
constructor(private readonly eventEmitter: EventEmitter2) {}
|
|
1079
|
+
|
|
1080
|
+
async send(notification: Notification): Promise<void> {
|
|
1081
|
+
this.eventEmitter.emit("notification.created", {
|
|
1082
|
+
userId: notification.recipient,
|
|
1083
|
+
subject: notification.subject,
|
|
1084
|
+
content: notification.content,
|
|
1085
|
+
metadata: notification.metadata,
|
|
1086
|
+
createdAt: new Date(),
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
`;
|
|
1091
|
+
await (0, file_utils_1.writeFile)(path.join(notifPath, 'channels/in-app.channel.ts'), inAppChannelContent);
|
|
1092
|
+
// Notification service
|
|
1093
|
+
const notifServiceContent = `import { Injectable, Logger } from "@nestjs/common";
|
|
1094
|
+
import { InjectQueue } from "@nestjs/bull";
|
|
1095
|
+
import { Queue } from "bull";
|
|
1096
|
+
import { Notification, NotificationOptions, NotificationType } from "./notification.interface";
|
|
1097
|
+
import { EmailChannel } from "./channels/email.channel";
|
|
1098
|
+
import { InAppChannel } from "./channels/in-app.channel";
|
|
1099
|
+
|
|
1100
|
+
@Injectable()
|
|
1101
|
+
export class NotificationService {
|
|
1102
|
+
private readonly logger = new Logger(NotificationService.name);
|
|
1103
|
+
|
|
1104
|
+
constructor(
|
|
1105
|
+
@InjectQueue("notifications") private notificationQueue: Queue,
|
|
1106
|
+
private readonly emailChannel: EmailChannel,
|
|
1107
|
+
private readonly inAppChannel: InAppChannel
|
|
1108
|
+
) {}
|
|
1109
|
+
|
|
1110
|
+
async send(notification: Notification, options: NotificationOptions): Promise<void> {
|
|
1111
|
+
if (options.scheduledAt) {
|
|
1112
|
+
const delay = options.scheduledAt.getTime() - Date.now();
|
|
1113
|
+
await this.notificationQueue.add(
|
|
1114
|
+
"send",
|
|
1115
|
+
{ notification, options },
|
|
1116
|
+
{ delay, priority: this.getPriority(options.priority) }
|
|
1117
|
+
);
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
await this.processNotification(notification, options);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
async processNotification(notification: Notification, options: NotificationOptions): Promise<void> {
|
|
1125
|
+
for (const channel of options.channels) {
|
|
1126
|
+
try {
|
|
1127
|
+
await this.sendToChannel(channel, notification);
|
|
1128
|
+
} catch (error) {
|
|
1129
|
+
this.logger.error(\`Failed to send notification via \${channel}\`, error);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
private async sendToChannel(channel: NotificationType, notification: Notification): Promise<void> {
|
|
1135
|
+
switch (channel) {
|
|
1136
|
+
case "email":
|
|
1137
|
+
await this.emailChannel.send(notification);
|
|
1138
|
+
break;
|
|
1139
|
+
case "in-app":
|
|
1140
|
+
await this.inAppChannel.send(notification);
|
|
1141
|
+
break;
|
|
1142
|
+
case "push":
|
|
1143
|
+
// Implement push notification
|
|
1144
|
+
this.logger.warn("Push notifications not yet implemented");
|
|
1145
|
+
break;
|
|
1146
|
+
case "sms":
|
|
1147
|
+
// Implement SMS notification
|
|
1148
|
+
this.logger.warn("SMS notifications not yet implemented");
|
|
1149
|
+
break;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
private getPriority(priority?: "low" | "normal" | "high"): number {
|
|
1154
|
+
switch (priority) {
|
|
1155
|
+
case "high":
|
|
1156
|
+
return 1;
|
|
1157
|
+
case "low":
|
|
1158
|
+
return 3;
|
|
1159
|
+
default:
|
|
1160
|
+
return 2;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
`;
|
|
1165
|
+
await (0, file_utils_1.writeFile)(path.join(notifPath, 'notification.service.ts'), notifServiceContent);
|
|
1166
|
+
// Sample template
|
|
1167
|
+
const welcomeTemplateContent = `<!DOCTYPE html>
|
|
1168
|
+
<html>
|
|
1169
|
+
<head>
|
|
1170
|
+
<style>
|
|
1171
|
+
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
|
1172
|
+
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
|
1173
|
+
.header { background: #007bff; color: white; padding: 20px; text-align: center; }
|
|
1174
|
+
.content { padding: 20px; background: #f9f9f9; }
|
|
1175
|
+
.footer { text-align: center; padding: 20px; color: #666; font-size: 12px; }
|
|
1176
|
+
</style>
|
|
1177
|
+
</head>
|
|
1178
|
+
<body>
|
|
1179
|
+
<div class="container">
|
|
1180
|
+
<div class="header">
|
|
1181
|
+
<h1>Welcome, {{name}}!</h1>
|
|
1182
|
+
</div>
|
|
1183
|
+
<div class="content">
|
|
1184
|
+
<p>Thank you for joining us. We're excited to have you on board.</p>
|
|
1185
|
+
{{#if actionUrl}}
|
|
1186
|
+
<p>
|
|
1187
|
+
<a href="{{actionUrl}}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
|
|
1188
|
+
{{actionText}}
|
|
1189
|
+
</a>
|
|
1190
|
+
</p>
|
|
1191
|
+
{{/if}}
|
|
1192
|
+
</div>
|
|
1193
|
+
<div class="footer">
|
|
1194
|
+
<p>© {{year}} Your Company. All rights reserved.</p>
|
|
1195
|
+
</div>
|
|
1196
|
+
</div>
|
|
1197
|
+
</body>
|
|
1198
|
+
</html>
|
|
1199
|
+
`;
|
|
1200
|
+
await (0, file_utils_1.writeFile)(path.join(notifPath, 'templates/welcome.hbs'), welcomeTemplateContent);
|
|
1201
|
+
// Index exports
|
|
1202
|
+
const indexContent = `export * from "./notification.interface";
|
|
1203
|
+
export * from "./notification.service";
|
|
1204
|
+
export * from "./channels/email.channel";
|
|
1205
|
+
export * from "./channels/in-app.channel";
|
|
1206
|
+
`;
|
|
1207
|
+
await (0, file_utils_1.writeFile)(path.join(notifPath, 'index.ts'), indexContent);
|
|
1208
|
+
console.log(chalk_1.default.green(' ✓ NotificationService'));
|
|
1209
|
+
console.log(chalk_1.default.green(' ✓ EmailChannel'));
|
|
1210
|
+
console.log(chalk_1.default.green(' ✓ InAppChannel'));
|
|
1211
|
+
console.log(chalk_1.default.green(' ✓ Welcome email template'));
|
|
1212
|
+
}
|
|
1213
|
+
async function applyWebhooksRecipe(basePath) {
|
|
1214
|
+
const sharedPath = path.join(basePath, 'src/shared');
|
|
1215
|
+
const webhookPath = path.join(sharedPath, 'webhooks');
|
|
1216
|
+
await (0, file_utils_1.ensureDir)(webhookPath);
|
|
1217
|
+
// Webhook entity
|
|
1218
|
+
const webhookEntityContent = `import {
|
|
1219
|
+
Entity,
|
|
1220
|
+
PrimaryGeneratedColumn,
|
|
1221
|
+
Column,
|
|
1222
|
+
CreateDateColumn,
|
|
1223
|
+
UpdateDateColumn,
|
|
1224
|
+
} from "typeorm";
|
|
1225
|
+
|
|
1226
|
+
export type WebhookEvent =
|
|
1227
|
+
| "entity.created"
|
|
1228
|
+
| "entity.updated"
|
|
1229
|
+
| "entity.deleted"
|
|
1230
|
+
| "user.registered"
|
|
1231
|
+
| "order.completed"
|
|
1232
|
+
| "payment.received";
|
|
1233
|
+
|
|
1234
|
+
@Entity("webhooks")
|
|
1235
|
+
export class WebhookEntity {
|
|
1236
|
+
@PrimaryGeneratedColumn("uuid")
|
|
1237
|
+
id: string;
|
|
1238
|
+
|
|
1239
|
+
@Column()
|
|
1240
|
+
name: string;
|
|
1241
|
+
|
|
1242
|
+
@Column()
|
|
1243
|
+
url: string;
|
|
1244
|
+
|
|
1245
|
+
@Column("simple-array")
|
|
1246
|
+
events: WebhookEvent[];
|
|
1247
|
+
|
|
1248
|
+
@Column({ nullable: true })
|
|
1249
|
+
secret?: string;
|
|
1250
|
+
|
|
1251
|
+
@Column({ default: true })
|
|
1252
|
+
isActive: boolean;
|
|
1253
|
+
|
|
1254
|
+
@Column({ default: 0 })
|
|
1255
|
+
failureCount: number;
|
|
1256
|
+
|
|
1257
|
+
@Column({ type: "timestamp", nullable: true })
|
|
1258
|
+
lastTriggeredAt?: Date;
|
|
1259
|
+
|
|
1260
|
+
@CreateDateColumn()
|
|
1261
|
+
createdAt: Date;
|
|
1262
|
+
|
|
1263
|
+
@UpdateDateColumn()
|
|
1264
|
+
updatedAt: Date;
|
|
1265
|
+
}
|
|
1266
|
+
`;
|
|
1267
|
+
await (0, file_utils_1.writeFile)(path.join(webhookPath, 'webhook.entity.ts'), webhookEntityContent);
|
|
1268
|
+
// Webhook delivery entity
|
|
1269
|
+
const deliveryEntityContent = `import {
|
|
1270
|
+
Entity,
|
|
1271
|
+
PrimaryGeneratedColumn,
|
|
1272
|
+
Column,
|
|
1273
|
+
CreateDateColumn,
|
|
1274
|
+
ManyToOne,
|
|
1275
|
+
JoinColumn,
|
|
1276
|
+
} from "typeorm";
|
|
1277
|
+
import { WebhookEntity } from "./webhook.entity";
|
|
1278
|
+
|
|
1279
|
+
export type DeliveryStatus = "pending" | "success" | "failed" | "retrying";
|
|
1280
|
+
|
|
1281
|
+
@Entity("webhook_deliveries")
|
|
1282
|
+
export class WebhookDeliveryEntity {
|
|
1283
|
+
@PrimaryGeneratedColumn("uuid")
|
|
1284
|
+
id: string;
|
|
1285
|
+
|
|
1286
|
+
@ManyToOne(() => WebhookEntity)
|
|
1287
|
+
@JoinColumn({ name: "webhook_id" })
|
|
1288
|
+
webhook: WebhookEntity;
|
|
1289
|
+
|
|
1290
|
+
@Column()
|
|
1291
|
+
webhookId: string;
|
|
1292
|
+
|
|
1293
|
+
@Column()
|
|
1294
|
+
event: string;
|
|
1295
|
+
|
|
1296
|
+
@Column({ type: "jsonb" })
|
|
1297
|
+
payload: Record<string, any>;
|
|
1298
|
+
|
|
1299
|
+
@Column({ default: "pending" })
|
|
1300
|
+
status: DeliveryStatus;
|
|
1301
|
+
|
|
1302
|
+
@Column({ nullable: true })
|
|
1303
|
+
responseStatus?: number;
|
|
1304
|
+
|
|
1305
|
+
@Column({ type: "text", nullable: true })
|
|
1306
|
+
responseBody?: string;
|
|
1307
|
+
|
|
1308
|
+
@Column({ default: 0 })
|
|
1309
|
+
attempts: number;
|
|
1310
|
+
|
|
1311
|
+
@Column({ type: "timestamp", nullable: true })
|
|
1312
|
+
nextRetryAt?: Date;
|
|
1313
|
+
|
|
1314
|
+
@CreateDateColumn()
|
|
1315
|
+
createdAt: Date;
|
|
1316
|
+
}
|
|
1317
|
+
`;
|
|
1318
|
+
await (0, file_utils_1.writeFile)(path.join(webhookPath, 'webhook-delivery.entity.ts'), deliveryEntityContent);
|
|
1319
|
+
// Webhook service
|
|
1320
|
+
const webhookServiceContent = `import { Injectable, Logger } from "@nestjs/common";
|
|
1321
|
+
import { InjectRepository } from "@nestjs/typeorm";
|
|
1322
|
+
import { Repository } from "typeorm";
|
|
1323
|
+
import { WebhookEntity, WebhookEvent } from "./webhook.entity";
|
|
1324
|
+
import { WebhookDeliveryEntity } from "./webhook-delivery.entity";
|
|
1325
|
+
import axios from "axios";
|
|
1326
|
+
import * as crypto from "crypto";
|
|
1327
|
+
|
|
1328
|
+
@Injectable()
|
|
1329
|
+
export class WebhookService {
|
|
1330
|
+
private readonly logger = new Logger(WebhookService.name);
|
|
1331
|
+
private readonly maxRetries = 3;
|
|
1332
|
+
private readonly retryDelays = [60, 300, 900]; // seconds
|
|
1333
|
+
|
|
1334
|
+
constructor(
|
|
1335
|
+
@InjectRepository(WebhookEntity)
|
|
1336
|
+
private readonly webhookRepo: Repository<WebhookEntity>,
|
|
1337
|
+
@InjectRepository(WebhookDeliveryEntity)
|
|
1338
|
+
private readonly deliveryRepo: Repository<WebhookDeliveryEntity>
|
|
1339
|
+
) {}
|
|
1340
|
+
|
|
1341
|
+
async registerWebhook(
|
|
1342
|
+
name: string,
|
|
1343
|
+
url: string,
|
|
1344
|
+
events: WebhookEvent[],
|
|
1345
|
+
secret?: string
|
|
1346
|
+
): Promise<WebhookEntity> {
|
|
1347
|
+
const webhook = this.webhookRepo.create({
|
|
1348
|
+
name,
|
|
1349
|
+
url,
|
|
1350
|
+
events,
|
|
1351
|
+
secret: secret || this.generateSecret(),
|
|
1352
|
+
});
|
|
1353
|
+
return this.webhookRepo.save(webhook);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
async trigger(event: WebhookEvent, payload: Record<string, any>): Promise<void> {
|
|
1357
|
+
const webhooks = await this.webhookRepo.find({
|
|
1358
|
+
where: { isActive: true },
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
const relevantWebhooks = webhooks.filter((w) => w.events.includes(event));
|
|
1362
|
+
|
|
1363
|
+
for (const webhook of relevantWebhooks) {
|
|
1364
|
+
await this.deliver(webhook, event, payload);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
private async deliver(
|
|
1369
|
+
webhook: WebhookEntity,
|
|
1370
|
+
event: string,
|
|
1371
|
+
payload: Record<string, any>
|
|
1372
|
+
): Promise<void> {
|
|
1373
|
+
const delivery = this.deliveryRepo.create({
|
|
1374
|
+
webhookId: webhook.id,
|
|
1375
|
+
event,
|
|
1376
|
+
payload,
|
|
1377
|
+
status: "pending",
|
|
1378
|
+
});
|
|
1379
|
+
await this.deliveryRepo.save(delivery);
|
|
1380
|
+
|
|
1381
|
+
await this.attemptDelivery(webhook, delivery);
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
private async attemptDelivery(
|
|
1385
|
+
webhook: WebhookEntity,
|
|
1386
|
+
delivery: WebhookDeliveryEntity
|
|
1387
|
+
): Promise<void> {
|
|
1388
|
+
try {
|
|
1389
|
+
const signature = this.generateSignature(delivery.payload, webhook.secret);
|
|
1390
|
+
|
|
1391
|
+
const response = await axios.post(webhook.url, delivery.payload, {
|
|
1392
|
+
headers: {
|
|
1393
|
+
"Content-Type": "application/json",
|
|
1394
|
+
"X-Webhook-Signature": signature,
|
|
1395
|
+
"X-Webhook-Event": delivery.event,
|
|
1396
|
+
"X-Webhook-Delivery": delivery.id,
|
|
1397
|
+
},
|
|
1398
|
+
timeout: 30000,
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
delivery.status = "success";
|
|
1402
|
+
delivery.responseStatus = response.status;
|
|
1403
|
+
delivery.responseBody = JSON.stringify(response.data).slice(0, 1000);
|
|
1404
|
+
delivery.attempts += 1;
|
|
1405
|
+
|
|
1406
|
+
webhook.lastTriggeredAt = new Date();
|
|
1407
|
+
webhook.failureCount = 0;
|
|
1408
|
+
|
|
1409
|
+
await Promise.all([
|
|
1410
|
+
this.deliveryRepo.save(delivery),
|
|
1411
|
+
this.webhookRepo.save(webhook),
|
|
1412
|
+
]);
|
|
1413
|
+
|
|
1414
|
+
this.logger.log(\`Webhook delivered successfully: \${delivery.id}\`);
|
|
1415
|
+
} catch (error: any) {
|
|
1416
|
+
delivery.attempts += 1;
|
|
1417
|
+
delivery.responseStatus = error.response?.status;
|
|
1418
|
+
delivery.responseBody = error.message;
|
|
1419
|
+
|
|
1420
|
+
if (delivery.attempts < this.maxRetries) {
|
|
1421
|
+
delivery.status = "retrying";
|
|
1422
|
+
delivery.nextRetryAt = new Date(
|
|
1423
|
+
Date.now() + this.retryDelays[delivery.attempts - 1] * 1000
|
|
1424
|
+
);
|
|
1425
|
+
} else {
|
|
1426
|
+
delivery.status = "failed";
|
|
1427
|
+
webhook.failureCount += 1;
|
|
1428
|
+
|
|
1429
|
+
if (webhook.failureCount >= 10) {
|
|
1430
|
+
webhook.isActive = false;
|
|
1431
|
+
this.logger.warn(\`Webhook disabled due to failures: \${webhook.id}\`);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
await Promise.all([
|
|
1436
|
+
this.deliveryRepo.save(delivery),
|
|
1437
|
+
this.webhookRepo.save(webhook),
|
|
1438
|
+
]);
|
|
1439
|
+
|
|
1440
|
+
this.logger.error(\`Webhook delivery failed: \${delivery.id}\`, error.message);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
private generateSignature(payload: Record<string, any>, secret?: string): string {
|
|
1445
|
+
if (!secret) return "";
|
|
1446
|
+
const hmac = crypto.createHmac("sha256", secret);
|
|
1447
|
+
hmac.update(JSON.stringify(payload));
|
|
1448
|
+
return \`sha256=\${hmac.digest("hex")}\`;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
private generateSecret(): string {
|
|
1452
|
+
return crypto.randomBytes(32).toString("hex");
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
verifySignature(
|
|
1456
|
+
payload: string,
|
|
1457
|
+
signature: string,
|
|
1458
|
+
secret: string
|
|
1459
|
+
): boolean {
|
|
1460
|
+
const expected = this.generateSignature(JSON.parse(payload), secret);
|
|
1461
|
+
return crypto.timingSafeEqual(
|
|
1462
|
+
Buffer.from(signature),
|
|
1463
|
+
Buffer.from(expected)
|
|
1464
|
+
);
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
async getDeliveries(webhookId: string): Promise<WebhookDeliveryEntity[]> {
|
|
1468
|
+
return this.deliveryRepo.find({
|
|
1469
|
+
where: { webhookId },
|
|
1470
|
+
order: { createdAt: "DESC" },
|
|
1471
|
+
take: 100,
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
async retryDelivery(deliveryId: string): Promise<void> {
|
|
1476
|
+
const delivery = await this.deliveryRepo.findOne({
|
|
1477
|
+
where: { id: deliveryId },
|
|
1478
|
+
relations: ["webhook"],
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
if (!delivery) {
|
|
1482
|
+
throw new Error("Delivery not found");
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
delivery.status = "pending";
|
|
1486
|
+
delivery.attempts = 0;
|
|
1487
|
+
await this.deliveryRepo.save(delivery);
|
|
1488
|
+
|
|
1489
|
+
const webhook = await this.webhookRepo.findOne({
|
|
1490
|
+
where: { id: delivery.webhookId },
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
if (webhook) {
|
|
1494
|
+
await this.attemptDelivery(webhook, delivery);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
`;
|
|
1499
|
+
await (0, file_utils_1.writeFile)(path.join(webhookPath, 'webhook.service.ts'), webhookServiceContent);
|
|
1500
|
+
// Index exports
|
|
1501
|
+
const indexContent = `export * from "./webhook.entity";
|
|
1502
|
+
export * from "./webhook-delivery.entity";
|
|
1503
|
+
export * from "./webhook.service";
|
|
1504
|
+
`;
|
|
1505
|
+
await (0, file_utils_1.writeFile)(path.join(webhookPath, 'index.ts'), indexContent);
|
|
1506
|
+
console.log(chalk_1.default.green(' ✓ WebhookEntity'));
|
|
1507
|
+
console.log(chalk_1.default.green(' ✓ WebhookDeliveryEntity'));
|
|
1508
|
+
console.log(chalk_1.default.green(' ✓ WebhookService with retry logic'));
|
|
1509
|
+
console.log(chalk_1.default.green(' ✓ Signature verification'));
|
|
1510
|
+
}
|
|
1511
|
+
async function applyTestFactoriesRecipe(basePath) {
|
|
1512
|
+
const testPath = path.join(basePath, 'test');
|
|
1513
|
+
const factoriesPath = path.join(testPath, 'factories');
|
|
1514
|
+
const fixturesPath = path.join(testPath, 'fixtures');
|
|
1515
|
+
const mocksPath = path.join(testPath, 'mocks');
|
|
1516
|
+
const utilsPath = path.join(testPath, 'utils');
|
|
1517
|
+
await (0, file_utils_1.ensureDir)(factoriesPath);
|
|
1518
|
+
await (0, file_utils_1.ensureDir)(fixturesPath);
|
|
1519
|
+
await (0, file_utils_1.ensureDir)(mocksPath);
|
|
1520
|
+
await (0, file_utils_1.ensureDir)(utilsPath);
|
|
1521
|
+
// Base factory class
|
|
1522
|
+
const baseFactoryContent = `import { faker } from "@faker-js/faker";
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* Base factory class for creating test data
|
|
1526
|
+
*
|
|
1527
|
+
* Usage:
|
|
1528
|
+
* const factory = new UserFactory();
|
|
1529
|
+
* const user = factory.make(); // Single instance
|
|
1530
|
+
* const users = factory.makeMany(5); // Multiple instances
|
|
1531
|
+
* const saved = await factory.create(); // Persisted to DB
|
|
1532
|
+
*/
|
|
1533
|
+
export abstract class BaseFactory<T> {
|
|
1534
|
+
protected abstract definition(): T;
|
|
1535
|
+
|
|
1536
|
+
/**
|
|
1537
|
+
* Create a single instance without persisting
|
|
1538
|
+
*/
|
|
1539
|
+
make(overrides: Partial<T> = {}): T {
|
|
1540
|
+
return { ...this.definition(), ...overrides };
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/**
|
|
1544
|
+
* Create multiple instances without persisting
|
|
1545
|
+
*/
|
|
1546
|
+
makeMany(count: number, overrides: Partial<T> = {}): T[] {
|
|
1547
|
+
return Array.from({ length: count }, () => this.make(overrides));
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* Create and persist a single instance
|
|
1552
|
+
* Override this in subclass to implement persistence
|
|
1553
|
+
*/
|
|
1554
|
+
async create(overrides: Partial<T> = {}): Promise<T> {
|
|
1555
|
+
return this.make(overrides);
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/**
|
|
1559
|
+
* Create and persist multiple instances
|
|
1560
|
+
*/
|
|
1561
|
+
async createMany(count: number, overrides: Partial<T> = {}): Promise<T[]> {
|
|
1562
|
+
return Promise.all(
|
|
1563
|
+
Array.from({ length: count }, () => this.create(overrides))
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
/**
|
|
1568
|
+
* Create instance with specific state
|
|
1569
|
+
*/
|
|
1570
|
+
state(stateOverrides: Partial<T>): this {
|
|
1571
|
+
const original = this.definition.bind(this);
|
|
1572
|
+
this.definition = () => ({ ...original(), ...stateOverrides });
|
|
1573
|
+
return this;
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/**
|
|
1578
|
+
* Factory with repository support for database persistence
|
|
1579
|
+
*/
|
|
1580
|
+
export abstract class PersistentFactory<T, R = any> extends BaseFactory<T> {
|
|
1581
|
+
constructor(protected repository?: R) {
|
|
1582
|
+
super();
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
setRepository(repository: R): this {
|
|
1586
|
+
this.repository = repository;
|
|
1587
|
+
return this;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
abstract persist(entity: T): Promise<T>;
|
|
1591
|
+
|
|
1592
|
+
async create(overrides: Partial<T> = {}): Promise<T> {
|
|
1593
|
+
const entity = this.make(overrides);
|
|
1594
|
+
if (this.repository) {
|
|
1595
|
+
return this.persist(entity);
|
|
1596
|
+
}
|
|
1597
|
+
return entity;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// Re-export faker for convenience
|
|
1602
|
+
export { faker };
|
|
1603
|
+
`;
|
|
1604
|
+
await (0, file_utils_1.writeFile)(path.join(factoriesPath, 'base.factory.ts'), baseFactoryContent);
|
|
1605
|
+
// Example user factory
|
|
1606
|
+
const exampleFactoryContent = `import { faker } from "@faker-js/faker";
|
|
1607
|
+
import { BaseFactory, PersistentFactory } from "./base.factory";
|
|
1608
|
+
|
|
1609
|
+
/**
|
|
1610
|
+
* Example interfaces - replace with your actual entity types
|
|
1611
|
+
*/
|
|
1612
|
+
interface User {
|
|
1613
|
+
id: string;
|
|
1614
|
+
email: string;
|
|
1615
|
+
firstName: string;
|
|
1616
|
+
lastName: string;
|
|
1617
|
+
password: string;
|
|
1618
|
+
isActive: boolean;
|
|
1619
|
+
role: "admin" | "user" | "guest";
|
|
1620
|
+
createdAt: Date;
|
|
1621
|
+
updatedAt: Date;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
interface Post {
|
|
1625
|
+
id: string;
|
|
1626
|
+
title: string;
|
|
1627
|
+
content: string;
|
|
1628
|
+
authorId: string;
|
|
1629
|
+
isPublished: boolean;
|
|
1630
|
+
tags: string[];
|
|
1631
|
+
createdAt: Date;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
/**
|
|
1635
|
+
* User Factory
|
|
1636
|
+
*/
|
|
1637
|
+
export class UserFactory extends BaseFactory<User> {
|
|
1638
|
+
protected definition(): User {
|
|
1639
|
+
return {
|
|
1640
|
+
id: faker.string.uuid(),
|
|
1641
|
+
email: faker.internet.email().toLowerCase(),
|
|
1642
|
+
firstName: faker.person.firstName(),
|
|
1643
|
+
lastName: faker.person.lastName(),
|
|
1644
|
+
password: faker.internet.password({ length: 12 }),
|
|
1645
|
+
isActive: true,
|
|
1646
|
+
role: "user",
|
|
1647
|
+
createdAt: faker.date.past(),
|
|
1648
|
+
updatedAt: new Date(),
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* Create an admin user
|
|
1654
|
+
*/
|
|
1655
|
+
admin(): this {
|
|
1656
|
+
return this.state({ role: "admin" });
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
/**
|
|
1660
|
+
* Create an inactive user
|
|
1661
|
+
*/
|
|
1662
|
+
inactive(): this {
|
|
1663
|
+
return this.state({ isActive: false });
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
/**
|
|
1667
|
+
* Create a user with specific email domain
|
|
1668
|
+
*/
|
|
1669
|
+
withDomain(domain: string): this {
|
|
1670
|
+
return this.state({
|
|
1671
|
+
email: \`\${faker.internet.username()}@\${domain}\`.toLowerCase(),
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
/**
|
|
1676
|
+
* Create a recently registered user
|
|
1677
|
+
*/
|
|
1678
|
+
recent(): this {
|
|
1679
|
+
return this.state({
|
|
1680
|
+
createdAt: faker.date.recent({ days: 7 }),
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
/**
|
|
1686
|
+
* Post Factory
|
|
1687
|
+
*/
|
|
1688
|
+
export class PostFactory extends BaseFactory<Post> {
|
|
1689
|
+
protected definition(): Post {
|
|
1690
|
+
return {
|
|
1691
|
+
id: faker.string.uuid(),
|
|
1692
|
+
title: faker.lorem.sentence(),
|
|
1693
|
+
content: faker.lorem.paragraphs(3),
|
|
1694
|
+
authorId: faker.string.uuid(),
|
|
1695
|
+
isPublished: false,
|
|
1696
|
+
tags: faker.helpers.arrayElements(
|
|
1697
|
+
["tech", "news", "tutorial", "review", "opinion"],
|
|
1698
|
+
{ min: 1, max: 3 }
|
|
1699
|
+
),
|
|
1700
|
+
createdAt: faker.date.past(),
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
/**
|
|
1705
|
+
* Create a published post
|
|
1706
|
+
*/
|
|
1707
|
+
published(): this {
|
|
1708
|
+
return this.state({ isPublished: true });
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
/**
|
|
1712
|
+
* Create a post by specific author
|
|
1713
|
+
*/
|
|
1714
|
+
byAuthor(authorId: string): this {
|
|
1715
|
+
return this.state({ authorId });
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
/**
|
|
1719
|
+
* Create a post with specific tags
|
|
1720
|
+
*/
|
|
1721
|
+
withTags(...tags: string[]): this {
|
|
1722
|
+
return this.state({ tags });
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
// Export singleton instances for convenience
|
|
1727
|
+
export const userFactory = new UserFactory();
|
|
1728
|
+
export const postFactory = new PostFactory();
|
|
1729
|
+
`;
|
|
1730
|
+
await (0, file_utils_1.writeFile)(path.join(factoriesPath, 'example.factory.ts'), exampleFactoryContent);
|
|
1731
|
+
// Test data builder pattern
|
|
1732
|
+
const builderContent = `import { faker } from "@faker-js/faker";
|
|
1733
|
+
|
|
1734
|
+
/**
|
|
1735
|
+
* Test Data Builder Pattern
|
|
1736
|
+
*
|
|
1737
|
+
* More flexible than factories for complex object construction
|
|
1738
|
+
*
|
|
1739
|
+
* Usage:
|
|
1740
|
+
* const user = new UserBuilder()
|
|
1741
|
+
* .withEmail("test@example.com")
|
|
1742
|
+
* .asAdmin()
|
|
1743
|
+
* .build();
|
|
1744
|
+
*/
|
|
1745
|
+
export class TestDataBuilder<T> {
|
|
1746
|
+
protected data: Partial<T> = {};
|
|
1747
|
+
|
|
1748
|
+
constructor(protected defaults: () => T) {}
|
|
1749
|
+
|
|
1750
|
+
/**
|
|
1751
|
+
* Set a specific field value
|
|
1752
|
+
*/
|
|
1753
|
+
with<K extends keyof T>(key: K, value: T[K]): this {
|
|
1754
|
+
this.data[key] = value;
|
|
1755
|
+
return this;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
/**
|
|
1759
|
+
* Set multiple fields at once
|
|
1760
|
+
*/
|
|
1761
|
+
withOverrides(overrides: Partial<T>): this {
|
|
1762
|
+
this.data = { ...this.data, ...overrides };
|
|
1763
|
+
return this;
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
/**
|
|
1767
|
+
* Build the final object
|
|
1768
|
+
*/
|
|
1769
|
+
build(): T {
|
|
1770
|
+
return { ...this.defaults(), ...this.data };
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
/**
|
|
1774
|
+
* Build multiple objects
|
|
1775
|
+
*/
|
|
1776
|
+
buildMany(count: number): T[] {
|
|
1777
|
+
return Array.from({ length: count }, () => this.build());
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
/**
|
|
1781
|
+
* Reset builder state
|
|
1782
|
+
*/
|
|
1783
|
+
reset(): this {
|
|
1784
|
+
this.data = {};
|
|
1785
|
+
return this;
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
/**
|
|
1790
|
+
* Example User Builder
|
|
1791
|
+
*/
|
|
1792
|
+
export class UserBuilder extends TestDataBuilder<{
|
|
1793
|
+
id: string;
|
|
1794
|
+
email: string;
|
|
1795
|
+
name: string;
|
|
1796
|
+
role: string;
|
|
1797
|
+
isVerified: boolean;
|
|
1798
|
+
}> {
|
|
1799
|
+
constructor() {
|
|
1800
|
+
super(() => ({
|
|
1801
|
+
id: faker.string.uuid(),
|
|
1802
|
+
email: faker.internet.email(),
|
|
1803
|
+
name: faker.person.fullName(),
|
|
1804
|
+
role: "user",
|
|
1805
|
+
isVerified: false,
|
|
1806
|
+
}));
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
withEmail(email: string): this {
|
|
1810
|
+
return this.with("email", email);
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
withName(name: string): this {
|
|
1814
|
+
return this.with("name", name);
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
asAdmin(): this {
|
|
1818
|
+
return this.with("role", "admin");
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
verified(): this {
|
|
1822
|
+
return this.with("isVerified", true);
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
`;
|
|
1826
|
+
await (0, file_utils_1.writeFile)(path.join(factoriesPath, 'builder.ts'), builderContent);
|
|
1827
|
+
// Database fixture utilities
|
|
1828
|
+
const fixtureUtilsContent = `import { DataSource, Repository, ObjectLiteral } from "typeorm";
|
|
1829
|
+
|
|
1830
|
+
/**
|
|
1831
|
+
* Database Fixture Manager
|
|
1832
|
+
*
|
|
1833
|
+
* Handles test database setup, seeding, and cleanup
|
|
1834
|
+
*/
|
|
1835
|
+
export class FixtureManager {
|
|
1836
|
+
private loadedFixtures: Map<string, any[]> = new Map();
|
|
1837
|
+
|
|
1838
|
+
constructor(private dataSource: DataSource) {}
|
|
1839
|
+
|
|
1840
|
+
/**
|
|
1841
|
+
* Load fixtures from a fixture class
|
|
1842
|
+
*/
|
|
1843
|
+
async load<T extends ObjectLiteral>(
|
|
1844
|
+
repository: Repository<T>,
|
|
1845
|
+
data: Partial<T>[]
|
|
1846
|
+
): Promise<T[]> {
|
|
1847
|
+
const entities = data.map((item) => repository.create(item));
|
|
1848
|
+
const saved = await repository.save(entities);
|
|
1849
|
+
|
|
1850
|
+
const key = repository.metadata.name;
|
|
1851
|
+
this.loadedFixtures.set(key, [
|
|
1852
|
+
...(this.loadedFixtures.get(key) || []),
|
|
1853
|
+
...saved,
|
|
1854
|
+
]);
|
|
1855
|
+
|
|
1856
|
+
return saved;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
/**
|
|
1860
|
+
* Get loaded fixtures by entity name
|
|
1861
|
+
*/
|
|
1862
|
+
get<T>(entityName: string): T[] {
|
|
1863
|
+
return (this.loadedFixtures.get(entityName) || []) as T[];
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
/**
|
|
1867
|
+
* Clear all data from specified tables
|
|
1868
|
+
*/
|
|
1869
|
+
async clear(...entityNames: string[]): Promise<void> {
|
|
1870
|
+
for (const name of entityNames) {
|
|
1871
|
+
const repository = this.dataSource.getRepository(name);
|
|
1872
|
+
await repository.clear();
|
|
1873
|
+
this.loadedFixtures.delete(name);
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
/**
|
|
1878
|
+
* Clear all loaded fixtures
|
|
1879
|
+
*/
|
|
1880
|
+
async clearAll(): Promise<void> {
|
|
1881
|
+
// Disable foreign key checks temporarily
|
|
1882
|
+
await this.dataSource.query("SET CONSTRAINTS ALL DEFERRED");
|
|
1883
|
+
|
|
1884
|
+
for (const [name] of this.loadedFixtures) {
|
|
1885
|
+
try {
|
|
1886
|
+
const repository = this.dataSource.getRepository(name);
|
|
1887
|
+
await repository.clear();
|
|
1888
|
+
} catch (error) {
|
|
1889
|
+
console.warn(\`Failed to clear \${name}:\`, error);
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
await this.dataSource.query("SET CONSTRAINTS ALL IMMEDIATE");
|
|
1894
|
+
this.loadedFixtures.clear();
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
/**
|
|
1898
|
+
* Reset database to clean state
|
|
1899
|
+
*/
|
|
1900
|
+
async reset(): Promise<void> {
|
|
1901
|
+
await this.clearAll();
|
|
1902
|
+
await this.dataSource.synchronize(true);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
/**
|
|
1907
|
+
* Fixture definition interface
|
|
1908
|
+
*/
|
|
1909
|
+
export interface Fixture<T = any> {
|
|
1910
|
+
name: string;
|
|
1911
|
+
entity: new () => T;
|
|
1912
|
+
data: () => Partial<T>[];
|
|
1913
|
+
dependencies?: string[];
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
/**
|
|
1917
|
+
* Fixture loader for managing fixture dependencies
|
|
1918
|
+
*/
|
|
1919
|
+
export class FixtureLoader {
|
|
1920
|
+
private fixtures: Map<string, Fixture> = new Map();
|
|
1921
|
+
private loaded: Set<string> = new Set();
|
|
1922
|
+
|
|
1923
|
+
register(fixture: Fixture): this {
|
|
1924
|
+
this.fixtures.set(fixture.name, fixture);
|
|
1925
|
+
return this;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
async load(
|
|
1929
|
+
name: string,
|
|
1930
|
+
manager: FixtureManager,
|
|
1931
|
+
dataSource: DataSource
|
|
1932
|
+
): Promise<any[]> {
|
|
1933
|
+
if (this.loaded.has(name)) {
|
|
1934
|
+
return manager.get(name);
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
const fixture = this.fixtures.get(name);
|
|
1938
|
+
if (!fixture) {
|
|
1939
|
+
throw new Error(\`Fixture "\${name}" not found\`);
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
// Load dependencies first
|
|
1943
|
+
if (fixture.dependencies) {
|
|
1944
|
+
for (const dep of fixture.dependencies) {
|
|
1945
|
+
await this.load(dep, manager, dataSource);
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
const repository = dataSource.getRepository(fixture.entity);
|
|
1950
|
+
const result = await manager.load(repository, fixture.data());
|
|
1951
|
+
this.loaded.add(name);
|
|
1952
|
+
|
|
1953
|
+
return result;
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
reset(): void {
|
|
1957
|
+
this.loaded.clear();
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
`;
|
|
1961
|
+
await (0, file_utils_1.writeFile)(path.join(fixturesPath, 'fixture.utils.ts'), fixtureUtilsContent);
|
|
1962
|
+
// Example fixtures
|
|
1963
|
+
const exampleFixturesContent = `import { faker } from "@faker-js/faker";
|
|
1964
|
+
import { Fixture } from "./fixture.utils";
|
|
1965
|
+
|
|
1966
|
+
/**
|
|
1967
|
+
* Example fixture definitions
|
|
1968
|
+
* Replace with your actual entities
|
|
1969
|
+
*/
|
|
1970
|
+
|
|
1971
|
+
export const usersFixture: Fixture = {
|
|
1972
|
+
name: "users",
|
|
1973
|
+
entity: class User {} as any, // Replace with actual entity
|
|
1974
|
+
data: () => [
|
|
1975
|
+
{
|
|
1976
|
+
id: "user-1",
|
|
1977
|
+
email: "admin@example.com",
|
|
1978
|
+
firstName: "Admin",
|
|
1979
|
+
lastName: "User",
|
|
1980
|
+
role: "admin",
|
|
1981
|
+
isActive: true,
|
|
1982
|
+
},
|
|
1983
|
+
{
|
|
1984
|
+
id: "user-2",
|
|
1985
|
+
email: "user@example.com",
|
|
1986
|
+
firstName: "Regular",
|
|
1987
|
+
lastName: "User",
|
|
1988
|
+
role: "user",
|
|
1989
|
+
isActive: true,
|
|
1990
|
+
},
|
|
1991
|
+
{
|
|
1992
|
+
id: "user-3",
|
|
1993
|
+
email: "inactive@example.com",
|
|
1994
|
+
firstName: "Inactive",
|
|
1995
|
+
lastName: "User",
|
|
1996
|
+
role: "user",
|
|
1997
|
+
isActive: false,
|
|
1998
|
+
},
|
|
1999
|
+
],
|
|
2000
|
+
};
|
|
2001
|
+
|
|
2002
|
+
export const postsFixture: Fixture = {
|
|
2003
|
+
name: "posts",
|
|
2004
|
+
entity: class Post {} as any, // Replace with actual entity
|
|
2005
|
+
dependencies: ["users"],
|
|
2006
|
+
data: () => [
|
|
2007
|
+
{
|
|
2008
|
+
id: "post-1",
|
|
2009
|
+
title: "First Post",
|
|
2010
|
+
content: "This is the first post content",
|
|
2011
|
+
authorId: "user-1",
|
|
2012
|
+
isPublished: true,
|
|
2013
|
+
},
|
|
2014
|
+
{
|
|
2015
|
+
id: "post-2",
|
|
2016
|
+
title: "Draft Post",
|
|
2017
|
+
content: "This is a draft",
|
|
2018
|
+
authorId: "user-2",
|
|
2019
|
+
isPublished: false,
|
|
2020
|
+
},
|
|
2021
|
+
],
|
|
2022
|
+
};
|
|
2023
|
+
|
|
2024
|
+
/**
|
|
2025
|
+
* Generate dynamic fixtures with faker
|
|
2026
|
+
*/
|
|
2027
|
+
export function generateUsersFixture(count: number): Fixture {
|
|
2028
|
+
return {
|
|
2029
|
+
name: \`users_\${count}\`,
|
|
2030
|
+
entity: class User {} as any,
|
|
2031
|
+
data: () =>
|
|
2032
|
+
Array.from({ length: count }, (_, i) => ({
|
|
2033
|
+
id: faker.string.uuid(),
|
|
2034
|
+
email: faker.internet.email(),
|
|
2035
|
+
firstName: faker.person.firstName(),
|
|
2036
|
+
lastName: faker.person.lastName(),
|
|
2037
|
+
role: i === 0 ? "admin" : "user",
|
|
2038
|
+
isActive: faker.datatype.boolean({ probability: 0.9 }),
|
|
2039
|
+
})),
|
|
2040
|
+
};
|
|
2041
|
+
}
|
|
2042
|
+
`;
|
|
2043
|
+
await (0, file_utils_1.writeFile)(path.join(fixturesPath, 'example.fixtures.ts'), exampleFixturesContent);
|
|
2044
|
+
// Mock utilities
|
|
2045
|
+
const mockUtilsContent = `/**
|
|
2046
|
+
* Mock Utilities for Testing
|
|
2047
|
+
*/
|
|
2048
|
+
|
|
2049
|
+
/**
|
|
2050
|
+
* Create a mock repository with common TypeORM methods
|
|
2051
|
+
*/
|
|
2052
|
+
export function createMockRepository<T = any>() {
|
|
2053
|
+
return {
|
|
2054
|
+
find: jest.fn().mockResolvedValue([]),
|
|
2055
|
+
findOne: jest.fn().mockResolvedValue(null),
|
|
2056
|
+
findOneBy: jest.fn().mockResolvedValue(null),
|
|
2057
|
+
save: jest.fn().mockImplementation((entity) => Promise.resolve({ id: "mock-id", ...entity })),
|
|
2058
|
+
create: jest.fn().mockImplementation((dto) => dto),
|
|
2059
|
+
update: jest.fn().mockResolvedValue({ affected: 1 }),
|
|
2060
|
+
delete: jest.fn().mockResolvedValue({ affected: 1 }),
|
|
2061
|
+
softDelete: jest.fn().mockResolvedValue({ affected: 1 }),
|
|
2062
|
+
restore: jest.fn().mockResolvedValue({ affected: 1 }),
|
|
2063
|
+
count: jest.fn().mockResolvedValue(0),
|
|
2064
|
+
createQueryBuilder: jest.fn(() => createMockQueryBuilder()),
|
|
2065
|
+
manager: {
|
|
2066
|
+
transaction: jest.fn((cb) => cb({
|
|
2067
|
+
save: jest.fn(),
|
|
2068
|
+
remove: jest.fn(),
|
|
2069
|
+
})),
|
|
2070
|
+
},
|
|
2071
|
+
};
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
/**
|
|
2075
|
+
* Create a mock query builder
|
|
2076
|
+
*/
|
|
2077
|
+
export function createMockQueryBuilder<T = any>() {
|
|
2078
|
+
const qb: any = {
|
|
2079
|
+
select: jest.fn().mockReturnThis(),
|
|
2080
|
+
addSelect: jest.fn().mockReturnThis(),
|
|
2081
|
+
where: jest.fn().mockReturnThis(),
|
|
2082
|
+
andWhere: jest.fn().mockReturnThis(),
|
|
2083
|
+
orWhere: jest.fn().mockReturnThis(),
|
|
2084
|
+
orderBy: jest.fn().mockReturnThis(),
|
|
2085
|
+
addOrderBy: jest.fn().mockReturnThis(),
|
|
2086
|
+
skip: jest.fn().mockReturnThis(),
|
|
2087
|
+
take: jest.fn().mockReturnThis(),
|
|
2088
|
+
leftJoin: jest.fn().mockReturnThis(),
|
|
2089
|
+
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
|
2090
|
+
innerJoin: jest.fn().mockReturnThis(),
|
|
2091
|
+
innerJoinAndSelect: jest.fn().mockReturnThis(),
|
|
2092
|
+
groupBy: jest.fn().mockReturnThis(),
|
|
2093
|
+
having: jest.fn().mockReturnThis(),
|
|
2094
|
+
getOne: jest.fn().mockResolvedValue(null),
|
|
2095
|
+
getMany: jest.fn().mockResolvedValue([]),
|
|
2096
|
+
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
|
|
2097
|
+
getCount: jest.fn().mockResolvedValue(0),
|
|
2098
|
+
getRawOne: jest.fn().mockResolvedValue(null),
|
|
2099
|
+
getRawMany: jest.fn().mockResolvedValue([]),
|
|
2100
|
+
execute: jest.fn().mockResolvedValue({ affected: 1 }),
|
|
2101
|
+
};
|
|
2102
|
+
return qb;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
/**
|
|
2106
|
+
* Create a mock Prisma client
|
|
2107
|
+
*/
|
|
2108
|
+
export function createMockPrismaClient() {
|
|
2109
|
+
const createModelMock = () => ({
|
|
2110
|
+
findUnique: jest.fn().mockResolvedValue(null),
|
|
2111
|
+
findFirst: jest.fn().mockResolvedValue(null),
|
|
2112
|
+
findMany: jest.fn().mockResolvedValue([]),
|
|
2113
|
+
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: "mock-id", ...data })),
|
|
2114
|
+
createMany: jest.fn().mockResolvedValue({ count: 0 }),
|
|
2115
|
+
update: jest.fn().mockImplementation(({ data }) => Promise.resolve(data)),
|
|
2116
|
+
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
|
|
2117
|
+
delete: jest.fn().mockResolvedValue({}),
|
|
2118
|
+
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
|
|
2119
|
+
count: jest.fn().mockResolvedValue(0),
|
|
2120
|
+
aggregate: jest.fn().mockResolvedValue({}),
|
|
2121
|
+
groupBy: jest.fn().mockResolvedValue([]),
|
|
2122
|
+
});
|
|
2123
|
+
|
|
2124
|
+
return {
|
|
2125
|
+
$connect: jest.fn().mockResolvedValue(undefined),
|
|
2126
|
+
$disconnect: jest.fn().mockResolvedValue(undefined),
|
|
2127
|
+
$transaction: jest.fn((cb) => cb({})),
|
|
2128
|
+
// Add model mocks as needed
|
|
2129
|
+
user: createModelMock(),
|
|
2130
|
+
post: createModelMock(),
|
|
2131
|
+
};
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
/**
|
|
2135
|
+
* Create a mock service with all methods as jest functions
|
|
2136
|
+
*/
|
|
2137
|
+
export function createMockService<T extends object>(
|
|
2138
|
+
methods: (keyof T)[]
|
|
2139
|
+
): jest.Mocked<T> {
|
|
2140
|
+
const mock: any = {};
|
|
2141
|
+
for (const method of methods) {
|
|
2142
|
+
mock[method] = jest.fn();
|
|
2143
|
+
}
|
|
2144
|
+
return mock;
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
/**
|
|
2148
|
+
* Create a mock request object
|
|
2149
|
+
*/
|
|
2150
|
+
export function createMockRequest(overrides: any = {}) {
|
|
2151
|
+
return {
|
|
2152
|
+
user: { id: "user-1", email: "test@example.com", roles: ["user"] },
|
|
2153
|
+
params: {},
|
|
2154
|
+
query: {},
|
|
2155
|
+
body: {},
|
|
2156
|
+
headers: {},
|
|
2157
|
+
ip: "127.0.0.1",
|
|
2158
|
+
...overrides,
|
|
2159
|
+
};
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
/**
|
|
2163
|
+
* Create a mock response object
|
|
2164
|
+
*/
|
|
2165
|
+
export function createMockResponse() {
|
|
2166
|
+
const res: any = {
|
|
2167
|
+
status: jest.fn().mockReturnThis(),
|
|
2168
|
+
json: jest.fn().mockReturnThis(),
|
|
2169
|
+
send: jest.fn().mockReturnThis(),
|
|
2170
|
+
setHeader: jest.fn().mockReturnThis(),
|
|
2171
|
+
cookie: jest.fn().mockReturnThis(),
|
|
2172
|
+
clearCookie: jest.fn().mockReturnThis(),
|
|
2173
|
+
};
|
|
2174
|
+
return res;
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
/**
|
|
2178
|
+
* Create a mock execution context for guards/interceptors
|
|
2179
|
+
*/
|
|
2180
|
+
export function createMockExecutionContext(request: any = {}, response: any = {}) {
|
|
2181
|
+
return {
|
|
2182
|
+
switchToHttp: () => ({
|
|
2183
|
+
getRequest: () => createMockRequest(request),
|
|
2184
|
+
getResponse: () => createMockResponse(),
|
|
2185
|
+
}),
|
|
2186
|
+
getHandler: () => jest.fn(),
|
|
2187
|
+
getClass: () => jest.fn(),
|
|
2188
|
+
getArgs: () => [],
|
|
2189
|
+
getArgByIndex: () => ({}),
|
|
2190
|
+
switchToRpc: () => ({}),
|
|
2191
|
+
switchToWs: () => ({}),
|
|
2192
|
+
getType: () => "http",
|
|
2193
|
+
};
|
|
2194
|
+
}
|
|
2195
|
+
`;
|
|
2196
|
+
await (0, file_utils_1.writeFile)(path.join(mocksPath, 'mock.utils.ts'), mockUtilsContent);
|
|
2197
|
+
// Test setup utilities
|
|
2198
|
+
const testSetupContent = `import { Test, TestingModule } from "@nestjs/testing";
|
|
2199
|
+
import { INestApplication, ValidationPipe } from "@nestjs/common";
|
|
2200
|
+
import { DataSource } from "typeorm";
|
|
2201
|
+
|
|
2202
|
+
/**
|
|
2203
|
+
* Test Setup Utilities
|
|
2204
|
+
*/
|
|
2205
|
+
|
|
2206
|
+
/**
|
|
2207
|
+
* Create a testing module with common configuration
|
|
2208
|
+
*/
|
|
2209
|
+
export async function createTestingModule(
|
|
2210
|
+
imports: any[] = [],
|
|
2211
|
+
providers: any[] = [],
|
|
2212
|
+
controllers: any[] = []
|
|
2213
|
+
): Promise<TestingModule> {
|
|
2214
|
+
return Test.createTestingModule({
|
|
2215
|
+
imports,
|
|
2216
|
+
providers,
|
|
2217
|
+
controllers,
|
|
2218
|
+
}).compile();
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
/**
|
|
2222
|
+
* Create and configure a test application
|
|
2223
|
+
*/
|
|
2224
|
+
export async function createTestApp(
|
|
2225
|
+
module: TestingModule,
|
|
2226
|
+
options: {
|
|
2227
|
+
validation?: boolean;
|
|
2228
|
+
prefix?: string;
|
|
2229
|
+
} = {}
|
|
2230
|
+
): Promise<INestApplication> {
|
|
2231
|
+
const app = module.createNestApplication();
|
|
2232
|
+
|
|
2233
|
+
if (options.validation !== false) {
|
|
2234
|
+
app.useGlobalPipes(
|
|
2235
|
+
new ValidationPipe({
|
|
2236
|
+
whitelist: true,
|
|
2237
|
+
transform: true,
|
|
2238
|
+
forbidNonWhitelisted: true,
|
|
2239
|
+
})
|
|
2240
|
+
);
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
if (options.prefix) {
|
|
2244
|
+
app.setGlobalPrefix(options.prefix);
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
await app.init();
|
|
2248
|
+
return app;
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
/**
|
|
2252
|
+
* Clean up test resources
|
|
2253
|
+
*/
|
|
2254
|
+
export async function cleanupTest(
|
|
2255
|
+
app?: INestApplication,
|
|
2256
|
+
dataSource?: DataSource
|
|
2257
|
+
): Promise<void> {
|
|
2258
|
+
if (dataSource?.isInitialized) {
|
|
2259
|
+
await dataSource.destroy();
|
|
2260
|
+
}
|
|
2261
|
+
if (app) {
|
|
2262
|
+
await app.close();
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
/**
|
|
2267
|
+
* Wait for condition with timeout
|
|
2268
|
+
*/
|
|
2269
|
+
export async function waitFor(
|
|
2270
|
+
condition: () => boolean | Promise<boolean>,
|
|
2271
|
+
timeout = 5000,
|
|
2272
|
+
interval = 100
|
|
2273
|
+
): Promise<void> {
|
|
2274
|
+
const start = Date.now();
|
|
2275
|
+
while (Date.now() - start < timeout) {
|
|
2276
|
+
if (await condition()) return;
|
|
2277
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
2278
|
+
}
|
|
2279
|
+
throw new Error(\`Condition not met within \${timeout}ms\`);
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
/**
|
|
2283
|
+
* Retry async operation
|
|
2284
|
+
*/
|
|
2285
|
+
export async function retry<T>(
|
|
2286
|
+
fn: () => Promise<T>,
|
|
2287
|
+
maxAttempts = 3,
|
|
2288
|
+
delay = 100
|
|
2289
|
+
): Promise<T> {
|
|
2290
|
+
let lastError: Error | undefined;
|
|
2291
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
2292
|
+
try {
|
|
2293
|
+
return await fn();
|
|
2294
|
+
} catch (error) {
|
|
2295
|
+
lastError = error as Error;
|
|
2296
|
+
if (i < maxAttempts - 1) {
|
|
2297
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
throw lastError;
|
|
2302
|
+
}
|
|
2303
|
+
`;
|
|
2304
|
+
await (0, file_utils_1.writeFile)(path.join(utilsPath, 'test-setup.utils.ts'), testSetupContent);
|
|
2305
|
+
// Index exports
|
|
2306
|
+
const factoriesIndexContent = `export * from "./base.factory";
|
|
2307
|
+
export * from "./example.factory";
|
|
2308
|
+
export * from "./builder";
|
|
2309
|
+
`;
|
|
2310
|
+
await (0, file_utils_1.writeFile)(path.join(factoriesPath, 'index.ts'), factoriesIndexContent);
|
|
2311
|
+
const fixturesIndexContent = `export * from "./fixture.utils";
|
|
2312
|
+
export * from "./example.fixtures";
|
|
2313
|
+
`;
|
|
2314
|
+
await (0, file_utils_1.writeFile)(path.join(fixturesPath, 'index.ts'), fixturesIndexContent);
|
|
2315
|
+
const mocksIndexContent = `export * from "./mock.utils";
|
|
2316
|
+
`;
|
|
2317
|
+
await (0, file_utils_1.writeFile)(path.join(mocksPath, 'index.ts'), mocksIndexContent);
|
|
2318
|
+
const utilsIndexContent = `export * from "./test-setup.utils";
|
|
2319
|
+
`;
|
|
2320
|
+
await (0, file_utils_1.writeFile)(path.join(utilsPath, 'index.ts'), utilsIndexContent);
|
|
2321
|
+
// Main test index
|
|
2322
|
+
const mainIndexContent = `export * from "./factories";
|
|
2323
|
+
export * from "./fixtures";
|
|
2324
|
+
export * from "./mocks";
|
|
2325
|
+
export * from "./utils";
|
|
2326
|
+
`;
|
|
2327
|
+
await (0, file_utils_1.writeFile)(path.join(testPath, 'index.ts'), mainIndexContent);
|
|
2328
|
+
console.log(chalk_1.default.green(' ✓ Base factory class with state support'));
|
|
2329
|
+
console.log(chalk_1.default.green(' ✓ Example factories (User, Post) with fluent API'));
|
|
2330
|
+
console.log(chalk_1.default.green(' ✓ Test data builder pattern'));
|
|
2331
|
+
console.log(chalk_1.default.green(' ✓ Database fixture manager with dependencies'));
|
|
2332
|
+
console.log(chalk_1.default.green(' ✓ Mock utilities (Repository, QueryBuilder, Prisma, Request/Response)'));
|
|
2333
|
+
console.log(chalk_1.default.green(' ✓ Test setup utilities (createTestApp, cleanup, waitFor, retry)'));
|
|
2334
|
+
}
|
|
2335
|
+
async function applyApiVersioningRecipe(basePath) {
|
|
2336
|
+
const sharedPath = path.join(basePath, 'src/shared');
|
|
2337
|
+
const versioningPath = path.join(sharedPath, 'versioning');
|
|
2338
|
+
await (0, file_utils_1.ensureDir)(versioningPath);
|
|
2339
|
+
await (0, file_utils_1.ensureDir)(path.join(versioningPath, 'decorators'));
|
|
2340
|
+
await (0, file_utils_1.ensureDir)(path.join(versioningPath, 'interceptors'));
|
|
2341
|
+
// Version configuration
|
|
2342
|
+
const versionConfigContent = `/**
|
|
2343
|
+
* API Versioning Configuration
|
|
2344
|
+
*
|
|
2345
|
+
* Supports multiple versioning strategies:
|
|
2346
|
+
* - URI: /api/v1/users, /api/v2/users
|
|
2347
|
+
* - Header: X-API-Version: 1
|
|
2348
|
+
* - Media Type: Accept: application/vnd.api.v1+json
|
|
2349
|
+
* - Query: /api/users?version=1
|
|
2350
|
+
*/
|
|
2351
|
+
|
|
2352
|
+
export enum VersioningStrategy {
|
|
2353
|
+
URI = "uri",
|
|
2354
|
+
HEADER = "header",
|
|
2355
|
+
MEDIA_TYPE = "media_type",
|
|
2356
|
+
QUERY = "query",
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
export interface ApiVersion {
|
|
2360
|
+
version: string;
|
|
2361
|
+
deprecatedAt?: Date;
|
|
2362
|
+
sunsetAt?: Date;
|
|
2363
|
+
replacedBy?: string;
|
|
2364
|
+
changelog?: string;
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
export const API_VERSIONS: ApiVersion[] = [
|
|
2368
|
+
{
|
|
2369
|
+
version: "1",
|
|
2370
|
+
deprecatedAt: undefined,
|
|
2371
|
+
sunsetAt: undefined,
|
|
2372
|
+
replacedBy: undefined,
|
|
2373
|
+
},
|
|
2374
|
+
// Add new versions here:
|
|
2375
|
+
// {
|
|
2376
|
+
// version: "2",
|
|
2377
|
+
// deprecatedAt: undefined,
|
|
2378
|
+
// sunsetAt: undefined,
|
|
2379
|
+
// replacedBy: undefined,
|
|
2380
|
+
// },
|
|
2381
|
+
];
|
|
2382
|
+
|
|
2383
|
+
export const CURRENT_VERSION = "1";
|
|
2384
|
+
export const SUPPORTED_VERSIONS = API_VERSIONS.map((v) => v.version);
|
|
2385
|
+
export const DEFAULT_VERSION = "1";
|
|
2386
|
+
|
|
2387
|
+
/**
|
|
2388
|
+
* Get version info by version number
|
|
2389
|
+
*/
|
|
2390
|
+
export function getVersionInfo(version: string): ApiVersion | undefined {
|
|
2391
|
+
return API_VERSIONS.find((v) => v.version === version);
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
/**
|
|
2395
|
+
* Check if a version is deprecated
|
|
2396
|
+
*/
|
|
2397
|
+
export function isVersionDeprecated(version: string): boolean {
|
|
2398
|
+
const info = getVersionInfo(version);
|
|
2399
|
+
return info?.deprecatedAt ? new Date() >= info.deprecatedAt : false;
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
/**
|
|
2403
|
+
* Check if a version is sunset (no longer supported)
|
|
2404
|
+
*/
|
|
2405
|
+
export function isVersionSunset(version: string): boolean {
|
|
2406
|
+
const info = getVersionInfo(version);
|
|
2407
|
+
return info?.sunsetAt ? new Date() >= info.sunsetAt : false;
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
/**
|
|
2411
|
+
* Get days until sunset for a version
|
|
2412
|
+
*/
|
|
2413
|
+
export function getDaysUntilSunset(version: string): number | null {
|
|
2414
|
+
const info = getVersionInfo(version);
|
|
2415
|
+
if (!info?.sunsetAt) return null;
|
|
2416
|
+
const diff = info.sunsetAt.getTime() - Date.now();
|
|
2417
|
+
return Math.ceil(diff / (1000 * 60 * 60 * 24));
|
|
2418
|
+
}
|
|
2419
|
+
`;
|
|
2420
|
+
await (0, file_utils_1.writeFile)(path.join(versioningPath, 'version.config.ts'), versionConfigContent);
|
|
2421
|
+
// Version decorator
|
|
2422
|
+
const versionDecoratorContent = `import { SetMetadata, applyDecorators } from "@nestjs/common";
|
|
2423
|
+
import { ApiHeader, ApiOperation } from "@nestjs/swagger";
|
|
2424
|
+
|
|
2425
|
+
export const API_VERSION_KEY = "api_version";
|
|
2426
|
+
export const DEPRECATED_VERSION_KEY = "deprecated_version";
|
|
2427
|
+
export const MIN_VERSION_KEY = "min_version";
|
|
2428
|
+
export const MAX_VERSION_KEY = "max_version";
|
|
2429
|
+
|
|
2430
|
+
/**
|
|
2431
|
+
* Mark a controller or method as available in specific version(s)
|
|
2432
|
+
* @param versions - Version(s) this endpoint is available in
|
|
2433
|
+
*/
|
|
2434
|
+
export const ApiVersion = (...versions: string[]) =>
|
|
2435
|
+
SetMetadata(API_VERSION_KEY, versions);
|
|
2436
|
+
|
|
2437
|
+
/**
|
|
2438
|
+
* Mark an endpoint as deprecated
|
|
2439
|
+
* @param message - Deprecation message
|
|
2440
|
+
* @param replacedBy - The new endpoint/version to use
|
|
2441
|
+
* @param sunsetDate - When this endpoint will be removed
|
|
2442
|
+
*/
|
|
2443
|
+
export const DeprecatedVersion = (
|
|
2444
|
+
message: string,
|
|
2445
|
+
replacedBy?: string,
|
|
2446
|
+
sunsetDate?: Date
|
|
2447
|
+
) =>
|
|
2448
|
+
applyDecorators(
|
|
2449
|
+
SetMetadata(DEPRECATED_VERSION_KEY, { message, replacedBy, sunsetDate }),
|
|
2450
|
+
ApiOperation({
|
|
2451
|
+
deprecated: true,
|
|
2452
|
+
description: \`**DEPRECATED**: \${message}\${replacedBy ? \` Use \${replacedBy} instead.\` : ""}\`,
|
|
2453
|
+
})
|
|
2454
|
+
);
|
|
2455
|
+
|
|
2456
|
+
/**
|
|
2457
|
+
* Require minimum API version
|
|
2458
|
+
*/
|
|
2459
|
+
export const MinVersion = (version: string) =>
|
|
2460
|
+
SetMetadata(MIN_VERSION_KEY, version);
|
|
2461
|
+
|
|
2462
|
+
/**
|
|
2463
|
+
* Require maximum API version (useful for sunset endpoints)
|
|
2464
|
+
*/
|
|
2465
|
+
export const MaxVersion = (version: string) =>
|
|
2466
|
+
SetMetadata(MAX_VERSION_KEY, version);
|
|
2467
|
+
|
|
2468
|
+
/**
|
|
2469
|
+
* Combined decorator for versioned endpoint with Swagger docs
|
|
2470
|
+
*/
|
|
2471
|
+
export const VersionedEndpoint = (version: string, deprecated = false) =>
|
|
2472
|
+
applyDecorators(
|
|
2473
|
+
ApiVersion(version),
|
|
2474
|
+
ApiHeader({
|
|
2475
|
+
name: "X-API-Version",
|
|
2476
|
+
description: \`API Version (current: \${version})\`,
|
|
2477
|
+
required: false,
|
|
2478
|
+
}),
|
|
2479
|
+
...(deprecated ? [DeprecatedVersion(\`This endpoint is deprecated in v\${version}\`)] : [])
|
|
2480
|
+
);
|
|
2481
|
+
`;
|
|
2482
|
+
await (0, file_utils_1.writeFile)(path.join(versioningPath, 'decorators/version.decorator.ts'), versionDecoratorContent);
|
|
2483
|
+
// Version interceptor
|
|
2484
|
+
const versionInterceptorContent = `import {
|
|
2485
|
+
Injectable,
|
|
2486
|
+
NestInterceptor,
|
|
2487
|
+
ExecutionContext,
|
|
2488
|
+
CallHandler,
|
|
2489
|
+
} from "@nestjs/common";
|
|
2490
|
+
import { Observable } from "rxjs";
|
|
2491
|
+
import { tap } from "rxjs/operators";
|
|
2492
|
+
import { Reflector } from "@nestjs/core";
|
|
2493
|
+
import {
|
|
2494
|
+
DEPRECATED_VERSION_KEY,
|
|
2495
|
+
API_VERSION_KEY,
|
|
2496
|
+
} from "../decorators/version.decorator";
|
|
2497
|
+
import { getVersionInfo, isVersionDeprecated } from "../version.config";
|
|
2498
|
+
|
|
2499
|
+
@Injectable()
|
|
2500
|
+
export class VersionHeaderInterceptor implements NestInterceptor {
|
|
2501
|
+
constructor(private reflector: Reflector) {}
|
|
2502
|
+
|
|
2503
|
+
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
|
2504
|
+
const response = context.switchToHttp().getResponse();
|
|
2505
|
+
const request = context.switchToHttp().getRequest();
|
|
2506
|
+
|
|
2507
|
+
// Get current API version from request
|
|
2508
|
+
const apiVersion = request.apiVersion || request.headers["x-api-version"] || "1";
|
|
2509
|
+
|
|
2510
|
+
// Get version metadata from handler/controller
|
|
2511
|
+
const versions = this.reflector.getAllAndOverride<string[]>(API_VERSION_KEY, [
|
|
2512
|
+
context.getHandler(),
|
|
2513
|
+
context.getClass(),
|
|
2514
|
+
]);
|
|
2515
|
+
|
|
2516
|
+
const deprecationInfo = this.reflector.getAllAndOverride<any>(
|
|
2517
|
+
DEPRECATED_VERSION_KEY,
|
|
2518
|
+
[context.getHandler(), context.getClass()]
|
|
2519
|
+
);
|
|
2520
|
+
|
|
2521
|
+
return next.handle().pipe(
|
|
2522
|
+
tap(() => {
|
|
2523
|
+
// Always add current version header
|
|
2524
|
+
response.setHeader("X-API-Version", apiVersion);
|
|
2525
|
+
|
|
2526
|
+
// Add supported versions header
|
|
2527
|
+
if (versions) {
|
|
2528
|
+
response.setHeader("X-API-Supported-Versions", versions.join(", "));
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
// Add deprecation headers if applicable
|
|
2532
|
+
if (deprecationInfo || isVersionDeprecated(apiVersion)) {
|
|
2533
|
+
const versionInfo = getVersionInfo(apiVersion);
|
|
2534
|
+
|
|
2535
|
+
response.setHeader("Deprecation", "true");
|
|
2536
|
+
|
|
2537
|
+
if (deprecationInfo?.sunsetDate || versionInfo?.sunsetAt) {
|
|
2538
|
+
const sunsetDate = deprecationInfo?.sunsetDate || versionInfo?.sunsetAt;
|
|
2539
|
+
response.setHeader("Sunset", sunsetDate.toUTCString());
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
if (deprecationInfo?.replacedBy || versionInfo?.replacedBy) {
|
|
2543
|
+
const replacement = deprecationInfo?.replacedBy || versionInfo?.replacedBy;
|
|
2544
|
+
response.setHeader("Link", \`<\${replacement}>; rel="successor-version"\`);
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
// Add warning header for deprecated APIs
|
|
2548
|
+
const message = deprecationInfo?.message || "This API version is deprecated";
|
|
2549
|
+
response.setHeader(
|
|
2550
|
+
"Warning",
|
|
2551
|
+
\`299 - "\${message}"\`
|
|
2552
|
+
);
|
|
2553
|
+
}
|
|
2554
|
+
})
|
|
2555
|
+
);
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
`;
|
|
2559
|
+
await (0, file_utils_1.writeFile)(path.join(versioningPath, 'interceptors/version-header.interceptor.ts'), versionInterceptorContent);
|
|
2560
|
+
// Version guard
|
|
2561
|
+
const versionGuardContent = `import {
|
|
2562
|
+
Injectable,
|
|
2563
|
+
CanActivate,
|
|
2564
|
+
ExecutionContext,
|
|
2565
|
+
HttpException,
|
|
2566
|
+
HttpStatus,
|
|
2567
|
+
} from "@nestjs/common";
|
|
2568
|
+
import { Reflector } from "@nestjs/core";
|
|
2569
|
+
import {
|
|
2570
|
+
API_VERSION_KEY,
|
|
2571
|
+
MIN_VERSION_KEY,
|
|
2572
|
+
MAX_VERSION_KEY,
|
|
2573
|
+
} from "../decorators/version.decorator";
|
|
2574
|
+
import {
|
|
2575
|
+
SUPPORTED_VERSIONS,
|
|
2576
|
+
isVersionSunset,
|
|
2577
|
+
getVersionInfo,
|
|
2578
|
+
} from "../version.config";
|
|
2579
|
+
|
|
2580
|
+
@Injectable()
|
|
2581
|
+
export class ApiVersionGuard implements CanActivate {
|
|
2582
|
+
constructor(private reflector: Reflector) {}
|
|
2583
|
+
|
|
2584
|
+
canActivate(context: ExecutionContext): boolean {
|
|
2585
|
+
const request = context.switchToHttp().getRequest();
|
|
2586
|
+
|
|
2587
|
+
// Extract version from various sources
|
|
2588
|
+
const version = this.extractVersion(request);
|
|
2589
|
+
request.apiVersion = version;
|
|
2590
|
+
|
|
2591
|
+
// Check if version is supported
|
|
2592
|
+
if (!SUPPORTED_VERSIONS.includes(version)) {
|
|
2593
|
+
throw new HttpException(
|
|
2594
|
+
{
|
|
2595
|
+
statusCode: HttpStatus.BAD_REQUEST,
|
|
2596
|
+
error: "Unsupported API Version",
|
|
2597
|
+
message: \`API version '\${version}' is not supported. Supported versions: \${SUPPORTED_VERSIONS.join(", ")}\`,
|
|
2598
|
+
supportedVersions: SUPPORTED_VERSIONS,
|
|
2599
|
+
},
|
|
2600
|
+
HttpStatus.BAD_REQUEST
|
|
2601
|
+
);
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
// Check if version is sunset
|
|
2605
|
+
if (isVersionSunset(version)) {
|
|
2606
|
+
const versionInfo = getVersionInfo(version);
|
|
2607
|
+
throw new HttpException(
|
|
2608
|
+
{
|
|
2609
|
+
statusCode: HttpStatus.GONE,
|
|
2610
|
+
error: "API Version Sunset",
|
|
2611
|
+
message: \`API version '\${version}' is no longer supported.\`,
|
|
2612
|
+
sunsetDate: versionInfo?.sunsetAt,
|
|
2613
|
+
replacedBy: versionInfo?.replacedBy,
|
|
2614
|
+
},
|
|
2615
|
+
HttpStatus.GONE
|
|
2616
|
+
);
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
// Check version constraints from decorators
|
|
2620
|
+
const allowedVersions = this.reflector.getAllAndOverride<string[]>(
|
|
2621
|
+
API_VERSION_KEY,
|
|
2622
|
+
[context.getHandler(), context.getClass()]
|
|
2623
|
+
);
|
|
2624
|
+
|
|
2625
|
+
if (allowedVersions && !allowedVersions.includes(version)) {
|
|
2626
|
+
throw new HttpException(
|
|
2627
|
+
{
|
|
2628
|
+
statusCode: HttpStatus.BAD_REQUEST,
|
|
2629
|
+
error: "Version Not Available",
|
|
2630
|
+
message: \`This endpoint is not available in API version '\${version}'. Available in: \${allowedVersions.join(", ")}\`,
|
|
2631
|
+
},
|
|
2632
|
+
HttpStatus.BAD_REQUEST
|
|
2633
|
+
);
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
// Check min version
|
|
2637
|
+
const minVersion = this.reflector.getAllAndOverride<string>(MIN_VERSION_KEY, [
|
|
2638
|
+
context.getHandler(),
|
|
2639
|
+
context.getClass(),
|
|
2640
|
+
]);
|
|
2641
|
+
|
|
2642
|
+
if (minVersion && this.compareVersions(version, minVersion) < 0) {
|
|
2643
|
+
throw new HttpException(
|
|
2644
|
+
{
|
|
2645
|
+
statusCode: HttpStatus.BAD_REQUEST,
|
|
2646
|
+
error: "Version Too Low",
|
|
2647
|
+
message: \`This endpoint requires API version \${minVersion} or higher.\`,
|
|
2648
|
+
},
|
|
2649
|
+
HttpStatus.BAD_REQUEST
|
|
2650
|
+
);
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
// Check max version
|
|
2654
|
+
const maxVersion = this.reflector.getAllAndOverride<string>(MAX_VERSION_KEY, [
|
|
2655
|
+
context.getHandler(),
|
|
2656
|
+
context.getClass(),
|
|
2657
|
+
]);
|
|
2658
|
+
|
|
2659
|
+
if (maxVersion && this.compareVersions(version, maxVersion) > 0) {
|
|
2660
|
+
throw new HttpException(
|
|
2661
|
+
{
|
|
2662
|
+
statusCode: HttpStatus.GONE,
|
|
2663
|
+
error: "Endpoint Removed",
|
|
2664
|
+
message: \`This endpoint was removed in API version \${maxVersion}.\`,
|
|
2665
|
+
},
|
|
2666
|
+
HttpStatus.GONE
|
|
2667
|
+
);
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
return true;
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
private extractVersion(request: any): string {
|
|
2674
|
+
// Priority: URL param > Header > Query > Default
|
|
2675
|
+
|
|
2676
|
+
// 1. URL versioning: /api/v1/...
|
|
2677
|
+
const urlMatch = request.url?.match(/\\/v(\\d+)\\//);
|
|
2678
|
+
if (urlMatch) return urlMatch[1];
|
|
2679
|
+
|
|
2680
|
+
// 2. Header versioning: X-API-Version
|
|
2681
|
+
const headerVersion = request.headers["x-api-version"];
|
|
2682
|
+
if (headerVersion) return headerVersion;
|
|
2683
|
+
|
|
2684
|
+
// 3. Accept header versioning: application/vnd.api.v1+json
|
|
2685
|
+
const acceptHeader = request.headers["accept"];
|
|
2686
|
+
if (acceptHeader) {
|
|
2687
|
+
const acceptMatch = acceptHeader.match(/vnd\\.api\\.v(\\d+)/);
|
|
2688
|
+
if (acceptMatch) return acceptMatch[1];
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
// 4. Query parameter: ?version=1
|
|
2692
|
+
if (request.query?.version) return request.query.version;
|
|
2693
|
+
|
|
2694
|
+
// 5. Default version
|
|
2695
|
+
return "1";
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
private compareVersions(a: string, b: string): number {
|
|
2699
|
+
const numA = parseInt(a, 10);
|
|
2700
|
+
const numB = parseInt(b, 10);
|
|
2701
|
+
return numA - numB;
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
`;
|
|
2705
|
+
await (0, file_utils_1.writeFile)(path.join(versioningPath, 'guards/version.guard.ts'), versionGuardContent);
|
|
2706
|
+
// Versioning module
|
|
2707
|
+
const versioningModuleContent = `import { Module, Global } from "@nestjs/common";
|
|
2708
|
+
import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
|
|
2709
|
+
import { ApiVersionGuard } from "./guards/version.guard";
|
|
2710
|
+
import { VersionHeaderInterceptor } from "./interceptors/version-header.interceptor";
|
|
2711
|
+
|
|
2712
|
+
@Global()
|
|
2713
|
+
@Module({
|
|
2714
|
+
providers: [
|
|
2715
|
+
{
|
|
2716
|
+
provide: APP_GUARD,
|
|
2717
|
+
useClass: ApiVersionGuard,
|
|
2718
|
+
},
|
|
2719
|
+
{
|
|
2720
|
+
provide: APP_INTERCEPTOR,
|
|
2721
|
+
useClass: VersionHeaderInterceptor,
|
|
2722
|
+
},
|
|
2723
|
+
],
|
|
2724
|
+
exports: [],
|
|
2725
|
+
})
|
|
2726
|
+
export class VersioningModule {}
|
|
2727
|
+
|
|
2728
|
+
/**
|
|
2729
|
+
* NestJS built-in versioning configuration helper
|
|
2730
|
+
* Use in main.ts:
|
|
2731
|
+
*
|
|
2732
|
+
* import { VersioningType } from "@nestjs/common";
|
|
2733
|
+
*
|
|
2734
|
+
* app.enableVersioning({
|
|
2735
|
+
* type: VersioningType.URI, // /v1/users
|
|
2736
|
+
* // OR
|
|
2737
|
+
* type: VersioningType.HEADER, // X-API-Version: 1
|
|
2738
|
+
* header: "X-API-Version",
|
|
2739
|
+
* // OR
|
|
2740
|
+
* type: VersioningType.MEDIA_TYPE, // Accept: application/vnd.api.v1+json
|
|
2741
|
+
* key: "v=",
|
|
2742
|
+
* });
|
|
2743
|
+
*/
|
|
2744
|
+
export const VERSIONING_CONFIG = {
|
|
2745
|
+
uri: {
|
|
2746
|
+
type: "URI" as const,
|
|
2747
|
+
prefix: "v",
|
|
2748
|
+
},
|
|
2749
|
+
header: {
|
|
2750
|
+
type: "HEADER" as const,
|
|
2751
|
+
header: "X-API-Version",
|
|
2752
|
+
},
|
|
2753
|
+
mediaType: {
|
|
2754
|
+
type: "MEDIA_TYPE" as const,
|
|
2755
|
+
key: "v=",
|
|
2756
|
+
},
|
|
2757
|
+
};
|
|
2758
|
+
`;
|
|
2759
|
+
await (0, file_utils_1.writeFile)(path.join(versioningPath, 'versioning.module.ts'), versioningModuleContent);
|
|
2760
|
+
// Version migration helper
|
|
2761
|
+
const migrationHelperContent = `/**
|
|
2762
|
+
* API Version Migration Helper
|
|
2763
|
+
*
|
|
2764
|
+
* Utilities for migrating between API versions
|
|
2765
|
+
*/
|
|
2766
|
+
|
|
2767
|
+
export interface MigrationRule<TFrom, TTo> {
|
|
2768
|
+
fromVersion: string;
|
|
2769
|
+
toVersion: string;
|
|
2770
|
+
transform: (data: TFrom) => TTo;
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
export class VersionMigrator<T = any> {
|
|
2774
|
+
private rules: MigrationRule<any, any>[] = [];
|
|
2775
|
+
|
|
2776
|
+
/**
|
|
2777
|
+
* Register a migration rule
|
|
2778
|
+
*/
|
|
2779
|
+
register<TFrom, TTo>(
|
|
2780
|
+
fromVersion: string,
|
|
2781
|
+
toVersion: string,
|
|
2782
|
+
transform: (data: TFrom) => TTo
|
|
2783
|
+
): this {
|
|
2784
|
+
this.rules.push({ fromVersion, toVersion, transform });
|
|
2785
|
+
return this;
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
/**
|
|
2789
|
+
* Migrate data from one version to another
|
|
2790
|
+
*/
|
|
2791
|
+
migrate(data: T, fromVersion: string, toVersion: string): T {
|
|
2792
|
+
if (fromVersion === toVersion) return data;
|
|
2793
|
+
|
|
2794
|
+
const path = this.findMigrationPath(fromVersion, toVersion);
|
|
2795
|
+
if (!path) {
|
|
2796
|
+
throw new Error(
|
|
2797
|
+
\`No migration path found from v\${fromVersion} to v\${toVersion}\`
|
|
2798
|
+
);
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
let result = data;
|
|
2802
|
+
for (const rule of path) {
|
|
2803
|
+
result = rule.transform(result);
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
return result;
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
private findMigrationPath(
|
|
2810
|
+
from: string,
|
|
2811
|
+
to: string
|
|
2812
|
+
): MigrationRule<any, any>[] | null {
|
|
2813
|
+
// Simple linear path finding
|
|
2814
|
+
const path: MigrationRule<any, any>[] = [];
|
|
2815
|
+
let current = from;
|
|
2816
|
+
|
|
2817
|
+
while (current !== to) {
|
|
2818
|
+
const rule = this.rules.find((r) => r.fromVersion === current);
|
|
2819
|
+
if (!rule) return null;
|
|
2820
|
+
path.push(rule);
|
|
2821
|
+
current = rule.toVersion;
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
return path;
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
/**
|
|
2829
|
+
* Example usage:
|
|
2830
|
+
*
|
|
2831
|
+
* const userMigrator = new VersionMigrator<UserDto>();
|
|
2832
|
+
*
|
|
2833
|
+
* userMigrator
|
|
2834
|
+
* .register("1", "2", (v1User) => ({
|
|
2835
|
+
* ...v1User,
|
|
2836
|
+
* fullName: \`\${v1User.firstName} \${v1User.lastName}\`,
|
|
2837
|
+
* }))
|
|
2838
|
+
* .register("2", "3", (v2User) => ({
|
|
2839
|
+
* ...v2User,
|
|
2840
|
+
* email: v2User.email.toLowerCase(),
|
|
2841
|
+
* }));
|
|
2842
|
+
*
|
|
2843
|
+
* const v3User = userMigrator.migrate(v1User, "1", "3");
|
|
2844
|
+
*/
|
|
2845
|
+
|
|
2846
|
+
/**
|
|
2847
|
+
* Decorator for automatic response transformation based on version
|
|
2848
|
+
*/
|
|
2849
|
+
export function TransformForVersion<T>(
|
|
2850
|
+
migrator: VersionMigrator<T>,
|
|
2851
|
+
targetVersion: string
|
|
2852
|
+
) {
|
|
2853
|
+
return function (
|
|
2854
|
+
target: any,
|
|
2855
|
+
propertyKey: string,
|
|
2856
|
+
descriptor: PropertyDescriptor
|
|
2857
|
+
) {
|
|
2858
|
+
const originalMethod = descriptor.value;
|
|
2859
|
+
|
|
2860
|
+
descriptor.value = async function (...args: any[]) {
|
|
2861
|
+
const result = await originalMethod.apply(this, args);
|
|
2862
|
+
const request = args.find((arg) => arg?.apiVersion);
|
|
2863
|
+
const currentVersion = request?.apiVersion || "1";
|
|
2864
|
+
|
|
2865
|
+
if (currentVersion !== targetVersion) {
|
|
2866
|
+
return migrator.migrate(result, targetVersion, currentVersion);
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
return result;
|
|
2870
|
+
};
|
|
2871
|
+
|
|
2872
|
+
return descriptor;
|
|
2873
|
+
};
|
|
2874
|
+
}
|
|
2875
|
+
`;
|
|
2876
|
+
await (0, file_utils_1.writeFile)(path.join(versioningPath, 'migration.helper.ts'), migrationHelperContent);
|
|
2877
|
+
// Create guards directory and index
|
|
2878
|
+
await (0, file_utils_1.ensureDir)(path.join(versioningPath, 'guards'));
|
|
2879
|
+
const guardsIndexContent = `export * from "./version.guard";
|
|
2880
|
+
`;
|
|
2881
|
+
await (0, file_utils_1.writeFile)(path.join(versioningPath, 'guards/index.ts'), guardsIndexContent);
|
|
2882
|
+
// Index exports
|
|
2883
|
+
const indexContent = `export * from "./version.config";
|
|
2884
|
+
export * from "./versioning.module";
|
|
2885
|
+
export * from "./migration.helper";
|
|
2886
|
+
export * from "./decorators/version.decorator";
|
|
2887
|
+
export * from "./guards/version.guard";
|
|
2888
|
+
export * from "./interceptors/version-header.interceptor";
|
|
2889
|
+
`;
|
|
2890
|
+
await (0, file_utils_1.writeFile)(path.join(versioningPath, 'index.ts'), indexContent);
|
|
2891
|
+
// Decorators index
|
|
2892
|
+
const decoratorsIndexContent = `export * from "./version.decorator";
|
|
2893
|
+
`;
|
|
2894
|
+
await (0, file_utils_1.writeFile)(path.join(versioningPath, 'decorators/index.ts'), decoratorsIndexContent);
|
|
2895
|
+
// Interceptors index
|
|
2896
|
+
const interceptorsIndexContent = `export * from "./version-header.interceptor";
|
|
2897
|
+
`;
|
|
2898
|
+
await (0, file_utils_1.writeFile)(path.join(versioningPath, 'interceptors/index.ts'), interceptorsIndexContent);
|
|
2899
|
+
console.log(chalk_1.default.green(' ✓ Version configuration (URI/Header/MediaType/Query support)'));
|
|
2900
|
+
console.log(chalk_1.default.green(' ✓ Version decorators (@ApiVersion, @DeprecatedVersion, @MinVersion)'));
|
|
2901
|
+
console.log(chalk_1.default.green(' ✓ Version guard with sunset support'));
|
|
2902
|
+
console.log(chalk_1.default.green(' ✓ Version header interceptor (Deprecation, Sunset, Warning headers)'));
|
|
2903
|
+
console.log(chalk_1.default.green(' ✓ Migration helper for version transformations'));
|
|
2904
|
+
}
|
|
2905
|
+
async function applyHealthRecipe(basePath) {
|
|
2906
|
+
const sharedPath = path.join(basePath, 'src/shared');
|
|
2907
|
+
const healthPath = path.join(sharedPath, 'health');
|
|
2908
|
+
const loggingPath = path.join(sharedPath, 'logging');
|
|
2909
|
+
await (0, file_utils_1.ensureDir)(healthPath);
|
|
2910
|
+
await (0, file_utils_1.ensureDir)(loggingPath);
|
|
2911
|
+
// Health controller
|
|
2912
|
+
const healthControllerContent = `import { Controller, Get } from "@nestjs/common";
|
|
2913
|
+
import {
|
|
2914
|
+
HealthCheck,
|
|
2915
|
+
HealthCheckService,
|
|
2916
|
+
HttpHealthIndicator,
|
|
2917
|
+
TypeOrmHealthIndicator,
|
|
2918
|
+
MemoryHealthIndicator,
|
|
2919
|
+
DiskHealthIndicator,
|
|
2920
|
+
} from "@nestjs/terminus";
|
|
2921
|
+
import { ApiTags, ApiOperation } from "@nestjs/swagger";
|
|
2922
|
+
import { SkipThrottle } from "@nestjs/throttler";
|
|
2923
|
+
import { Public } from "../auth/decorators/public.decorator";
|
|
2924
|
+
|
|
2925
|
+
@ApiTags("Health")
|
|
2926
|
+
@Controller("health")
|
|
2927
|
+
@SkipThrottle()
|
|
2928
|
+
export class HealthController {
|
|
2929
|
+
constructor(
|
|
2930
|
+
private health: HealthCheckService,
|
|
2931
|
+
private http: HttpHealthIndicator,
|
|
2932
|
+
private db: TypeOrmHealthIndicator,
|
|
2933
|
+
private memory: MemoryHealthIndicator,
|
|
2934
|
+
private disk: DiskHealthIndicator
|
|
2935
|
+
) {}
|
|
2936
|
+
|
|
2937
|
+
@Get()
|
|
2938
|
+
@Public()
|
|
2939
|
+
@HealthCheck()
|
|
2940
|
+
@ApiOperation({ summary: "Basic health check" })
|
|
2941
|
+
check() {
|
|
2942
|
+
return this.health.check([
|
|
2943
|
+
() => this.db.pingCheck("database"),
|
|
2944
|
+
]);
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
@Get("ready")
|
|
2948
|
+
@Public()
|
|
2949
|
+
@HealthCheck()
|
|
2950
|
+
@ApiOperation({ summary: "Readiness check - is the service ready to receive traffic?" })
|
|
2951
|
+
readiness() {
|
|
2952
|
+
return this.health.check([
|
|
2953
|
+
() => this.db.pingCheck("database"),
|
|
2954
|
+
() => this.memory.checkHeap("memory_heap", 300 * 1024 * 1024), // 300MB
|
|
2955
|
+
]);
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
@Get("live")
|
|
2959
|
+
@Public()
|
|
2960
|
+
@HealthCheck()
|
|
2961
|
+
@ApiOperation({ summary: "Liveness check - is the service alive?" })
|
|
2962
|
+
liveness() {
|
|
2963
|
+
return this.health.check([
|
|
2964
|
+
() => this.memory.checkRSS("memory_rss", 500 * 1024 * 1024), // 500MB
|
|
2965
|
+
]);
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
@Get("detailed")
|
|
2969
|
+
@HealthCheck()
|
|
2970
|
+
@ApiOperation({ summary: "Detailed health check with all indicators" })
|
|
2971
|
+
detailed() {
|
|
2972
|
+
return this.health.check([
|
|
2973
|
+
() => this.db.pingCheck("database"),
|
|
2974
|
+
() => this.memory.checkHeap("memory_heap", 300 * 1024 * 1024),
|
|
2975
|
+
() => this.memory.checkRSS("memory_rss", 500 * 1024 * 1024),
|
|
2976
|
+
() =>
|
|
2977
|
+
this.disk.checkStorage("disk", {
|
|
2978
|
+
path: "/",
|
|
2979
|
+
thresholdPercent: 0.9, // 90% threshold
|
|
2980
|
+
}),
|
|
2981
|
+
]);
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
`;
|
|
2985
|
+
await (0, file_utils_1.writeFile)(path.join(healthPath, 'health.controller.ts'), healthControllerContent);
|
|
2986
|
+
// Health module
|
|
2987
|
+
const healthModuleContent = `import { Module } from "@nestjs/common";
|
|
2988
|
+
import { TerminusModule } from "@nestjs/terminus";
|
|
2989
|
+
import { HttpModule } from "@nestjs/axios";
|
|
2990
|
+
import { HealthController } from "./health.controller";
|
|
2991
|
+
|
|
2992
|
+
@Module({
|
|
2993
|
+
imports: [TerminusModule, HttpModule],
|
|
2994
|
+
controllers: [HealthController],
|
|
2995
|
+
})
|
|
2996
|
+
export class HealthModule {}
|
|
2997
|
+
`;
|
|
2998
|
+
await (0, file_utils_1.writeFile)(path.join(healthPath, 'health.module.ts'), healthModuleContent);
|
|
2999
|
+
// Custom health indicator example
|
|
3000
|
+
const customIndicatorContent = `import { Injectable } from "@nestjs/common";
|
|
3001
|
+
import {
|
|
3002
|
+
HealthIndicator,
|
|
3003
|
+
HealthIndicatorResult,
|
|
3004
|
+
HealthCheckError,
|
|
3005
|
+
} from "@nestjs/terminus";
|
|
3006
|
+
import Redis from "ioredis";
|
|
3007
|
+
|
|
3008
|
+
@Injectable()
|
|
3009
|
+
export class RedisHealthIndicator extends HealthIndicator {
|
|
3010
|
+
private redis: Redis;
|
|
3011
|
+
|
|
3012
|
+
constructor() {
|
|
3013
|
+
super();
|
|
3014
|
+
this.redis = new Redis({
|
|
3015
|
+
host: process.env.REDIS_HOST || "localhost",
|
|
3016
|
+
port: parseInt(process.env.REDIS_PORT || "6379"),
|
|
3017
|
+
password: process.env.REDIS_PASSWORD,
|
|
3018
|
+
maxRetriesPerRequest: 1,
|
|
3019
|
+
lazyConnect: true,
|
|
3020
|
+
});
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
async isHealthy(key: string): Promise<HealthIndicatorResult> {
|
|
3024
|
+
try {
|
|
3025
|
+
await this.redis.ping();
|
|
3026
|
+
return this.getStatus(key, true);
|
|
3027
|
+
} catch (error) {
|
|
3028
|
+
throw new HealthCheckError(
|
|
3029
|
+
"Redis check failed",
|
|
3030
|
+
this.getStatus(key, false, { error: (error as Error).message })
|
|
3031
|
+
);
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
@Injectable()
|
|
3037
|
+
export class ExternalServiceHealthIndicator extends HealthIndicator {
|
|
3038
|
+
async isHealthy(key: string, url: string): Promise<HealthIndicatorResult> {
|
|
3039
|
+
try {
|
|
3040
|
+
const start = Date.now();
|
|
3041
|
+
const response = await fetch(url, {
|
|
3042
|
+
method: "HEAD",
|
|
3043
|
+
signal: AbortSignal.timeout(5000),
|
|
3044
|
+
});
|
|
3045
|
+
const latency = Date.now() - start;
|
|
3046
|
+
|
|
3047
|
+
if (response.ok) {
|
|
3048
|
+
return this.getStatus(key, true, { latency: \`\${latency}ms\` });
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
throw new Error(\`HTTP \${response.status}\`);
|
|
3052
|
+
} catch (error) {
|
|
3053
|
+
throw new HealthCheckError(
|
|
3054
|
+
\`\${key} check failed\`,
|
|
3055
|
+
this.getStatus(key, false, { error: (error as Error).message })
|
|
3056
|
+
);
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
`;
|
|
3061
|
+
await (0, file_utils_1.writeFile)(path.join(healthPath, 'custom.indicators.ts'), customIndicatorContent);
|
|
3062
|
+
// Logging configuration
|
|
3063
|
+
const loggingConfigContent = `import { Params } from "nestjs-pino";
|
|
3064
|
+
|
|
3065
|
+
export const loggerConfig: Params = {
|
|
3066
|
+
pinoHttp: {
|
|
3067
|
+
level: process.env.LOG_LEVEL || "info",
|
|
3068
|
+
transport:
|
|
3069
|
+
process.env.NODE_ENV !== "production"
|
|
3070
|
+
? {
|
|
3071
|
+
target: "pino-pretty",
|
|
3072
|
+
options: {
|
|
3073
|
+
colorize: true,
|
|
3074
|
+
singleLine: true,
|
|
3075
|
+
translateTime: "SYS:standard",
|
|
3076
|
+
ignore: "pid,hostname",
|
|
3077
|
+
},
|
|
3078
|
+
}
|
|
3079
|
+
: undefined,
|
|
3080
|
+
autoLogging: {
|
|
3081
|
+
ignore: (req) => {
|
|
3082
|
+
// Don't log health check requests
|
|
3083
|
+
return req.url?.includes("/health") || false;
|
|
3084
|
+
},
|
|
3085
|
+
},
|
|
3086
|
+
redact: {
|
|
3087
|
+
paths: [
|
|
3088
|
+
"req.headers.authorization",
|
|
3089
|
+
"req.headers.cookie",
|
|
3090
|
+
"res.headers['set-cookie']",
|
|
3091
|
+
"body.password",
|
|
3092
|
+
"body.token",
|
|
3093
|
+
"body.secret",
|
|
3094
|
+
],
|
|
3095
|
+
censor: "[REDACTED]",
|
|
3096
|
+
},
|
|
3097
|
+
customProps: (req) => ({
|
|
3098
|
+
requestId: req.id,
|
|
3099
|
+
userAgent: req.headers["user-agent"],
|
|
3100
|
+
}),
|
|
3101
|
+
customLogLevel: (req, res, err) => {
|
|
3102
|
+
if (res.statusCode >= 500 || err) return "error";
|
|
3103
|
+
if (res.statusCode >= 400) return "warn";
|
|
3104
|
+
return "info";
|
|
3105
|
+
},
|
|
3106
|
+
customSuccessMessage: (req, res) => {
|
|
3107
|
+
return \`\${req.method} \${req.url} \${res.statusCode}\`;
|
|
3108
|
+
},
|
|
3109
|
+
customErrorMessage: (req, res, err) => {
|
|
3110
|
+
return \`\${req.method} \${req.url} failed: \${err.message}\`;
|
|
3111
|
+
},
|
|
3112
|
+
},
|
|
3113
|
+
};
|
|
3114
|
+
`;
|
|
3115
|
+
await (0, file_utils_1.writeFile)(path.join(loggingPath, 'logger.config.ts'), loggingConfigContent);
|
|
3116
|
+
// Logging module
|
|
3117
|
+
const loggingModuleContent = `import { Module } from "@nestjs/common";
|
|
3118
|
+
import { LoggerModule as PinoLoggerModule } from "nestjs-pino";
|
|
3119
|
+
import { loggerConfig } from "./logger.config";
|
|
3120
|
+
|
|
3121
|
+
@Module({
|
|
3122
|
+
imports: [PinoLoggerModule.forRoot(loggerConfig)],
|
|
3123
|
+
exports: [PinoLoggerModule],
|
|
3124
|
+
})
|
|
3125
|
+
export class LoggingModule {}
|
|
3126
|
+
`;
|
|
3127
|
+
await (0, file_utils_1.writeFile)(path.join(loggingPath, 'logging.module.ts'), loggingModuleContent);
|
|
3128
|
+
// Request context
|
|
3129
|
+
const requestContextContent = `import { Injectable, NestMiddleware } from "@nestjs/common";
|
|
3130
|
+
import { Request, Response, NextFunction } from "express";
|
|
3131
|
+
import { v4 as uuid } from "uuid";
|
|
3132
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
3133
|
+
|
|
3134
|
+
export interface RequestContext {
|
|
3135
|
+
requestId: string;
|
|
3136
|
+
userId?: string;
|
|
3137
|
+
startTime: number;
|
|
3138
|
+
path: string;
|
|
3139
|
+
method: string;
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
export const requestContextStorage = new AsyncLocalStorage<RequestContext>();
|
|
3143
|
+
|
|
3144
|
+
@Injectable()
|
|
3145
|
+
export class RequestContextMiddleware implements NestMiddleware {
|
|
3146
|
+
use(req: Request, res: Response, next: NextFunction) {
|
|
3147
|
+
const requestId = (req.headers["x-request-id"] as string) || uuid();
|
|
3148
|
+
const userId = (req as any).user?.id;
|
|
3149
|
+
|
|
3150
|
+
const context: RequestContext = {
|
|
3151
|
+
requestId,
|
|
3152
|
+
userId,
|
|
3153
|
+
startTime: Date.now(),
|
|
3154
|
+
path: req.path,
|
|
3155
|
+
method: req.method,
|
|
3156
|
+
};
|
|
3157
|
+
|
|
3158
|
+
// Set request ID header for response
|
|
3159
|
+
res.setHeader("X-Request-Id", requestId);
|
|
3160
|
+
|
|
3161
|
+
requestContextStorage.run(context, () => {
|
|
3162
|
+
next();
|
|
3163
|
+
});
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
/**
|
|
3168
|
+
* Get current request context
|
|
3169
|
+
*/
|
|
3170
|
+
export function getRequestContext(): RequestContext | undefined {
|
|
3171
|
+
return requestContextStorage.getStore();
|
|
3172
|
+
}
|
|
3173
|
+
|
|
3174
|
+
/**
|
|
3175
|
+
* Get current request ID
|
|
3176
|
+
*/
|
|
3177
|
+
export function getRequestId(): string | undefined {
|
|
3178
|
+
return getRequestContext()?.requestId;
|
|
3179
|
+
}
|
|
3180
|
+
`;
|
|
3181
|
+
await (0, file_utils_1.writeFile)(path.join(loggingPath, 'request-context.ts'), requestContextContent);
|
|
3182
|
+
// Metrics utility
|
|
3183
|
+
const metricsContent = `import { Injectable, Logger } from "@nestjs/common";
|
|
3184
|
+
|
|
3185
|
+
export interface MetricData {
|
|
3186
|
+
name: string;
|
|
3187
|
+
value: number;
|
|
3188
|
+
tags?: Record<string, string>;
|
|
3189
|
+
timestamp?: Date;
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
@Injectable()
|
|
3193
|
+
export class MetricsService {
|
|
3194
|
+
private readonly logger = new Logger(MetricsService.name);
|
|
3195
|
+
private metrics: Map<string, number[]> = new Map();
|
|
3196
|
+
|
|
3197
|
+
/**
|
|
3198
|
+
* Record a metric value
|
|
3199
|
+
*/
|
|
3200
|
+
record(name: string, value: number, tags?: Record<string, string>): void {
|
|
3201
|
+
const key = this.getKey(name, tags);
|
|
3202
|
+
|
|
3203
|
+
if (!this.metrics.has(key)) {
|
|
3204
|
+
this.metrics.set(key, []);
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
const values = this.metrics.get(key)!;
|
|
3208
|
+
values.push(value);
|
|
3209
|
+
|
|
3210
|
+
// Keep only last 1000 values
|
|
3211
|
+
if (values.length > 1000) {
|
|
3212
|
+
values.shift();
|
|
3213
|
+
}
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
/**
|
|
3217
|
+
* Increment a counter
|
|
3218
|
+
*/
|
|
3219
|
+
increment(name: string, tags?: Record<string, string>): void {
|
|
3220
|
+
this.record(name, 1, tags);
|
|
3221
|
+
}
|
|
3222
|
+
|
|
3223
|
+
/**
|
|
3224
|
+
* Record timing in milliseconds
|
|
3225
|
+
*/
|
|
3226
|
+
timing(name: string, duration: number, tags?: Record<string, string>): void {
|
|
3227
|
+
this.record(\`\${name}.timing\`, duration, tags);
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
/**
|
|
3231
|
+
* Measure execution time of a function
|
|
3232
|
+
*/
|
|
3233
|
+
async measure<T>(
|
|
3234
|
+
name: string,
|
|
3235
|
+
fn: () => Promise<T>,
|
|
3236
|
+
tags?: Record<string, string>
|
|
3237
|
+
): Promise<T> {
|
|
3238
|
+
const start = Date.now();
|
|
3239
|
+
try {
|
|
3240
|
+
const result = await fn();
|
|
3241
|
+
this.timing(name, Date.now() - start, { ...tags, status: "success" });
|
|
3242
|
+
return result;
|
|
3243
|
+
} catch (error) {
|
|
3244
|
+
this.timing(name, Date.now() - start, { ...tags, status: "error" });
|
|
3245
|
+
throw error;
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
|
|
3249
|
+
/**
|
|
3250
|
+
* Get metric statistics
|
|
3251
|
+
*/
|
|
3252
|
+
getStats(name: string, tags?: Record<string, string>): {
|
|
3253
|
+
count: number;
|
|
3254
|
+
min: number;
|
|
3255
|
+
max: number;
|
|
3256
|
+
avg: number;
|
|
3257
|
+
p50: number;
|
|
3258
|
+
p95: number;
|
|
3259
|
+
p99: number;
|
|
3260
|
+
} | null {
|
|
3261
|
+
const key = this.getKey(name, tags);
|
|
3262
|
+
const values = this.metrics.get(key);
|
|
3263
|
+
|
|
3264
|
+
if (!values || values.length === 0) return null;
|
|
3265
|
+
|
|
3266
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
3267
|
+
const count = sorted.length;
|
|
3268
|
+
|
|
3269
|
+
return {
|
|
3270
|
+
count,
|
|
3271
|
+
min: sorted[0],
|
|
3272
|
+
max: sorted[count - 1],
|
|
3273
|
+
avg: sorted.reduce((a, b) => a + b, 0) / count,
|
|
3274
|
+
p50: sorted[Math.floor(count * 0.5)],
|
|
3275
|
+
p95: sorted[Math.floor(count * 0.95)],
|
|
3276
|
+
p99: sorted[Math.floor(count * 0.99)],
|
|
3277
|
+
};
|
|
3278
|
+
}
|
|
3279
|
+
|
|
3280
|
+
/**
|
|
3281
|
+
* Get all metrics
|
|
3282
|
+
*/
|
|
3283
|
+
getAllMetrics(): Record<string, any> {
|
|
3284
|
+
const result: Record<string, any> = {};
|
|
3285
|
+
|
|
3286
|
+
for (const [key] of this.metrics) {
|
|
3287
|
+
result[key] = this.getStats(key);
|
|
3288
|
+
}
|
|
3289
|
+
|
|
3290
|
+
return result;
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
private getKey(name: string, tags?: Record<string, string>): string {
|
|
3294
|
+
if (!tags) return name;
|
|
3295
|
+
const tagStr = Object.entries(tags)
|
|
3296
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
3297
|
+
.map(([k, v]) => \`\${k}=\${v}\`)
|
|
3298
|
+
.join(",");
|
|
3299
|
+
return \`\${name}{\${tagStr}}\`;
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
`;
|
|
3303
|
+
await (0, file_utils_1.writeFile)(path.join(loggingPath, 'metrics.service.ts'), metricsContent);
|
|
3304
|
+
// Index exports
|
|
3305
|
+
const healthIndexContent = `export * from "./health.controller";
|
|
3306
|
+
export * from "./health.module";
|
|
3307
|
+
export * from "./custom.indicators";
|
|
3308
|
+
`;
|
|
3309
|
+
await (0, file_utils_1.writeFile)(path.join(healthPath, 'index.ts'), healthIndexContent);
|
|
3310
|
+
const loggingIndexContent = `export * from "./logger.config";
|
|
3311
|
+
export * from "./logging.module";
|
|
3312
|
+
export * from "./request-context";
|
|
3313
|
+
export * from "./metrics.service";
|
|
3314
|
+
`;
|
|
3315
|
+
await (0, file_utils_1.writeFile)(path.join(loggingPath, 'index.ts'), loggingIndexContent);
|
|
3316
|
+
console.log(chalk_1.default.green(' ✓ Health controller (/health, /health/ready, /health/live)'));
|
|
3317
|
+
console.log(chalk_1.default.green(' ✓ Custom health indicators (Redis, External Services)'));
|
|
3318
|
+
console.log(chalk_1.default.green(' ✓ Pino logging with redaction'));
|
|
3319
|
+
console.log(chalk_1.default.green(' ✓ Request context with AsyncLocalStorage'));
|
|
3320
|
+
console.log(chalk_1.default.green(' ✓ Metrics service with percentiles'));
|
|
3321
|
+
}
|
|
3322
|
+
async function applyRateLimitingRecipe(basePath) {
|
|
3323
|
+
const sharedPath = path.join(basePath, 'src/shared');
|
|
3324
|
+
const rateLimitPath = path.join(sharedPath, 'rate-limit');
|
|
3325
|
+
await (0, file_utils_1.ensureDir)(rateLimitPath);
|
|
3326
|
+
await (0, file_utils_1.ensureDir)(path.join(rateLimitPath, 'guards'));
|
|
3327
|
+
await (0, file_utils_1.ensureDir)(path.join(rateLimitPath, 'decorators'));
|
|
3328
|
+
// Custom throttler decorator
|
|
3329
|
+
const throttleDecoratorContent = `import { SetMetadata, applyDecorators } from "@nestjs/common";
|
|
3330
|
+
import { Throttle, SkipThrottle } from "@nestjs/throttler";
|
|
3331
|
+
|
|
3332
|
+
export const RATE_LIMIT_KEY = "rate_limit_config";
|
|
3333
|
+
|
|
3334
|
+
export interface RateLimitConfig {
|
|
3335
|
+
ttl: number; // Time window in seconds
|
|
3336
|
+
limit: number; // Max requests in time window
|
|
3337
|
+
blockDuration?: number; // How long to block after exceeding limit (seconds)
|
|
3338
|
+
}
|
|
3339
|
+
|
|
3340
|
+
/**
|
|
3341
|
+
* Apply rate limiting to a route
|
|
3342
|
+
* @param limit - Max requests allowed
|
|
3343
|
+
* @param ttl - Time window in seconds (default: 60)
|
|
3344
|
+
*/
|
|
3345
|
+
export const RateLimit = (limit: number, ttl: number = 60) =>
|
|
3346
|
+
applyDecorators(
|
|
3347
|
+
Throttle({ default: { limit, ttl: ttl * 1000 } }),
|
|
3348
|
+
SetMetadata(RATE_LIMIT_KEY, { limit, ttl })
|
|
3349
|
+
);
|
|
3350
|
+
|
|
3351
|
+
/**
|
|
3352
|
+
* Skip rate limiting for this route
|
|
3353
|
+
*/
|
|
3354
|
+
export { SkipThrottle };
|
|
3355
|
+
|
|
3356
|
+
/**
|
|
3357
|
+
* Stricter rate limit for sensitive operations
|
|
3358
|
+
*/
|
|
3359
|
+
export const StrictRateLimit = () => RateLimit(5, 60);
|
|
3360
|
+
|
|
3361
|
+
/**
|
|
3362
|
+
* Relaxed rate limit for public endpoints
|
|
3363
|
+
*/
|
|
3364
|
+
export const RelaxedRateLimit = () => RateLimit(100, 60);
|
|
3365
|
+
|
|
3366
|
+
/**
|
|
3367
|
+
* Rate limit by user ID instead of IP
|
|
3368
|
+
*/
|
|
3369
|
+
export const RATE_LIMIT_BY_USER = "rate_limit_by_user";
|
|
3370
|
+
export const RateLimitByUser = () => SetMetadata(RATE_LIMIT_BY_USER, true);
|
|
3371
|
+
`;
|
|
3372
|
+
await (0, file_utils_1.writeFile)(path.join(rateLimitPath, 'decorators/throttle.decorator.ts'), throttleDecoratorContent);
|
|
3373
|
+
// Custom throttler guard
|
|
3374
|
+
const throttlerGuardContent = `import { Injectable, ExecutionContext } from "@nestjs/common";
|
|
3375
|
+
import { ThrottlerGuard, ThrottlerException } from "@nestjs/throttler";
|
|
3376
|
+
import { Reflector } from "@nestjs/core";
|
|
3377
|
+
import { RATE_LIMIT_BY_USER } from "../decorators/throttle.decorator";
|
|
3378
|
+
|
|
3379
|
+
@Injectable()
|
|
3380
|
+
export class CustomThrottlerGuard extends ThrottlerGuard {
|
|
3381
|
+
constructor(
|
|
3382
|
+
options: any,
|
|
3383
|
+
storageService: any,
|
|
3384
|
+
private readonly reflector: Reflector
|
|
3385
|
+
) {
|
|
3386
|
+
super(options, storageService, reflector);
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
/**
|
|
3390
|
+
* Generate tracking key based on IP or user ID
|
|
3391
|
+
*/
|
|
3392
|
+
protected async getTracker(req: Record<string, any>): Promise<string> {
|
|
3393
|
+
const byUser = this.reflector.get<boolean>(
|
|
3394
|
+
RATE_LIMIT_BY_USER,
|
|
3395
|
+
this.context?.getHandler()
|
|
3396
|
+
);
|
|
3397
|
+
|
|
3398
|
+
if (byUser && req.user?.id) {
|
|
3399
|
+
return \`user_\${req.user.id}\`;
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
// Use X-Forwarded-For if behind proxy, otherwise use IP
|
|
3403
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
3404
|
+
const ip = forwarded
|
|
3405
|
+
? (Array.isArray(forwarded) ? forwarded[0] : forwarded.split(",")[0])
|
|
3406
|
+
: req.ip || req.connection?.remoteAddress;
|
|
3407
|
+
|
|
3408
|
+
return \`ip_\${ip}\`;
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3411
|
+
private context?: ExecutionContext;
|
|
3412
|
+
|
|
3413
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
3414
|
+
this.context = context;
|
|
3415
|
+
return super.canActivate(context);
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3418
|
+
/**
|
|
3419
|
+
* Custom error response
|
|
3420
|
+
*/
|
|
3421
|
+
protected throwThrottlingException(context: ExecutionContext): void {
|
|
3422
|
+
const req = context.switchToHttp().getRequest();
|
|
3423
|
+
const res = context.switchToHttp().getResponse();
|
|
3424
|
+
|
|
3425
|
+
// Add retry-after header
|
|
3426
|
+
const retryAfter = 60; // Default retry after 60 seconds
|
|
3427
|
+
res.header("Retry-After", retryAfter.toString());
|
|
3428
|
+
res.header("X-RateLimit-Reset", new Date(Date.now() + retryAfter * 1000).toISOString());
|
|
3429
|
+
|
|
3430
|
+
throw new ThrottlerException("Too many requests. Please try again later.");
|
|
3431
|
+
}
|
|
3432
|
+
}
|
|
3433
|
+
`;
|
|
3434
|
+
await (0, file_utils_1.writeFile)(path.join(rateLimitPath, 'guards/throttler.guard.ts'), throttlerGuardContent);
|
|
3435
|
+
// Redis throttler storage
|
|
3436
|
+
const redisStorageContent = `import { Injectable, OnModuleDestroy } from "@nestjs/common";
|
|
3437
|
+
import { ThrottlerStorage } from "@nestjs/throttler";
|
|
3438
|
+
import Redis from "ioredis";
|
|
3439
|
+
|
|
3440
|
+
export interface ThrottlerStorageRecord {
|
|
3441
|
+
totalHits: number;
|
|
3442
|
+
timeToExpire: number;
|
|
3443
|
+
isBlocked: boolean;
|
|
3444
|
+
timeToBlockExpire: number;
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
@Injectable()
|
|
3448
|
+
export class RedisThrottlerStorage implements ThrottlerStorage, OnModuleDestroy {
|
|
3449
|
+
private redis: Redis;
|
|
3450
|
+
private prefix = "throttle:";
|
|
3451
|
+
|
|
3452
|
+
constructor() {
|
|
3453
|
+
this.redis = new Redis({
|
|
3454
|
+
host: process.env.REDIS_HOST || "localhost",
|
|
3455
|
+
port: parseInt(process.env.REDIS_PORT || "6379"),
|
|
3456
|
+
password: process.env.REDIS_PASSWORD,
|
|
3457
|
+
keyPrefix: this.prefix,
|
|
3458
|
+
});
|
|
3459
|
+
}
|
|
3460
|
+
|
|
3461
|
+
async onModuleDestroy() {
|
|
3462
|
+
await this.redis.quit();
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
async increment(
|
|
3466
|
+
key: string,
|
|
3467
|
+
ttl: number,
|
|
3468
|
+
limit: number,
|
|
3469
|
+
blockDuration: number,
|
|
3470
|
+
throttlerName: string
|
|
3471
|
+
): Promise<ThrottlerStorageRecord> {
|
|
3472
|
+
const fullKey = \`\${throttlerName}:\${key}\`;
|
|
3473
|
+
|
|
3474
|
+
// Check if blocked
|
|
3475
|
+
const blockedUntil = await this.redis.get(\`blocked:\${fullKey}\`);
|
|
3476
|
+
if (blockedUntil) {
|
|
3477
|
+
const timeToBlockExpire = parseInt(blockedUntil) - Date.now();
|
|
3478
|
+
if (timeToBlockExpire > 0) {
|
|
3479
|
+
return {
|
|
3480
|
+
totalHits: limit + 1,
|
|
3481
|
+
timeToExpire: 0,
|
|
3482
|
+
isBlocked: true,
|
|
3483
|
+
timeToBlockExpire,
|
|
3484
|
+
};
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
|
|
3488
|
+
// Increment counter
|
|
3489
|
+
const multi = this.redis.multi();
|
|
3490
|
+
multi.incr(fullKey);
|
|
3491
|
+
multi.pttl(fullKey);
|
|
3492
|
+
|
|
3493
|
+
const results = await multi.exec();
|
|
3494
|
+
const totalHits = results?.[0]?.[1] as number || 1;
|
|
3495
|
+
let timeToExpire = results?.[1]?.[1] as number || -1;
|
|
3496
|
+
|
|
3497
|
+
// Set TTL if this is a new key
|
|
3498
|
+
if (timeToExpire === -1) {
|
|
3499
|
+
await this.redis.pexpire(fullKey, ttl);
|
|
3500
|
+
timeToExpire = ttl;
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3503
|
+
// Block if limit exceeded
|
|
3504
|
+
let isBlocked = false;
|
|
3505
|
+
let timeToBlockExpire = 0;
|
|
3506
|
+
|
|
3507
|
+
if (totalHits > limit && blockDuration > 0) {
|
|
3508
|
+
const blockUntil = Date.now() + blockDuration;
|
|
3509
|
+
await this.redis.set(\`blocked:\${fullKey}\`, blockUntil.toString(), "PX", blockDuration);
|
|
3510
|
+
isBlocked = true;
|
|
3511
|
+
timeToBlockExpire = blockDuration;
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
return {
|
|
3515
|
+
totalHits,
|
|
3516
|
+
timeToExpire,
|
|
3517
|
+
isBlocked,
|
|
3518
|
+
timeToBlockExpire,
|
|
3519
|
+
};
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3522
|
+
/**
|
|
3523
|
+
* Get current rate limit status for a key
|
|
3524
|
+
*/
|
|
3525
|
+
async getStatus(key: string, throttlerName: string): Promise<{ hits: number; ttl: number } | null> {
|
|
3526
|
+
const fullKey = \`\${throttlerName}:\${key}\`;
|
|
3527
|
+
const [hits, ttl] = await Promise.all([
|
|
3528
|
+
this.redis.get(fullKey),
|
|
3529
|
+
this.redis.pttl(fullKey),
|
|
3530
|
+
]);
|
|
3531
|
+
|
|
3532
|
+
if (!hits) return null;
|
|
3533
|
+
|
|
3534
|
+
return {
|
|
3535
|
+
hits: parseInt(hits),
|
|
3536
|
+
ttl: Math.max(0, ttl),
|
|
3537
|
+
};
|
|
3538
|
+
}
|
|
3539
|
+
|
|
3540
|
+
/**
|
|
3541
|
+
* Reset rate limit for a key
|
|
3542
|
+
*/
|
|
3543
|
+
async reset(key: string, throttlerName: string): Promise<void> {
|
|
3544
|
+
const fullKey = \`\${throttlerName}:\${key}\`;
|
|
3545
|
+
await this.redis.del(fullKey, \`blocked:\${fullKey}\`);
|
|
3546
|
+
}
|
|
3547
|
+
}
|
|
3548
|
+
`;
|
|
3549
|
+
await (0, file_utils_1.writeFile)(path.join(rateLimitPath, 'redis-throttler.storage.ts'), redisStorageContent);
|
|
3550
|
+
// Rate limit module
|
|
3551
|
+
const moduleContent = `import { Module, Global } from "@nestjs/common";
|
|
3552
|
+
import { ThrottlerModule } from "@nestjs/throttler";
|
|
3553
|
+
import { APP_GUARD } from "@nestjs/core";
|
|
3554
|
+
import { CustomThrottlerGuard } from "./guards/throttler.guard";
|
|
3555
|
+
import { RedisThrottlerStorage } from "./redis-throttler.storage";
|
|
3556
|
+
|
|
3557
|
+
@Global()
|
|
3558
|
+
@Module({
|
|
3559
|
+
imports: [
|
|
3560
|
+
ThrottlerModule.forRoot({
|
|
3561
|
+
throttlers: [
|
|
3562
|
+
{
|
|
3563
|
+
name: "default",
|
|
3564
|
+
ttl: parseInt(process.env.THROTTLE_TTL || "60") * 1000,
|
|
3565
|
+
limit: parseInt(process.env.THROTTLE_LIMIT || "100"),
|
|
3566
|
+
},
|
|
3567
|
+
{
|
|
3568
|
+
name: "strict",
|
|
3569
|
+
ttl: 60000,
|
|
3570
|
+
limit: 5,
|
|
3571
|
+
},
|
|
3572
|
+
{
|
|
3573
|
+
name: "auth",
|
|
3574
|
+
ttl: 300000, // 5 minutes
|
|
3575
|
+
limit: 5, // 5 attempts
|
|
3576
|
+
},
|
|
3577
|
+
],
|
|
3578
|
+
storage: new RedisThrottlerStorage(),
|
|
3579
|
+
}),
|
|
3580
|
+
],
|
|
3581
|
+
providers: [
|
|
3582
|
+
{
|
|
3583
|
+
provide: APP_GUARD,
|
|
3584
|
+
useClass: CustomThrottlerGuard,
|
|
3585
|
+
},
|
|
3586
|
+
],
|
|
3587
|
+
exports: [ThrottlerModule],
|
|
3588
|
+
})
|
|
3589
|
+
export class RateLimitModule {}
|
|
3590
|
+
`;
|
|
3591
|
+
await (0, file_utils_1.writeFile)(path.join(rateLimitPath, 'rate-limit.module.ts'), moduleContent);
|
|
3592
|
+
// Index exports
|
|
3593
|
+
const indexContent = `export * from "./decorators/throttle.decorator";
|
|
3594
|
+
export * from "./guards/throttler.guard";
|
|
3595
|
+
export * from "./redis-throttler.storage";
|
|
3596
|
+
export * from "./rate-limit.module";
|
|
3597
|
+
`;
|
|
3598
|
+
await (0, file_utils_1.writeFile)(path.join(rateLimitPath, 'index.ts'), indexContent);
|
|
3599
|
+
// Decorators index
|
|
3600
|
+
const decoratorsIndexContent = `export * from "./throttle.decorator";
|
|
3601
|
+
`;
|
|
3602
|
+
await (0, file_utils_1.writeFile)(path.join(rateLimitPath, 'decorators/index.ts'), decoratorsIndexContent);
|
|
3603
|
+
// Guards index
|
|
3604
|
+
const guardsIndexContent = `export * from "./throttler.guard";
|
|
3605
|
+
`;
|
|
3606
|
+
await (0, file_utils_1.writeFile)(path.join(rateLimitPath, 'guards/index.ts'), guardsIndexContent);
|
|
3607
|
+
console.log(chalk_1.default.green(' ✓ Rate limit decorators (@RateLimit, @StrictRateLimit, @RateLimitByUser)'));
|
|
3608
|
+
console.log(chalk_1.default.green(' ✓ Custom throttler guard with IP/User tracking'));
|
|
3609
|
+
console.log(chalk_1.default.green(' ✓ Redis-based throttler storage'));
|
|
3610
|
+
console.log(chalk_1.default.green(' ✓ Rate limit module'));
|
|
3611
|
+
}
|
|
3612
|
+
async function applyFilteringRecipe(basePath) {
|
|
3613
|
+
const sharedPath = path.join(basePath, 'src/shared');
|
|
3614
|
+
const filterPath = path.join(sharedPath, 'filtering');
|
|
3615
|
+
await (0, file_utils_1.ensureDir)(filterPath);
|
|
3616
|
+
// Filter operators and types
|
|
3617
|
+
const filterTypesContent = `export type FilterOperator =
|
|
3618
|
+
| "eq" // Equal
|
|
3619
|
+
| "ne" // Not equal
|
|
3620
|
+
| "gt" // Greater than
|
|
3621
|
+
| "gte" // Greater than or equal
|
|
3622
|
+
| "lt" // Less than
|
|
3623
|
+
| "lte" // Less than or equal
|
|
3624
|
+
| "in" // In array
|
|
3625
|
+
| "nin" // Not in array
|
|
3626
|
+
| "like" // Contains (case insensitive)
|
|
3627
|
+
| "ilike" // Contains (PostgreSQL)
|
|
3628
|
+
| "between" // Between two values
|
|
3629
|
+
| "isNull" // Is null
|
|
3630
|
+
| "isNotNull"; // Is not null
|
|
3631
|
+
|
|
3632
|
+
export interface FilterCondition {
|
|
3633
|
+
field: string;
|
|
3634
|
+
operator: FilterOperator;
|
|
3635
|
+
value?: any;
|
|
3636
|
+
}
|
|
3637
|
+
|
|
3638
|
+
export interface QueryOptions {
|
|
3639
|
+
page?: number;
|
|
3640
|
+
limit?: number;
|
|
3641
|
+
sortBy?: string;
|
|
3642
|
+
sortOrder?: "ASC" | "DESC";
|
|
3643
|
+
search?: string;
|
|
3644
|
+
searchFields?: string[];
|
|
3645
|
+
filters?: Record<string, any>;
|
|
3646
|
+
}
|
|
3647
|
+
|
|
3648
|
+
export interface PaginatedResult<T> {
|
|
3649
|
+
items: T[];
|
|
3650
|
+
meta: {
|
|
3651
|
+
total: number;
|
|
3652
|
+
page: number;
|
|
3653
|
+
limit: number;
|
|
3654
|
+
totalPages: number;
|
|
3655
|
+
hasNextPage: boolean;
|
|
3656
|
+
hasPreviousPage: boolean;
|
|
3657
|
+
};
|
|
3658
|
+
}
|
|
3659
|
+
`;
|
|
3660
|
+
await (0, file_utils_1.writeFile)(path.join(filterPath, 'filter.types.ts'), filterTypesContent);
|
|
3661
|
+
// TypeORM Query Builder
|
|
3662
|
+
const typeormBuilderContent = `import { SelectQueryBuilder, Brackets } from "typeorm";
|
|
3663
|
+
import { FilterCondition, QueryOptions, PaginatedResult } from "./filter.types";
|
|
3664
|
+
|
|
3665
|
+
/**
|
|
3666
|
+
* Parse filter parameters from query string
|
|
3667
|
+
* Supports format: field__operator=value (e.g., age__gte=18, name__like=john)
|
|
3668
|
+
*/
|
|
3669
|
+
export function parseFilters(query: Record<string, any>): FilterCondition[] {
|
|
3670
|
+
const conditions: FilterCondition[] = [];
|
|
3671
|
+
const operatorMap: Record<string, string> = {
|
|
3672
|
+
eq: "eq", ne: "ne", gt: "gt", gte: "gte", lt: "lt", lte: "lte",
|
|
3673
|
+
in: "in", nin: "nin", like: "like", ilike: "ilike",
|
|
3674
|
+
between: "between", isNull: "isNull", isNotNull: "isNotNull",
|
|
3675
|
+
};
|
|
3676
|
+
|
|
3677
|
+
for (const [key, value] of Object.entries(query)) {
|
|
3678
|
+
if (value === undefined || value === null || value === "") continue;
|
|
3679
|
+
if (["page", "limit", "sortBy", "sortOrder", "search", "searchFields"].includes(key)) continue;
|
|
3680
|
+
|
|
3681
|
+
const parts = key.split("__");
|
|
3682
|
+
const field = parts[0];
|
|
3683
|
+
const operatorKey = parts[1] || "eq";
|
|
3684
|
+
const operator = operatorMap[operatorKey] || "eq";
|
|
3685
|
+
|
|
3686
|
+
conditions.push({ field, operator: operator as any, value });
|
|
3687
|
+
}
|
|
3688
|
+
|
|
3689
|
+
return conditions;
|
|
3690
|
+
}
|
|
3691
|
+
|
|
3692
|
+
/**
|
|
3693
|
+
* Apply filters to TypeORM QueryBuilder
|
|
3694
|
+
*/
|
|
3695
|
+
export function applyFilters<T>(
|
|
3696
|
+
qb: SelectQueryBuilder<T>,
|
|
3697
|
+
conditions: FilterCondition[],
|
|
3698
|
+
alias: string
|
|
3699
|
+
): SelectQueryBuilder<T> {
|
|
3700
|
+
for (const { field, operator, value } of conditions) {
|
|
3701
|
+
const paramKey = \`\${field}_\${Date.now()}_\${Math.random().toString(36).slice(2)}\`;
|
|
3702
|
+
const col = \`\${alias}.\${field}\`;
|
|
3703
|
+
|
|
3704
|
+
switch (operator) {
|
|
3705
|
+
case "eq":
|
|
3706
|
+
qb.andWhere(\`\${col} = :\${paramKey}\`, { [paramKey]: value });
|
|
3707
|
+
break;
|
|
3708
|
+
case "ne":
|
|
3709
|
+
qb.andWhere(\`\${col} != :\${paramKey}\`, { [paramKey]: value });
|
|
3710
|
+
break;
|
|
3711
|
+
case "gt":
|
|
3712
|
+
qb.andWhere(\`\${col} > :\${paramKey}\`, { [paramKey]: value });
|
|
3713
|
+
break;
|
|
3714
|
+
case "gte":
|
|
3715
|
+
qb.andWhere(\`\${col} >= :\${paramKey}\`, { [paramKey]: value });
|
|
3716
|
+
break;
|
|
3717
|
+
case "lt":
|
|
3718
|
+
qb.andWhere(\`\${col} < :\${paramKey}\`, { [paramKey]: value });
|
|
3719
|
+
break;
|
|
3720
|
+
case "lte":
|
|
3721
|
+
qb.andWhere(\`\${col} <= :\${paramKey}\`, { [paramKey]: value });
|
|
3722
|
+
break;
|
|
3723
|
+
case "in":
|
|
3724
|
+
qb.andWhere(\`\${col} IN (:...\${paramKey})\`, {
|
|
3725
|
+
[paramKey]: Array.isArray(value) ? value : value.split(",")
|
|
3726
|
+
});
|
|
3727
|
+
break;
|
|
3728
|
+
case "nin":
|
|
3729
|
+
qb.andWhere(\`\${col} NOT IN (:...\${paramKey})\`, {
|
|
3730
|
+
[paramKey]: Array.isArray(value) ? value : value.split(",")
|
|
3731
|
+
});
|
|
3732
|
+
break;
|
|
3733
|
+
case "like":
|
|
3734
|
+
qb.andWhere(\`LOWER(\${col}) LIKE LOWER(:\${paramKey})\`, { [paramKey]: \`%\${value}%\` });
|
|
3735
|
+
break;
|
|
3736
|
+
case "ilike":
|
|
3737
|
+
qb.andWhere(\`\${col} ILIKE :\${paramKey}\`, { [paramKey]: \`%\${value}%\` });
|
|
3738
|
+
break;
|
|
3739
|
+
case "between":
|
|
3740
|
+
const [min, max] = Array.isArray(value) ? value : value.split(",");
|
|
3741
|
+
qb.andWhere(\`\${col} BETWEEN :\${paramKey}_min AND :\${paramKey}_max\`, {
|
|
3742
|
+
[\`\${paramKey}_min\`]: min, [\`\${paramKey}_max\`]: max
|
|
3743
|
+
});
|
|
3744
|
+
break;
|
|
3745
|
+
case "isNull":
|
|
3746
|
+
qb.andWhere(\`\${col} IS NULL\`);
|
|
3747
|
+
break;
|
|
3748
|
+
case "isNotNull":
|
|
3749
|
+
qb.andWhere(\`\${col} IS NOT NULL\`);
|
|
3750
|
+
break;
|
|
3751
|
+
}
|
|
3752
|
+
}
|
|
3753
|
+
return qb;
|
|
3754
|
+
}
|
|
3755
|
+
|
|
3756
|
+
/**
|
|
3757
|
+
* Apply search across multiple fields
|
|
3758
|
+
*/
|
|
3759
|
+
export function applySearch<T>(
|
|
3760
|
+
qb: SelectQueryBuilder<T>,
|
|
3761
|
+
search: string,
|
|
3762
|
+
fields: string[],
|
|
3763
|
+
alias: string
|
|
3764
|
+
): SelectQueryBuilder<T> {
|
|
3765
|
+
if (!search || fields.length === 0) return qb;
|
|
3766
|
+
|
|
3767
|
+
qb.andWhere(new Brackets((sub) => {
|
|
3768
|
+
for (const field of fields) {
|
|
3769
|
+
sub.orWhere(\`LOWER(\${alias}.\${field}) LIKE LOWER(:search)\`, { search: \`%\${search}%\` });
|
|
3770
|
+
}
|
|
3771
|
+
}));
|
|
3772
|
+
|
|
3773
|
+
return qb;
|
|
3774
|
+
}
|
|
3775
|
+
|
|
3776
|
+
/**
|
|
3777
|
+
* Execute filtered, paginated query
|
|
3778
|
+
*/
|
|
3779
|
+
export async function executeQuery<T>(
|
|
3780
|
+
qb: SelectQueryBuilder<T>,
|
|
3781
|
+
options: QueryOptions,
|
|
3782
|
+
alias: string,
|
|
3783
|
+
defaultSearchFields: string[] = []
|
|
3784
|
+
): Promise<PaginatedResult<T>> {
|
|
3785
|
+
const { page = 1, limit = 10, sortBy = "createdAt", sortOrder = "DESC" } = options;
|
|
3786
|
+
|
|
3787
|
+
// Apply filters
|
|
3788
|
+
if (options.filters) {
|
|
3789
|
+
applyFilters(qb, parseFilters(options.filters), alias);
|
|
3790
|
+
}
|
|
3791
|
+
|
|
3792
|
+
// Apply search
|
|
3793
|
+
if (options.search) {
|
|
3794
|
+
applySearch(qb, options.search, options.searchFields || defaultSearchFields, alias);
|
|
3795
|
+
}
|
|
3796
|
+
|
|
3797
|
+
// Get total before pagination
|
|
3798
|
+
const total = await qb.getCount();
|
|
3799
|
+
|
|
3800
|
+
// Apply pagination
|
|
3801
|
+
qb.skip((page - 1) * limit).take(limit).orderBy(\`\${alias}.\${sortBy}\`, sortOrder);
|
|
3802
|
+
|
|
3803
|
+
const items = await qb.getMany();
|
|
3804
|
+
const totalPages = Math.ceil(total / limit);
|
|
3805
|
+
|
|
3806
|
+
return {
|
|
3807
|
+
items,
|
|
3808
|
+
meta: {
|
|
3809
|
+
total,
|
|
3810
|
+
page,
|
|
3811
|
+
limit,
|
|
3812
|
+
totalPages,
|
|
3813
|
+
hasNextPage: page < totalPages,
|
|
3814
|
+
hasPreviousPage: page > 1,
|
|
3815
|
+
},
|
|
3816
|
+
};
|
|
3817
|
+
}
|
|
3818
|
+
`;
|
|
3819
|
+
await (0, file_utils_1.writeFile)(path.join(filterPath, 'typeorm-query-builder.ts'), typeormBuilderContent);
|
|
3820
|
+
// Prisma Query Builder
|
|
3821
|
+
const prismaBuilderContent = `import { FilterCondition, QueryOptions, PaginatedResult } from "./filter.types";
|
|
3822
|
+
|
|
3823
|
+
/**
|
|
3824
|
+
* Parse filters to Prisma where conditions
|
|
3825
|
+
*/
|
|
3826
|
+
export function parseFiltersToPrisma(query: Record<string, any>): Record<string, any> {
|
|
3827
|
+
const where: Record<string, any> = {};
|
|
3828
|
+
|
|
3829
|
+
for (const [key, value] of Object.entries(query)) {
|
|
3830
|
+
if (value === undefined || value === null || value === "") continue;
|
|
3831
|
+
if (["page", "limit", "sortBy", "sortOrder", "search", "searchFields"].includes(key)) continue;
|
|
3832
|
+
|
|
3833
|
+
const parts = key.split("__");
|
|
3834
|
+
const field = parts[0];
|
|
3835
|
+
const operator = parts[1] || "eq";
|
|
3836
|
+
|
|
3837
|
+
where[field] = convertOperator(operator, value);
|
|
3838
|
+
}
|
|
3839
|
+
|
|
3840
|
+
return where;
|
|
3841
|
+
}
|
|
3842
|
+
|
|
3843
|
+
function convertOperator(op: string, value: any): any {
|
|
3844
|
+
switch (op) {
|
|
3845
|
+
case "eq": return value;
|
|
3846
|
+
case "ne": return { not: value };
|
|
3847
|
+
case "gt": return { gt: value };
|
|
3848
|
+
case "gte": return { gte: value };
|
|
3849
|
+
case "lt": return { lt: value };
|
|
3850
|
+
case "lte": return { lte: value };
|
|
3851
|
+
case "in": return { in: Array.isArray(value) ? value : value.split(",") };
|
|
3852
|
+
case "nin": return { notIn: Array.isArray(value) ? value : value.split(",") };
|
|
3853
|
+
case "like":
|
|
3854
|
+
case "contains": return { contains: value, mode: "insensitive" };
|
|
3855
|
+
case "startsWith": return { startsWith: value, mode: "insensitive" };
|
|
3856
|
+
case "endsWith": return { endsWith: value, mode: "insensitive" };
|
|
3857
|
+
case "isNull": return value === "true" ? null : { not: null };
|
|
3858
|
+
case "between":
|
|
3859
|
+
const [min, max] = Array.isArray(value) ? value : value.split(",");
|
|
3860
|
+
return { gte: min, lte: max };
|
|
3861
|
+
default: return value;
|
|
3862
|
+
}
|
|
3863
|
+
}
|
|
3864
|
+
|
|
3865
|
+
/**
|
|
3866
|
+
* Build search conditions for Prisma
|
|
3867
|
+
*/
|
|
3868
|
+
export function buildSearchCondition(search: string, fields: string[]): Record<string, any> {
|
|
3869
|
+
if (!search || fields.length === 0) return {};
|
|
3870
|
+
return { OR: fields.map((field) => ({ [field]: { contains: search, mode: "insensitive" } })) };
|
|
3871
|
+
}
|
|
3872
|
+
|
|
3873
|
+
/**
|
|
3874
|
+
* Execute filtered, paginated Prisma query
|
|
3875
|
+
*/
|
|
3876
|
+
export async function executePrismaQuery<T>(
|
|
3877
|
+
model: any,
|
|
3878
|
+
options: QueryOptions,
|
|
3879
|
+
defaultSearchFields: string[] = []
|
|
3880
|
+
): Promise<PaginatedResult<T>> {
|
|
3881
|
+
const { page = 1, limit = 10, sortBy = "created_at", sortOrder = "DESC" } = options;
|
|
3882
|
+
|
|
3883
|
+
// Build where
|
|
3884
|
+
const filterWhere = options.filters ? parseFiltersToPrisma(options.filters) : {};
|
|
3885
|
+
const searchWhere = options.search
|
|
3886
|
+
? buildSearchCondition(options.search, options.searchFields || defaultSearchFields)
|
|
3887
|
+
: {};
|
|
3888
|
+
|
|
3889
|
+
const where = {
|
|
3890
|
+
...filterWhere,
|
|
3891
|
+
...searchWhere,
|
|
3892
|
+
deleted_at: null,
|
|
3893
|
+
};
|
|
3894
|
+
|
|
3895
|
+
const [items, total] = await Promise.all([
|
|
3896
|
+
model.findMany({
|
|
3897
|
+
where,
|
|
3898
|
+
skip: (page - 1) * limit,
|
|
3899
|
+
take: limit,
|
|
3900
|
+
orderBy: { [sortBy]: sortOrder.toLowerCase() },
|
|
3901
|
+
}),
|
|
3902
|
+
model.count({ where }),
|
|
3903
|
+
]);
|
|
3904
|
+
|
|
3905
|
+
const totalPages = Math.ceil(total / limit);
|
|
3906
|
+
|
|
3907
|
+
return {
|
|
3908
|
+
items,
|
|
3909
|
+
meta: {
|
|
3910
|
+
total,
|
|
3911
|
+
page,
|
|
3912
|
+
limit,
|
|
3913
|
+
totalPages,
|
|
3914
|
+
hasNextPage: page < totalPages,
|
|
3915
|
+
hasPreviousPage: page > 1,
|
|
3916
|
+
},
|
|
3917
|
+
};
|
|
3918
|
+
}
|
|
3919
|
+
`;
|
|
3920
|
+
await (0, file_utils_1.writeFile)(path.join(filterPath, 'prisma-query-builder.ts'), prismaBuilderContent);
|
|
3921
|
+
// Index exports
|
|
3922
|
+
const indexContent = `export * from "./filter.types";
|
|
3923
|
+
export * from "./typeorm-query-builder";
|
|
3924
|
+
export * from "./prisma-query-builder";
|
|
3925
|
+
`;
|
|
3926
|
+
await (0, file_utils_1.writeFile)(path.join(filterPath, 'index.ts'), indexContent);
|
|
3927
|
+
console.log(chalk_1.default.green(' ✓ Filter types and interfaces'));
|
|
3928
|
+
console.log(chalk_1.default.green(' ✓ TypeORM query builder utilities'));
|
|
3929
|
+
console.log(chalk_1.default.green(' ✓ Prisma query builder utilities'));
|
|
3930
|
+
}
|
|
3931
|
+
function listRecipes() {
|
|
3932
|
+
console.log(chalk_1.default.blue('\\n📚 Available Recipes:\\n'));
|
|
3933
|
+
Object.entries(AVAILABLE_RECIPES).forEach(([key, value]) => {
|
|
3934
|
+
console.log(chalk_1.default.cyan(` ${key.padEnd(15)}`), '-', value.description);
|
|
3935
|
+
if (value.dependencies.length > 0) {
|
|
3936
|
+
console.log(chalk_1.default.gray(` Dependencies: ${value.dependencies.join(', ')}`));
|
|
3937
|
+
}
|
|
3938
|
+
});
|
|
3939
|
+
console.log(chalk_1.default.yellow('\nUsage: ddd recipe <recipe-name> [--install-deps]'));
|
|
3940
|
+
}
|
|
3941
|
+
//# sourceMappingURL=recipe.js.map
|