@neohm/nh-cli 1.1.4 → 1.2.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.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  const {
4
+ toWords,
4
5
  toDotCase,
5
6
  toPascalCase,
6
7
  toKebabCase,
@@ -16,6 +17,17 @@ const {
16
17
  * across nestjs-boilerplate.
17
18
  */
18
19
 
20
+ /**
21
+ * A named import, wrapped the way prettier's 80-column printer would: one line
22
+ * while it fits, one member per line once it doesn't. Generated files are
23
+ * committed as-is, so they have to come out already formatted.
24
+ */
25
+ function namedImport(members, from) {
26
+ const oneLine = `import { ${members.join(', ')} } from '${from}';`;
27
+ if (oneLine.length <= 80) return oneLine;
28
+ return `import {\n${members.map((m) => ` ${m},`).join('\n')}\n} from '${from}';`;
29
+ }
30
+
19
31
  /** Build the canonical names for a given entity-ish base (e.g. "Shape"). */
20
32
  function names(base) {
21
33
  const dot = toDotCase(base);
@@ -32,6 +44,8 @@ function names(base) {
32
44
  attributesDtoFile: `${dot}.attributes.dto.ts`,
33
45
  controllerClass: `${pascal}Controller`,
34
46
  controllerFile: `${dot}.controller.ts`,
47
+ serviceClass: `${pascal}Service`,
48
+ serviceFile: `${dot}.service.ts`,
35
49
  dtoClass: `Add${pascal}DataDto`,
36
50
  dtoFile: `add.${dot}.data.dto.ts`,
37
51
  processorClass: `${pascal}DataProcessor`,
@@ -42,6 +56,17 @@ function names(base) {
42
56
  subscriberFile: `${dot}.subscriber.ts`,
43
57
  jobClass: `${pascal}Job`,
44
58
  jobFile: `${dot}.job.ts`,
59
+ // External (API-key) surface. Mirrors the internal names with an
60
+ // `external.` filename prefix and an `External` class prefix, matching
61
+ // external/controllers/external.test.controller.ts in the boilerplate.
62
+ externalControllerClass: `External${pascal}Controller`,
63
+ externalControllerFile: `external.${dot}.controller.ts`,
64
+ externalServiceClass: `External${pascal}Service`,
65
+ externalServiceFile: `external.${dot}.service.ts`,
66
+ externalProcessorClass: `External${pascal}DataProcessor`,
67
+ externalProcessorFile: `external.${dot}.data.processor.ts`,
68
+ externalListProcessorClass: `External${pascal}ListProcessor`,
69
+ externalListProcessorFile: `external.${dot}.list.processor.ts`,
45
70
  // Fed through naming.migrationFileName/migrationClassName so the migration
46
71
  // `nh gen` writes is named exactly like one from `nh migration`.
47
72
  migrationName: `Add${pascal}Table`,
@@ -50,14 +75,26 @@ function names(base) {
50
75
 
51
76
  function entity(base, tableName) {
52
77
  const n = names(base);
53
- return `import { Column, Entity } from 'typeorm';
54
- import { CommonEntity } from '@neohm/nestend';
78
+ return `import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
79
+ import { CommonEntity, TenantEntity } from '@neohm/nestend';
55
80
  import { ${n.attributesDtoClass} } from '../dtos/${n.dot}.attributes.dto';
56
81
 
57
82
  @Entity({ name: '${tableName}' })
58
83
  export class ${n.entityClass} extends CommonEntity {
84
+ // Tenant scope — the service and the list processor both filter on it.
85
+ @Column()
86
+ tenant_id: string;
87
+
88
+ // TODO: declare the columns ${n.pascal} owns.
89
+
59
90
  @Column({ type: 'jsonb' })
60
91
  attributes: ${n.attributesDtoClass};
92
+
93
+ // Relations and methods
94
+
95
+ @ManyToOne(() => TenantEntity)
96
+ @JoinColumn({ name: 'tenant_id' })
97
+ tenant: TenantEntity;
61
98
  }
62
99
  `;
63
100
  }
@@ -70,32 +107,259 @@ function attributesDto(base) {
70
107
  `;
71
108
  }
72
109
 
73
- function controller(base, { listProcessor = false } = {}) {
110
+ /**
111
+ * Controllers hold no business logic. They authenticate the caller, then
112
+ * delegate to the module's service — so the HTTP surface stays readable and
113
+ * the same operation is reusable from a job, a subscriber or another service.
114
+ */
115
+ function controller(base, { processor = false, listProcessor = false } = {}) {
74
116
  const n = names(base);
75
- if (!listProcessor) {
76
- return `import { Controller } from '@nestjs/common';
77
- import { ApiTags } from '@nestjs/swagger';
117
+ const svcProp = `${n.camel}Service`;
118
+ const hasPost = processor || listProcessor;
119
+
120
+ // @nestjs/common members, alphabetised the way prettier leaves them.
121
+ const nestCommon = ['Controller', 'Get', 'Param'];
122
+ if (hasPost) nestCommon.push('Body', 'Post');
123
+ nestCommon.sort();
124
+
125
+ const nestendImports = ['AuthService'];
126
+ if (listProcessor) nestendImports.push('CommonListFilterDto');
127
+ nestendImports.push('CommonParamsDto');
128
+
129
+ const imports = [
130
+ namedImport(nestCommon, '@nestjs/common'),
131
+ // @ApiBearerAuth() attaches the requirement to these operations. The
132
+ // DocumentBuilder call only registers the scheme (under its default name,
133
+ // 'bearer') and draws the Authorize button — without the decorator
134
+ // swagger-ui shows no padlock and never sends the Authorization header.
135
+ `import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';`,
136
+ namedImport(nestendImports, '@neohm/nestend'),
137
+ `import { ${n.serviceClass} } from '../services/${n.dot}.service';`,
138
+ ];
139
+ if (processor) {
140
+ imports.push(
141
+ `import { ${n.dtoClass} } from '../dtos/add.${n.dot}.data.dto';`,
142
+ );
143
+ }
78
144
 
145
+ const methods = [
146
+ ` @Get(':id')
147
+ async get${n.pascal}(@Param() params: CommonParamsDto) {
148
+ const { user } = await this.authService.validateAccess(
149
+ 'add-scope',
150
+ 'add-role',
151
+ );
152
+
153
+ return this.${svcProp}.get(user, params.id);
154
+ }`,
155
+ ];
156
+
157
+ if (processor) {
158
+ methods.push(` @Post()
159
+ async upsert${n.pascal}(@Body() body: ${n.dtoClass}) {
160
+ await this.authService.validateAccess('add-scope', 'add-role');
161
+
162
+ return this.${svcProp}.upsert(body);
163
+ }`);
164
+ }
165
+
166
+ if (listProcessor) {
167
+ methods.push(` @Post('list')
168
+ async list(@Body() body: CommonListFilterDto) {
169
+ const { user } = await this.authService.validateAccess(
170
+ 'add-scope',
171
+ 'add-role',
172
+ );
173
+ return this.${svcProp}.list(user, body);
174
+ }`);
175
+ }
176
+
177
+ return `${imports.join('\n')}
178
+
179
+ // TODO: replace 'add-scope' / 'add-role' with this module's scope and role.
79
180
  @ApiTags('${n.kebab}')
181
+ @ApiBearerAuth()
80
182
  @Controller('api/v1/${n.kebab}')
81
183
  export class ${n.controllerClass} {
82
- constructor() {}
184
+ constructor(
185
+ private readonly ${svcProp}: ${n.serviceClass},
186
+ private readonly authService: AuthService,
187
+ ) {}
188
+
189
+ ${methods.join('\n\n')}
83
190
  }
84
191
  `;
192
+ }
193
+
194
+ /**
195
+ * The module's service layer, always generated: single-record reads live here,
196
+ * and write / list work is handed to the processors. Injected into the
197
+ * controller, so nothing downstream has to know how a record is fetched.
198
+ *
199
+ * `get()` is unconditional — it is the one operation that needs no processor.
200
+ * `upsert()` appears with a data processor, `list()` with a listing processor.
201
+ */
202
+ function service(base, { processor = false, listProcessor = false } = {}) {
203
+ const n = names(base);
204
+ const label = toWords(base).join(' ');
205
+
206
+ const nestendImports = [];
207
+ if (listProcessor) {
208
+ nestendImports.push('CommonListFilterDto', 'ListFilterService');
209
+ }
210
+ nestendImports.push('NotFoundException', 'UserEntity');
211
+
212
+ const imports = [
213
+ namedImport(nestendImports, '@neohm/nestend'),
214
+ `import { Injectable } from '@nestjs/common';`,
215
+ ];
216
+ if (listProcessor) {
217
+ imports.push(
218
+ `import { ${n.listProcessorClass} } from '../libraries/${n.dot}.list.processor';`,
219
+ );
220
+ }
221
+ if (processor) {
222
+ imports.push(
223
+ `import { ${n.dtoClass} } from '../dtos/add.${n.dot}.data.dto';`,
224
+ `import { ${n.processorClass} } from '../libraries/${n.dot}.data.processor';`,
225
+ );
226
+ }
227
+ imports.push(
228
+ `import { ${n.entityClass} } from '../entities/${n.dot}.entity';`,
229
+ );
230
+
231
+ const members = [];
232
+ if (listProcessor) {
233
+ members.push(
234
+ ` constructor(protected readonly listFilterService: ListFilterService) {}`,
235
+ );
85
236
  }
86
- return `import { Body, Controller, Post } from '@nestjs/common';
87
- import { ApiTags } from '@nestjs/swagger';
88
- import { CommonListFilterDto, SqlService } from '@neohm/nestend';
89
- import { ${n.listProcessorClass} } from '../libraries/${n.dot}.list.processor';
90
237
 
91
- @ApiTags('${n.kebab}')
92
- @Controller('api/v1/${n.kebab}')
238
+ // The tenant scope is part of the lookup, not a check after it: a row
239
+ // belonging to another tenant must read as "not found", never as "forbidden".
240
+ members.push(` async get(user: UserEntity, id: string) {
241
+ const ${n.camel} = await ${n.entityClass}.findOne({
242
+ where: { id, tenant_id: user.organization_id },
243
+ });
244
+ if (!${n.camel}) throw new NotFoundException('Invalid ${label} id');
245
+
246
+ return ${n.camel};
247
+ }`);
248
+
249
+ if (processor) {
250
+ // Insert or update: CommonDataDto carries an optional `id`, and the data
251
+ // processor decides which of the two the payload asks for.
252
+ members.push(` async upsert(data: ${n.dtoClass}) {
253
+ return new ${n.processorClass}().process(data);
254
+ }`);
255
+ }
256
+
257
+ if (listProcessor) {
258
+ // Emit the call already wrapped the way prettier's 80-column printer
259
+ // would, so a long entity name doesn't leave the generated file dirty.
260
+ // Prettier's three tiers: keep the fluent one-liner; else break the
261
+ // .process() argument; else break the constructor arguments too.
262
+ const call = `new ${n.listProcessorClass}(this.listFilterService, user)`;
263
+ const oneLine = ` return ${call}.process(data);`;
264
+ const brokenArg = ` return ${call}.process(`;
265
+ let body;
266
+ if (oneLine.length <= 80) {
267
+ body = oneLine;
268
+ } else if (brokenArg.length <= 80) {
269
+ body = `${brokenArg}\n data,\n );`;
270
+ } else {
271
+ body = ` return new ${n.listProcessorClass}(\n this.listFilterService,\n user,\n ).process(data);`;
272
+ }
273
+ members.push(` async list(user: UserEntity, data: CommonListFilterDto) {
274
+ ${body}
275
+ }`);
276
+ }
277
+
278
+ return `${imports.join('\n')}
279
+
280
+ @Injectable()
281
+ export class ${n.serviceClass} {
282
+ ${members.join('\n\n')}
283
+ }
284
+ `;
285
+ }
286
+
287
+ /**
288
+ * Bare controller for `nh gen -c`: swagger tag, the caller-supplied route
289
+ * under api/v1, AuthService on hand, and one empty `check()` to build on.
290
+ */
291
+ function bareController(base, routePath) {
292
+ const n = names(base);
293
+ return `import { Controller, Get } from '@nestjs/common';
294
+ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
295
+ import { AuthService } from '@neohm/nestend';
296
+
297
+ @ApiTags('${routePath}')
298
+ @ApiBearerAuth()
299
+ @Controller('api/v1/${routePath}')
93
300
  export class ${n.controllerClass} {
94
- constructor(private readonly sqlService: SqlService) {}
301
+ constructor(private readonly authService: AuthService) {}
95
302
 
96
- @Post('list')
97
- async list(@Body() body: CommonListFilterDto) {
98
- return new ${n.listProcessorClass}(this.sqlService).process(body);
303
+ @Get('check')
304
+ async check() {}
305
+ }
306
+ `;
307
+ }
308
+
309
+ /**
310
+ * Bare external controller for `nh gen -x -c`. The API-key surface differs
311
+ * from the internal one in three ways, all of them load-bearing: the route is
312
+ * api/e/v1, the key is declared to swagger with @ApiSecurity('api-key'), and
313
+ * access is checked through ApiKeyService rather than AuthService.
314
+ */
315
+ function bareExternalController(base, routePath) {
316
+ const n = names(base);
317
+ return `import { Controller, Get } from '@nestjs/common';
318
+ import { ApiSecurity, ApiTags } from '@nestjs/swagger';
319
+ import { ApiKeyService } from '@neohm/nestend';
320
+
321
+ @ApiTags('external-${routePath} (Api key)')
322
+ @ApiSecurity('api-key')
323
+ @Controller('api/e/v1/${routePath}')
324
+ export class ${n.externalControllerClass} {
325
+ constructor(private readonly apiKeyService: ApiKeyService) {}
326
+
327
+ @Get('check')
328
+ async check() {}
329
+ }
330
+ `;
331
+ }
332
+
333
+ /** Bare injectable service for `nh gen -s` / `nh gen -x -s`. */
334
+ function bareService(base, { external = false } = {}) {
335
+ const n = names(base);
336
+ const cls = external ? n.externalServiceClass : n.serviceClass;
337
+ return `import { Injectable } from '@nestjs/common';
338
+
339
+ @Injectable()
340
+ export class ${cls} {}
341
+ `;
342
+ }
343
+
344
+ /**
345
+ * Standalone job for `nh gen -j`. Unlike the entity job the wizard scaffolds,
346
+ * this one is not driven by a subscriber, so it takes no DatabaseEventDto and
347
+ * references no entity — whatever `dispatch()` was handed arrives as `data`.
348
+ */
349
+ function additionalJob(base) {
350
+ const n = names(base);
351
+ return `import { CommonJob, QueueService } from '@neohm/nestend';
352
+ import { Injectable } from '@nestjs/common';
353
+ import { JobConstants } from '../../constants/job.constants';
354
+
355
+ @Injectable()
356
+ export class ${n.jobClass} extends CommonJob {
357
+ constructor(protected readonly queueService: QueueService) {
358
+ super(JobConstants.${n.camel}Job);
359
+ }
360
+
361
+ async handle(data: unknown): Promise<void> {
362
+ return;
99
363
  }
100
364
  }
101
365
  `;
@@ -111,12 +375,16 @@ export class ${n.dtoClass} extends CommonDataDto {
111
375
  `;
112
376
  }
113
377
 
114
- function processor(base) {
378
+ function processor(base, { external = false } = {}) {
115
379
  const n = names(base);
380
+ // An external processor sits one level deeper (external/libraries/), so its
381
+ // reach back into the module's dtos/ gains a segment.
382
+ const up = external ? '../..' : '..';
383
+ const cls = external ? n.externalProcessorClass : n.processorClass;
116
384
  return `import { CommonDataProcessor } from '@neohm/nestend';
117
- import { ${n.dtoClass} } from '../dtos/add.${n.dot}.data.dto';
385
+ import { ${n.dtoClass} } from '${up}/dtos/add.${n.dot}.data.dto';
118
386
 
119
- export class ${n.processorClass} extends CommonDataProcessor {
387
+ export class ${cls} extends CommonDataProcessor {
120
388
  protected payload: ${n.dtoClass};
121
389
 
122
390
  async process(data: ${n.dtoClass}) {
@@ -139,33 +407,63 @@ export class ${n.processorClass} extends CommonDataProcessor {
139
407
  `;
140
408
  }
141
409
 
142
- function listProcessor(base, tableName) {
410
+ function listProcessor(base, tableName, { external = false } = {}) {
143
411
  const n = names(base);
412
+ const cls = external ? n.externalListProcessorClass : n.listProcessorClass;
144
413
  return `import {
145
414
  CommonListFilterConfig,
146
415
  CommonListFilterDto,
147
416
  CommonListProcessor,
417
+ FilterValueTypeEnum,
418
+ ListFilterService,
148
419
  SortDirection,
149
- SqlService,
420
+ UserEntity,
150
421
  } from '@neohm/nestend';
151
422
 
152
423
  /**
153
- * Lists / filters ${n.pascal}. The \`columns\` config below is the allowlist:
154
- * an identifier must appear here before a caller can filter or sort on it.
155
- * Mark a column \`searchable: true\` to include it in the free-text \`str\` sweep.
424
+ * Lists / filters ${n.pascal}.
425
+ *
426
+ * Two separate surfaces, and it matters which one a rule belongs to:
427
+ *
428
+ * - \`columns\` is the caller's allowlist. An identifier must appear here
429
+ * before a request can filter or sort on it, and \`searchable: true\` adds
430
+ * it to the free-text \`str\` sweep. Everything here is public. Declare a
431
+ * \`type\`: it decides how a caller's value is cast and which operators the
432
+ * column accepts — add \`operators\` to narrow that further.
433
+ *
434
+ * - \`predicates\` and \`applyCustomPredicates()\` are yours. Both compile
435
+ * inside the relation — before any join aggregate or LATERAL runs — and
436
+ * carry no public identifier, so a payload cannot name, weaken or compete
437
+ * with them. Lifecycle and tenant scoping belong here, never in
438
+ * \`columns\`. Standing literal clauses go in \`predicates\`; anything
439
+ * carrying a value goes in the method, where add() binds it.
156
440
  */
157
- export class ${n.listProcessorClass} extends CommonListProcessor {
441
+ export class ${cls} extends CommonListProcessor {
158
442
  protected config: CommonListFilterConfig = {
159
443
  query: '${tableName} a',
160
444
  columns: [
161
- { key: 'a.id', identifier: 'id' },
162
- { key: 'a.attributes', identifier: 'attributes' },
163
- { key: 'a.created_at', identifier: 'created_at' },
445
+ { key: 'a.id', identifier: 'id', type: FilterValueTypeEnum.UUID },
446
+ {
447
+ key: 'a.attributes',
448
+ identifier: 'attributes',
449
+ type: FilterValueTypeEnum.JSON,
450
+ },
451
+ {
452
+ key: 'a.created_at',
453
+ identifier: 'created_at',
454
+ type: FilterValueTypeEnum.DATETIME,
455
+ },
164
456
  ],
165
- defaultSort: { field: 'id', direction: SortDirection.DESC },
457
+ // Soft-deleted rows are never listed. \`deleted_at\` is deliberately not a
458
+ // declared column: there is nothing a caller should be doing with it.
459
+ predicates: ['a.deleted_at is null'],
460
+ defaultSort: { field: 'created_at', direction: SortDirection.DESC },
166
461
  };
167
462
 
168
- constructor(protected readonly sqlService: SqlService) {
463
+ constructor(
464
+ protected readonly listFilterService: ListFilterService,
465
+ private readonly user: UserEntity,
466
+ ) {
169
467
  super();
170
468
  }
171
469
 
@@ -174,6 +472,11 @@ export class ${n.listProcessorClass} extends CommonListProcessor {
174
472
 
175
473
  return this.handle();
176
474
  }
475
+
476
+ protected applyCustomPredicates() {
477
+ // Filter for the caller's tenant/organization
478
+ this.predicates.add('a.tenant_id = :tenant_id', this.user.organization_id);
479
+ }
177
480
  }
178
481
  `;
179
482
  }
@@ -241,10 +544,19 @@ export class ${className} extends MigrationUtility {
241
544
 
242
545
  process() {
243
546
  this.rootPrimary();
547
+
548
+ this.foreign({ name: 'tenant_id', foreignTable: 'sys_tenants' });
244
549
  this.foreign({ name: 'created_by', foreignTable: 'sys_users' });
245
- // TODO: add columns matching ${n.entityClass} attributes.
550
+ this.foreign({ name: 'updated_by', foreignTable: 'sys_users' });
551
+
552
+ // TODO: add columns matching ${n.entityClass}.
553
+
246
554
  this.json('attributes');
247
555
  this.timestamps();
556
+
557
+ this.index('tenant_id');
558
+ this.index('created_by');
559
+ this.index('updated_by');
248
560
  }
249
561
  }
250
562
  `;
@@ -384,6 +696,11 @@ module.exports = {
384
696
  entity,
385
697
  attributesDto,
386
698
  controller,
699
+ service,
700
+ bareController,
701
+ bareExternalController,
702
+ bareService,
703
+ additionalJob,
387
704
  dto,
388
705
  processor,
389
706
  listProcessor,