@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 +13 -7
- package/package.json +2 -2
- package/src/commands/gen.flag.js +7 -1
- package/src/commands/gen.js +1 -1
- package/src/lib/templates.js +106 -28
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 → `
|
|
124
|
-
relation,
|
|
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
|
|
219
|
-
with `@
|
|
220
|
-
|
|
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.
|
|
4
|
-
"description": "nh / nhx
|
|
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",
|
package/src/commands/gen.flag.js
CHANGED
|
@@ -283,7 +283,13 @@ async function genFlag(target, { external = false } = {}) {
|
|
|
283
283
|
break;
|
|
284
284
|
|
|
285
285
|
case 'dataProcessor':
|
|
286
|
-
write(
|
|
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));
|
package/src/commands/gen.js
CHANGED
|
@@ -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) ------
|
package/src/lib/templates.js
CHANGED
|
@@ -103,26 +103,20 @@ export class ${n.entityClass} extends CommonEntity {
|
|
|
103
103
|
`;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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('
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
}
|