speedrun-cli 2.7.8 → 2.7.9

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/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ All notable changes to create-nestjs-auth will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.8.0] - 2026-08-22
9
+
10
+ ### Added
11
+ - **Auto-Scaffold Missing Base Architecture (`ensureBaseArchitecture`)** — When generating modules via `speedrun-cli g [module]`, `src/common/base` is automatically scaffolded if missing from target project, resolving TS2307 & TS4112 compilation errors.
12
+ - **Custom Primary Key Selection** — Prompt to choose primary key format (`id`, `<singular_snake>_id`, `<singular_camel>Id`, or `Custom...`), applied dynamically across ORM schemas, DTOs, response decorators, and `@Param()` controller annotations.
13
+ - **ORM-Native Field Types** — Customized field type choices in the interactive builder according to detected ORM:
14
+ - **Prisma:** `[String, Int, Float, Decimal, Boolean, DateTime, Json]`
15
+ - **TypeORM:** `[varchar, text, int, float, decimal, boolean, timestamp, json]`
16
+ - **Mongoose:** `[String, Number, Boolean, Date, Array, Object]`
17
+ - **Drizzle:** `[varchar, text, integer, numeric, boolean, timestamp, json]`
18
+ - **Specific Field Editing Sub-menu** — Interactive sub-menu allows modifying specific field properties (Field Name, Field Type, Optional Status, or All) instead of forcing full re-entry.
19
+ - **Advanced Relationship Builder** — Interactively add relations (`Many-to-One`, `One-to-Many`) pointing to target modules and foreign keys, automatically injecting attributes into Prisma, TypeORM, Mongoose, and Drizzle schemas.
20
+ - **Auth & Roles Guard Protection Prompt** — Optional step to protect write operations (`POST`, `PUT`, `DELETE`) with `@UseGuards()` and role-based decorators (`ADMIN`, `USER`, `MANAGER`).
21
+
22
+ ### Changed
23
+ - **Standardized Import Statements** — Controller & Service templates now fetch `BaseController`, `BaseService`, `IBaseRepository`, `ApiResponseDto`, `ApiResponseSchema`, `PaginatedResponseDto`, `PaginatedResponseSchema`, `PaginationQueryDto` cleanly from the single barrel export `../../common/base`.
24
+
25
+ ---
26
+
8
27
  ## [2.7.1] - 2026-08-22
9
28
 
10
29
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "speedrun-cli",
3
- "version": "2.7.8",
3
+ "version": "2.7.9",
4
4
  "description": "CLI tool to scaffold a production-ready NestJS authentication system with JWT, refresh tokens, and RBAC",
