speedrun-cli 2.7.10 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Module Configurator for speedrun-cli
3
+ * Allows dynamic customization of Auth/Roles Guards and active CRUD endpoints on existing controllers.
4
+ * @module moduleConfigurator
5
+ */
6
+
7
+ const inquirer = require('inquirer');
8
+ const fs = require('fs-extra');
9
+ const path = require('path');
10
+ const chalk = require('chalk');
11
+ const {
12
+ toKebabCase,
13
+ toPascalCase,
14
+ toSingularPascal,
15
+ detectModulePrimaryKey,
16
+ } = require('./moduleGenerator');
17
+
18
+ /**
19
+ * Detects whether JwtAuthGuard or AuthGuard is used in the project
20
+ */
21
+ async function getGuardImportDetails(targetDir) {
22
+ const commonGuardsDir = path.join(targetDir, 'src', 'common', 'guards');
23
+ if (await fs.pathExists(path.join(commonGuardsDir, 'jwt-auth.guard.ts'))) {
24
+ return {
25
+ guardName: 'JwtAuthGuard',
26
+ guardImportPath: '../../common/guards/jwt-auth.guard',
27
+ };
28
+ }
29
+ if (await fs.pathExists(path.join(commonGuardsDir, 'auth.guard.ts'))) {
30
+ return {
31
+ guardName: 'AuthGuard',
32
+ guardImportPath: '../../common/guards/auth.guard',
33
+ };
34
+ }
35
+ return {
36
+ guardName: 'JwtAuthGuard',
37
+ guardImportPath: '../../common/guards/jwt-auth.guard',
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Parses existing controller file to extract active CRUD operations and roles configuration
43
+ */
44
+ function parseExistingController(controllerContent) {
45
+ const ops = {
46
+ create: /@Post\s*\(/m.test(controllerContent) && /override\s+async\s+create\s*\(/m.test(controllerContent),
47
+ findAll: /@Get\s*\(\s*\)/m.test(controllerContent) && /override\s+async\s+findAll\s*\(/m.test(controllerContent),
48
+ findOne: /@Get\s*\(\s*':/m.test(controllerContent) && /override\s+async\s+findOne\s*\(/m.test(controllerContent),
49
+ update: /@Put\s*\(/m.test(controllerContent) && /override\s+async\s+update\s*\(/m.test(controllerContent),
50
+ remove: /@Delete\s*\(/m.test(controllerContent) && /override\s+async\s+remove\s*\(/m.test(controllerContent),
51
+ };
52
+
53
+ const hasGuards = /@UseGuards\s*\(/m.test(controllerContent);
54
+
55
+ let roles = [];
56
+ const rolesMatch = controllerContent.match(/@Roles\s*\(\s*([^)]+)\s*\)/m) || controllerContent.match(/\/\/\s*Roles:\s*(.+)/m);
57
+ if (rolesMatch && rolesMatch[1]) {
58
+ roles = rolesMatch[1]
59
+ .split(',')
60
+ .map((r) => r.replace(/['"\s]/g, '').trim())
61
+ .filter(Boolean);
62
+ }
63
+
64
+ return { ops, hasGuards, roles };
65
+ }
66
+
67
+ /**
68
+ * Generates pristine controller TypeScript file content
69
+ */
70
+ function renderControllerContent({
71
+ pascalName,
72
+ kebabName,
73
+ primaryKey = 'id',
74
+ createDtoName,
75
+ updateDtoName,
76
+ responseDtoName,
77
+ ops = { create: true, findAll: true, findOne: true, update: true, remove: true },
78
+ protectWriteOps = false,
79
+ roles = ['ADMIN'],
80
+ guardName = 'JwtAuthGuard',
81
+ guardImportPath = '../../common/guards/jwt-auth.guard',
82
+ }) {
83
+ const formattedRoles = roles.map((r) => `'${r}'`).join(', ');
84
+ const guardDecorator = protectWriteOps && roles.length > 0
85
+ ? `\n @UseGuards(${guardName}, RolesGuard)\n @Roles(${formattedRoles})`
86
+ : protectWriteOps
87
+ ? `\n @UseGuards(${guardName})`
88
+ : '';
89
+
90
+ const extraGuardsImports = protectWriteOps
91
+ ? `import { ${guardName} } from '${guardImportPath}';\nimport { RolesGuard } from '../../common/guards/roles.guard';\nimport { Roles } from '../../common/decorators/roles.decorator';\n`
92
+ : '';
93
+
94
+ const nestCommonImports = ['Controller'];
95
+ if (ops.findAll) nestCommonImports.push('Query');
96
+ if (ops.findOne || ops.update || ops.remove) nestCommonImports.push('Param', 'ParseUUIDPipe', 'HttpStatus');
97
+ if (ops.create) nestCommonImports.push('Post', 'Body');
98
+ if (ops.findAll || ops.findOne) nestCommonImports.push('Get');
99
+ if (ops.update) nestCommonImports.push('Put');
100
+ if (ops.remove) nestCommonImports.push('Delete');
101
+ nestCommonImports.push('Type');
102
+ if (protectWriteOps) nestCommonImports.push('UseGuards');
103
+
104
+ return `import { ${Array.from(new Set(nestCommonImports)).join(', ')} } from '@nestjs/common';
105
+ import { ApiTags, ApiBearerAuth, ApiExtraModels, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
106
+ import { BaseController, ApiResponseDto, ApiResponseSchema, PaginatedResponseDto, PaginatedResponseSchema, PaginationQueryDto } from '../../common/base';
107
+ ${extraGuardsImports}import { ${pascalName}Service } from './${kebabName}.service';
108
+ ${ops.create || ops.update ? `import { ${createDtoName} } from './dto/create-${kebabName}.dto';\nimport { ${updateDtoName} } from './dto/update-${kebabName}.dto';` : `type ${createDtoName} = any;\ntype ${updateDtoName} = any;`}
109
+ import { ${responseDtoName} } from './dto/${kebabName}.dto';
110
+
111
+ type ${pascalName}Entity = any;
112
+
113
+ @ApiTags('${pascalName}')
114
+ @ApiBearerAuth('bearer')
115
+ @ApiExtraModels(ApiResponseDto, PaginatedResponseDto, ${responseDtoName})
116
+ @Controller('${kebabName}')
117
+ export class ${pascalName}Controller extends BaseController<${pascalName}Entity, ${createDtoName}, ${updateDtoName}> {
118
+ constructor(protected readonly service: ${pascalName}Service) {
119
+ super(service);
120
+ }
121
+
122
+ protected getDtoClass(): Type<${pascalName}Entity> {
123
+ return ${responseDtoName} as unknown as Type<${pascalName}Entity>;
124
+ }
125
+ ${ops.create ? `
126
+ @Post()${guardDecorator}
127
+ @ApiOperation({ summary: 'Create a new ${kebabName}' })
128
+ @ApiResponse({ status: HttpStatus.CREATED, schema: ApiResponseSchema(${responseDtoName}) })
129
+ override async create(@Body() dto: ${createDtoName}): Promise<ApiResponseDto<${pascalName}Entity>> {
130
+ return super.create(dto);
131
+ }
132
+ ` : ''}${ops.findAll ? `
133
+ @Get()
134
+ @ApiOperation({ summary: 'Get all ${kebabName} (paginated)' })
135
+ @ApiResponse({ status: HttpStatus.OK, schema: PaginatedResponseSchema(${responseDtoName}) })
136
+ override async findAll(@Query() pagination: PaginationQueryDto): Promise<PaginatedResponseDto<${pascalName}Entity>> {
137
+ return super.findAll(pagination);
138
+ }
139
+ ` : ''}${ops.findOne ? `
140
+ @Get(':${primaryKey}')
141
+ @ApiOperation({ summary: 'Get ${kebabName} by ID' })
142
+ @ApiParam({ name: '${primaryKey}', format: 'uuid' })
143
+ @ApiResponse({ status: HttpStatus.OK, schema: ApiResponseSchema(${responseDtoName}) })
144
+ @ApiResponse({ status: HttpStatus.NOT_FOUND, description: '${pascalName} not found' })
145
+ override async findOne(@Param('${primaryKey}', new ParseUUIDPipe({ version: '4', errorHttpStatusCode: HttpStatus.BAD_REQUEST })) ${primaryKey}: string): Promise<ApiResponseDto<${pascalName}Entity>> {
146
+ return super.findOne(${primaryKey});
147
+ }
148
+ ` : ''}${ops.update ? `
149
+ @Put(':${primaryKey}')${guardDecorator}
150
+ @ApiOperation({ summary: 'Update ${kebabName} by ID' })
151
+ @ApiParam({ name: '${primaryKey}', format: 'uuid' })
152
+ @ApiResponse({ status: HttpStatus.OK, schema: ApiResponseSchema(${responseDtoName}) })
153
+ @ApiResponse({ status: HttpStatus.NOT_FOUND, description: '${pascalName} not found' })
154
+ override async update(
155
+ @Param('${primaryKey}', new ParseUUIDPipe({ version: '4', errorHttpStatusCode: HttpStatus.BAD_REQUEST })) ${primaryKey}: string,
156
+ @Body() dto: ${updateDtoName}
157
+ ): Promise<ApiResponseDto<${pascalName}Entity>> {
158
+ return super.update(${primaryKey}, dto);
159
+ }
160
+ ` : ''}${ops.remove ? `
161
+ @Delete(':${primaryKey}')${guardDecorator}
162
+ @ApiOperation({ summary: 'Delete ${kebabName} by ID' })
163
+ @ApiParam({ name: '${primaryKey}', format: 'uuid' })
164
+ @ApiResponse({ status: HttpStatus.OK, schema: ApiResponseSchema(${responseDtoName}) })
165
+ @ApiResponse({ status: HttpStatus.NOT_FOUND, description: '${pascalName} not found' })
166
+ override async remove(@Param('${primaryKey}', new ParseUUIDPipe({ version: '4', errorHttpStatusCode: HttpStatus.BAD_REQUEST })) ${primaryKey}: string): Promise<ApiResponseDto<${pascalName}Entity>> {
167
+ return super.remove(${primaryKey});
168
+ }
169
+ ` : ''}
170
+ }
171
+ `;
172
+ }
173
+
174
+ /**
175
+ * Main Interactive Module Configurator Entry Point
176
+ */
177
+ async function configureModule(providedModuleName, targetDir = process.cwd()) {
178
+ try {
179
+ let moduleName = providedModuleName;
180
+
181
+ if (!moduleName) {
182
+ const nameAnswer = await inquirer.prompt([{
183
+ type: 'input',
184
+ name: 'moduleName',
185
+ message: 'Which module do you want to configure? (e.g., orders, products)',
186
+ validate: (input) => (input && input.trim() ? true : 'Module name is required'),
187
+ }]);
188
+ moduleName = nameAnswer.moduleName.trim();
189
+ }
190
+
191
+ const kebabName = toKebabCase(moduleName);
192
+ const pascalName = toPascalCase(moduleName);
193
+ const singularPascal = toSingularPascal(pascalName);
194
+
195
+ const srcDir = path.join(targetDir, 'src');
196
+ const moduleDir = (await fs.pathExists(srcDir))
197
+ ? path.join(srcDir, 'modules', kebabName)
198
+ : path.join(targetDir, 'modules', kebabName);
199
+ const controllerPath = path.join(moduleDir, `${kebabName}.controller.ts`);
200
+
201
+ if (!(await fs.pathExists(controllerPath))) {
202
+ console.error(chalk.red(`\n❌ Controller for module "${kebabName}" not found at ${controllerPath}`));
203
+ return false;
204
+ }
205
+
206
+ const controllerContent = await fs.readFile(controllerPath, 'utf8');
207
+ const { ops: currentOps, hasGuards, roles: currentRoles } = parseExistingController(controllerContent);
208
+ const primaryKey = await detectModulePrimaryKey(moduleDir, kebabName);
209
+ const { guardName, guardImportPath } = await getGuardImportDetails(targetDir);
210
+
211
+ const createDtoName = `Create${singularPascal}Dto`;
212
+ const updateDtoName = `Update${singularPascal}Dto`;
213
+ const responseDtoName = `${pascalName}Dto`;
214
+
215
+ console.log(chalk.cyan(`\n⚙️ Configuring module: ${chalk.bold(kebabName)}`));
216
+
217
+ const { configChoice } = await inquirer.prompt([{
218
+ type: 'list',
219
+ name: 'configChoice',
220
+ message: 'Select configuration action:',
221
+ choices: [
222
+ { name: '🔐 Manage Auth & Roles Guards (POST, PUT, DELETE protection)', value: 'guards' },
223
+ { name: '🛠️ Toggle Active CRUD Operations (Enable/Disable endpoints)', value: 'operations' },
224
+ { name: '❌ Cancel', value: 'cancel' },
225
+ ],
226
+ }]);
227
+
228
+ if (configChoice === 'cancel') {
229
+ console.log(chalk.gray('Cancelled configuration. No files modified.'));
230
+ return true;
231
+ }
232
+
233
+ let protectWriteOps = hasGuards;
234
+ let roles = currentRoles.length > 0 ? currentRoles : ['ADMIN'];
235
+ let updatedOps = { ...currentOps };
236
+
237
+ if (configChoice === 'guards') {
238
+ const guardAnswer = await inquirer.prompt([{
239
+ type: 'confirm',
240
+ name: 'protectWriteOps',
241
+ message: 'Protect write operations (POST, PUT, DELETE) with Auth/Roles Guard?',
242
+ default: hasGuards,
243
+ }]);
244
+
245
+ protectWriteOps = guardAnswer.protectWriteOps;
246
+
247
+ if (protectWriteOps) {
248
+ const roleAnswer = await inquirer.prompt([{
249
+ type: 'checkbox',
250
+ name: 'selectedRoles',
251
+ message: 'Select allowed roles for write operations:',
252
+ choices: [
253
+ { name: 'ADMIN', value: 'ADMIN', checked: roles.includes('ADMIN') || roles.length === 0 },
254
+ { name: 'USER', value: 'USER', checked: roles.includes('USER') },
255
+ { name: 'SUPERADMIN', value: 'SUPERADMIN', checked: roles.includes('SUPERADMIN') },
256
+ { name: 'MANAGER', value: 'MANAGER', checked: roles.includes('MANAGER') },
257
+ { name: 'Custom Role...', value: '__custom__' },
258
+ ],
259
+ }]);
260
+
261
+ let finalRoles = roleAnswer.selectedRoles.filter((r) => r !== '__custom__');
262
+
263
+ if (roleAnswer.selectedRoles.includes('__custom__')) {
264
+ const customRoleAnswer = await inquirer.prompt([{
265
+ type: 'input',
266
+ name: 'customRoles',
267
+ message: 'Enter custom role name(s) (comma-separated, e.g., AUDITOR, EDITOR):',
268
+ validate: (input) => (input && input.trim() ? true : 'Custom role is required'),
269
+ }]);
270
+
271
+ const parsedCustom = customRoleAnswer.customRoles
272
+ .split(',')
273
+ .map((r) => r.trim().toUpperCase())
274
+ .filter(Boolean);
275
+
276
+ finalRoles = Array.from(new Set([...finalRoles, ...parsedCustom]));
277
+ }
278
+
279
+ roles = finalRoles.length > 0 ? finalRoles : ['ADMIN'];
280
+ }
281
+ } else if (configChoice === 'operations') {
282
+ const opsAnswer = await inquirer.prompt([{
283
+ type: 'checkbox',
284
+ name: 'activeOps',
285
+ message: 'Select active CRUD operations:',
286
+ choices: [
287
+ { name: 'Create (POST)', value: 'create', checked: currentOps.create },
288
+ { name: 'Read All / findAll (GET)', value: 'findAll', checked: currentOps.findAll },
289
+ { name: 'Read One / findOne (GET /:id)', value: 'findOne', checked: currentOps.findOne },
290
+ { name: 'Update (PUT /:id)', value: 'update', checked: currentOps.update },
291
+ { name: 'Delete / remove (DELETE /:id)', value: 'remove', checked: currentOps.remove },
292
+ ],
293
+ }]);
294
+
295
+ updatedOps = {
296
+ create: opsAnswer.activeOps.includes('create'),
297
+ findAll: opsAnswer.activeOps.includes('findAll'),
298
+ findOne: opsAnswer.activeOps.includes('findOne'),
299
+ update: opsAnswer.activeOps.includes('update'),
300
+ remove: opsAnswer.activeOps.includes('remove'),
301
+ };
302
+ }
303
+
304
+ const updatedController = renderControllerContent({
305
+ pascalName,
306
+ kebabName,
307
+ primaryKey,
308
+ createDtoName,
309
+ updateDtoName,
310
+ responseDtoName,
311
+ ops: updatedOps,
312
+ protectWriteOps,
313
+ roles,
314
+ guardName,
315
+ guardImportPath,
316
+ });
317
+
318
+ await fs.writeFile(controllerPath, updatedController, 'utf8');
319
+ console.log(chalk.green(`\n✅ Successfully updated module configuration for "${kebabName}"!`));
320
+ console.log(chalk.gray(` Controller: ${path.relative(process.cwd(), controllerPath)}`));
321
+ if (protectWriteOps) {
322
+ console.log(chalk.gray(` Protected Write Ops: ${roles.join(', ')}`));
323
+ } else {
324
+ console.log(chalk.gray(` Protected Write Ops: Disabled`));
325
+ }
326
+ const activeOpsList = Object.keys(updatedOps).filter((op) => updatedOps[op]);
327
+ console.log(chalk.gray(` Active Operations: ${activeOpsList.join(', ')}`));
328
+
329
+ return true;
330
+ } catch (error) {
331
+ console.error(chalk.red('\n❌ Module configuration failed:'), error.message);
332
+ return false;
333
+ }
334
+ }
335
+
336
+ module.exports = { configureModule, renderControllerContent };
@@ -7,7 +7,7 @@ const inquirer = require('inquirer');
7
7
  const fs = require('fs-extra');
8
8
  const path = require('path');
9
9
  const chalk = require('chalk');
10
- require('./utils');
10
+ const { detectPackageManager, getRunPrefix } = require('./utils');
11
11
 
12
12
  function toPascalCase(str) {
13
13
  return str
@@ -503,15 +503,19 @@ function getFieldExampleValue(field, pascalName) {
503
503
 
504
504
  /**
505
505
  * Dynamic ORM Schema Synchronization: Prisma
506
+ * @param {boolean} forceUpdate - When true, replace existing model block instead of skipping
506
507
  */
507
- async function syncPrismaSchema(targetDir, singularPascal, kebabName, primaryKey, fields, relations, includeStatus = true) {
508
+ async function syncPrismaSchema(targetDir, singularPascal, kebabName, primaryKey, fields, relations, includeStatus = true, forceUpdate = false) {
508
509
  try {
509
510
  const schemaPath = path.join(targetDir, 'prisma', 'schema.prisma');
510
511
  if (!(await fs.pathExists(schemaPath))) return;
511
512
 
512
513
  let content = await fs.readFile(schemaPath, 'utf8');
513
514
 
514
- if (new RegExp(`\\bmodel\\s+${singularPascal}\\b`).test(content)) {
515
+ const modelRegex = new RegExp(`(\\bmodel\\s+${singularPascal}\\s*\\{[^}]*\\})`, 's');
516
+ const modelExists = modelRegex.test(content);
517
+
518
+ if (modelExists && !forceUpdate) {
515
519
  return;
516
520
  }
517
521
 
@@ -547,9 +551,15 @@ ${relLines.length > 0 ? relLines.join('\n') + '\n' : ''}${statusLine} createdAt
547
551
  }
548
552
  `;
549
553
 
550
- content += modelDefinition;
554
+ if (modelExists && forceUpdate) {
555
+ content = content.replace(modelRegex, modelDefinition.trim());
556
+ console.log(chalk.green(` ✓ Updated existing Prisma model ${singularPascal} in schema.prisma`));
557
+ } else {
558
+ content += modelDefinition;
559
+ console.log(chalk.green(` ✓ Added Prisma model ${singularPascal} to schema.prisma`));
560
+ }
561
+
551
562
  await fs.writeFile(schemaPath, content, 'utf8');
552
- console.log(chalk.green(` ✓ Updated prisma/schema.prisma with model ${singularPascal}`));
553
563
  } catch (error) {
554
564
  console.warn(chalk.yellow(` ⚠️ Could not sync prisma/schema.prisma: ${error.message}`));
555
565
  }
@@ -835,7 +845,7 @@ class TypeOrm${pascalName}Repository implements IBaseRepository<${pascalName}Ent
835
845
  }` : `async findOne(${primaryKey}: string): Promise<${pascalName}Entity | null> { throw new Error('Not implemented'); }`}
836
846
 
837
847
  ${ops.update ? `async update(${primaryKey}: string, dto: ${updateDtoName}): Promise<${pascalName}Entity> {
838
- await this.repo.update(${primaryKey}, dto as any);
848
+ await this.repo.update({ ${primaryKey} } as any, dto as any);
839
849
  return this.repo.findOneOrFail({ where: { ${primaryKey} } as any });
840
850
  }` : `async update(${primaryKey}: string, dto: ${updateDtoName}): Promise<${pascalName}Entity> { throw new Error('Not implemented'); }`}
841
851
 
@@ -1206,17 +1216,27 @@ async function generateModule(providedModuleName, targetDir = process.cwd(), spe
1206
1216
  return ` ${swaggerDecorator}\n ${valDecorators.join('\n ')}\n ${f.name}${f.isOptional ? '?' : ''}: ${details.tsType};`;
1207
1217
  }).join('\n\n');
1208
1218
 
1219
+ // Build FK fields for Create DTO from relations (e.g., userId, categoryId)
1220
+ const relationFkFields = (options.relations || []).map((r) => {
1221
+ return ` @ApiPropertyOptional({ description: 'Foreign key linking to ${r.targetModule}', example: '123e4567-e89b-12d3-a456-426614174000', format: 'uuid' })\n @IsOptional()\n @IsUUID()\n ${r.fkField}?: string;`;
1222
+ }).join('\n\n');
1223
+
1209
1224
  const hasDateFields = options.fields.some((f) => ['DateTime', 'timestamp', 'Date'].includes(f.type));
1225
+ const hasRelations = (options.relations || []).length > 0;
1210
1226
 
1211
- // Validator import gathering
1227
+ // Validator import gathering — only collect 'Is*' decorators (class-validator)
1212
1228
  const allValDecorators = new Set(['IsOptional', 'IsNotEmpty']);
1213
1229
  options.fields.forEach((f) => {
1214
1230
  const details = getFieldDetails(f.type);
1215
1231
  details.valDecorators.forEach((dec) => {
1216
1232
  const name = dec.replace('@', '').replace(/\(.*\)/, '');
1217
- if (name) allValDecorators.add(name);
1233
+ // Only include class-validator decorators (Is* prefix), not Type from class-transformer
1234
+ if (name && name.startsWith('Is')) allValDecorators.add(name);
1218
1235
  });
1219
1236
  });
1237
+ if (hasRelations) allValDecorators.add('IsUUID');
1238
+
1239
+ const createDtoBody = [createFieldsText, relationFkFields].filter(Boolean).join('\n\n');
1220
1240
 
1221
1241
  if (ops.create || ops.update) {
1222
1242
  await fs.writeFile(
@@ -1225,7 +1245,7 @@ async function generateModule(providedModuleName, targetDir = process.cwd(), spe
1225
1245
  import { ${Array.from(allValDecorators).join(', ')} } from 'class-validator';
1226
1246
  ${hasDateFields ? `import { Type } from 'class-transformer';\n` : ''}
1227
1247
  export class ${createDtoName} {
1228
- ${createFieldsText}
1248
+ ${createDtoBody}
1229
1249
  }
1230
1250
  `
1231
1251
  );
@@ -1252,6 +1272,11 @@ export class ${updateDtoName} extends PartialType(${createDtoName}) {}
1252
1272
  return ` ${swaggerDecorator}\n ${f.name}${f.isOptional ? '?' : ''}: ${details.tsType};`;
1253
1273
  }).join('\n\n');
1254
1274
 
1275
+ // FK relation fields in response DTO (always optional — may be null if not populated)
1276
+ const responseFkFields = (options.relations || []).map((r) => {
1277
+ return ` @ApiPropertyOptional({ example: '123e4567-e89b-12d3-a456-426614174000', format: 'uuid' })\n ${r.fkField}?: string;`;
1278
+ }).join('\n\n');
1279
+
1255
1280
  const statusDtoField = options.includeStatus !== false
1256
1281
  ? ` @ApiProperty({ example: 'ACTIVE' })\n status: string;\n\n`
1257
1282
  : '';
@@ -1264,7 +1289,9 @@ export class ${responseDtoName} {
1264
1289
  @ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000', format: 'uuid' })
1265
1290
  ${primaryKey}: string;
1266
1291
 
1267
- ${responseFieldsText}
1292
+ ${responseFieldsText}${
1293
+ responseFkFields ? `\n\n${responseFkFields}` : ''
1294
+ }
1268
1295
 
1269
1296
  ${statusDtoField} @ApiProperty({ example: '2025-01-01T00:00:00.000Z' })
1270
1297
  createdAt: Date;
@@ -1293,79 +1320,18 @@ ${statusDtoField} @ApiProperty({ example: '2025-01-01T00:00:00.000Z' })
1293
1320
  await fs.writeFile(path.join(moduleDir, `${kebabName}.service.ts`), serviceContent);
1294
1321
 
1295
1322
  // 4. Generate Controller
1296
- const guardImports = options.protectWriteOps ? `, UseGuards` : '';
1297
- const guardDecorator = options.protectWriteOps && options.roles?.length > 0
1298
- ? `\n @UseGuards()\n // Roles: ${options.roles.join(', ')}`
1299
- : '';
1300
-
1301
- const controllerContent = `import { Controller${ops.findAll ? ', Query' : ''}${ops.findOne || ops.update || ops.remove ? ', Param, ParseUUIDPipe, HttpStatus' : ''}${ops.create ? ', Post, Body' : ''}${ops.findAll || ops.findOne ? ', Get' : ''}${ops.update ? ', Put' : ''}${ops.remove ? ', Delete' : ''}, Type${guardImports} } from '@nestjs/common';
1302
- import { ApiTags, ApiBearerAuth, ApiExtraModels, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
1303
- import { BaseController, ApiResponseDto, ApiResponseSchema, PaginatedResponseDto, PaginatedResponseSchema, PaginationQueryDto } from '../../common/base';
1304
- import { ${pascalName}Service } from './${kebabName}.service';
1305
- ${ops.create || ops.update ? `import { ${createDtoName} } from './dto/create-${kebabName}.dto';\nimport { ${updateDtoName} } from './dto/update-${kebabName}.dto';` : `type ${createDtoName} = any;\ntype ${updateDtoName} = any;`}
1306
- import { ${responseDtoName} } from './dto/${kebabName}.dto';
1307
-
1308
- type ${pascalName}Entity = any;
1309
-
1310
- @ApiTags('${pascalName}')
1311
- @ApiBearerAuth('bearer')
1312
- @ApiExtraModels(ApiResponseDto, PaginatedResponseDto, ${responseDtoName})
1313
- @Controller('${kebabName}')
1314
- export class ${pascalName}Controller extends BaseController<${pascalName}Entity, ${createDtoName}, ${updateDtoName}> {
1315
- constructor(protected readonly service: ${pascalName}Service) {
1316
- super(service);
1317
- }
1318
-
1319
- protected getDtoClass(): Type<${pascalName}Entity> {
1320
- return ${responseDtoName} as unknown as Type<${pascalName}Entity>;
1321
- }
1322
- ${ops.create ? `
1323
- @Post()${guardDecorator}
1324
- @ApiOperation({ summary: 'Create a new ${kebabName}' })
1325
- @ApiResponse({ status: HttpStatus.CREATED, schema: ApiResponseSchema(${responseDtoName}) })
1326
- override async create(@Body() dto: ${createDtoName}): Promise<ApiResponseDto<${pascalName}Entity>> {
1327
- return super.create(dto);
1328
- }
1329
- ` : ''}${ops.findAll ? `
1330
- @Get()
1331
- @ApiOperation({ summary: 'Get all ${kebabName} (paginated)' })
1332
- @ApiResponse({ status: HttpStatus.OK, schema: PaginatedResponseSchema(${responseDtoName}) })
1333
- override async findAll(@Query() pagination: PaginationQueryDto): Promise<PaginatedResponseDto<${pascalName}Entity>> {
1334
- return super.findAll(pagination);
1335
- }
1336
- ` : ''}${ops.findOne ? `
1337
- @Get(':${primaryKey}')
1338
- @ApiOperation({ summary: 'Get ${kebabName} by ID' })
1339
- @ApiParam({ name: '${primaryKey}', format: 'uuid' })
1340
- @ApiResponse({ status: HttpStatus.OK, schema: ApiResponseSchema(${responseDtoName}) })
1341
- @ApiResponse({ status: HttpStatus.NOT_FOUND, description: '${pascalName} not found' })
1342
- override async findOne(@Param('${primaryKey}', new ParseUUIDPipe({ version: '4', errorHttpStatusCode: HttpStatus.BAD_REQUEST })) ${primaryKey}: string): Promise<ApiResponseDto<${pascalName}Entity>> {
1343
- return super.findOne(${primaryKey});
1344
- }
1345
- ` : ''}${ops.update ? `
1346
- @Put(':${primaryKey}')${guardDecorator}
1347
- @ApiOperation({ summary: 'Update ${kebabName} by ID' })
1348
- @ApiParam({ name: '${primaryKey}', format: 'uuid' })
1349
- @ApiResponse({ status: HttpStatus.OK, schema: ApiResponseSchema(${responseDtoName}) })
1350
- @ApiResponse({ status: HttpStatus.NOT_FOUND, description: '${pascalName} not found' })
1351
- override async update(
1352
- @Param('${primaryKey}', new ParseUUIDPipe({ version: '4', errorHttpStatusCode: HttpStatus.BAD_REQUEST })) ${primaryKey}: string,
1353
- @Body() dto: ${updateDtoName}
1354
- ): Promise<ApiResponseDto<${pascalName}Entity>> {
1355
- return super.update(${primaryKey}, dto);
1356
- }
1357
- ` : ''}${ops.remove ? `
1358
- @Delete(':${primaryKey}')${guardDecorator}
1359
- @ApiOperation({ summary: 'Delete ${kebabName} by ID' })
1360
- @ApiParam({ name: '${primaryKey}', format: 'uuid' })
1361
- @ApiResponse({ status: HttpStatus.OK, schema: ApiResponseSchema(${responseDtoName}) })
1362
- @ApiResponse({ status: HttpStatus.NOT_FOUND, description: '${pascalName} not found' })
1363
- override async remove(@Param('${primaryKey}', new ParseUUIDPipe({ version: '4', errorHttpStatusCode: HttpStatus.BAD_REQUEST })) ${primaryKey}: string): Promise<ApiResponseDto<${pascalName}Entity>> {
1364
- return super.remove(${primaryKey});
1365
- }
1366
- ` : ''}
1367
- }
1368
- `;
1323
+ const { renderControllerContent } = require('./moduleConfigurator');
1324
+ const controllerContent = renderControllerContent({
1325
+ pascalName,
1326
+ kebabName,
1327
+ primaryKey,
1328
+ createDtoName,
1329
+ updateDtoName,
1330
+ responseDtoName,
1331
+ ops,
1332
+ protectWriteOps: options.protectWriteOps,
1333
+ roles: options.roles || ['ADMIN'],
1334
+ });
1369
1335
  await fs.writeFile(path.join(moduleDir, `${kebabName}.controller.ts`), controllerContent);
1370
1336
 
1371
1337
  // 5. Generate Module
@@ -1403,6 +1369,30 @@ export class AppModule {}
1403
1369
  }
1404
1370
  }
1405
1371
 
1372
+ /**
1373
+ * Detect the primary key used by a module by scanning its response DTO file.
1374
+ * Falls back to 'id' if the file is missing or no PK can be found.
1375
+ */
1376
+ async function detectModulePrimaryKey(moduleDir, kebabName) {
1377
+ try {
1378
+ const dtoPath = path.join(moduleDir, 'dto', `${kebabName}.dto.ts`);
1379
+ if (!(await fs.pathExists(dtoPath))) return 'id';
1380
+
1381
+ const content = await fs.readFile(dtoPath, 'utf8');
1382
+ // The PK is the first property after 'export class Xxx {'
1383
+ // It is decorated with @ApiProperty and has format: 'uuid'
1384
+ const pkMatch = content.match(/@ApiProperty\([^)]*format:\s*['"](uuid)['"][^)]*\)[\s\S]*?\n\s*(\w+)\s*:/m);
1385
+ if (pkMatch && pkMatch[2]) return pkMatch[2];
1386
+
1387
+ // Fallback: look for *_id or *Id as the first bare property
1388
+ const firstPropMatch = content.match(/^\s*([a-zA-Z][a-zA-Z0-9_]*)\s*:/m);
1389
+ if (firstPropMatch && firstPropMatch[1] !== 'status') return firstPropMatch[1];
1390
+ } catch {
1391
+ // swallow
1392
+ }
1393
+ return 'id';
1394
+ }
1395
+
1406
1396
  /**
1407
1397
  * Regenerate module DTOs and ORM schemas when fields are updated via fieldManager
1408
1398
  */
@@ -1411,7 +1401,6 @@ async function regenerateModuleComponents(moduleName, fields, targetDir = proces
1411
1401
  const kebabName = toKebabCase(moduleName);
1412
1402
  const pascalName = toPascalCase(moduleName);
1413
1403
  const singularPascal = toSingularPascal(pascalName);
1414
- const primaryKey = 'id';
1415
1404
 
1416
1405
  const srcDir = path.join(targetDir, 'src');
1417
1406
  const moduleDir = (await fs.pathExists(srcDir))
@@ -1422,9 +1411,12 @@ async function regenerateModuleComponents(moduleName, fields, targetDir = proces
1422
1411
  await fs.ensureDir(moduleDir);
1423
1412
  await fs.ensureDir(dtoDir);
1424
1413
 
1425
- // 1. Sync ORM Schema
1414
+ // Detect the existing primary key from the module's response DTO
1415
+ const primaryKey = await detectModulePrimaryKey(moduleDir, kebabName);
1416
+
1417
+ // 1. Sync ORM Schema (forceUpdate=true so existing definitions are replaced)
1426
1418
  if (orm === 'prisma') {
1427
- await syncPrismaSchema(targetDir, singularPascal, kebabName, primaryKey, fields, [], true);
1419
+ await syncPrismaSchema(targetDir, singularPascal, kebabName, primaryKey, fields, [], true, true);
1428
1420
  } else if (orm === 'typeorm') {
1429
1421
  await syncTypeOrmSchema(moduleDir, singularPascal, kebabName, primaryKey, fields, [], true);
1430
1422
  } else if (orm === 'mongoose') {
@@ -1464,7 +1456,8 @@ async function regenerateModuleComponents(moduleName, fields, targetDir = proces
1464
1456
  const details = getFieldDetails(f.type);
1465
1457
  details.valDecorators.forEach((dec) => {
1466
1458
  const name = dec.replace('@', '').replace(/\(.*\)/, '');
1467
- if (name) allValDecorators.add(name);
1459
+ // Only class-validator Is* decorators — Type comes from class-transformer, not here
1460
+ if (name && name.startsWith('Is')) allValDecorators.add(name);
1468
1461
  });
1469
1462
  });
1470
1463
 
@@ -1506,7 +1499,7 @@ export class ${updateDtoName} extends PartialType(${createDtoName}) {}
1506
1499
 
1507
1500
  export class ${responseDtoName} {
1508
1501
  @ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000', format: 'uuid' })
1509
- id: string;
1502
+ ${primaryKey}: string;
1510
1503
 
1511
1504
  ${responseFieldsText}
1512
1505
 
@@ -1530,6 +1523,9 @@ module.exports = {
1530
1523
  generateModule,
1531
1524
  promptForModuleOptions,
1532
1525
  detectOrm,
1526
+ detectModulePrimaryKey,
1527
+ getOrmFieldChoices,
1528
+ toKebabCase,
1533
1529
  registerInAppModule,
1534
1530
  ensureBaseArchitecture,
1535
1531
  regenerateModuleComponents,