@neohm/nh-cli 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -120,15 +120,21 @@ created spreading `baseJobConstants` / `baseEntityConstants` /
120
120
 
121
121
  Generated artifacts extend the `@neohm/nestend` base classes:
122
122
 
123
- - Entity → `CommonEntity` (a `tenant_id` column with its `TenantEntity`
124
- relation, plus an empty `attributes` jsonb column)
123
+ - Entity → `CommonTenantEntity` under `nh` (it carries `tenant_id` and the
124
+ `TenantEntity` relation, and `TenantScopeSubscriber` stamps and guards it),
125
+ `CommonEntity` under `nhx`; both with an empty `attributes` jsonb column
125
126
  - DTO → `CommonDataDto`
126
127
  - Data processor → `CommonDataProcessor` (`process()` → `validate()` → `set()`),
127
- reached through the service's `upsert()`
128
+ reached through the service's `upsert()`. It takes the caller and, on edit,
129
+ loads the row scoped to them — another owner's `id` is a field error, never an
130
+ update.
128
131
  - Listing processor → `CommonListProcessor` (column allowlist config, `id` default sort). It takes `CommonListFilterDto` directly — no per-list DTO is generated, since paging, sorting, filters and free-text search all live on the base DTO and the filterable columns are declared in the processor's `columns` config.
129
132
 
130
133
  ### Controller → service → processor
131
134
 
135
+ Generated controllers run under `@UsePipes(StrictValidationPipe)`, take ids
136
+ with `ParseUUIDPipe`, and describe every route with `@ApiOperation`.
137
+
132
138
  Controllers hold no business logic. They authenticate with
133
139
  `AuthService.validateAccess()`, then delegate to the module's service — so the
134
140
  same operation is reusable from a job, a subscriber or another service, and the
@@ -141,7 +147,7 @@ answers you already gave:
141
147
  | Service method | Generated when | Controller endpoint |
142
148
  | --- | --- | --- |
143
149
  | `get(user, id)` | always | `GET /api/v1/shape/:id` |
144
- | `upsert(data)` | data processor: Yes | `POST /api/v1/shape` |
150
+ | `upsert(user, data)` | data processor: Yes | `POST /api/v1/shape` |
145
151
  | `list(user, data)` | listing processor: Yes | `POST /api/v1/shape/list` |
146
152
 
147
153
  `get()` is the one operation that needs no processor, so a module that declines
@@ -215,9 +221,9 @@ Only one target flag per run; the module is created if it does not exist yet.
215
221
 
216
222
  External files take an `external.` filename prefix and an `External` class
217
223
  prefix. An external controller differs from an internal one in three ways, all
218
- load-bearing: it mounts at `api/e/v1/<path>`, it declares the key to swagger
219
- with `@ApiSecurity('api-key')`, and it authenticates through `ApiKeyService`
220
- rather than `AuthService`.
224
+ load-bearing: it mounts at `api/e/v1/<path>`, it documents the key header to
225
+ swagger with `@ApiHeader`, and it authenticates through `ApiKeyService` rather
226
+ than `AuthService`.
221
227
 
222
228
  `-x` does not apply to `-e` or `-j`: a table and a queue worker have no
223
229
  inward/outward face, so there is nothing to mirror. Both refuse the flag rather
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@neohm/nh-cli",
3
- "version": "1.3.0",
4
- "description": "nh / nhx \u2014 Command Line Interface for neohm. NestJS projects",
3
+ "version": "1.3.1",
4
+ "description": "nh / nhx — Command Line Interface for neohm. NestJS projects",
5
5
  "main": "src/index.js",