5
5
  "keywords": [
6
6
  "nestjs",
@@ -87,77 +87,72 @@ async function ensureBaseArchitecture(targetDir) {
87
87
  const commonBaseDir = path.join(targetDir, 'src', 'common', 'base');
88
88
 
89
89
  if (!(await fs.pathExists(commonBaseDir))) {
90
- await fs.ensureDir(commonBaseDir);
91
-
92
- // 1. base.controller.ts
93
- await fs.writeFile(
94
- path.join(commonBaseDir, 'base.controller.ts'),
95
- `import { Type } from '@nestjs/common';
96
-
97
- export abstract class BaseController<T, CreateDto, UpdateDto> {
98
- constructor(protected readonly service: any) {}
99
- protected abstract getDtoClass(): Type<T>;
100
- async create(dto: CreateDto): Promise<any> { return this.service.create(dto); }
101
- async findAll(query: any): Promise<any> { return this.service.findAll(query); }
102
- async findOne(id: string): Promise<any> { return this.service.findOne(id); }
103
- async update(id: string, dto: UpdateDto): Promise<any> { return this.service.update(id, dto); }
104
- async remove(id: string): Promise<any> { return this.service.remove(id); }
105
- }
106
- `
107
- );
108
-
109
- // 2. base.service.ts
110
- await fs.writeFile(
111
- path.join(commonBaseDir, 'base.service.ts'),
112
- `import { Injectable } from '@nestjs/common';
113
-
114
- export interface PaginationQueryDto { page?: number; limit?: number; }
115
- export interface IBaseRepository<T, CreateDto, UpdateDto> {
116
- create(dto: CreateDto): Promise<T>;
117
- findAll(pagination: PaginationQueryDto): Promise<{ data: T[]; total: number }>;
118
- findOne(id: string): Promise<T | null>;
119
- update(id: string, dto: UpdateDto): Promise<T>;
120
- remove(id: string): Promise<T>;
90
+ const templateBaseDir = path.join(__dirname, '..', 'templates', 'base-crud', 'src', 'common', 'base');
91
+ if (await fs.pathExists(templateBaseDir)) {
92
+ await fs.copy(templateBaseDir, commonBaseDir);
93
+ console.log(chalk.green(' ✓ Scaffolded Base CRUD architecture at src/common/base'));
94
+ }
95
+ }
96
+ } catch (error) {
97
+ console.warn(chalk.yellow(` ⚠️ Could not scaffold Base CRUD architecture: ${error.message}`));
98
+ }
121
99
  }
122
100
 
123
- @Injectable()
124
- export abstract class BaseService<T, CreateDto, UpdateDto> {
125
- protected abstract getRepository(): IBaseRepository<T, CreateDto, UpdateDto>;
126
- async create(dto: CreateDto): Promise<T> { return this.getRepository().create(dto); }
127
- async findAll(pagination: PaginationQueryDto): Promise<{ data: T[]; total: number }> { return this.getRepository().findAll(pagination); }
128
- async findOne(id: string): Promise<T | null> { return this.getRepository().findOne(id); }
129
- async update(id: string, dto: UpdateDto): Promise<T> { return this.getRepository().update(id, dto); }
130
- async remove(id: string): Promise<T> { return this.getRepository().remove(id); }
101
+ /**
102
+ * Returns field type choices based on ORM
103
+ */
104
+ function getOrmFieldChoices(orm) {
105
+ switch (orm) {
106
+ case 'typeorm':
107
+ return ['varchar', 'text', 'int', 'float', 'decimal', 'boolean', 'timestamp', 'json'];
108
+ case 'mongoose':
109
+ return ['String', 'Number', 'Boolean', 'Date', 'Array', 'Object'];
110
+ case 'drizzle':
111
+ return ['varchar', 'text', 'integer', 'numeric', 'boolean', 'timestamp', 'json'];
112
+ case 'prisma':
113
+ default:
114
+ return ['String', 'Int', 'Float', 'Decimal', 'Boolean', 'DateTime', 'Json'];
115
+ }
131
116
  }
132
- `
133
- );
134
117
 
135
- // 3. index.ts (Re-exports & DTO helpers)
136
- await fs.writeFile(
137
- path.join(commonBaseDir, 'index.ts'),
138
- `export * from './base.controller';
139
- export * from './base.service';
140
-
141
- export class ApiResponseDto<T> { statusCode: number; message: string; data: T; }
142
- export class PaginatedResponseDto<T> { statusCode: number; message: string; data: T[]; total: number; page: number; limit: number; }
143
- export class PaginationQueryDto { page?: number; limit?: number; }
144
-
145
- export function ApiResponseSchema(dto: any): any { return {}; }
146
- export function PaginatedResponseSchema(dto: any): any { return {}; }
147
- `
148
- );
118
+ /**
119
+ * Helper to map field type to TypeScript type, validator decorators, and sample example value
120
+ */
121
+ function getFieldDetails(fieldType) {
122
+ const ft = fieldType;
149
123
 
150
- console.log(chalk.green(' Auto-generated missing src/common/base architecture'));
151
- }
152
- } catch (error) {
153
- console.warn(chalk.yellow(` ⚠️ Could not verify/create base architecture: ${error.message}`));
124
+ if (['String', 'varchar', 'text'].includes(ft)) {
125
+ return { tsType: 'string', valDecorators: ['@IsString()'], ex: 'Sample value' };
154
126
  }
127
+ if (['Int', 'int', 'integer'].includes(ft)) {
128
+ return { tsType: 'number', valDecorators: ['@IsInt()'], ex: 10 };
129
+ }
130
+ if (['Float', 'Decimal', 'float', 'decimal', 'numeric', 'Number'].includes(ft)) {
131
+ return { tsType: 'number', valDecorators: ['@IsNumber()'], ex: 99.99 };
132
+ }
133
+ if (['Boolean', 'boolean'].includes(ft)) {
134
+ return { tsType: 'boolean', valDecorators: ['@IsBoolean()'], ex: true };
135
+ }
136
+ if (['DateTime', 'timestamp', 'Date'].includes(ft)) {
137
+ return {
138
+ tsType: 'Date',
139
+ valDecorators: ['@IsDate()', '@Type(() => Date)'],
140
+ ex: '2025-01-01T00:00:00.000Z',
141
+ };
142
+ }
143
+ if (['Json', 'json', 'Object'].includes(ft)) {
144
+ return { tsType: 'object', valDecorators: ['@IsObject()'], ex: { key: 'value' } };
145
+ }
146
+ if (ft === 'Array') {
147
+ return { tsType: 'string[]', valDecorators: ['@IsArray()'], ex: ['item1', 'item2'] };
148
+ }
149
+ return { tsType: 'string', valDecorators: ['@IsString()'], ex: 'Sample value' };
155
150
  }
156
151
 
157
152
  /**
158
- * Interactive prompt for module options (Name, CRUD Mode, Fields)
153
+ * Interactive prompt for module options (Name, CRUD Mode, PK, Fields, Relations, Auth Guards)
159
154
  */
160
- async function promptForModuleOptions(providedModuleName) {
155
+ async function promptForModuleOptions(providedModuleName, detectedOrm = 'prisma') {
161
156
  let moduleName = providedModuleName;
162
157
 
163
158
  if (!moduleName) {
@@ -170,6 +165,13 @@ async function promptForModuleOptions(providedModuleName) {
170
165
  moduleName = nameAnswer.moduleName.trim();
171
166
  }
172
167
 
168
+ const kebabName = toKebabCase(moduleName);
169
+ const pascalName = toPascalCase(moduleName);
170
+ const singularPascal = toSingularPascal(pascalName);
171
+ const singularSnake = toSnakeCase(singularPascal);
172
+ const singularCamel = toSingularCamel(kebabName);
173
+
174
+ // CRUD mode selection
173
175
  const { crudMode } = await inquirer.prompt([{
174
176
  type: 'list',
175
177
  name: 'crudMode',
@@ -182,7 +184,6 @@ async function promptForModuleOptions(providedModuleName) {
182
184
  }]);
183
185
 
184
186
  let selectedOperations = ['create', 'findAll', 'findOne', 'update', 'remove'];
185
-
186
187
  if (crudMode === 'custom') {
187
188
  const { customOps } = await inquirer.prompt([{
188
189
  type: 'checkbox',
@@ -199,8 +200,36 @@ async function promptForModuleOptions(providedModuleName) {
199
200
  selectedOperations = customOps;
200
201
  }
201
202
 
202
- // Interactive Field Builder Loop
203
- // Interactive Field Builder Loop
203
+ // 1. Primary Key Selection
204
+ const { pkChoice } = await inquirer.prompt([{
205
+ type: 'list',
206
+ name: 'pkChoice',
207
+ message: `Select primary key format for '${moduleName}':`,
208
+ choices: [
209
+ { name: 'id (Default UUID)', value: 'id' },
210
+ { name: `${singularSnake}_id (e.g., ${singularSnake}_id)`, value: `${singularSnake}_id` },
211
+ { name: `${singularCamel}Id (e.g., ${singularCamel}Id)`, value: `${singularCamel}Id` },
212
+ { name: 'Custom Primary Key Name...', value: 'custom' },
213
+ ],
214
+ default: 'id',
215
+ }]);
216
+
217
+ let primaryKey = pkChoice;
218
+ if (pkChoice === 'custom') {
219
+ const { customPk } = await inquirer.prompt([{
220
+ type: 'input',
221
+ name: 'customPk',
222
+ message: 'Enter custom primary key name:',
223
+ default: 'id',
224
+ validate: (input) => (input && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(input.trim()) ? true : 'Invalid identifier for primary key'),
225
+ }]);
226
+ primaryKey = customPk.trim();
227
+ }
228
+
229
+ // 2. Interactive Field Builder Loop
230
+ const fields = [];
231
+ const ormTypeChoices = getOrmFieldChoices(detectedOrm);
232
+
204
233
  const { addCustomFields } = await inquirer.prompt([{
205
234
  type: 'confirm',
206
235
  name: 'addCustomFields',
@@ -208,211 +237,298 @@ async function promptForModuleOptions(providedModuleName) {
208
237
  default: true,
209
238
  }]);
210
239
 
211
- const fields = [];
212
-
213
240
  if (addCustomFields) {
214
- let building = true;
241
+ let managingFields = true;
242
+ while (managingFields) {
243
+ if (fields.length > 0) {
244
+ console.log(chalk.cyan(`\n📋 Current fields for '${moduleName}':`));
245
+ fields.forEach((f, idx) => {
246
+ console.log(chalk.gray(` ${idx + 1}. ${f.name}: ${f.type} (${f.isOptional ? 'Optional' : 'Required'})`));
247
+ });
248
+ console.log('');
249
+
250
+ const { fieldAction } = await inquirer.prompt([{
251
+ type: 'list',
252
+ name: 'fieldAction',
253
+ message: 'Choose an action:',
254
+ choices: [
255
+ { name: '➕ Add another field', value: 'add' },
256
+ { name: '✏️ Edit an existing field', value: 'edit' },
257
+ { name: '🗑️ Delete a field', value: 'delete' },
258
+ { name: '✅ Finish defining fields', value: 'finish' },
259
+ ],
260
+ }]);
215
261
 
216
- // Helper untuk input field baru / edit
217
- const promptSingleField = async (initialValues = {}) => {
218
- return await inquirer.prompt([
262
+ if (fieldAction === 'finish') {
263
+ managingFields = false;
264
+ break;
265
+ }
266
+
267
+ if (fieldAction === 'delete') {
268
+ const { fieldToDelete } = await inquirer.prompt([{
269
+ type: 'list',
270
+ name: 'fieldToDelete',
271
+ message: 'Select field to delete:',
272
+ choices: fields.map((f, i) => ({ name: `${f.name} (${f.type})`, value: i })),
273
+ }]);
274
+ const removed = fields.splice(fieldToDelete, 1);
275
+ console.log(chalk.yellow(` Removed field '${removed[0].name}'`));
276
+ continue;
277
+ }
278
+
279
+ if (fieldAction === 'edit') {
280
+ const { fieldToEditIndex } = await inquirer.prompt([{
281
+ type: 'list',
282
+ name: 'fieldToEditIndex',
283
+ message: 'Select field to edit:',
284
+ choices: fields.map((f, i) => ({ name: `${f.name}: ${f.type} (${f.isOptional ? 'Optional' : 'Required'})`, value: i })),
285
+ }]);
286
+
287
+ const targetField = fields[fieldToEditIndex];
288
+
289
+ const { editPropChoice } = await inquirer.prompt([{
290
+ type: 'list',
291
+ name: 'editPropChoice',
292
+ message: `Select property to edit for '${targetField.name}':`,
293
+ choices: [
294
+ { name: '1. Change Field Name', value: 'name' },
295
+ { name: '2. Change Field Type', value: 'type' },
296
+ { name: '3. Change Optional Status', value: 'optional' },
297
+ { name: '4. Edit All Properties', value: 'all' },
298
+ ],
299
+ }]);
300
+
301
+ if (editPropChoice === 'name' || editPropChoice === 'all') {
302
+ const { newName } = await inquirer.prompt([{
303
+ type: 'input',
304
+ name: 'newName',
305
+ message: 'Enter field name:',
306
+ default: targetField.name,
307
+ validate: (input) => (input && /^[a-zA-Z][a-zA-Z0-9_]*$/.test(input.trim()) ? true : 'Field name must be a valid identifier'),
308
+ }]);
309
+ targetField.name = newName.trim();
310
+ }
311
+
312
+ if (editPropChoice === 'type' || editPropChoice === 'all') {
313
+ const { newType } = await inquirer.prompt([{
314
+ type: 'list',
315
+ name: 'newType',
316
+ message: `Select field type for '${targetField.name}':`,
317
+ choices: ormTypeChoices,
318
+ default: targetField.type,
319
+ }]);
320
+ targetField.type = newType;
321
+ }
322
+
323
+ if (editPropChoice === 'optional' || editPropChoice === 'all') {
324
+ const { newOpt } = await inquirer.prompt([{
325
+ type: 'confirm',
326
+ name: 'newOpt',
327
+ message: `Is '${targetField.name}' optional?`,
328
+ default: targetField.isOptional,
329
+ }]);
330
+ targetField.isOptional = newOpt;
331
+ }
332
+
333
+ console.log(chalk.green(` ✓ Updated field '${targetField.name}'`));
334
+ continue;
335
+ }
336
+ }
337
+
338
+ // Add field path
339
+ const fieldAnswers = await inquirer.prompt([
219
340
  {
220
341
  type: 'input',
221
342
  name: 'fieldName',
222
343
  message: 'Enter field name (e.g., totalAmount, title):',
223
- default: initialValues.name,
224
344
  validate: (input) => {
225
345
  if (!input || !input.trim()) return 'Field name is required';
226
346
  if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(input.trim())) {
227
347
  return 'Field name must be a valid identifier (e.g., totalAmount)';
228
348
  }
349
+ if (input.trim() === primaryKey) {
350
+ return `Primary key '${primaryKey}' is already defined. Please choose another field name.`;
351
+ }
229
352
  return true;
230
353
  },
231
354
  },
232
355
  {
233
356
  type: 'list',
234
357
  name: 'fieldType',
235
- message: (answers) => `Select field type for '${answers.fieldName}':`,
236
- choices: ['String', 'Number', 'Boolean', 'Date'],
237
- default: initialValues.type || 'String',
358
+ message: (ans) => `Select field type for '${ans.fieldName}':`,
359
+ choices: ormTypeChoices,
360
+ default: ormTypeChoices[0],
238
361
  },
239
362
  {
240
363
  type: 'confirm',
241
364
  name: 'isOptional',
242
- message: (answers) => `Is '${answers.fieldName}' optional?`,
243
- default: initialValues.isOptional !== undefined ? initialValues.isOptional : false,
365
+ message: (ans) => `Is '${ans.fieldName}' optional?`,
366
+ default: false,
244
367
  },
245
368
  ]);
246
- };
247
369
 
248
- // Tambah field pertama
249
- console.log(chalk.cyan('\n--- Add Field 1 ---'));
250
- const firstField = await promptSingleField();
251
- fields.push({
252
- name: firstField.fieldName.trim(),
253
- type: firstField.fieldType,
254
- isOptional: firstField.isOptional,
255
- });
256
-
257
- // Menu Navigasi (Add, Edit, Delete, Finish)
258
- while (building) {
259
- console.log(chalk.gray(`\nCurrent Fields (${fields.length}): `) + fields.map(f => chalk.yellow(`${f.name} (${f.type}${f.isOptional ? '?' : ''})`)).join(', '));
260
-
261
- const { action } = await inquirer.prompt([{
262
- type: 'list',
263
- name: 'action',
264
- message: 'What do you want to do next?',
265
- choices: [
266
- { name: '➕ Add another field', value: 'add' },
267
- { name: '✏️ Edit an existing field', value: 'edit' },
268
- { name: '🗑️ Delete a field', value: 'delete' },
269
- { name: '✅ Finish and generate module', value: 'done' },
270
- ],
271
- }]);
370
+ fields.push({
371
+ name: fieldAnswers.fieldName.trim(),
372
+ type: fieldAnswers.fieldType,
373
+ isOptional: fieldAnswers.isOptional,
374
+ });
272
375
 
273
- if (action === 'add') {
274
- console.log(chalk.cyan(`\n--- Add Field ${fields.length + 1} ---`));
275
- const newField = await promptSingleField();
276
- fields.push({
277
- name: newField.fieldName.trim(),
278
- type: newField.fieldType,
279
- isOptional: newField.isOptional,
280
- });
281
- } else if (action === 'edit') {
282
- if (fields.length === 0) {
283
- console.log(chalk.yellow('⚠️ No fields available to edit.'));
284
- continue;
376
+ if (fields.length === 1) {
377
+ const { continueLoop } = await inquirer.prompt([{
378
+ type: 'confirm',
379
+ name: 'continueLoop',
380
+ message: 'Do you want to add another field?',
381
+ default: false,
382
+ }]);
383
+ if (!continueLoop) {
384
+ managingFields = false;
285
385
  }
386
+ }
387
+ }
388
+ }
286
389
 
287
- const { fieldToEditIndex } = await inquirer.prompt([{
288
- type: 'list',
289
- name: 'fieldToEditIndex',
290
- message: 'Select field to edit:',
291
- choices: fields.map((f, index) => ({
292
- name: `${f.name} (${f.type}${f.isOptional ? '?' : ''})`,
293
- value: index,
294
- })),
295
- }]);
390
+ // Fallback if no custom fields added
391
+ if (fields.length === 0) {
392
+ fields.push({ name: 'name', type: ormTypeChoices[0] || 'String', isOptional: false });
393
+ }
296
394
 
297
- console.log(chalk.cyan(`\n--- Editing Field '${fields[fieldToEditIndex].name}' ---`));
298
- const editedField = await promptSingleField(fields[fieldToEditIndex]);
299
- fields[fieldToEditIndex] = {
300
- name: editedField.fieldName.trim(),
301
- type: editedField.fieldType,
302
- isOptional: editedField.isOptional,
303
- };
304
- console.log(chalk.green(`✓ Field '${fields[fieldToEditIndex].name}' updated successfully.`));
305
- } else if (action === 'delete') {
306
- if (fields.length === 0) {
307
- console.log(chalk.yellow('⚠️ No fields available to delete.'));
308
- continue;
309
- }
395
+ // 3. Relationships Prompt
396
+ const relations = [];
397
+ const { addRelation } = await inquirer.prompt([{
398
+ type: 'confirm',
399
+ name: 'addRelation',
400
+ message: 'Do you want to add a relation to another module?',
401
+ default: false,
402
+ }]);
310
403
 
311
- const { fieldToDeleteIndex } = await inquirer.prompt([{
404
+ if (addRelation) {
405
+ let addingRel = true;
406
+ while (addingRel) {
407
+ const relAnswers = await inquirer.prompt([
408
+ {
312
409
  type: 'list',
313
- name: 'fieldToDeleteIndex',
314
- message: 'Select field to delete:',
315
- choices: fields.map((f, index) => ({
316
- name: `${f.name} (${f.type}${f.isOptional ? '?' : ''})`,
317
- value: index,
318
- })),
319
- }]);
410
+ name: 'relType',
411
+ message: 'Select relation type:',
412
+ choices: [
413
+ { name: 'Many-to-One (e.g. Order belongs to User)', value: 'Many-to-One' },
414
+ { name: 'One-to-Many', value: 'One-to-Many' },
415
+ ],
416
+ },
417
+ {
418
+ type: 'input',
419
+ name: 'targetModule',
420
+ message: 'Target module name (e.g., users, categories):',
421
+ validate: (input) => (input && input.trim() ? true : 'Target module name is required'),
422
+ },
423
+ {
424
+ type: 'input',
425
+ name: 'fkField',
426
+ message: (ans) => `Foreign key field name (e.g., ${toSingularCamel(ans.targetModule)}Id):`,
427
+ default: (ans) => `${toSingularCamel(ans.targetModule)}Id`,
428
+ },
429
+ ]);
320
430
 
321
- const deletedName = fields[fieldToDeleteIndex].name;
322
- fields.splice(fieldToDeleteIndex, 1);
323
- console.log(chalk.red(`🗑️ Field '${deletedName}' removed.`));
324
- } else if (action === 'done') {
325
- building = false;
326
- }
431
+ relations.push({
432
+ type: relAnswers.relType,
433
+ targetModule: relAnswers.targetModule.trim(),
434
+ fkField: relAnswers.fkField.trim(),
435
+ });
436
+
437
+ const { continueRel } = await inquirer.prompt([{
438
+ type: 'confirm',
439
+ name: 'continueRel',
440
+ message: 'Do you want to add another relation?',
441
+ default: false,
442
+ }]);
443
+ addingRel = continueRel;
327
444
  }
328
445
  }
329
446
 
330
- // Fallback if no custom fields added
331
- if (fields.length === 0) {
332
- fields.push({ name: 'name', type: 'String', isOptional: false });
447
+ // 4. Role & Auth Guard Protection Prompt
448
+ const { protectWriteOps } = await inquirer.prompt([{
449
+ type: 'confirm',
450
+ name: 'protectWriteOps',
451
+ message: 'Protect write operations (POST, PUT, DELETE) with Auth/Roles Guard?',
452
+ default: true,
453
+ }]);
454
+
455
+ let selectedRoles = [];
456
+ if (protectWriteOps) {
457
+ const { roles } = await inquirer.prompt([{
458
+ type: 'checkbox',
459
+ name: 'roles',
460
+ message: 'Select allowed roles:',
461
+ choices: [
462
+ { name: 'ADMIN', value: 'ADMIN', checked: true },
463
+ { name: 'USER', value: 'USER' },
464
+ { name: 'MANAGER', value: 'MANAGER' },
465
+ ],
466
+ }]);
467
+ selectedRoles = roles.length > 0 ? roles : ['ADMIN'];
333
468
  }
469
+
470
+ return {
471
+ moduleName,
472
+ operations: selectedOperations,
473
+ primaryKey,
474
+ fields,
475
+ relations,
476
+ protectWriteOps,
477
+ roles: selectedRoles,
478
+ };
334
479
  }
335
480
 
336
481
  function getFieldExampleValue(field, pascalName) {
482
+ const details = getFieldDetails(field.type);
337
483
  const name = field.name.toLowerCase();
338
- if (field.type === 'String') {
484
+
485
+ if (details.tsType === 'string') {
339
486
  if (name.includes('email')) return 'user@example.com';
340
487
  if (name.includes('phone')) return '+1234567890';
341
488
  if (name.includes('url')) return 'https://example.com';
342
489
  if (name.includes('sku') || name.includes('code')) return 'SKU-1001';
343
490
  return `Sample ${field.name}`;
344
491
  }
345
- if (field.type === 'Number') {
346
- if (name.includes('price') || name.includes('amount') || name.includes('total') || name.includes('cost')) {
347
- return 99.99;
348
- }
349
- return 10;
350
- }
351
- if (field.type === 'Boolean') return true;
352
- if (field.type === 'Date') return '2025-01-01T00:00:00.000Z';
353
- return 'Example value';
354
- }
355
-
356
- function getTsType(fieldType) {
357
- switch (fieldType) {
358
- case 'String':
359
- return 'string';
360
- case 'Number':
361
- return 'number';
362
- case 'Boolean':
363
- return 'boolean';
364
- case 'Date':
365
- return 'Date';
366
- default:
367
- return 'string';
368
- }
492
+ return details.ex;
369
493
  }
370
494
 
371
495
  /**
372
496
  * Dynamic ORM Schema Synchronization: Prisma
373
497
  */
374
- async function syncPrismaSchema(targetDir, singularPascal, kebabName, fields) {
498
+ async function syncPrismaSchema(targetDir, singularPascal, kebabName, primaryKey, fields, relations) {
375
499
  try {
376
500
  const schemaPath = path.join(targetDir, 'prisma', 'schema.prisma');
377
501
  if (!(await fs.pathExists(schemaPath))) return;
378
502
 
379
503
  let content = await fs.readFile(schemaPath, 'utf8');
380
504
 
381
- // Avoid duplicate model definition
382
505
  if (new RegExp(`\\bmodel\\s+${singularPascal}\\b`).test(content)) {
383
506
  return;
384
507
  }
385
508
 
386
509
  const fieldLines = fields.map((f) => {
387
510
  let pType = 'String';
388
- if (f.type === 'Number') {
389
- const nameLower = f.name.toLowerCase();
390
- if (
391
- nameLower.includes('price') ||
392
- nameLower.includes('amount') ||
393
- nameLower.includes('total') ||
394
- nameLower.includes('cost') ||
395
- nameLower.includes('fee') ||
396
- nameLower.includes('rate') ||
397
- nameLower.includes('score')
398
- ) {
399
- pType = 'Float';
400
- } else {
401
- pType = 'Int';
402
- }
403
- } else if (f.type === 'Boolean') {
404
- pType = 'Boolean';
405
- } else if (f.type === 'Date') {
406
- pType = 'DateTime';
407
- }
511
+ if (['Int', 'int', 'integer'].includes(f.type)) pType = 'Int';
512
+ else if (['Float', 'float'].includes(f.type)) pType = 'Float';
513
+ else if (['Decimal', 'decimal', 'numeric'].includes(f.type)) pType = 'Decimal';
514
+ else if (['Boolean', 'boolean'].includes(f.type)) pType = 'Boolean';
515
+ else if (['DateTime', 'timestamp', 'Date'].includes(f.type)) pType = 'DateTime';
516
+ else if (['Json', 'json', 'Object'].includes(f.type)) pType = 'Json';
517
+
408
518
  return ` ${f.name} ${pType}${f.isOptional ? '?' : ''}`;
409
519
  });
410
520
 
521
+ const relLines = relations.map((r) => {
522
+ const targetPascal = toSingularPascal(toPascalCase(r.targetModule));
523
+ const targetCamel = toSingularCamel(r.targetModule);
524
+ return ` ${targetCamel} ${targetPascal}? @relation(fields: [${r.fkField}], references: [id])\n ${r.fkField} String?`;
525
+ });
526
+
411
527
  const modelDefinition = `
412
528
  model ${singularPascal} {
413
- id String @id @default(uuid())
529
+ ${primaryKey} String @id @default(uuid())
414
530
  ${fieldLines.join('\n')}
415
- status String @default("ACTIVE")
531
+ ${relLines.length > 0 ? relLines.join('\n') + '\n' : ''} status String @default("ACTIVE")
416
532
  createdAt DateTime @default(now())
417
533
  updatedAt DateTime @updatedAt
418
534
  deletedAt DateTime?
@@ -423,49 +539,59 @@ ${fieldLines.join('\n')}
423
539
 
424
540
  content += modelDefinition;
425
541
  await fs.writeFile(schemaPath, content, 'utf8');
426
- console.log(chalk.green(` ✓ Updated prisma/schema.prisma with model ${singularPascal}`));
542
+ console.log(chalk.green(` ✓ Updated prisma/schema.prisma with model ${singularPascal}`));
427
543
  } catch (error) {
428
- console.warn(chalk.yellow(` ⚠️ Could not sync prisma/schema.prisma: ${error.message}`));
544
+ console.warn(chalk.yellow(` ⚠️ Could not sync prisma/schema.prisma: ${error.message}`));
429
545
  }
430
546
  }
431
547
 
432
548
  /**
433
549
  * Dynamic ORM Schema Synchronization: TypeORM
434
550
  */
435
- async function syncTypeOrmSchema(moduleDir, singularPascal, kebabName, fields) {
551
+ async function syncTypeOrmSchema(moduleDir, singularPascal, kebabName, primaryKey, fields, relations) {
436
552
  try {
437
553
  const entityDir = path.join(moduleDir, 'entities');
438
554
  await fs.ensureDir(entityDir);
439
555
  const entityPath = path.join(entityDir, `${toSingularKebab(kebabName)}.entity.ts`);
440
556
 
441
557
  const fieldLines = fields.map((f) => {
558
+ const details = getFieldDetails(f.type);
442
559
  let colDecorator = `@Column({ nullable: ${f.isOptional} })`;
443
- let tsType = 'string';
444
560
 
445
- if (f.type === 'Number') {
561
+ if (['decimal', 'numeric', 'Decimal'].includes(f.type)) {
446
562
  colDecorator = `@Column('decimal', { precision: 10, scale: 2, nullable: ${f.isOptional} })`;
447
- tsType = 'number';
448
- } else if (f.type === 'Boolean') {
563
+ } else if (['boolean', 'Boolean'].includes(f.type)) {
449
564
  colDecorator = `@Column({ default: false, nullable: ${f.isOptional} })`;
450
- tsType = 'boolean';
451
- } else if (f.type === 'Date') {
565
+ } else if (['timestamp', 'DateTime', 'Date'].includes(f.type)) {
452
566
  colDecorator = `@Column({ type: 'timestamp', nullable: ${f.isOptional} })`;
453
- tsType = 'Date';
567
+ } else if (['json', 'Json', 'Object'].includes(f.type)) {
568
+ colDecorator = `@Column({ type: 'json', nullable: ${f.isOptional} })`;
569
+ } else if (['text'].includes(f.type)) {
570
+ colDecorator = `@Column({ type: 'text', nullable: ${f.isOptional} })`;
454
571
  }
455
572
 
456
- return ` ${colDecorator}\n ${f.name}${f.isOptional ? '?' : ''}: ${tsType};`;
573
+ return ` ${colDecorator}\n ${f.name}${f.isOptional ? '?' : ''}: ${details.tsType};`;
574
+ });
575
+
576
+ const relLines = relations.map((r) => {
577
+ const targetPascal = toSingularPascal(toPascalCase(r.targetModule));
578
+ const targetCamel = toSingularCamel(r.targetModule);
579
+ return ` @ManyToOne(() => ${targetPascal}, { nullable: true })\n @JoinColumn({ name: '${r.fkField}' })\n ${targetCamel}?: any;\n\n @Column({ nullable: true })\n ${r.fkField}?: string;`;
457
580
  });
458
581
 
459
- const entityContent = `import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn } from 'typeorm';
582
+ const hasRelations = relations.length > 0;
583
+ const imports = [`Entity`, `PrimaryGeneratedColumn`, `Column`, `CreateDateColumn`, `UpdateDateColumn`, `DeleteDateColumn`].concat(hasRelations ? [`ManyToOne`, `JoinColumn`] : []);
584
+
585
+ const entityContent = `import { ${imports.join(', ')} } from 'typeorm';
460
586
 
461
587
  @Entity('${toSnakeCase(kebabName)}')
462
588
  export class ${singularPascal} {
463
589
  @PrimaryGeneratedColumn('uuid')
464
- id: string;
590
+ ${primaryKey}: string;
465
591
 
466
592
  ${fieldLines.join('\n\n')}
467
593
 
468
- @Column({ default: 'ACTIVE' })
594
+ ${relLines.length > 0 ? relLines.join('\n\n') + '\n\n' : ''} @Column({ default: 'ACTIVE' })
469
595
  status: string;
470
596
 
471
597
  @CreateDateColumn()
@@ -480,49 +606,57 @@ ${fieldLines.join('\n\n')}
480
606
  `;
481
607
 
482
608
  await fs.writeFile(entityPath, entityContent, 'utf8');
483
- console.log(chalk.green(` ✓ Generated TypeORM entity at src/modules/${kebabName}/entities/${toSingularKebab(kebabName)}.entity.ts`));
609
+ console.log(chalk.green(` ✓ Generated TypeORM entity at src/modules/${kebabName}/entities/${toSingularKebab(kebabName)}.entity.ts`));
484
610
  } catch (error) {
485
- console.warn(chalk.yellow(` ⚠️ Could not generate TypeORM entity: ${error.message}`));
611
+ console.warn(chalk.yellow(` ⚠️ Could not generate TypeORM entity: ${error.message}`));
486
612
  }
487
613
  }
488
614
 
489
615
  /**
490
616
  * Dynamic ORM Schema Synchronization: Mongoose
491
617
  */
492
- async function syncMongooseSchema(moduleDir, singularPascal, kebabName, fields) {
618
+ async function syncMongooseSchema(moduleDir, singularPascal, kebabName, primaryKey, fields, relations) {
493
619
  try {
494
620
  const schemaDir = path.join(moduleDir, 'schemas');
495
621
  await fs.ensureDir(schemaDir);
496
622
  const schemaPath = path.join(schemaDir, `${toSingularKebab(kebabName)}.schema.ts`);
497
623
 
498
624
  const fieldLines = fields.map((f) => {
625
+ const details = getFieldDetails(f.type);
499
626
  let propDecorator = `@Prop({ required: ${!f.isOptional} })`;
500
- let tsType = 'string';
501
627
 
502
- if (f.type === 'Number') {
503
- propDecorator = `@Prop({ required: ${!f.isOptional} })`;
504
- tsType = 'number';
505
- } else if (f.type === 'Boolean') {
628
+ if (['boolean', 'Boolean'].includes(f.type)) {
506
629
  propDecorator = `@Prop({ default: false })`;
507
- tsType = 'boolean';
508
- } else if (f.type === 'Date') {
630
+ } else if (['timestamp', 'DateTime', 'Date'].includes(f.type)) {
509
631
  propDecorator = `@Prop({ type: Date, required: ${!f.isOptional} })`;
510
- tsType = 'Date';
632
+ } else if (['json', 'Json', 'Object'].includes(f.type)) {
633
+ propDecorator = `@Prop({ type: Object, required: ${!f.isOptional} })`;
634
+ } else if (f.type === 'Array') {
635
+ propDecorator = `@Prop({ type: [String], default: [] })`;
511
636
  }
512
637
 
513
- return ` ${propDecorator}\n ${f.name}${f.isOptional ? '?' : ''}: ${tsType};`;
638
+ return ` ${propDecorator}\n ${f.name}${f.isOptional ? '?' : ''}: ${details.tsType};`;
514
639
  });
515
640
 
641
+ const relLines = relations.map((r) => {
642
+ const targetPascal = toSingularPascal(toPascalCase(r.targetModule));
643
+ return ` @Prop({ type: SchemaTypes.ObjectId, ref: '${targetPascal}', default: null })\n ${r.fkField}?: string;`;
644
+ });
645
+
646
+ const pkLine = primaryKey !== 'id'
647
+ ? ` @Prop({ default: () => new Types.ObjectId().toString() })\n ${primaryKey}: string;\n\n`
648
+ : '';
649
+
516
650
  const schemaContent = `import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
517
- import { HydratedDocument } from 'mongoose';
651
+ import { HydratedDocument, SchemaTypes, Types } from 'mongoose';
518
652
 
519
653
  export type ${singularPascal}Document = HydratedDocument<${singularPascal}>;
520
654
 
521
655
  @Schema({ timestamps: true })
522
656
  export class ${singularPascal} {
523
- ${fieldLines.join('\n\n')}
657
+ ${pkLine}${fieldLines.join('\n\n')}
524
658
 
525
- @Prop({ default: 'ACTIVE' })
659
+ ${relLines.length > 0 ? relLines.join('\n\n') + '\n\n' : ''} @Prop({ default: 'ACTIVE' })
526
660
  status: string;
527
661
 
528
662
  @Prop({ type: Date, default: null })
@@ -533,16 +667,16 @@ export const ${singularPascal}Schema = SchemaFactory.createForClass(${singularPa
533
667
  `;
534
668
 
535
669
  await fs.writeFile(schemaPath, schemaContent, 'utf8');
536
- console.log(chalk.green(` ✓ Generated Mongoose schema at src/modules/${kebabName}/schemas/${toSingularKebab(kebabName)}.schema.ts`));
670
+ console.log(chalk.green(` ✓ Generated Mongoose schema at src/modules/${kebabName}/schemas/${toSingularKebab(kebabName)}.schema.ts`));
537
671
  } catch (error) {
538
- console.warn(chalk.yellow(` ⚠️ Could not generate Mongoose schema: ${error.message}`));
672
+ console.warn(chalk.yellow(` ⚠️ Could not generate Mongoose schema: ${error.message}`));
539
673
  }
540
674
  }
541
675
 
542
676
  /**
543
677
  * Dynamic ORM Schema Synchronization: Drizzle
544
678
  */
545
- async function syncDrizzleSchema(moduleDir, singularPascal, kebabName, fields) {
679
+ async function syncDrizzleSchema(moduleDir, singularPascal, kebabName, primaryKey, fields, relations) {
546
680
  try {
547
681
  const schemaDir = path.join(moduleDir, 'schema');
548
682
  await fs.ensureDir(schemaDir);
@@ -550,12 +684,18 @@ async function syncDrizzleSchema(moduleDir, singularPascal, kebabName, fields) {
550
684
 
551
685
  const fieldLines = fields.map((f) => {
552
686
  let colDef = `varchar('${toSnakeCase(f.name)}', { length: 255 })`;
553
- if (f.type === 'Number') {
687
+ if (['int', 'integer', 'Int'].includes(f.type)) {
688
+ colDef = `integer('${toSnakeCase(f.name)}')`;
689
+ } else if (['float', 'decimal', 'numeric', 'Float', 'Decimal', 'Number'].includes(f.type)) {
554
690
  colDef = `numeric('${toSnakeCase(f.name)}')`;
555
- } else if (f.type === 'Boolean') {
691
+ } else if (['boolean', 'Boolean'].includes(f.type)) {
556
692
  colDef = `boolean('${toSnakeCase(f.name)}').default(false)`;
557
- } else if (f.type === 'Date') {
693
+ } else if (['timestamp', 'DateTime', 'Date'].includes(f.type)) {
558
694
  colDef = `timestamp('${toSnakeCase(f.name)}')`;
695
+ } else if (['json', 'Json', 'Object'].includes(f.type)) {
696
+ colDef = `json('${toSnakeCase(f.name)}')`;
697
+ } else if (f.type === 'text') {
698
+ colDef = `text('${toSnakeCase(f.name)}')`;
559
699
  }
560
700
 
561
701
  if (!f.isOptional) {
@@ -565,12 +705,16 @@ async function syncDrizzleSchema(moduleDir, singularPascal, kebabName, fields) {
565
705
  return ` ${f.name}: ${colDef},`;
566
706
  });
567
707
 
568
- const schemaContent = `import { pgTable, varchar, numeric, boolean, timestamp } from 'drizzle-orm/pg-core';
708
+ const relLines = relations.map((r) => {
709
+ return ` ${r.fkField}: varchar('${toSnakeCase(r.fkField)}', { length: 36 }),`;
710
+ });
711
+
712
+ const schemaContent = `import { pgTable, varchar, text, integer, numeric, boolean, timestamp, json } from 'drizzle-orm/pg-core';
569
713
 
570
714
  export const ${toCamelCase(kebabName)}s = pgTable('${toSnakeCase(kebabName)}', {
571
- id: varchar('id', { length: 36 }).primaryKey().$defaultFn(() => crypto.randomUUID()),
715
+ ${primaryKey}: varchar('${toSnakeCase(primaryKey)}', { length: 36 }).primaryKey().$defaultFn(() => crypto.randomUUID()),
572
716
  ${fieldLines.join('\n')}
573
- status: varchar('status', { length: 50 }).default('ACTIVE').notNull(),
717
+ ${relLines.length > 0 ? relLines.join('\n') + '\n' : ''} status: varchar('status', { length: 50 }).default('ACTIVE').notNull(),
574
718
  createdAt: timestamp('created_at').defaultNow().notNull(),
575
719
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
576
720
  deletedAt: timestamp('deleted_at'),
@@ -581,21 +725,21 @@ export type New${singularPascal} = typeof ${toCamelCase(kebabName)}s.$inferInser
581
725
  `;
582
726
 
583
727
  await fs.writeFile(schemaPath, schemaContent, 'utf8');
584
- console.log(chalk.green(` ✓ Generated Drizzle schema at src/modules/${kebabName}/schema/${kebabName}.schema.ts`));
728
+ console.log(chalk.green(` ✓ Generated Drizzle schema at src/modules/${kebabName}/schema/${kebabName}.schema.ts`));
585
729
  } catch (error) {
586
- console.warn(chalk.yellow(` ⚠️ Could not generate Drizzle schema: ${error.message}`));
730
+ console.warn(chalk.yellow(` ⚠️ Could not generate Drizzle schema: ${error.message}`));
587
731
  }
588
732
  }
589
733
 
590
734
  /**
591
735
  * Auto-Generate Starter Seed File Template
592
736
  */
593
- async function generateSeedFileTemplate(targetDir, orm, singularPascal, kebabName, fields) {
737
+ async function generateSeedFileTemplate(targetDir, orm, singularPascal, kebabName, primaryKey, fields) {
594
738
  try {
595
739
  const singularCamel = toSingularCamel(kebabName);
596
740
  const dummyObjFields = fields.map((f) => {
597
741
  const ex = getFieldExampleValue(f, singularPascal);
598
- const valStr = typeof ex === 'string' ? `'${ex}'` : ex;
742
+ const valStr = typeof ex === 'string' ? `'${ex}'` : JSON.stringify(ex);
599
743
  return ` ${f.name}: ${valStr},`;
600
744
  }).join('\n');
601
745
 
@@ -617,7 +761,7 @@ ${dummyObjFields}
617
761
  }
618
762
  `;
619
763
  await fs.writeFile(seedPath, seedContent, 'utf8');
620
- console.log(chalk.green(` ✓ Generated Prisma seed template at prisma/seeds/${kebabName}.seed.ts`));
764
+ console.log(chalk.green(` ✓ Generated Prisma seed template at prisma/seeds/${kebabName}.seed.ts`));
621
765
  } else {
622
766
  const seedsDir = path.join(targetDir, 'src', 'database', 'seeds');
623
767
  await fs.ensureDir(seedsDir);
@@ -641,14 +785,14 @@ ${dummyObjFields}
641
785
  }
642
786
  `;
643
787
  await fs.writeFile(seedPath, seedContent, 'utf8');
644
- console.log(chalk.green(` ✓ Generated seed template at src/database/seeds/${kebabName}.seed.ts`));
788
+ console.log(chalk.green(` ✓ Generated seed template at src/database/seeds/${kebabName}.seed.ts`));
645
789
  }
646
790
  } catch (error) {
647
- console.warn(chalk.yellow(` ⚠️ Could not generate seed template: ${error.message}`));
791
+ console.warn(chalk.yellow(` ⚠️ Could not generate seed template: ${error.message}`));
648
792
  }
649
793
  }
650
794
 
651
- function getServiceContent(orm, pascalName, singularPascal, camelName, kebabName, createDtoName, updateDtoName, ops) {
795
+ function getServiceContent(orm, pascalName, singularPascal, camelName, kebabName, primaryKey, createDtoName, updateDtoName, ops) {
652
796
  const singularKebab = toSingularKebab(kebabName);
653
797
 
654
798
  if (orm === 'typeorm') {
@@ -676,20 +820,20 @@ class TypeOrm${pascalName}Repository implements IBaseRepository<${pascalName}Ent
676
820
  return { data, total };
677
821
  }` : `async findAll(p: PaginationQueryDto): Promise<{ data: ${pascalName}Entity[]; total: number }> { throw new Error('Not implemented'); }`}
678
822
 
679
- ${ops.findOne ? `async findOne(id: string): Promise<${pascalName}Entity | null> {
680
- return this.repo.findOne({ where: { id, deletedAt: IsNull() } as any });
681
- }` : `async findOne(id: string): Promise<${pascalName}Entity | null> { throw new Error('Not implemented'); }`}
823
+ ${ops.findOne ? `async findOne(${primaryKey}: string): Promise<${pascalName}Entity | null> {
824
+ return this.repo.findOne({ where: { ${primaryKey}, deletedAt: IsNull() } as any });
825
+ }` : `async findOne(${primaryKey}: string): Promise<${pascalName}Entity | null> { throw new Error('Not implemented'); }`}
682
826
 
683
- ${ops.update ? `async update(id: string, dto: ${updateDtoName}): Promise<${pascalName}Entity> {
684
- await this.repo.update(id, dto as any);
685
- return this.repo.findOneOrFail({ where: { id } as any });
686
- }` : `async update(id: string, dto: ${updateDtoName}): Promise<${pascalName}Entity> { throw new Error('Not implemented'); }`}
827
+ ${ops.update ? `async update(${primaryKey}: string, dto: ${updateDtoName}): Promise<${pascalName}Entity> {
828
+ await this.repo.update(${primaryKey}, dto as any);
829
+ return this.repo.findOneOrFail({ where: { ${primaryKey} } as any });
830
+ }` : `async update(${primaryKey}: string, dto: ${updateDtoName}): Promise<${pascalName}Entity> { throw new Error('Not implemented'); }`}
687
831
 
688
- ${ops.remove ? `async remove(id: string): Promise<${pascalName}Entity> {
689
- const entity = await this.repo.findOneOrFail({ where: { id } as any });
832
+ ${ops.remove ? `async remove(${primaryKey}: string): Promise<${pascalName}Entity> {
833
+ const entity = await this.repo.findOneOrFail({ where: { ${primaryKey} } as any });
690
834
  entity.deletedAt = new Date();
691
835
  return this.repo.save(entity);
692
- }` : `async remove(id: string): Promise<${pascalName}Entity> { throw new Error('Not implemented'); }`}
836
+ }` : `async remove(${primaryKey}: string): Promise<${pascalName}Entity> { throw new Error('Not implemented'); }`}
693
837
  }
694
838
 
695
839
  @Injectable()
@@ -734,17 +878,17 @@ class Mongoose${pascalName}Repository implements IBaseRepository<${pascalName}En
734
878
  return { data, total };
735
879
  }` : `async findAll(p: PaginationQueryDto): Promise<{ data: ${pascalName}Entity[]; total: number }> { throw new Error('Not implemented'); }`}
736
880
 
737
- ${ops.findOne ? `async findOne(id: string): Promise<${pascalName}Entity | null> {
738
- return this.model.findOne({ _id: id, deletedAt: null }).exec();
739
- }` : `async findOne(id: string): Promise<${pascalName}Entity | null> { throw new Error('Not implemented'); }`}
881
+ ${ops.findOne ? `async findOne(${primaryKey}: string): Promise<${pascalName}Entity | null> {
882
+ return this.model.findOne({ ${primaryKey === 'id' ? '_id' : primaryKey}: ${primaryKey}, deletedAt: null }).exec();
883
+ }` : `async findOne(${primaryKey}: string): Promise<${pascalName}Entity | null> { throw new Error('Not implemented'); }`}
740
884
 
741
- ${ops.update ? `async update(id: string, dto: ${updateDtoName}): Promise<${pascalName}Entity> {
742
- return this.model.findByIdAndUpdate(id, dto as any, { new: true }).exec() as Promise<${pascalName}Entity>;
743
- }` : `async update(id: string, dto: ${updateDtoName}): Promise<${pascalName}Entity> { throw new Error('Not implemented'); }`}
885
+ ${ops.update ? `async update(${primaryKey}: string, dto: ${updateDtoName}): Promise<${pascalName}Entity> {
886
+ return this.model.findOneAndUpdate({ ${primaryKey === 'id' ? '_id' : primaryKey}: ${primaryKey} }, dto as any, { new: true }).exec() as Promise<${pascalName}Entity>;
887
+ }` : `async update(${primaryKey}: string, dto: ${updateDtoName}): Promise<${pascalName}Entity> { throw new Error('Not implemented'); }`}
744
888
 
745
- ${ops.remove ? `async remove(id: string): Promise<${pascalName}Entity> {
746
- return this.model.findByIdAndUpdate(id, { deletedAt: new Date() }, { new: true }).exec() as Promise<${pascalName}Entity>;
747
- }` : `async remove(id: string): Promise<${pascalName}Entity> { throw new Error('Not implemented'); }`}
889
+ ${ops.remove ? `async remove(${primaryKey}: string): Promise<${pascalName}Entity> {
890
+ return this.model.findOneAndUpdate({ ${primaryKey === 'id' ? '_id' : primaryKey}: ${primaryKey} }, { deletedAt: new Date() }, { new: true }).exec() as Promise<${pascalName}Entity>;
891
+ }` : `async remove(${primaryKey}: string): Promise<${pascalName}Entity> { throw new Error('Not implemented'); }`}
748
892
  }
749
893
 
750
894
  @Injectable()
@@ -790,21 +934,21 @@ class Drizzle${pascalName}Repository implements IBaseRepository<${pascalName}Ent
790
934
  return { data, total: 0 };
791
935
  }` : `async findAll(p: PaginationQueryDto): Promise<{ data: ${pascalName}Entity[]; total: number }> { throw new Error('Not implemented'); }`}
792
936
 
793
- ${ops.findOne ? `async findOne(id: string): Promise<${pascalName}Entity | null> {
937
+ ${ops.findOne ? `async findOne(${primaryKey}: string): Promise<${pascalName}Entity | null> {
794
938
  const [result] = await this.db.select().from(${camelName}s)
795
- .where(eq(${camelName}s.id, id));
939
+ .where(eq(${camelName}s.${primaryKey}, ${primaryKey}));
796
940
  return result || null;
797
- }` : `async findOne(id: string): Promise<${pascalName}Entity | null> { throw new Error('Not implemented'); }`}
941
+ }` : `async findOne(${primaryKey}: string): Promise<${pascalName}Entity | null> { throw new Error('Not implemented'); }`}
798
942
 
799
- ${ops.update ? `async update(id: string, dto: ${updateDtoName}): Promise<${pascalName}Entity> {
800
- const [result] = await this.db.update(${camelName}s).set(dto).where(eq(${camelName}s.id, id)).returning();
943
+ ${ops.update ? `async update(${primaryKey}: string, dto: ${updateDtoName}): Promise<${pascalName}Entity> {
944
+ const [result] = await this.db.update(${camelName}s).set(dto).where(eq(${camelName}s.${primaryKey}, ${primaryKey})).returning();
801
945
  return result;
802
- }` : `async update(id: string, dto: ${updateDtoName}): Promise<${pascalName}Entity> { throw new Error('Not implemented'); }`}
946
+ }` : `async update(${primaryKey}: string, dto: ${updateDtoName}): Promise<${pascalName}Entity> { throw new Error('Not implemented'); }`}
803
947
 
804
- ${ops.remove ? `async remove(id: string): Promise<${pascalName}Entity> {
805
- const [result] = await this.db.update(${camelName}s).set({ deletedAt: new Date() }).where(eq(${camelName}s.id, id)).returning();
948
+ ${ops.remove ? `async remove(${primaryKey}: string): Promise<${pascalName}Entity> {
949
+ const [result] = await this.db.update(${camelName}s).set({ deletedAt: new Date() }).where(eq(${camelName}s.${primaryKey}, ${primaryKey})).returning();
806
950
  return result;
807
- }` : `async remove(id: string): Promise<${pascalName}Entity> { throw new Error('Not implemented'); }`}
951
+ }` : `async remove(${primaryKey}: string): Promise<${pascalName}Entity> { throw new Error('Not implemented'); }`}
808
952
  }
809
953
 
810
954
  @Injectable()
@@ -850,17 +994,17 @@ class Prisma${pascalName}Repository implements IBaseRepository<${pascalName}Enti
850
994
  return { data, total };
851
995
  }` : `async findAll(p: PaginationQueryDto): Promise<{ data: ${pascalName}Entity[]; total: number }> { throw new Error('Not implemented'); }`}
852
996
 
853
- ${ops.findOne ? `async findOne(id: string): Promise<${pascalName}Entity | null> {
854
- return (this.prisma as any).${singularCamel}.findFirst({ where: { id, deletedAt: null } });
855
- }` : `async findOne(id: string): Promise<${pascalName}Entity | null> { throw new Error('Not implemented'); }`}
997
+ ${ops.findOne ? `async findOne(${primaryKey}: string): Promise<${pascalName}Entity | null> {
998
+ return (this.prisma as any).${singularCamel}.findFirst({ where: { ${primaryKey}, deletedAt: null } });
999
+ }` : `async findOne(${primaryKey}: string): Promise<${pascalName}Entity | null> { throw new Error('Not implemented'); }`}
856
1000
 
857
- ${ops.update ? `async update(id: string, dto: ${updateDtoName}): Promise<${pascalName}Entity> {
858
- return (this.prisma as any).${singularCamel}.update({ where: { id }, data: dto });
859
- }` : `async update(id: string, dto: ${updateDtoName}): Promise<${pascalName}Entity> { throw new Error('Not implemented'); }`}
1001
+ ${ops.update ? `async update(${primaryKey}: string, dto: ${updateDtoName}): Promise<${pascalName}Entity> {
1002
+ return (this.prisma as any).${singularCamel}.update({ where: { ${primaryKey} }, data: dto });
1003
+ }` : `async update(${primaryKey}: string, dto: ${updateDtoName}): Promise<${pascalName}Entity> { throw new Error('Not implemented'); }`}
860
1004
 
861
- ${ops.remove ? `async remove(id: string): Promise<${pascalName}Entity> {
862
- return (this.prisma as any).${singularCamel}.update({ where: { id }, data: { deletedAt: new Date() } });
863
- }` : `async remove(id: string): Promise<${pascalName}Entity> { throw new Error('Not implemented'); }`}
1005
+ ${ops.remove ? `async remove(${primaryKey}: string): Promise<${pascalName}Entity> {
1006
+ return (this.prisma as any).${singularCamel}.update({ where: { ${primaryKey} }, data: { deletedAt: new Date() } });
1007
+ }` : `async remove(${primaryKey}: string): Promise<${pascalName}Entity> { throw new Error('Not implemented'); }`}
864
1008
  }
865
1009
 
866
1010
  @Injectable()
@@ -982,17 +1126,18 @@ async function registerInAppModule(targetDir, pascalName, kebabName) {
982
1126
  */
983
1127
  async function generateModule(providedModuleName, targetDir = process.cwd(), specifiedOrm = null) {
984
1128
  try {
985
- // Ensure src/common/base exists before generating any module components
1129
+ // 0. Ensure Base Architecture exists at src/common/base
986
1130
  await ensureBaseArchitecture(targetDir);
987
1131
 
988
- const options = await promptForModuleOptions(providedModuleName);
1132
+ // Detect ORM first so prompt choices match
1133
+ const orm = specifiedOrm || (await detectOrm(targetDir));
1134
+
1135
+ const options = await promptForModuleOptions(providedModuleName, orm);
989
1136
  const kebabName = toKebabCase(options.moduleName);
990
1137
  const pascalName = toPascalCase(options.moduleName);
991
1138
  const camelName = toCamelCase(options.moduleName);
992
1139
  const singularPascal = toSingularPascal(pascalName);
993
-
994
- // Detect ORM
995
- const orm = specifiedOrm || (await detectOrm(targetDir));
1140
+ const primaryKey = options.primaryKey || 'id';
996
1141
 
997
1142
  // Ensure inside a NestJS project structure
998
1143
  const srcDir = path.join(targetDir, 'src');
@@ -1018,13 +1163,13 @@ async function generateModule(providedModuleName, targetDir = process.cwd(), spe
1018
1163
 
1019
1164
  // 1. Dynamic ORM Schema Synchronization
1020
1165
  if (orm === 'prisma') {
1021
- await syncPrismaSchema(targetDir, singularPascal, kebabName, options.fields);
1166
+ await syncPrismaSchema(targetDir, singularPascal, kebabName, primaryKey, options.fields, options.relations);
1022
1167
  } else if (orm === 'typeorm') {
1023
- await syncTypeOrmSchema(moduleDir, singularPascal, kebabName, options.fields);
1168
+ await syncTypeOrmSchema(moduleDir, singularPascal, kebabName, primaryKey, options.fields, options.relations);
1024
1169
  } else if (orm === 'mongoose') {
1025
- await syncMongooseSchema(moduleDir, singularPascal, kebabName, options.fields);
1170
+ await syncMongooseSchema(moduleDir, singularPascal, kebabName, primaryKey, options.fields, options.relations);
1026
1171
  } else if (orm === 'drizzle') {
1027
- await syncDrizzleSchema(moduleDir, singularPascal, kebabName, options.fields);
1172
+ await syncDrizzleSchema(moduleDir, singularPascal, kebabName, primaryKey, options.fields, options.relations);
1028
1173
  }
1029
1174
 
1030
1175
  // 2. Generate DTOs
@@ -1033,39 +1178,41 @@ async function generateModule(providedModuleName, targetDir = process.cwd(), spe
1033
1178
  const responseDtoName = `${pascalName}Dto`;
1034
1179
 
1035
1180
  const createFieldsText = options.fields.map((f) => {
1036
- const tsType = getTsType(f.type);
1181
+ const details = getFieldDetails(f.type);
1037
1182
  const ex = getFieldExampleValue(f, pascalName);
1038
- const exValStr = typeof ex === 'string' ? `'${ex}'` : ex;
1183
+ const exValStr = typeof ex === 'string' ? `'${ex}'` : JSON.stringify(ex);
1039
1184
 
1040
1185
  const swaggerDecorator = f.isOptional
1041
1186
  ? `@ApiPropertyOptional({ description: '${f.name} property', example: ${exValStr} })`
1042
1187
  : `@ApiProperty({ description: '${f.name} property', example: ${exValStr} })`;
1043
1188
 
1044
- const valDecorators = [];
1045
- if (f.type === 'String') valDecorators.push('@IsString()');
1046
- if (f.type === 'Number') valDecorators.push('@IsNumber()');
1047
- if (f.type === 'Boolean') valDecorators.push('@IsBoolean()');
1048
- if (f.type === 'Date') {
1049
- valDecorators.push('@IsDate()');
1050
- valDecorators.push('@Type(() => Date)');
1051
- }
1052
-
1189
+ const valDecorators = [...details.valDecorators];
1053
1190
  if (f.isOptional) {
1054
1191
  valDecorators.push('@IsOptional()');
1055
1192
  } else {
1056
1193
  valDecorators.push('@IsNotEmpty()');
1057
1194
  }
1058
1195
 
1059
- return ` ${swaggerDecorator}\n ${valDecorators.join('\n ')}\n ${f.name}${f.isOptional ? '?' : ''}: ${tsType};`;
1196
+ return ` ${swaggerDecorator}\n ${valDecorators.join('\n ')}\n ${f.name}${f.isOptional ? '?' : ''}: ${details.tsType};`;
1060
1197
  }).join('\n\n');
1061
1198
 
1062
- const hasDateFields = options.fields.some((f) => f.type === 'Date');
1199
+ const hasDateFields = options.fields.some((f) => ['DateTime', 'timestamp', 'Date'].includes(f.type));
1200
+
1201
+ // Validator import gathering
1202
+ const allValDecorators = new Set(['IsOptional', 'IsNotEmpty']);
1203
+ options.fields.forEach((f) => {
1204
+ const details = getFieldDetails(f.type);
1205
+ details.valDecorators.forEach((dec) => {
1206
+ const name = dec.replace('@', '').replace(/\(.*\)/, '');
1207
+ if (name) allValDecorators.add(name);
1208
+ });
1209
+ });
1063
1210
 
1064
1211
  if (ops.create || ops.update) {
1065
1212
  await fs.writeFile(
1066
1213
  path.join(dtoDir, `create-${kebabName}.dto.ts`),
1067
1214
  `import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
1068
- import { IsString, IsNumber, IsBoolean, IsDate, IsNotEmpty, IsOptional } from 'class-validator';
1215
+ import { ${Array.from(allValDecorators).join(', ')} } from 'class-validator';
1069
1216
  ${hasDateFields ? `import { Type } from 'class-transformer';\n` : ''}
1070
1217
  export class ${createDtoName} {
1071
1218
  ${createFieldsText}
@@ -1084,15 +1231,15 @@ export class ${updateDtoName} extends PartialType(${createDtoName}) {}
1084
1231
  }
1085
1232
 
1086
1233
  const responseFieldsText = options.fields.map((f) => {
1087
- const tsType = getTsType(f.type);
1234
+ const details = getFieldDetails(f.type);
1088
1235
  const ex = getFieldExampleValue(f, pascalName);
1089
- const exValStr = typeof ex === 'string' ? `'${ex}'` : ex;
1236
+ const exValStr = typeof ex === 'string' ? `'${ex}'` : JSON.stringify(ex);
1090
1237
 
1091
1238
  const swaggerDecorator = f.isOptional
1092
1239
  ? `@ApiPropertyOptional({ example: ${exValStr} })`
1093
1240
  : `@ApiProperty({ example: ${exValStr} })`;
1094
1241
 
1095
- return ` ${swaggerDecorator}\n ${f.name}${f.isOptional ? '?' : ''}: ${tsType};`;
1242
+ return ` ${swaggerDecorator}\n ${f.name}${f.isOptional ? '?' : ''}: ${details.tsType};`;
1096
1243
  }).join('\n\n');
1097
1244
 
1098
1245
  await fs.writeFile(
@@ -1101,7 +1248,7 @@ export class ${updateDtoName} extends PartialType(${createDtoName}) {}
1101
1248
 
1102
1249
  export class ${responseDtoName} {
1103
1250
  @ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000', format: 'uuid' })
1104
- id: string;
1251
+ ${primaryKey}: string;
1105
1252
 
1106
1253
  ${responseFieldsText}
1107
1254
 
@@ -1127,14 +1274,20 @@ ${responseFieldsText}
1127
1274
  singularPascal,
1128
1275
  camelName,
1129
1276
  kebabName,
1277
+ primaryKey,
1130
1278
  createDtoName,
1131
1279
  updateDtoName,
1132
1280
  ops
1133
1281
  );
1134
1282
  await fs.writeFile(path.join(moduleDir, `${kebabName}.service.ts`), serviceContent);
1135
1283
 
1136
- // 4. Generate Controller (Fixed single clean import path from common/base)
1137
- 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 } from '@nestjs/common';
1284
+ // 4. Generate Controller
1285
+ const guardImports = options.protectWriteOps ? `, UseGuards` : '';
1286
+ const guardDecorator = options.protectWriteOps && options.roles?.length > 0
1287
+ ? `\n @UseGuards()\n // Roles: ${options.roles.join(', ')}`
1288
+ : '';
1289
+
1290
+ 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';
1138
1291
  import { ApiTags, ApiBearerAuth, ApiExtraModels, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
1139
1292
  import { BaseController, ApiResponseDto, ApiResponseSchema, PaginatedResponseDto, PaginatedResponseSchema, PaginationQueryDto } from '../../common/base';
1140
1293
  import { ${pascalName}Service } from './${kebabName}.service';
@@ -1156,7 +1309,7 @@ export class ${pascalName}Controller extends BaseController<${pascalName}Entity,
1156
1309
  return ${responseDtoName} as unknown as Type<${pascalName}Entity>;
1157
1310
  }
1158
1311
  ${ops.create ? `
1159
- @Post()
1312
+ @Post()${guardDecorator}
1160
1313
  @ApiOperation({ summary: 'Create a new ${kebabName}' })
1161
1314
  @ApiResponse({ status: HttpStatus.CREATED, schema: ApiResponseSchema(${responseDtoName}) })
1162
1315
  override async create(@Body() dto: ${createDtoName}): Promise<ApiResponseDto<${pascalName}Entity>> {
@@ -1170,34 +1323,34 @@ ${ops.create ? `
1170
1323
  return super.findAll(pagination);
1171
1324
  }
1172
1325
  ` : ''}${ops.findOne ? `
1173
- @Get(':id')
1326
+ @Get(':${primaryKey}')
1174
1327
  @ApiOperation({ summary: 'Get ${kebabName} by ID' })
1175
- @ApiParam({ name: 'id', format: 'uuid' })
1328
+ @ApiParam({ name: '${primaryKey}', format: 'uuid' })
1176
1329
  @ApiResponse({ status: HttpStatus.OK, schema: ApiResponseSchema(${responseDtoName}) })
1177
1330
  @ApiResponse({ status: HttpStatus.NOT_FOUND, description: '${pascalName} not found' })
1178
- override async findOne(@Param('id', new ParseUUIDPipe({ version: '4', errorHttpStatusCode: HttpStatus.BAD_REQUEST })) id: string): Promise<ApiResponseDto<${pascalName}Entity>> {
1179
- return super.findOne(id);
1331
+ override async findOne(@Param('${primaryKey}', new ParseUUIDPipe({ version: '4', errorHttpStatusCode: HttpStatus.BAD_REQUEST })) ${primaryKey}: string): Promise<ApiResponseDto<${pascalName}Entity>> {
1332
+ return super.findOne(${primaryKey});
1180
1333
  }
1181
1334
  ` : ''}${ops.update ? `
1182
- @Put(':id')
1335
+ @Put(':${primaryKey}')${guardDecorator}
1183
1336
  @ApiOperation({ summary: 'Update ${kebabName} by ID' })
1184
- @ApiParam({ name: 'id', format: 'uuid' })
1337
+ @ApiParam({ name: '${primaryKey}', format: 'uuid' })
1185
1338
  @ApiResponse({ status: HttpStatus.OK, schema: ApiResponseSchema(${responseDtoName}) })
1186
1339
  @ApiResponse({ status: HttpStatus.NOT_FOUND, description: '${pascalName} not found' })
1187
1340
  override async update(
1188
- @Param('id', new ParseUUIDPipe({ version: '4', errorHttpStatusCode: HttpStatus.BAD_REQUEST })) id: string,
1341
+ @Param('${primaryKey}', new ParseUUIDPipe({ version: '4', errorHttpStatusCode: HttpStatus.BAD_REQUEST })) ${primaryKey}: string,
1189
1342
  @Body() dto: ${updateDtoName}
1190
1343
  ): Promise<ApiResponseDto<${pascalName}Entity>> {
1191
- return super.update(id, dto);
1344
+ return super.update(${primaryKey}, dto);
1192
1345
  }
1193
1346
  ` : ''}${ops.remove ? `
1194
- @Delete(':id')
1347
+ @Delete(':${primaryKey}')${guardDecorator}
1195
1348
  @ApiOperation({ summary: 'Delete ${kebabName} by ID' })
1196
- @ApiParam({ name: 'id', format: 'uuid' })
1349
+ @ApiParam({ name: '${primaryKey}', format: 'uuid' })
1197
1350
  @ApiResponse({ status: HttpStatus.OK, schema: ApiResponseSchema(${responseDtoName}) })
1198
1351
  @ApiResponse({ status: HttpStatus.NOT_FOUND, description: '${pascalName} not found' })
1199
- override async remove(@Param('id', new ParseUUIDPipe({ version: '4', errorHttpStatusCode: HttpStatus.BAD_REQUEST })) id: string): Promise<ApiResponseDto<${pascalName}Entity>> {
1200
- return super.remove(id);
1352
+ override async remove(@Param('${primaryKey}', new ParseUUIDPipe({ version: '4', errorHttpStatusCode: HttpStatus.BAD_REQUEST })) ${primaryKey}: string): Promise<ApiResponseDto<${pascalName}Entity>> {
1353
+ return super.remove(${primaryKey});
1201
1354
  }
1202
1355
  ` : ''}
1203
1356
  }
@@ -1212,10 +1365,10 @@ ${ops.create ? `
1212
1365
  const isAutoRegistered = await registerInAppModule(targetDir, pascalName, kebabName);
1213
1366
 
1214
1367
  // 7. Auto-Generate Starter Seed File Template
1215
- await generateSeedFileTemplate(targetDir, orm, singularPascal, kebabName, options.fields);
1368
+ await generateSeedFileTemplate(targetDir, orm, singularPascal, kebabName, primaryKey, options.fields);
1216
1369
 
1217
1370
  console.log(chalk.green(`\n✅ Module "${kebabName}" successfully generated in ${path.relative(process.cwd(), moduleDir)}`));
1218
- console.log(chalk.gray(` Detected ORM: ${orm}`));
1371
+ console.log(chalk.gray(` Detected ORM: ${orm}`));
1219
1372
 
1220
1373
  if (!isAutoRegistered) {
1221
1374
  console.log(chalk.yellow(`\n⚠️ Please manually register ${pascalName}Module in src/app.module.ts:`));
@@ -1244,4 +1397,5 @@ module.exports = {
1244
1397
  promptForModuleOptions,
1245
1398
  detectOrm,
1246
1399
  registerInAppModule,
1400
+ ensureBaseArchitecture,
1247
1401
  };