@neohm/nh-cli 1.1.5 → 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,46 +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
 
79
- @ApiTags('${n.kebab}')
80
- @Controller('api/v1/${n.kebab}')
81
- export class ${n.controllerClass} {
82
- constructor() {}
83
- }
84
- `;
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
+ }`);
85
164
  }
86
- return `import { Body, Controller, Post } from '@nestjs/common';
87
- import { ApiTags } from '@nestjs/swagger';
88
- import {
89
- AuthService,
90
- CommonListFilterDto,
91
- ListFilterService,
92
- } from '@neohm/nestend';
93
- import { ${n.listProcessorClass} } from '../libraries/${n.dot}.list.processor';
94
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.
95
180
  @ApiTags('${n.kebab}')
181
+ @ApiBearerAuth()
96
182
  @Controller('api/v1/${n.kebab}')
97
183
  export class ${n.controllerClass} {
98
184
  constructor(
99
- private readonly listFilterService: ListFilterService,
185
+ private readonly ${svcProp}: ${n.serviceClass},
100
186
  private readonly authService: AuthService,
101
187
  ) {}
102
188
 
103
- @Post('list')
104
- async list(@Body() body: CommonListFilterDto) {
105
- // Open by default: authenticate explicitly. validateAccess() gates the
106
- // feature on the caller's organization, and guarantees the
107
- // organization_id the processor scopes on — a tenantless user is
108
- // refused here rather than reaching the query with a null scope.
109
- // TODO: replace with the sys_scopes identifier this endpoint sits behind.
110
- const { user } = await this.authService.validateAccess('add-scope', 'add-role');
111
-
112
- return new ${n.listProcessorClass}(this.listFilterService, user).process(body);
189
+ ${methods.join('\n\n')}
190
+ }
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
+ );
236
+ }
237
+
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}')
300
+ export class ${n.controllerClass} {
301
+ constructor(private readonly authService: AuthService) {}
302
+
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;
113
363
  }
114
364
  }
115
365
  `;
@@ -125,12 +375,16 @@ export class ${n.dtoClass} extends CommonDataDto {
125
375
  `;
126
376
  }
127
377
 
128
- function processor(base) {
378
+ function processor(base, { external = false } = {}) {
129
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;
130
384
  return `import { CommonDataProcessor } from '@neohm/nestend';
131
- import { ${n.dtoClass} } from '../dtos/add.${n.dot}.data.dto';
385
+ import { ${n.dtoClass} } from '${up}/dtos/add.${n.dot}.data.dto';
132
386
 
133
- export class ${n.processorClass} extends CommonDataProcessor {
387
+ export class ${cls} extends CommonDataProcessor {
134
388
  protected payload: ${n.dtoClass};
135
389
 
136
390
  async process(data: ${n.dtoClass}) {
@@ -153,8 +407,9 @@ export class ${n.processorClass} extends CommonDataProcessor {
153
407
  `;
154
408
  }
155
409
 
156
- function listProcessor(base, tableName) {
410
+ function listProcessor(base, tableName, { external = false } = {}) {
157
411
  const n = names(base);
412
+ const cls = external ? n.externalListProcessorClass : n.listProcessorClass;
158
413
  return `import {
159
414
  CommonListFilterConfig,
160
415
  CommonListFilterDto,
@@ -183,7 +438,7 @@ function listProcessor(base, tableName) {
183
438
  * \`columns\`. Standing literal clauses go in \`predicates\`; anything
184
439
  * carrying a value goes in the method, where add() binds it.
185
440
  */
186
- export class ${n.listProcessorClass} extends CommonListProcessor {
441
+ export class ${cls} extends CommonListProcessor {
187
442
  protected config: CommonListFilterConfig = {
188
443
  query: '${tableName} a',
189
444
  columns: [
@@ -219,11 +474,8 @@ export class ${n.listProcessorClass} extends CommonListProcessor {
219
474
  }
220
475
 
221
476
  protected applyCustomPredicates() {
222
- // Filter for specific user's tenant/organization
223
- this.predicates.add(
224
- 'a.organization_id = :organization_id',
225
- this.user.organization_id,
226
- );
477
+ // Filter for the caller's tenant/organization
478
+ this.predicates.add('a.tenant_id = :tenant_id', this.user.organization_id);
227
479
  }
228
480
  }
229
481
  `;
@@ -292,10 +544,19 @@ export class ${className} extends MigrationUtility {
292
544
 
293
545
  process() {
294
546
  this.rootPrimary();
547
+
548
+ this.foreign({ name: 'tenant_id', foreignTable: 'sys_tenants' });
295
549
  this.foreign({ name: 'created_by', foreignTable: 'sys_users' });
296
- // 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
+
297
554
  this.json('attributes');
298
555
  this.timestamps();
556
+
557
+ this.index('tenant_id');
558
+ this.index('created_by');
559
+ this.index('updated_by');
299
560
  }
300
561
  }
301
562
  `;
@@ -435,6 +696,11 @@ module.exports = {
435
696
  entity,
436
697
  attributesDto,
437
698
  controller,
699
+ service,
700
+ bareController,
701
+ bareExternalController,
702
+ bareService,
703
+ additionalJob,
438
704
  dto,
439
705
  processor,
440
706
  listProcessor,