@neohm/nh-cli 1.2.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
@@ -1,15 +1,37 @@
1
- # @neohm/nh-cli — `nh`
1
+ # @neohm/nh-cli — `nh` and `nhx`
2
2
 
3
3
  A scaffolding CLI that accelerates development on **neohm. NestJS projects**
4
- built on top of the shared [`@neohm/nestend`](../nestend) library.
4
+ built on top of the shared [`@neohm/nestend`](../nestend) library — or its B2C
5
+ sibling [`@neohm/nestendx`](../nestendx).
5
6
 
6
7
  `nh` generates fully wired modules, entities, controllers, services, DTOs, data
7
8
  processors, migrations and seeds — and keeps your import barrels
8
9
  (`es6.classes.ts`) and `app.module.ts` registration in sync, so the project
9
10
  builds without any manual import wiring.
10
11
 
11
- Every generated artifact matches the conventions of the `nestjs-boilerplate`
12
- reference project:
12
+ ## Two flavours, one CLI
13
+
14
+ `nh` writes code against **`@neohm/nestend`**, `nhx` against
15
+ **`@neohm/nestendx`**. Same commands, same conventions, same wiring — they
16
+ differ only where the two packages do:
17
+
18
+ | | `nh` (nestend) | `nhx` (nestendx) |
19
+ | ------------------------ | ------------------------------------------ | ----------------------------------------- |
20
+ | Import specifier | `@neohm/nestend` | `@neohm/nestendx` |
21
+ | What a row belongs to | a tenant (`tenant_id` + `TenantEntity`) | the user who created it (`created_by`) |
22
+ | Access check | `validateAccess(scope, role)` | `validateAccess(scope)` — there are no roles |
23
+ | Generated migration | FK + index on `tenant_id` | no tenant column to key |
24
+
25
+ Everything that does not write package-specific code — `sync`, `check`, `list`,
26
+ `remove` — behaves identically under either name.
27
+
28
+ Running the wrong one is refused rather than half-generated: both read the
29
+ target project's `package.json`, and scaffolding tenanted code into a nestendx
30
+ project (or the reverse) stops with a message naming the command to use instead.
31
+ A project that depends on neither is taken at its word.
32
+
33
+ Every generated artifact matches the conventions of the reference project for
34
+ that flavour — `nestjs-boilerplate` for `nh`, `nestendx-boilerplate` for `nhx`:
13
35
 
14
36
  - **Filenames** are `dot.separated.lowercase` (no hyphens) — e.g.
15
37
  `shape.entity.ts`, and a multi-word `RoundShape` becomes
@@ -22,13 +44,15 @@ reference project:
22
44
  ```bash
23
45
  # from the neohm-cli directory
24
46
  npm install
25
- npm link # exposes `nh` globally
47
+ npm link # exposes both `nh` and `nhx` globally
26
48
 
27
49
  # or run without linking
28
50
  npx nh --help
51
+ npx nhx --help
29
52
  ```
30
53
 
31
- Run `nh` from **inside** a target NestJS project (e.g. `nestjs-boilerplate`).
54
+ Run `nh` from **inside** a target NestJS project (e.g. `nestjs-boilerplate`),
55
+ or `nhx` from inside a nestendx one (e.g. `nestendx-boilerplate`).
32
56
  The project root is auto-detected by walking up from the current directory for a
33
57
  `package.json` next to a `src/` folder.
34
58
 
@@ -46,6 +70,9 @@ The project root is auto-detected by walking up from the current directory for a
46
70
  | `nh remove <name>` | Safely remove a scaffolded module and de-register it (migrations are preserved). |
47
71
  | `nh --help` / `nh -v` | Help and version. |
48
72
 
73
+ Read `nhx` for `nh` throughout the table above when generating against
74
+ `@neohm/nestendx`.
75
+
49
76
  ### `nh gen`
50
77
 
51
78
  ```text
@@ -93,15 +120,21 @@ created spreading `baseJobConstants` / `baseEntityConstants` /
93
120
 
94
121
  Generated artifacts extend the `@neohm/nestend` base classes:
95
122
 
96
- - Entity → `CommonEntity` (a `tenant_id` column with its `TenantEntity`
97
- 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
98
126
  - DTO → `CommonDataDto`
99
127
  - Data processor → `CommonDataProcessor` (`process()` → `validate()` → `set()`),
100
- 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.
101
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.
102
132
 
103
133
  ### Controller → service → processor
104
134
 
135
+ Generated controllers run under `@UsePipes(StrictValidationPipe)`, take ids
136
+ with `ParseUUIDPipe`, and describe every route with `@ApiOperation`.
137
+
105
138
  Controllers hold no business logic. They authenticate with
106
139
  `AuthService.validateAccess()`, then delegate to the module's service — so the
107
140
  same operation is reusable from a job, a subscriber or another service, and the
@@ -114,7 +147,7 @@ answers you already gave:
114
147
  | Service method | Generated when | Controller endpoint |
115
148
  | --- | --- | --- |
116
149
  | `get(user, id)` | always | `GET /api/v1/shape/:id` |
117
- | `upsert(data)` | data processor: Yes | `POST /api/v1/shape` |
150
+ | `upsert(user, data)` | data processor: Yes | `POST /api/v1/shape` |
118
151
  | `list(user, data)` | listing processor: Yes | `POST /api/v1/shape/list` |
119
152
 
120
153
  `get()` is the one operation that needs no processor, so a module that declines
@@ -188,9 +221,9 @@ Only one target flag per run; the module is created if it does not exist yet.
188
221
 
189
222
  External files take an `external.` filename prefix and an `External` class
190
223
  prefix. An external controller differs from an internal one in three ways, all
191
- load-bearing: it mounts at `api/e/v1/<path>`, it declares the key to swagger
192
- with `@ApiSecurity('api-key')`, and it authenticates through `ApiKeyService`
193
- 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`.
194
227
 