6
6
  "bin": {
7
7
  "nh": "bin/nh.js",
@@ -283,7 +283,13 @@ async function genFlag(target, { external = false } = {}) {
283
283
  break;
284
284
 
285
285
  case 'dataProcessor':
286
- write(dest, tpl.processor(rawName, { external }));
286
+ write(
287
+ dest,
288
+ tpl.processor(rawName, {
289
+ external,
290
+ withEntity: fs.existsSync(path.join(moduleDir, 'entities', n.entityFile)),
291
+ }),
292
+ );
287
293
  // The processor's payload DTO lives in the module's dtos/ either way —
288
294
  // created here only if it is not already there.
289
295
  write(path.join(moduleDir, 'dtos', n.dtoFile), tpl.dto(rawName));
@@ -135,7 +135,7 @@ async function gen() {
135
135
  report(writeFile(dtoPath, tpl.dto(entityName)), dtoPath, paths.root);
136
136
 
137
137
  const procPath = path.join(moduleDir, "libraries", n.processorFile);
138
- report(writeFile(procPath, tpl.processor(entityName)), procPath, paths.root);
138
+ report(writeFile(procPath, tpl.processor(entityName, { withEntity: true })), procPath, paths.root);
139
139
  }
140
140
 
141
141
  // 6. Optional: listing processor (takes CommonListFilterDto directly) ------
@@ -103,26 +103,20 @@ export class ${n.entityClass} extends CommonEntity {
103
103
  `;
104
104
  }
105
105
 
106
- return `import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
107
- import { CommonEntity, TenantEntity } from '${pkg()}';
106
+ // CommonTenantEntity adds tenant_id + the tenant relation; TenantScopeSubscriber
107
+ // stamps it from the session and refuses cross-tenant writes.
108
+ return `import { Column, Entity } from 'typeorm';
109
+ import { CommonTenantEntity } from '${pkg()}';
108
110
  import { ${n.attributesDtoClass} } from '../dtos/${n.dot}.attributes.dto';
109
111
 
110
112
  @Entity({ name: '${tableName}' })
111
- export class ${n.entityClass} extends CommonEntity {
112
- // Tenant scope — the service and the list processor both filter on it.
113
- @Column()
114
- tenant_id: string;
115
-
113
+ export class ${n.entityClass} extends CommonTenantEntity {
116
114
  // TODO: declare the columns ${n.pascal} owns.
117
115
 
118
116
  @Column({ type: 'jsonb' })
119
117
  attributes: ${n.attributesDtoClass};
120
118
 
121
119
  // Relations and methods
122
-
123
- @ManyToOne(() => TenantEntity)
124
- @JoinColumn({ name: 'tenant_id' })
125
- tenant: TenantEntity;
126
120
  }
127
121
  `;
128
122
  }
@@ -146,13 +140,13 @@ function controller(base, { processor = false, listProcessor = false } = {}) {
146
140
  const hasPost = processor || listProcessor;
147
141
 
148
142
  // @nestjs/common members, alphabetised the way prettier leaves them.
149
- const nestCommon = ['Controller', 'Get', 'Param'];
143
+ const nestCommon = ['Controller', 'Get', 'Param', 'ParseUUIDPipe', 'UsePipes'];
150
144
  if (hasPost) nestCommon.push('Body', 'Post');
151
145
  nestCommon.sort();
152
146
 
153
147
  const nestendImports = ['AuthService'];
154
148
  if (listProcessor) nestendImports.push('CommonListFilterDto');
155
- nestendImports.push('CommonParamsDto');
149
+ nestendImports.push('StrictValidationPipe');
156
150
 
157
151
  const imports = [
158
152
  namedImport(nestCommon, '@nestjs/common'),
@@ -160,7 +154,7 @@ function controller(base, { processor = false, listProcessor = false } = {}) {
160
154
  // DocumentBuilder call only registers the scheme (under its default name,
161
155
  // 'bearer') and draws the Authorize button — without the decorator
162
156
  // swagger-ui shows no padlock and never sends the Authorization header.
163
- `import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';`,
157
+ `import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';`,
164
158
  namedImport(nestendImports, pkg()),
165
159
  `import { ${n.serviceClass} } from '../services/${n.dot}.service';`,
166
160
  ];
@@ -180,24 +174,27 @@ function controller(base, { processor = false, listProcessor = false } = {}) {
180
174
 
181
175
  const methods = [
182
176
  ` @Get(':id')
183
- async get${n.pascal}(@Param() params: CommonParamsDto) {
177
+ @ApiOperation({ summary: 'One ${n.kebab} by id.' })
178
+ async get${n.pascal}(@Param('id', ParseUUIDPipe) id: string) {
184
179
  const { user } = ${gate(' ')};
185
180
 
186
- return this.${svcProp}.get(user, params.id);
181
+ return this.${svcProp}.get(user, id);
187
182
  }`,
188
183
  ];
189
184
 
190
185
  if (processor) {
191
186
  methods.push(` @Post()
187
+ @ApiOperation({ summary: 'Create a ${n.kebab}, or update it when an id is sent.' })
192
188
  async upsert${n.pascal}(@Body() body: ${n.dtoClass}) {
193
- ${tenanted ? `await this.authService.validateAccess('add-scope', 'add-role');` : `await this.authService.validateAccess('add-scope');`}
189
+ const { user } = ${gate(' ')};
194
190
 
195
- return this.${svcProp}.upsert(body);
191
+ return this.${svcProp}.upsert(user, body);
196
192
  }`);
197
193
  }
198
194
 
199
195
  if (listProcessor) {
200
196
  methods.push(` @Post('list')
197
+ @ApiOperation({ summary: 'List, search and filter ${n.kebab}.' })
201
198
  async list(@Body() body: CommonListFilterDto) {
202
199
  const { user } = ${gate(' ')};
203
200
 
@@ -214,6 +211,7 @@ function controller(base, { processor = false, listProcessor = false } = {}) {
214
211
  ${gateTodo}
215
212
  @ApiTags('${n.kebab}')
216
213
  @ApiBearerAuth()
214
+ @UsePipes(StrictValidationPipe)
217
215
  @Controller('api/v1/${n.kebab}')
218
216
  export class ${n.controllerClass} {
219
217
  constructor(
@@ -288,8 +286,8 @@ function service(base, { processor = false, listProcessor = false } = {}) {
288
286
  if (processor) {
289
287
  // Insert or update: CommonDataDto carries an optional `id`, and the data
290
288
  // processor decides which of the two the payload asks for.
291
- members.push(` async upsert(data: ${n.dtoClass}) {
292
- return new ${n.processorClass}().process(data);
289
+ members.push(` async upsert(user: UserEntity, data: ${n.dtoClass}) {
290
+ return new ${n.processorClass}(user).process(data);
293
291
  }`);
294
292
  }
295
293
 
@@ -329,12 +327,13 @@ ${members.join('\n\n')}
329
327
  */
330
328
  function bareController(base, routePath) {
331
329
  const n = names(base);
332
- return `import { Controller, Get } from '@nestjs/common';
330
+ return `import { Controller, Get, UsePipes } from '@nestjs/common';
333
331
  import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
334
- import { AuthService } from '${pkg()}';
332
+ import { AuthService, StrictValidationPipe } from '${pkg()}';
335
333
 
336
334
  @ApiTags('${routePath}')
337
335
  @ApiBearerAuth()
336
+ @UsePipes(StrictValidationPipe)
338
337
  @Controller('api/v1/${routePath}')
339
338
  export class ${n.controllerClass} {
340
339
  constructor(private readonly authService: AuthService) {}
@@ -353,12 +352,13 @@ export class ${n.controllerClass} {
353
352
  */
354
353
  function bareExternalController(base, routePath) {
355
354
  const n = names(base);
356
- return `import { Controller, Get } from '@nestjs/common';
357
- import { ApiSecurity, ApiTags } from '@nestjs/swagger';
358
- import { ApiKeyService } from '${pkg()}';
355
+ return `import { Controller, Get, UsePipes } from '@nestjs/common';
356
+ import { ApiHeader, ApiTags } from '@nestjs/swagger';
357
+ import { ApiKeyService, StrictValidationPipe } from '${pkg()}';
359
358
 
360
359
  @ApiTags('external-${routePath} (Api key)')
361
- @ApiSecurity('api-key')
360
+ @ApiHeader({ name: 'x-api-key', required: true })
361
+ @UsePipes(StrictValidationPipe)
362
362
  @Controller('api/e/v1/${routePath}')
363
363
  export class ${n.externalControllerClass} {
364
364
  constructor(private readonly apiKeyService: ApiKeyService) {}
@@ -414,13 +414,14 @@ export class ${n.dtoClass} extends CommonDataDto {
414
414
  `;
415
415
  }
416
416
 
417
- function processor(base, { external = false } = {}) {
417
+ function processor(base, { external = false, withEntity = false } = {}) {
418
418
  const n = names(base);
419
419
  // An external processor sits one level deeper (external/libraries/), so its
420
420
  // reach back into the module's dtos/ gains a segment.
421
421
  const up = external ? '../..' : '..';
422
422
  const cls = external ? n.externalProcessorClass : n.processorClass;
423
- return `import { CommonDataProcessor } from '${pkg()}';
423
+ if (external) {
424
+ return `import { CommonDataProcessor } from '${pkg()}';
424
425
  import { ${n.dtoClass} } from '${up}/dtos/add.${n.dot}.data.dto';
425
426
 
426
427
  export class ${cls} extends CommonDataProcessor {
@@ -443,6 +444,80 @@ export class ${cls} extends CommonDataProcessor {
443
444
  // TODO: persist ${n.pascal} using this.payload.
444
445
  }
445
446
  }
447
+ `;
448
+ }
449
+
450
+ // Edits load the caller's own row: another owner's id reads as not found.
451
+ const owner = current().tenantScoped
452
+ ? 'tenant_id: this.user.organization_id'
453
+ : 'created_by: this.user.id';
454
+ const label = toWords(base).join(' ');
455
+ const entityImport = withEntity
456
+ ? `\nimport { ${n.entityClass} } from '${up}/entities/${n.dot}.entity';`
457
+ : '';
458
+ const existingField = withEntity
459
+ ? `\n private existing: ${n.entityClass} | null = null;`
460
+ : '';
461
+ const load = withEntity
462
+ ? ` if (this.payload.id) {
463
+ this.existing = await ${n.entityClass}.findOne({
464
+ where: { id: this.payload.id, ${owner} },
465
+ });
466
+ if (!this.existing) this.setColumnError('id', 'Invalid ${label} id');
467
+ }
468
+
469
+ `
470
+ : ` // TODO: on edit (payload.id), load the row scoped to the caller
471
+ // ({ id, ${owner} }) and record an 'id' error when it is not found.
472
+
473
+ `;
474
+ const set = withEntity
475
+ ? ` private set(): Promise<${n.entityClass}> {
476
+ const ${n.camel} = this.existing ?? ${n.entityClass}.create();
477
+ // TODO: assign fields from this.payload explicitly — never spread the DTO.
478
+
479
+ return ${n.camel}.save();
480
+ }`
481
+ : ` private set(): void {
482
+ // TODO: persist ${n.pascal} from this.payload, assigning fields explicitly.
483
+ }`;
484
+ return `import { CommonDataProcessor, UserEntity } from '${pkg()}';
485
+ import { ${n.dtoClass} } from '${up}/dtos/add.${n.dot}.data.dto';${entityImport}
486
+
487
+ export class ${cls} extends CommonDataProcessor {
488
+ protected payload: ${n.dtoClass};${existingField}
489
+
490
+ constructor(private readonly user: UserEntity) {
491
+ super();
492
+ }
493
+
494
+ ${
495
+ withEntity
496
+ ? ` async process(data: ${n.dtoClass}) {
497
+ this.payload = data;
498
+
499
+ await this.validate();
500
+
501
+ return this.set();
502
+ }
503
+
504
+ private async validate() {`
505
+ : ` process(data: ${n.dtoClass}) {
506
+ this.payload = data;
507
+
508
+ this.validate();
509
+
510
+ return this.set();
511
+ }
512
+
513
+ private validate(): void {`
514
+ }
515
+ ${load} // TODO: collect validation errors via this.addErrors({ column: 'message' }).
516
+ this.raiseExceptionOnValidationFailure();
517
+ }
518
+
519
+ ${set}
520
+ }
446
521
  `;
447
522
  }
448
523
 
@@ -543,6 +618,9 @@ export class ${n.subscriberClass} extends CommonSubscriber<${n.entityClass}> {
543
618
  datasource.subscribers.push(this);
544
619
  }
545
620
 
621
+ // Jobs receive only \`id\` unless the columns they need are listed here.
622
+ protected readonly snapshotColumns: (keyof ${n.entityClass} & string)[] = [];
623
+
546
624
  listenTo() {
547
625
  return ${n.entityClass};
548
626
  }