195
228
  `-x` does not apply to `-e` or `-j`: a table and a queue worker have no
196
229
  inward/outward face, so there is nothing to mirror. Both refuse the flag rather
package/bin/nh.js CHANGED
@@ -1,6 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
+ // Generates @neohm/nestend code — the tenanted model. `nhx` is the same CLI
5
+ // against @neohm/nestendx; the flavour is set before anything else loads,
6
+ // because the templates read it while they are being defined.
7
+ require('../src/lib/flavour').setFlavour('nestend');
8
+
4
9
  const { program } = require('../src/index');
5
10
 
6
11
  program.parseAsync(process.argv);
package/bin/nhx.js ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // The nestendx flavour of `nh`: same commands, same conventions, code written
5
+ // against @neohm/nestendx — users hold scopes rather than roles, and a row
6
+ // belongs to whoever created it rather than to a tenant.
7
+ require('../src/lib/flavour').setFlavour('nestendx');
8
+
9
+ const { program } = require('../src/index');
10
+
11
+ program.parseAsync(process.argv);
package/package.json CHANGED
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "name": "@neohm/nh-cli",
3
- "version": "1.2.0",
4
- "description": "nh — 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
- "nh": "bin/nh.js"
7
+ "nh": "bin/nh.js",
8
+ "nhx": "bin/nhx.js"
8
9
  },
9
10
  "scripts": {
10
11
  "start": "node bin/nh.js",
11
- "prepublishOnly": "find bin src -name '*.js' | xargs node --check"
12
+ "prepublishOnly": "find bin src -name '*.js' | xargs node --check",
13
+ "start:x": "node bin/nhx.js"
12
14
  },
13
15
  "packageManager": "yarn@1.22.22",
14
16
  "files": [
@@ -20,7 +22,8 @@
20
22
  "cli",
21
23
  "scaffold",
22
24
  "neohm",
23
- "nestend"
25
+ "nestend",
26
+ "nestendx"
24
27
  ],
25
28
  "author": "Ashim Baral",
26
29
  "license": "MIT",
@@ -2,6 +2,7 @@
2
2
 
3
3
  const path = require('path');
4
4
  const chalk = require('chalk');
5
+ const { cli } = require('../lib/flavour');
5
6
 
6
7
  const { projectPaths } = require('../lib/paths');
7
8
  const engine = require('../lib/sync.engine');
@@ -47,9 +48,13 @@ async function check() {
47
48
  }
48
49
 
49
50
  if (changes) {
50
- console.log(chalk.yellow(`\n${changes} file(s) would change. Run \`nh sync\` to apply.`));
51
+ console.log(
52
+ chalk.yellow(
53
+ `\n${changes} file(s) would change. Run \`${cli()} sync\` to apply.`,
54
+ ),
55
+ );
51
56
  } else {
52
- log.done('In sync — `nh sync` would make no changes.');
57
+ log.done(`In sync — \`${cli()} sync\` would make no changes.`);
53
58
  }
54
59
  }
55
60
 
@@ -6,6 +6,7 @@ const inquirer = require('inquirer');
6
6
  const chalk = require('chalk');
7
7
 
8
8
  const { projectPaths, MODULE_SUBFOLDERS } = require('../lib/paths');
9
+ const { assertFlavourMatchesProject, cli } = require('../lib/flavour');
9
10
  const naming = require('../lib/naming');
10
11
  const tpl = require('../lib/templates');
11
12
  const {
@@ -170,6 +171,7 @@ async function genFlag(target, { external = false } = {}) {
170
171
  }
171
172
 
172
173
  const paths = projectPaths();
174
+ assertFlavourMatchesProject(paths.root);
173
175
  log.info(`Project: ${paths.root}\n`);
174
176
 
175
177
  const touched = [];
@@ -281,7 +283,13 @@ async function genFlag(target, { external = false } = {}) {
281
283
  break;
282
284
 
283
285
  case 'dataProcessor':
284
- 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
+ );
285
293
  // The processor's payload DTO lives in the module's dtos/ either way —
286
294
  // created here only if it is not already there.
287
295
  write(path.join(moduleDir, 'dtos', n.dtoFile), tpl.dto(rawName));
@@ -346,7 +354,9 @@ async function genFlag(target, { external = false } = {}) {
346
354
  const made = artifacts.map((a) => a.className).join(', ');
347
355
  log.done(`${made} generated in "${moduleDot}".`);
348
356
  console.log(
349
- chalk.dim('Run `nh sync` anytime to re-wire imports after manual edits.'),
357
+ chalk.dim(
358
+ `Run \`${cli()} sync\` anytime to re-wire imports after manual edits.`,
359
+ ),
350
360
  );
351
361
  }
352
362
 
@@ -6,6 +6,7 @@ const inquirer = require("inquirer");
6
6
  const chalk = require("chalk");
7
7
 
8
8
  const { projectPaths, MODULE_SUBFOLDERS } = require("../lib/paths");
9
+ const { assertFlavourMatchesProject, cli } = require("../lib/flavour");
9
10
  const naming = require("../lib/naming");
10
11
  const tpl = require("../lib/templates");
11
12
  const { writeFile, report: reportFile, log, ensureDir } = require("../lib/fs.utils");
@@ -15,6 +16,7 @@ const { registerJob, registerEntity } = require("../lib/registry");
15
16
 
16
17
  async function gen() {
17
18
  const paths = projectPaths();
19
+ assertFlavourMatchesProject(paths.root);
18
20
  log.info(`Project: ${paths.root}\n`);
19
21
 
20
22
  // Every file this run writes or rewrites, formatted in one pass at the end.
@@ -133,7 +135,7 @@ async function gen() {
133
135
  report(writeFile(dtoPath, tpl.dto(entityName)), dtoPath, paths.root);
134
136
 
135
137
  const procPath = path.join(moduleDir, "libraries", n.processorFile);
136
- report(writeFile(procPath, tpl.processor(entityName)), procPath, paths.root);
138
+ report(writeFile(procPath, tpl.processor(entityName, { withEntity: true })), procPath, paths.root);
137
139
  }
138
140
 
139
141
  // 6. Optional: listing processor (takes CommonListFilterDto directly) ------
@@ -215,7 +217,9 @@ async function gen() {
215
217
 
216
218
  log.done(`Module "${moduleDot}" scaffolded.`);
217
219
  console.log(
218
- chalk.dim("Run `nh sync` anytime to re-wire imports after manual edits."),
220
+ chalk.dim(
221
+ `Run \`${cli()} sync\` anytime to re-wire imports after manual edits.`,
222
+ ),
219
223
  );
220
224
  }
221
225
 
@@ -4,12 +4,14 @@ const path = require("path");
4
4
  const inquirer = require("inquirer");
5
5
 
6
6
  const { projectPaths } = require("../lib/paths");
7
+ const { assertFlavourMatchesProject } = require("../lib/flavour");
7
8
  const naming = require("../lib/naming");
8
9
  const tpl = require("../lib/templates");
9
10
  const { writeFile, report, log } = require("../lib/fs.utils");
10
11
 
11
12
  async function migration() {
12
13
  const paths = projectPaths();
14
+ assertFlavourMatchesProject(paths.root);
13
15
 
14
16
  const { tableName, migrationName } = await inquirer.prompt([
15
17
  {
@@ -4,12 +4,14 @@ const path = require("path");
4
4
  const inquirer = require("inquirer");
5
5
 
6
6
  const { projectPaths } = require("../lib/paths");
7
+ const { assertFlavourMatchesProject } = require("../lib/flavour");
7
8
  const naming = require("../lib/naming");
8
9
  const tpl = require("../lib/templates");
9
10
  const { writeFile, report, log } = require("../lib/fs.utils");
10
11
 
11
12
  async function seed() {
12
13
  const paths = projectPaths();
14
+ assertFlavourMatchesProject(paths.root);
13
15
 
14
16
  const { tableName, seedName } = await inquirer.prompt([
15
17
  {
package/src/index.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const { Command } = require('commander');
4
4
  const chalk = require('chalk');
5
5
  const pkg = require('../package.json');
6
+ const flavour = require('./lib/flavour');
6
7
 
7
8
  const program = new Command();
8
9
 
@@ -17,14 +18,21 @@ function run(action) {
17
18
  };
18
19
  }
19
20
 
21
+ // `nh` and `nhx` are the same program against different packages: the bin
22
+ // entrypoint picks the flavour before this module is required, and everything
23
+ // that generates code reads it from there.
24
+ const { cli, package: packageName } = flavour.current();
25
+
20
26
  program
21
- .name('nh')
22
- .description('nh — scaffolding CLI for neohm. NestJS projects (built on @neohm/nestend)')
27
+ .name(cli)
28
+ .description(
29
+ `${cli} — scaffolding CLI for neohm. NestJS projects (built on ${packageName})`,
30
+ )
23
31
  .version(pkg.version, '-v, --version', 'output the current version');
24
32
 
25
- // `nh gen` with no target flag runs the full wizard; a target flag generates
26
- // just that one artifact. -x mirrors a target onto the module's external
27
- // (API-key) surface and applies to -c, -s, -dp and -lp only.
33
+ // `gen` with no target flag runs the full wizard; a target flag generates just
34
+ // that one artifact. -x mirrors a target onto the module's external (API-key)
35
+ // surface and applies to -c, -s, -dp and -lp only.
28
36
  const GEN_TARGETS = [
29
37
  ['controller', 'controller'],
30
38
  ['service', 'service'],
@@ -50,15 +58,15 @@ program
50
58
  'after',
51
59
  `
52
60
  Examples:
53
- nh gen full wizard: module, entity, controller, service, ...
54
- nh gen -c controller only (asks module, name, route path)
55
- nh gen -s bare injectable service
56
- nh gen -dp data processor (+ its Add<Name>DataDto if missing)
57
- nh gen -lp listing processor
58
- nh gen -e entity + migration, registered in the constants/maps
59
- nh gen -j job, registered in job.constants/job.maps
60
- nh gen -x -c external controller under <module>/external/controllers
61
- nh gen -x -dp external data processor under <module>/external/libraries`,
61
+ ${cli} gen full wizard: module, entity, controller, service, ...
62
+ ${cli} gen -c controller only (asks module, name, route path)
63
+ ${cli} gen -s bare injectable service
64
+ ${cli} gen -dp data processor (+ its Add<Name>DataDto if missing)
65
+ ${cli} gen -lp listing processor
66
+ ${cli} gen -e entity + migration, registered in the constants/maps
67
+ ${cli} gen -j job, registered in job.constants/job.maps
68
+ ${cli} gen -x -c external controller under <module>/external/controllers
69
+ ${cli} gen -x -dp external data processor under <module>/external/libraries`,
62
70
  )
63
71
  .action(
64
72
  run((opts) => {
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * Which package the generated code is written against.
8
+ *
9
+ * The two share every convention this CLI encodes — dot.separated filenames,
10
+ * the processor lifecycle, es6.classes barrels, the migration utility — and
11
+ * differ in what a record belongs to. `nestend` is the tenanted model: rows
12
+ * carry a `tenant_id`, access is a scope *and* a role. `nestendx` is the B2C
13
+ * one: users register themselves and hold scopes alone, so a row belongs to the
14
+ * user who created it and access is a scope.
15
+ *
16
+ * `nh` generates the first, `nhx` the second. Everything that does not generate
17
+ * code — sync, check, list, remove — is the same command either way.
18
+ */
19
+ const FLAVOURS = {
20
+ nestend: {
21
+ key: 'nestend',
22
+ cli: 'nh',
23
+ package: '@neohm/nestend',
24
+ /** Rows belong to a tenant; the scope check carries a role beside it. */
25
+ tenantScoped: true,
26
+ },
27
+ nestendx: {
28
+ key: 'nestendx',
29
+ cli: 'nhx',
30
+ package: '@neohm/nestendx',
31
+ tenantScoped: false,
32
+ },
33
+ };
34
+
35
+ let active = FLAVOURS.nestend;
36
+
37
+ /** Set by the bin entrypoint before anything else is required. */
38
+ function setFlavour(key) {
39
+ const flavour = FLAVOURS[key];
40
+ if (!flavour) throw new Error(`Unknown flavour "${key}".`);
41
+ active = flavour;
42
+ return active;
43
+ }
44
+
45
+ function current() {
46
+ return active;
47
+ }
48
+
49
+ /** The package specifier generated imports are written against. */
50
+ function pkg() {
51
+ return active.package;
52
+ }
53
+
54
+ /** The command the person actually typed, for help text and hints. */
55
+ function cli() {
56
+ return active.cli;
57
+ }
58
+
59
+ /**
60
+ * Which package a project depends on, read from its package.json. Returns null
61
+ * when it depends on neither — a project that has not installed one yet is not
62
+ * an error, it is simply not evidence either way.
63
+ */
64
+ function detectFlavour(projectRoot) {
65
+ const manifest = path.join(projectRoot, 'package.json');
66
+ let deps;
67
+ try {
68
+ const parsed = JSON.parse(fs.readFileSync(manifest, 'utf8'));
69
+ deps = { ...parsed.dependencies, ...parsed.devDependencies };
70
+ } catch {
71
+ return null;
72
+ }
73
+
74
+ // nestendx first: "@neohm/nestend" is a prefix of it, and a project can only
75
+ // sensibly depend on one.
76
+ if (deps[FLAVOURS.nestendx.package]) return FLAVOURS.nestendx.key;
77
+ if (deps[FLAVOURS.nestend.package]) return FLAVOURS.nestend.key;
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Refuse to scaffold against the wrong package. Generating tenanted code into a
83
+ * nestendx project produces files that import a package that is not there and
84
+ * columns no table has — a failure the person would meet later, in a compiler
85
+ * error, with nothing pointing back here.
86
+ */
87
+ function assertFlavourMatchesProject(projectRoot) {
88
+ const detected = detectFlavour(projectRoot);
89
+ if (!detected || detected === active.key) return;
90
+
91
+ const wanted = FLAVOURS[detected];
92
+ throw new Error(
93
+ `This project depends on ${wanted.package}, but you ran ${active.cli}, ` +
94
+ `which generates ${active.package} code.\n Use \`${wanted.cli}\` here instead.`,
95
+ );
96
+ }
97
+
98
+ module.exports = {
99
+ FLAVOURS,
100
+ setFlavour,
101
+ current,
102
+ pkg,
103
+ cli,
104
+ detectFlavour,
105
+ assertFlavourMatchesProject,
106
+ };
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
 
3
+ const { pkg, current } = require('./flavour');
3
4
  const {
4
5
  toWords,
5
6
  toDotCase,
@@ -11,10 +12,17 @@ const {
11
12
 
12
13
  /**
13
14
  * Code templates. All generated code imports shared base classes from the
14
- * published library `@neohm/nestend` (verified exports: CommonEntity,
15
- * CommonDataDto, CommonDataProcessor, MigrationUtility, SeederUtility, etc.)
16
- * and matches the dot.separated filename / PascalCase class conventions used
17
- * across nestjs-boilerplate.
15
+ * package the active flavour names — `@neohm/nestend` under `nh`,
16
+ * `@neohm/nestendx` under `nhx` (verified exports: CommonEntity, CommonDataDto,
17
+ * CommonDataProcessor, MigrationUtility, SeederUtility, etc.) — and matches the
18
+ * dot.separated filename / PascalCase class conventions used across the
19
+ * boilerplates.
20
+ *
21
+ * The two differ in what a record belongs to, and that difference is read from
22
+ * `current().tenantScoped` rather than branched on a package name: tenanted
23
+ * code scopes rows by `tenant_id` and checks a scope with a role beside it,
24
+ * while the B2C model scopes by the user who created the row and checks the
25
+ * scope alone.
18
26
  */
19
27
 
20
28
  /**
@@ -75,26 +83,40 @@ function names(base) {
75
83
 
76
84
  function entity(base, tableName) {
77
85
  const n = names(base);
78
- return `import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
79
- import { CommonEntity, TenantEntity } from '@neohm/nestend';
86
+
87
+ // Without tenants there is nothing extra to declare: CommonEntity already
88
+ // carries `created_by` and its relation, which is what a row belongs to.
89
+ if (!current().tenantScoped) {
90
+ return `import { Column, Entity } from 'typeorm';
91
+ import { CommonEntity } from '${pkg()}';
80
92
  import { ${n.attributesDtoClass} } from '../dtos/${n.dot}.attributes.dto';
81
93
 
82
94
  @Entity({ name: '${tableName}' })
83
95
  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
96
  // TODO: declare the columns ${n.pascal} owns.
89
97
 
90
98
  @Column({ type: 'jsonb' })
91
99
  attributes: ${n.attributesDtoClass};
92
100
 
93
101
  // Relations and methods
102
+ }
103
+ `;
104
+ }
105
+
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()}';
110
+ import { ${n.attributesDtoClass} } from '../dtos/${n.dot}.attributes.dto';
111
+
112
+ @Entity({ name: '${tableName}' })
113
+ export class ${n.entityClass} extends CommonTenantEntity {
114
+ // TODO: declare the columns ${n.pascal} owns.
115
+
116
+ @Column({ type: 'jsonb' })
117
+ attributes: ${n.attributesDtoClass};
94
118
 
95
- @ManyToOne(() => TenantEntity)
96
- @JoinColumn({ name: 'tenant_id' })
97
- tenant: TenantEntity;
119
+ // Relations and methods
98
120
  }
99
121
  `;
100
122
  }
@@ -118,13 +140,13 @@ function controller(base, { processor = false, listProcessor = false } = {}) {
118
140
  const hasPost = processor || listProcessor;
119
141
 
120
142
  // @nestjs/common members, alphabetised the way prettier leaves them.
121
- const nestCommon = ['Controller', 'Get', 'Param'];
143
+ const nestCommon = ['Controller', 'Get', 'Param', 'ParseUUIDPipe', 'UsePipes'];
122
144
  if (hasPost) nestCommon.push('Body', 'Post');
123
145
  nestCommon.sort();
124
146
 
125
147
  const nestendImports = ['AuthService'];
126
148
  if (listProcessor) nestendImports.push('CommonListFilterDto');
127
- nestendImports.push('CommonParamsDto');
149
+ nestendImports.push('StrictValidationPipe');
128
150
 
129
151
  const imports = [
130
152
  namedImport(nestCommon, '@nestjs/common'),
@@ -132,8 +154,8 @@ function controller(base, { processor = false, listProcessor = false } = {}) {
132
154
  // DocumentBuilder call only registers the scheme (under its default name,
133
155
  // 'bearer') and draws the Authorize button — without the decorator
134
156
  // swagger-ui shows no padlock and never sends the Authorization header.
135
- `import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';`,
136
- namedImport(nestendImports, '@neohm/nestend'),
157
+ `import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';`,
158
+ namedImport(nestendImports, pkg()),
137
159
  `import { ${n.serviceClass} } from '../services/${n.dot}.service';`,
138
160
  ];
139
161
  if (processor) {
@@ -142,43 +164,54 @@ function controller(base, { processor = false, listProcessor = false } = {}) {
142
164
  );
143
165
  }
144
166
 
167
+ // Tenanted access takes a scope and a role; the B2C model has no roles at
168
+ // all, so the same call carries the scope alone.
169
+ const tenanted = current().tenantScoped;
170
+ const gate = (indent) =>
171
+ tenanted
172
+ ? `await this.authService.validateAccess(\n${indent} 'add-scope',\n${indent} 'add-role',\n${indent})`
173
+ : `await this.authService.validateAccess('add-scope')`;
174
+
145
175
  const methods = [
146
176
  ` @Get(':id')
147
- async get${n.pascal}(@Param() params: CommonParamsDto) {
148
- const { user } = await this.authService.validateAccess(
149
- 'add-scope',
150
- 'add-role',
151
- );
177
+ @ApiOperation({ summary: 'One ${n.kebab} by id.' })
178
+ async get${n.pascal}(@Param('id', ParseUUIDPipe) id: string) {
179
+ const { user } = ${gate(' ')};
152
180
 
153
- return this.${svcProp}.get(user, params.id);
181
+ return this.${svcProp}.get(user, id);
154
182
  }`,
155
183
  ];
156
184
 
157
185
  if (processor) {
158
186
  methods.push(` @Post()
187
+ @ApiOperation({ summary: 'Create a ${n.kebab}, or update it when an id is sent.' })
159
188
  async upsert${n.pascal}(@Body() body: ${n.dtoClass}) {
160
- await this.authService.validateAccess('add-scope', 'add-role');
189
+ const { user } = ${gate(' ')};
161
190
 
162
- return this.${svcProp}.upsert(body);
191
+ return this.${svcProp}.upsert(user, body);
163
192
  }`);
164
193
  }
165
194
 
166
195
  if (listProcessor) {
167
196
  methods.push(` @Post('list')
197
+ @ApiOperation({ summary: 'List, search and filter ${n.kebab}.' })
168
198
  async list(@Body() body: CommonListFilterDto) {
169
- const { user } = await this.authService.validateAccess(
170
- 'add-scope',
171
- 'add-role',
172
- );
199
+ const { user } = ${gate(' ')};
200
+
173
201
  return this.${svcProp}.list(user, body);
174
202
  }`);
175
203
  }
176
204
 
205
+ const gateTodo = tenanted
206
+ ? `// TODO: replace 'add-scope' / 'add-role' with this module's scope and role.`
207
+ : `// TODO: replace 'add-scope' with this module's scope — a granted parent\n// scope reaches its children, so name the one the feature is filed under.`;
208
+
177
209
  return `${imports.join('\n')}
178
210
 
179
- // TODO: replace 'add-scope' / 'add-role' with this module's scope and role.
211
+ ${gateTodo}
180
212
  @ApiTags('${n.kebab}')
181
213
  @ApiBearerAuth()
214
+ @UsePipes(StrictValidationPipe)
182
215
  @Controller('api/v1/${n.kebab}')
183
216
  export class ${n.controllerClass} {
184
217
  constructor(
@@ -210,7 +243,7 @@ function service(base, { processor = false, listProcessor = false } = {}) {
210
243
  nestendImports.push('NotFoundException', 'UserEntity');
211
244
 
212
245
  const imports = [
213
- namedImport(nestendImports, '@neohm/nestend'),
246
+ namedImport(nestendImports, pkg()),
214
247
  `import { Injectable } from '@nestjs/common';`,
215
248
  ];
216
249
  if (listProcessor) {
@@ -235,11 +268,15 @@ function service(base, { processor = false, listProcessor = false } = {}) {
235
268
  );
236
269
  }
237
270
 
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".
271
+ // Ownership is part of the lookup, not a check after it: a row belonging to
272
+ // someone else must read as "not found", never as "forbidden". Tenanted rows
273
+ // carry the organization; without tenants it is the user who created it.
274
+ const owner = current().tenantScoped
275
+ ? 'tenant_id: user.organization_id'
276
+ : 'created_by: user.id';
240
277
  members.push(` async get(user: UserEntity, id: string) {
241
278
  const ${n.camel} = await ${n.entityClass}.findOne({
242
- where: { id, tenant_id: user.organization_id },
279
+ where: { id, ${owner} },
243
280
  });
244
281
  if (!${n.camel}) throw new NotFoundException('Invalid ${label} id');
245
282
 
@@ -249,8 +286,8 @@ function service(base, { processor = false, listProcessor = false } = {}) {
249
286
  if (processor) {
250
287
  // Insert or update: CommonDataDto carries an optional `id`, and the data
251
288
  // 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);
289
+ members.push(` async upsert(user: UserEntity, data: ${n.dtoClass}) {
290
+ return new ${n.processorClass}(user).process(data);
254
291
  }`);
255
292
  }
256
293
 
@@ -290,12 +327,13 @@ ${members.join('\n\n')}
290
327
  */
291
328
  function bareController(base, routePath) {
292
329
  const n = names(base);
293
- return `import { Controller, Get } from '@nestjs/common';
330
+ return `import { Controller, Get, UsePipes } from '@nestjs/common';
294
331
  import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
295
- import { AuthService } from '@neohm/nestend';
332
+ import { AuthService, StrictValidationPipe } from '${pkg()}';
296
333
 
297
334
  @ApiTags('${routePath}')
298
335
  @ApiBearerAuth()
336
+ @UsePipes(StrictValidationPipe)
299
337
  @Controller('api/v1/${routePath}')
300
338
  export class ${n.controllerClass} {
301
339
  constructor(private readonly authService: AuthService) {}
@@ -314,12 +352,13 @@ export class ${n.controllerClass} {
314
352
  */
315
353
  function bareExternalController(base, routePath) {
316
354
  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';
355
+ return `import { Controller, Get, UsePipes } from '@nestjs/common';
356
+ import { ApiHeader, ApiTags } from '@nestjs/swagger';
357
+ import { ApiKeyService, StrictValidationPipe } from '${pkg()}';
320
358
 
321
359
  @ApiTags('external-${routePath} (Api key)')
322
- @ApiSecurity('api-key')
360
+ @ApiHeader({ name: 'x-api-key', required: true })
361
+ @UsePipes(StrictValidationPipe)
323
362
  @Controller('api/e/v1/${routePath}')
324
363
  export class ${n.externalControllerClass} {
325
364
  constructor(private readonly apiKeyService: ApiKeyService) {}
@@ -348,7 +387,7 @@ export class ${cls} {}
348
387
  */
349
388
  function additionalJob(base) {
350
389
  const n = names(base);
351
- return `import { CommonJob, QueueService } from '@neohm/nestend';
390
+ return `import { CommonJob, QueueService } from '${pkg()}';
352
391
  import { Injectable } from '@nestjs/common';
353
392
  import { JobConstants } from '../../constants/job.constants';
354
393
 
@@ -367,7 +406,7 @@ export class ${n.jobClass} extends CommonJob {
367
406
 
368
407
  function dto(base) {
369
408
  const n = names(base);
370
- return `import { CommonDataDto } from '@neohm/nestend';
409
+ return `import { CommonDataDto } from '${pkg()}';
371
410
 
372
411
  export class ${n.dtoClass} extends CommonDataDto {
373
412
  // TODO: declare validated payload fields for ${n.pascal}.
@@ -375,13 +414,14 @@ export class ${n.dtoClass} extends CommonDataDto {
375
414
  `;
376
415
  }
377
416
 
378
- function processor(base, { external = false } = {}) {
417
+ function processor(base, { external = false, withEntity = false } = {}) {
379
418
  const n = names(base);
380
419
  // An external processor sits one level deeper (external/libraries/), so its
381
420
  // reach back into the module's dtos/ gains a segment.
382
421
  const up = external ? '../..' : '..';
383
422
  const cls = external ? n.externalProcessorClass : n.processorClass;
384
- return `import { CommonDataProcessor } from '@neohm/nestend';
423
+ if (external) {
424
+ return `import { CommonDataProcessor } from '${pkg()}';
385
425
  import { ${n.dtoClass} } from '${up}/dtos/add.${n.dot}.data.dto';
386
426
 
387
427
  export class ${cls} extends CommonDataProcessor {
@@ -404,6 +444,80 @@ export class ${cls} extends CommonDataProcessor {
404
444
  // TODO: persist ${n.pascal} using this.payload.
405
445
  }
406
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
+ }
407
521
  `;
408
522
  }
409
523
 
@@ -418,7 +532,7 @@ function listProcessor(base, tableName, { external = false } = {}) {
418
532
  ListFilterService,
419
533
  SortDirection,
420
534
  UserEntity,
421
- } from '@neohm/nestend';
535
+ } from '${pkg()}';
422
536
 
423
537
  /**
424
538
  * Lists / filters ${n.pascal}.
@@ -474,8 +588,13 @@ export class ${cls} extends CommonListProcessor {
474
588
  }
475
589
 
476
590
  protected applyCustomPredicates() {
477
- // Filter for the caller's tenant/organization
478
- this.predicates.add('a.tenant_id = :tenant_id', this.user.organization_id);
591
+ ${
592
+ current().tenantScoped
593
+ ? ` // Filter for the caller's tenant/organization
594
+ this.predicates.add('a.tenant_id = :tenant_id', this.user.organization_id);`
595
+ : ` // Filter for the caller: rows belong to whoever created them.
596
+ this.predicates.add('a.created_by = :created_by', this.user.id);`
597
+ }
479
598
  }
480
599
  }
481
600
  `;
@@ -483,7 +602,7 @@ export class ${cls} extends CommonListProcessor {
483
602
 
484
603
  function subscriber(base) {
485
604
  const n = names(base);
486
- return `import { CommonSubscriber } from '@neohm/nestend';
605
+ return `import { CommonSubscriber } from '${pkg()}';
487
606
  import { EventSubscriber } from 'typeorm';
488
607
  import { ${n.entityClass} } from '../entities/${n.dot}.entity';
489
608
  import { DataSource } from 'typeorm';
@@ -499,6 +618,9 @@ export class ${n.subscriberClass} extends CommonSubscriber<${n.entityClass}> {
499
618
  datasource.subscribers.push(this);
500
619
  }
501
620
 
621
+ // Jobs receive only \`id\` unless the columns they need are listed here.
622
+ protected readonly snapshotColumns: (keyof ${n.entityClass} & string)[] = [];
623
+
502
624
  listenTo() {
503
625
  return ${n.entityClass};
504
626
  }
@@ -508,7 +630,7 @@ export class ${n.subscriberClass} extends CommonSubscriber<${n.entityClass}> {
508
630
 
509
631
  function job(base) {
510
632
  const n = names(base);
511
- return `import { CommonJob, QueueService, DatabaseEventDto } from '@neohm/nestend';
633
+ return `import { CommonJob, QueueService, DatabaseEventDto } from '${pkg()}';
512
634
  import { Injectable } from '@nestjs/common';
513
635
  import { JobConstants } from '../../constants/job.constants';
514
636
  import { ${n.entityClass} } from '../entities/${n.dot}.entity';
@@ -534,7 +656,7 @@ export class ${n.jobClass} extends CommonJob {
534
656
  function genMigration(base, tableName, timestamp) {
535
657
  const n = names(base);
536
658
  const className = migrationClassName(n.migrationName, timestamp);
537
- return `import { MigrationUtility } from '@neohm/nestend';
659
+ return `import { MigrationUtility } from '${pkg()}';
538
660
 
539
661
  export class ${className} extends MigrationUtility {
540
662
  constructor() {
@@ -545,8 +667,11 @@ export class ${className} extends MigrationUtility {
545
667
  process() {
546
668
  this.rootPrimary();
547
669
 
548
- this.foreign({ name: 'tenant_id', foreignTable: 'sys_tenants' });
549
- this.foreign({ name: 'created_by', foreignTable: 'sys_users' });
670
+ ${
671
+ current().tenantScoped
672
+ ? ` this.foreign({ name: 'tenant_id', foreignTable: 'sys_tenants' });\n`
673
+ : ''
674
+ } this.foreign({ name: 'created_by', foreignTable: 'sys_users' });
550
675
  this.foreign({ name: 'updated_by', foreignTable: 'sys_users' });
551
676
 
552
677
  // TODO: add columns matching ${n.entityClass}.
@@ -554,8 +679,7 @@ export class ${className} extends MigrationUtility {
554
679
  this.json('attributes');
555
680
  this.timestamps();
556
681
 
557
- this.index('tenant_id');
558
- this.index('created_by');
682
+ ${current().tenantScoped ? ` this.index('tenant_id');\n` : ''} this.index('created_by');
559
683
  this.index('updated_by');
560
684
  }
561
685
  }
@@ -565,7 +689,7 @@ export class ${className} extends MigrationUtility {
565
689
  /** Standalone migration (`nh migration`). */
566
690
  function migration(migrationName, tableName, timestamp) {
567
691
  const className = migrationClassName(migrationName, timestamp);
568
- return `import { MigrationUtility } from '@neohm/nestend';
692
+ return `import { MigrationUtility } from '${pkg()}';
569
693
 
570
694
  export class ${className} extends MigrationUtility {
571
695
  constructor() {
@@ -585,7 +709,7 @@ export class ${className} extends MigrationUtility {
585
709
  /** Seed file (`nh seed`). */
586
710
  function seed(seedName, tableName, timestamp) {
587
711
  const className = `${toPascalCase(seedName)}${timestamp}`;
588
- return `import { SeederUtility } from '@neohm/nestend';
712
+ return `import { SeederUtility } from '${pkg()}';
589
713
 
590
714
  export class ${className} extends SeederUtility {
591
715
  constructor() {
@@ -604,7 +728,7 @@ export class ${className} extends SeederUtility {
604
728
  /** Fresh src/constants/job.constants.ts (first entry included). */
605
729
  function jobConstantsFile(base) {
606
730
  const n = names(base);
607
- return `import { baseJobConstants } from '@neohm/nestend';
731
+ return `import { baseJobConstants } from '${pkg()}';
608
732
 
609
733
  export const JobConstants = {
610
734
  ...baseJobConstants,
@@ -616,7 +740,7 @@ export const JobConstants = {
616
740
  /** Fresh src/constants/entity.constants.ts (first entry included). */
617
741
  function entityConstantsFile(base) {
618
742
  const n = names(base);
619
- return `import { baseEntityConstants } from '@neohm/nestend';
743
+ return `import { baseEntityConstants } from '${pkg()}';
620
744
 
621
745
  export const EntityConstants = {
622
746
  ...baseEntityConstants,
@@ -628,7 +752,7 @@ export const EntityConstants = {
628
752
  /** Fresh src/constants/job.maps.ts (first entry included). */
629
753
  function jobMapsFile(base, moduleDot, constants) {
630
754
  const n = names(base);
631
- return `import { baseJobMaps } from '@neohm/nestend';
755
+ return `import { baseJobMaps } from '${pkg()}';
632
756
  import { ${n.jobClass} } from '../${moduleDot}/jobs/${n.dot}.job';
633
757
  import { ${constants.exportName} } from '${constants.importPath}';
634
758
 
@@ -642,7 +766,7 @@ export const JobMaps = {
642
766
  /** Fresh src/constants/entity.maps.ts (first entry included). */
643
767
  function entityMapsFile(base, moduleDot, constants) {
644
768
  const n = names(base);
645
- return `import { baseEntityMaps } from '@neohm/nestend';
769
+ return `import { baseEntityMaps } from '${pkg()}';
646
770
  import { ${n.entityClass} } from '../${moduleDot}/entities/${n.dot}.entity';
647
771
  import { ${constants.exportName} } from '${constants.importPath}';
648
772
 
@@ -674,7 +798,7 @@ import {
674
798
  QueueModule,
675
799
  AuthModule,
676
800
  SystemPropertyModule,
677
- } from '@neohm/nestend';
801
+ } from '${pkg()}';
678
802
  import { es6Classes } from './es6.classes';
679
803
 
680
804
  @Module({