@neohm/nh-cli 1.3.0 → 1.4.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.
package/README.md CHANGED
@@ -105,7 +105,8 @@ shape/
105
105
  ```
106
106
 
107
107
  plus `src/database/migrations/<timestamp>M-AddShapeTable.ts`, and it
108
- registers `ShapeModule` in `src/app.module.ts`.
108
+ registers `ShapeModule` in `src/app.module.ts` — appended to the `@Module()`
109
+ `imports` array without disturbing comments inside it.
109
110
 
110
111
  It also registers the entity and its job in the project constant registries —
111
112
  `shapeJob: 'shape.job'` in `src/constants/job.constants.ts` (which the
@@ -120,15 +121,21 @@ created spreading `baseJobConstants` / `baseEntityConstants` /
120
121
 
121
122
  Generated artifacts extend the `@neohm/nestend` base classes:
122
123
 
123
- - Entity → `CommonEntity` (a `tenant_id` column with its `TenantEntity`
124
- relation, plus an empty `attributes` jsonb column)
124
+ - Entity → `CommonTenantEntity` under `nh` (it carries `tenant_id` and the
125
+ `TenantEntity` relation, and `TenantScopeSubscriber` stamps and guards it),
126
+ `CommonEntity` under `nhx`; both with an empty `attributes` jsonb column
125
127
  - DTO → `CommonDataDto`
126
128
  - Data processor → `CommonDataProcessor` (`process()` → `validate()` → `set()`),
127
- reached through the service's `upsert()`
129
+ reached through the service's `upsert()`. It takes the caller and, on edit,
130
+ loads the row scoped to them — another owner's `id` is a field error, never an
131
+ update.
128
132
  - 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
133
 
130
134
  ### Controller → service → processor
131
135
 
136
+ Generated controllers run under `@UsePipes(StrictValidationPipe)`, take ids
137
+ with `ParseUUIDPipe`, and describe every route with `@ApiOperation`.
138
+
132
139
  Controllers hold no business logic. They authenticate with
133
140
  `AuthService.validateAccess()`, then delegate to the module's service — so the
134
141
  same operation is reusable from a job, a subscriber or another service, and the
@@ -141,12 +148,14 @@ answers you already gave:
141
148
  | Service method | Generated when | Controller endpoint |
142
149
  | --- | --- | --- |
143
150
  | `get(user, id)` | always | `GET /api/v1/shape/:id` |
144
- | `upsert(data)` | data processor: Yes | `POST /api/v1/shape` |
151
+ | `remove(user, id)` | always | `DELETE /api/v1/shape/:id` |
152
+ | `upsert(user, data)` | data processor: Yes | `POST /api/v1/shape` |
145
153
  | `list(user, data)` | listing processor: Yes | `POST /api/v1/shape/list` |
146
154
 
147
- `get()` is the one operation that needs no processor, so a module that declines
148
- both still gets a useful service with a single read method (and a controller
149
- with just the `GET`). Declining a processor drops the matching service method
155
+ `get()` and `remove()` keep their logic in the service — no processor: `remove()`
156
+ soft-deletes through the same owner-scoped lookup as `get()`. A module that
157
+ declines both processors still gets a service that reads and deletes (and a
158
+ controller with just the `GET` and `DELETE`). Declining a processor drops the matching service method
150
159
  *and* its endpoint together, so the generated module always compiles.
151
160
 
152
161
  `upsert()` rather than `create()` because `CommonDataDto` carries an optional
@@ -162,9 +171,38 @@ is what the tenant is called on `UserEntity`; `tenant_id` is what it is called
162
171
  on the row — the same value on two sides of one relation.) The listing
163
172
  processor applies the same scope as a private predicate.
164
173
 
165
- The generated scope and role identifiers are `'add-scope'` / `'add-role'`
166
- placeholders, flagged with a `TODO` on the controller — replace them with the
167
- `sys_scopes` identifier and role each endpoint actually sits behind.
174
+ ### Scope and role enums
175
+
176
+ `nh gen` registers the entity's scope and default roles in auto-maintained enums
177
+ under `src/global/enums/` (created if missing, appended to otherwise — comments
178
+ and existing members are left alone). For entity `Billing`:
179
+
180
+ ```ts
181
+ // scope.enum.ts
182
+ export enum ScopeEnum {
183
+ BILLING = 'billing',
184
+ }
185
+
186
+ // role.enum.ts (nh only — nhx has no roles)
187
+ export enum RoleEnum {
188
+ BILLING_ADMIN = 'billing.admin', // priority 1 — the scope's top role
189
+ BILLING_MANAGER = 'billing.manager', // priority 2
190
+ BILLING_READER = 'billing.reader', // priority 3
191
+ }
192
+ ```
193
+
194
+ The controller gates each endpoint at the lowest role that should reach it — a
195
+ senior role satisfies a junior one in nestend's scoped RBAC:
196
+
197
+ | Endpoint | Gate |
198
+ | --- | --- |
199
+ | `GET :id`, `POST list` | `validateAccess(ScopeEnum.BILLING, RoleEnum.BILLING_READER)` |
200
+ | `POST` (upsert) | `validateAccess(ScopeEnum.BILLING, RoleEnum.BILLING_MANAGER)` |
201
+ | `DELETE :id` | `validateAccess(ScopeEnum.BILLING, RoleEnum.BILLING_ADMIN)` |
202
+
203
+ Under `nhx` the gate is `validateAccess(ScopeEnum.BILLING)`. The enums name the
204
+ identifiers only: create the `sys_scopes` row and the three `sys_roles` rows
205
+ (scope `billing`, priorities 1/2/3) in the back office so the checks can pass.
168
206
 
169
207
  ## Flag generation
170
208
 
@@ -215,9 +253,9 @@ Only one target flag per run; the module is created if it does not exist yet.
215
253
 
216
254
  External files take an `external.` filename prefix and an `External` class
217
255
  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`.
256
+ load-bearing: it mounts at `api/e/v1/<path>`, it documents the key header to
257
+ swagger with `@ApiHeader`, and it authenticates through `ApiKeyService` rather
258
+ than `AuthService`.
221
259
 
222
260
  `-x` does not apply to `-e` or `-j`: a table and a queue worker have no
223
261
  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.4.0",
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));
@@ -12,7 +12,11 @@ const tpl = require("../lib/templates");
12
12
  const { writeFile, report: reportFile, log, ensureDir } = require("../lib/fs.utils");
13
13
  const sync = require("../lib/sync.engine");
14
14
  const { formatFiles } = require("../lib/format");
15
- const { registerJob, registerEntity } = require("../lib/registry");
15
+ const {
16
+ registerJob,
17
+ registerEntity,
18
+ registerAccess,
19
+ } = require("../lib/registry");
16
20
 
17
21
  async function gen() {
18
22
  const paths = projectPaths();
@@ -103,6 +107,9 @@ async function gen() {
103
107
  // (the generated job references JobConstants.<name>Job, so the entry must exist).
104
108
  registerJob(paths, entityName, moduleDot, report);
105
109
  registerEntity(paths, entityName, moduleDot, report);
110
+ // The entity's scope and, tenanted, its default roles (admin > manager >
111
+ // reader) in src/global/enums — what the generated controller gates on.
112
+ const enums = registerAccess(paths, entityName, report);
106
113
 
107
114
  // 4. Optional: controller -------------------------------------------------
108
115
  const { controller } = await inquirer.prompt([
@@ -135,7 +142,7 @@ async function gen() {
135
142
  report(writeFile(dtoPath, tpl.dto(entityName)), dtoPath, paths.root);
136
143
 
137
144
  const procPath = path.join(moduleDir, "libraries", n.processorFile);
138
- report(writeFile(procPath, tpl.processor(entityName)), procPath, paths.root);
145
+ report(writeFile(procPath, tpl.processor(entityName, { withEntity: true })), procPath, paths.root);
139
146
  }
140
147
 
141
148
  // 6. Optional: listing processor (takes CommonListFilterDto directly) ------
@@ -167,7 +174,10 @@ async function gen() {
167
174
  if (controller) {
168
175
  const p = path.join(moduleDir, "controllers", n.controllerFile);
169
176
  report(
170
- writeFile(p, tpl.controller(entityName, { processor, listProcessor })),
177
+ writeFile(
178
+ p,
179
+ tpl.controller(entityName, { processor, listProcessor, enums }),
180
+ ),
171
181
  p,
172
182
  paths.root,
173
183
  );
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ const { toWords, toDotCase } = require('./naming');
4
+
5
+ /**
6
+ * The scope and default roles `nh gen` registers for an entity. Roles rank
7
+ * within the scope by priority (1 = most senior) and a senior role satisfies a
8
+ * junior requirement, so each endpoint is gated at the lowest role that should
9
+ * reach it: reader for get/list, manager for upsert, admin for delete. The
10
+ * admin role is the scope's top role — its holders grant the scope's roles.
11
+ */
12
+ const ROLE_LEVELS = [
13
+ { level: 'admin', priority: 1 },
14
+ { level: 'manager', priority: 2 },
15
+ { level: 'reader', priority: 3 },
16
+ ];
17
+
18
+ /** BILLING_ITEM */
19
+ function toConstantCase(input) {
20
+ return toWords(input).join('_').toUpperCase();
21
+ }
22
+
23
+ /** Enum keys and identifiers for `base`, e.g. Billing → BILLING: 'billing'. */
24
+ function accessNames(base) {
25
+ const scopeKey = toConstantCase(base);
26
+ const scopeValue = toDotCase(base);
27
+ const roles = {};
28
+ for (const { level, priority } of ROLE_LEVELS) {
29
+ roles[level] = {
30
+ key: `${scopeKey}_${level.toUpperCase()}`,
31
+ value: `${scopeValue}.${level}`,
32
+ priority,
33
+ };
34
+ }
35
+ return { scopeKey, scopeValue, roles };
36
+ }
37
+
38
+ module.exports = { ROLE_LEVELS, toConstantCase, accessNames };
@@ -4,6 +4,7 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
 
6
6
  const { writeFile } = require('./fs.utils');
7
+ const scan = require('./source.scan');
7
8
 
8
9
  /**
9
10
  * Keeps the project-level constant registries (src/constants/job.constants.ts
@@ -26,36 +27,14 @@ function ensureConstantEntry(filePath, freshContent, key, value) {
26
27
  }
27
28
 
28
29
  const content = fs.readFileSync(filePath, 'utf8');
29
- if (new RegExp(`(^|[^\\w])${escapeRe(key)}\\s*:`).test(content)) {
30
- return 'skipped';
31
- }
32
-
33
- // First exported object literal in the file, e.g. `export const JobConstants = {`.
34
- const open = content.search(/export\s+const\s+\w+\s*(?::[^=]+)?=\s*{/);
35
- if (open === -1) return 'failed';
36
- const braceStart = content.indexOf('{', open);
37
-
38
- // Walk to the matching closing brace (entries are flat string values, but
39
- // stay correct if a nested object ever appears).
40
- let depth = 0;
41
- let close = -1;
42
- for (let i = braceStart; i < content.length; i++) {
43
- if (content[i] === '{') depth++;
44
- else if (content[i] === '}') {
45
- depth--;
46
- if (depth === 0) {
47
- close = i;
48
- break;
49
- }
50
- }
51
- }
52
- if (close === -1) return 'failed';
30
+ const literal = exportedLiteral(content, EXPORT_OBJECT);
31
+ if (!literal) return 'failed';
32
+ if (hasMember(content, literal, `${escapeRe(key)}\\s*:`)) return 'skipped';
53
33
 
54
- const before = content.slice(0, close).replace(/\s*$/, '');
55
- const entry = `${before.endsWith(',') || before.endsWith('{') ? '' : ','}\n ${key}: '${value}',\n`;
56
- const next = before + entry + content.slice(close);
57
-
58
- fs.writeFileSync(filePath, next);
34
+ fs.writeFileSync(
35
+ filePath,
36
+ scan.appendMember(content, literal.open, literal.close, `${key}: '${value}',`, ' '),
37
+ );
59
38
  return 'overwritten';
60
39
  }
61
40
 
@@ -71,40 +50,64 @@ function ensureMapEntry(filePath, freshContent, entryKey, entryValue, imports) {
71
50
  }
72
51
 
73
52
  let content = fs.readFileSync(filePath, 'utf8');
74
- if (content.includes(entryKey)) return 'skipped';
75
-
76
- const open = content.search(/export\s+const\s+\w+\s*(?::[^=]+)?=\s*{/);
77
- if (open === -1) return 'failed';
53
+ const existing = exportedLiteral(content, EXPORT_OBJECT);
54
+ if (!existing) return 'failed';
55
+ if (hasMember(content, existing, escapeRe(entryKey))) return 'skipped';
78
56
 
79
57
  for (const imp of imports) {
80
58
  content = ensureImportLine(content, imp.className, imp.importPath);
81
59
  }
82
60
 
83
61
  // Re-locate the object after import insertion shifted offsets.
84
- const braceStart = content.indexOf(
85
- '{',
86
- content.search(/export\s+const\s+\w+\s*(?::[^=]+)?=\s*{/),
62
+ const literal = exportedLiteral(content, EXPORT_OBJECT);
63
+ fs.writeFileSync(
64
+ filePath,
65
+ scan.appendMember(content, literal.open, literal.close, `${entryKey}: ${entryValue},`, ' '),
87
66
  );
88
- let depth = 0;
89
- let close = -1;
90
- for (let i = braceStart; i < content.length; i++) {
91
- if (content[i] === '{') depth++;
92
- else if (content[i] === '}') {
93
- depth--;
94
- if (depth === 0) {
95
- close = i;
96
- break;
97
- }
98
- }
67
+ return 'overwritten';
68
+ }
69
+
70
+ /**
71
+ * Ensure `KEY = 'value',` is a member of the enum exported by `filePath`.
72
+ * Creates the file with `freshContent` when absent. Returns 'created' |
73
+ * 'skipped' | 'overwritten' | 'failed'.
74
+ */
75
+ function ensureEnumMember(filePath, freshContent, key, value) {
76
+ if (!fs.existsSync(filePath)) {
77
+ return writeFile(filePath, freshContent);
99
78
  }
100
- if (close === -1) return 'failed';
101
79
 
102
- const before = content.slice(0, close).replace(/\s*$/, '');
103
- const entry = `${before.endsWith(',') || before.endsWith('{') ? '' : ','}\n ${entryKey}: ${entryValue},\n`;
104
- fs.writeFileSync(filePath, before + entry + content.slice(close));
80
+ const content = fs.readFileSync(filePath, 'utf8');
81
+ const literal = exportedLiteral(content, EXPORT_ENUM);
82
+ if (!literal) return 'failed';
83
+ if (hasMember(content, literal, `${escapeRe(key)}\\s*=`)) return 'skipped';
84
+
85
+ fs.writeFileSync(
86
+ filePath,
87
+ scan.appendMember(content, literal.open, literal.close, `${key} = '${value}',`, ' '),
88
+ );
105
89
  return 'overwritten';
106
90
  }
107
91
 
92
+ const EXPORT_OBJECT = /export\s+const\s+\w+\s*(?::[^=]+)?=\s*{/;
93
+ const EXPORT_ENUM = /export\s+enum\s+\w+\s*{/;
94
+
95
+ /** Brace positions of the first exported literal matching `declaration`; null if none. */
96
+ function exportedLiteral(content, declaration) {
97
+ const mask = scan.codeMask(content);
98
+ const found = scan.findInCode(content, declaration, 0, mask);
99
+ if (!found) return null;
100
+ const open = content.indexOf('{', found.index);
101
+ const close = scan.matchingClose(content, open, mask);
102
+ return close === -1 ? null : { open, close };
103
+ }
104
+
105
+ /** Whether the literal's code (comments ignored) has a member starting with `pattern`. */
106
+ function hasMember(content, literal, pattern) {
107
+ const inner = scan.codeOnly(content, literal.open + 1, literal.close);
108
+ return new RegExp(`(^|[\\s,{])${pattern}`).test(inner);
109
+ }
110
+
108
111
  function ensureImportLine(content, className, importPath) {
109
112
  if (new RegExp(`from\\s+['"]${escapeRe(importPath)}['"]`).test(content)) {
110
113
  return content;
@@ -131,6 +134,15 @@ function detectExportName(filePath) {
131
134
  return match ? match[1] : null;
132
135
  }
133
136
 
137
+ /** Name of the first `export enum X {` in the file (null if unreadable). */
138
+ function detectEnumName(filePath) {
139
+ if (!fs.existsSync(filePath)) return null;
140
+ const match = fs
141
+ .readFileSync(filePath, 'utf8')
142
+ .match(/export\s+enum\s+(\w+)\s*{/);
143
+ return match ? match[1] : null;
144
+ }
145
+
134
146
  /**
135
147
  * Resolve the entity constants file, tolerating the legacy misspelled
136
148
  * `enity.constants.ts` some projects carry — an existing registry is updated
@@ -150,6 +162,8 @@ function escapeRe(str) {
150
162
  module.exports = {
151
163
  ensureConstantEntry,
152
164
  ensureMapEntry,
165
+ ensureEnumMember,
153
166
  detectExportName,
167
+ detectEnumName,
154
168
  entityConstantsPath,
155
169
  };
package/src/lib/paths.js CHANGED
@@ -41,6 +41,7 @@ function projectPaths(root = requireProjectRoot()) {
41
41
  appModule: path.join(srcDir, 'app.module.ts'),
42
42
  migrationsDir: path.join(srcDir, 'database', 'migrations'),
43
43
  constantsDir: path.join(srcDir, 'constants'),
44
+ enumsDir: path.join(srcDir, 'global', 'enums'),
44
45
  moduleDir: (moduleDot) => path.join(srcDir, moduleDot),
45
46
  };
46
47
  }
@@ -7,9 +7,13 @@ const { log } = require('./fs.utils');
7
7
  const {
8
8
  ensureConstantEntry,
9
9
  ensureMapEntry,
10
+ ensureEnumMember,
10
11
  detectExportName,
12
+ detectEnumName,
11
13
  entityConstantsPath,
12
14
  } = require('./constants.engine');
15
+ const { accessNames } = require('./access');
16
+ const { current } = require('./flavour');
13
17
 
14
18
  /**
15
19
  * The project-wide identifier registries.
@@ -117,4 +121,58 @@ function registerEntity(paths, base, moduleDot, report) {
117
121
  }
118
122
  }
119
123
 
120
- module.exports = { registerJob, registerEntity };
124
+ /**
125
+ * Register `<base>`'s scope in src/global/enums/scope.enum.ts and, for the
126
+ * tenanted flavour, its default roles (admin, manager, reader) in role.enum.ts.
127
+ * Returns the enum names the generated controller imports.
128
+ */
129
+ function registerAccess(paths, base, report) {
130
+ const a = accessNames(base);
131
+ const enums = { scope: 'ScopeEnum', role: 'RoleEnum' };
132
+
133
+ const scopePath = path.join(paths.enumsDir, 'scope.enum.ts');
134
+ const scope = ensureEnumMember(
135
+ scopePath,
136
+ tpl.scopeEnumFile(base),
137
+ a.scopeKey,
138
+ a.scopeValue,
139
+ );
140
+ if (scope === 'failed')
141
+ log.warn(
142
+ `Could not update ${path.relative(paths.root, scopePath)} — add ${a.scopeKey} = '${a.scopeValue}' manually.`,
143
+ );
144
+ else report(scope, scopePath, paths.root);
145
+ enums.scope = detectEnumName(scopePath) || enums.scope;
146
+
147
+ // The B2C model has scopes and no roles.
148
+ if (!current().tenantScoped) return enums;
149
+
150
+ const rolePath = path.join(paths.enumsDir, 'role.enum.ts');
151
+ const results = [];
152
+ for (const role of Object.values(a.roles)) {
153
+ const result = ensureEnumMember(
154
+ rolePath,
155
+ tpl.roleEnumFile(base),
156
+ role.key,
157
+ role.value,
158
+ );
159
+ if (result === 'failed') {
160
+ log.warn(
161
+ `Could not update ${path.relative(paths.root, rolePath)} — add ${role.key} = '${role.value}' manually.`,
162
+ );
163
+ break;
164
+ }
165
+ results.push(result);
166
+ }
167
+ // A fresh file already carries every default role, so later calls skip.
168
+ const summary = results.includes('created')
169
+ ? 'created'
170
+ : results.includes('overwritten')
171
+ ? 'overwritten'
172
+ : 'skipped';
173
+ if (results.length) report(summary, rolePath, paths.root);
174
+ enums.role = detectEnumName(rolePath) || enums.role;
175
+ return enums;
176
+ }
177
+
178
+ module.exports = { registerJob, registerEntity, registerAccess };
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Comment- and string-aware scanning of TypeScript source, so the CLI can edit
5
+ * array and object literals without being fooled by a `,`, `]` or `}` that sits
6
+ * inside a comment or a string. Every edit that inserts into an existing literal
7
+ * goes through here.
8
+ */
9
+
10
+ /** `mask[i]` is true when `content[i]` is code (not inside a comment or string). */
11
+ function codeMask(content) {
12
+ return scanMask(content, true);
13
+ }
14
+
15
+ /** `mask[i]` is true unless `content[i]` is inside a comment (strings count). */
16
+ function commentMask(content) {
17
+ return scanMask(content, false);
18
+ }
19
+
20
+ function scanMask(content, maskStrings) {
21
+ const mask = new Array(content.length).fill(true);
22
+ let i = 0;
23
+ while (i < content.length) {
24
+ const ch = content[i];
25
+ const next = content[i + 1];
26
+ let end = -1;
27
+ let isString = false;
28
+ if (ch === '/' && next === '/') {
29
+ end = content.indexOf('\n', i);
30
+ if (end === -1) end = content.length;
31
+ } else if (ch === '/' && next === '*') {
32
+ end = content.indexOf('*/', i + 2);
33
+ end = end === -1 ? content.length : end + 2;
34
+ } else if (ch === "'" || ch === '"' || ch === '`') {
35
+ isString = true;
36
+ end = i + 1;
37
+ while (end < content.length && content[end] !== ch) {
38
+ if (content[end] === '\\') end++;
39
+ end++;
40
+ }
41
+ end = Math.min(end + 1, content.length);
42
+ }
43
+ if (end === -1) {
44
+ i++;
45
+ continue;
46
+ }
47
+ if (!isString || maskStrings)
48
+ for (let k = i; k < end; k++) mask[k] = false;
49
+ i = end;
50
+ }
51
+ return mask;
52
+ }
53
+
54
+ /** First match of `regex` (global flag added) whose start is code; -1 if none. */
55
+ function findInCode(content, regex, from = 0, mask = codeMask(content)) {
56
+ const re = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : `${regex.flags}g`);
57
+ re.lastIndex = from;
58
+ let match;
59
+ while ((match = re.exec(content))) {
60
+ if (mask[match.index]) return { index: match.index, match };
61
+ re.lastIndex = match.index + 1;
62
+ }
63
+ return null;
64
+ }
65
+
66
+ /** Index of the bracket closing the one at `openIdx` (any of `([{`), skipping comments and strings. */
67
+ function matchingClose(content, openIdx, mask = codeMask(content)) {
68
+ const pairs = { '(': ')', '[': ']', '{': '}' };
69
+ const stack = [];
70
+ for (let i = openIdx; i < content.length; i++) {
71
+ if (!mask[i]) continue;
72
+ const ch = content[i];
73
+ if (pairs[ch]) stack.push(pairs[ch]);
74
+ else if (ch === ')' || ch === ']' || ch === '}') {
75
+ if (stack.pop() !== ch) return -1;
76
+ if (!stack.length) return i;
77
+ }
78
+ }
79
+ return -1;
80
+ }
81
+
82
+ /** Index of the last non-whitespace character `mask` marks true in [from, to); -1 if none. */
83
+ function lastCodeChar(content, from, to, mask = codeMask(content)) {
84
+ for (let i = to - 1; i >= from; i--) {
85
+ if (mask[i] && !/\s/.test(content[i])) return i;
86
+ }
87
+ return -1;
88
+ }
89
+
90
+ /** `content` between `from` and `to` with comments and strings blanked out. */
91
+ function codeOnly(content, from, to, mask = codeMask(content)) {
92
+ let out = '';
93
+ for (let i = from; i < to; i++) out += mask[i] ? content[i] : ' ';
94
+ return out;
95
+ }
96
+
97
+ /**
98
+ * Insert `entryLine` (no indentation, no trailing newline) as the last member of
99
+ * the literal whose brackets sit at `open`/`close`. A missing comma after the
100
+ * current last member is added right after its code — never inside a trailing
101
+ * comment — and comments in the literal stay exactly where they were.
102
+ */
103
+ function appendMember(content, open, close, entryLine, indent) {
104
+ const last = lastCodeChar(content, open + 1, close, commentMask(content));
105
+ let next = content;
106
+ let closeAt = close;
107
+ if (last !== -1 && content[last] !== ',' && last !== open) {
108
+ next = next.slice(0, last + 1) + ',' + next.slice(last + 1);
109
+ closeAt++;
110
+ }
111
+ // Insert before the whitespace that leads to the closing bracket.
112
+ let insertAt = closeAt;
113
+ while (insertAt > 0 && /[ \t]/.test(next[insertAt - 1])) insertAt--;
114
+ const closingIndent = next.slice(insertAt, closeAt);
115
+ const lead = next[insertAt - 1] === '\n' ? '' : '\n';
116
+ return (
117
+ next.slice(0, insertAt) +
118
+ `${lead}${indent}${entryLine}\n` +
119
+ closingIndent +
120
+ next.slice(closeAt)
121
+ );
122
+ }
123
+
124
+ module.exports = {
125
+ codeMask,
126
+ commentMask,
127
+ findInCode,
128
+ matchingClose,
129
+ lastCodeChar,
130
+ codeOnly,
131
+ appendMember,
132
+ };
@@ -3,6 +3,8 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
 
6
+ const scan = require('./source.scan');
7
+
6
8
  /**
7
9
  * The sync engine is the single brain behind `nh sync`, `nh check` and the
8
10
  * auto-wiring step of `nh gen`. It scans scaffolded modules, regenerates each
@@ -178,21 +180,37 @@ function ensureImportLine(content, className, importPath) {
178
180
  return lines.join('\n');
179
181
  }
180
182
 
183
+ /**
184
+ * Add `className` to the `imports: [...]` array of the @Module() decorator.
185
+ * The array is located and edited through the comment-aware scanner: splitting
186
+ * it on commas used to break any comment holding a comma, turning the rest of
187
+ * that comment into code.
188
+ */
181
189
  function ensureInImportsArray(content, className) {
182
- const idx = content.indexOf('imports:');
183
- if (idx === -1) return content;
184
- const open = content.indexOf('[', idx);
185
- const close = content.indexOf(']', open);
186
- if (open === -1 || close === -1) return content;
187
- const inner = content.slice(open + 1, close);
188
- const items = inner
189
- .split(',')
190
- .map((s) => s.trim())
191
- .filter(Boolean);
192
- if (items.includes(className)) return content;
193
- items.push(className);
194
- const rebuilt = '\n ' + items.join(',\n ') + ',\n ';
195
- return content.slice(0, open + 1) + rebuilt + content.slice(close);
190
+ const mask = scan.codeMask(content);
191
+ const decorator = scan.findInCode(content, /@Module\s*\(/, 0, mask);
192
+ const key = scan.findInCode(
193
+ content,
194
+ /\bimports\s*:/,
195
+ decorator ? decorator.index : 0,
196
+ mask,
197
+ );
198
+ if (!key) return content;
199
+ const open = scan.findInCode(content, /\[/, key.index, mask);
200
+ if (!open) return content;
201
+ const close = scan.matchingClose(content, open.index, mask);
202
+ if (close === -1) return content;
203
+
204
+ const inner = scan.codeOnly(content, open.index + 1, close, mask);
205
+ if (new RegExp(`(^|[^\\w.])${escapeRe(className)}\\b`).test(inner))
206
+ return content;
207
+ return scan.appendMember(
208
+ content,
209
+ open.index,
210
+ close,
211
+ `${className},`,
212
+ ' ',
213
+ );
196
214
  }
197
215
 
198
216
  function escapeRe(str) {
@@ -9,6 +9,7 @@ const {
9
9
  toCamelCase,
10
10
  migrationClassName,
11
11
  } = require("./naming");
12
+ const { accessNames } = require('./access');
12
13
 
13
14
  /**
14
15
  * Code templates. All generated code imports shared base classes from the
@@ -103,26 +104,20 @@ export class ${n.entityClass} extends CommonEntity {
103
104
  `;
104
105
  }
105
106
 
106
- return `import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
107
- import { CommonEntity, TenantEntity } from '${pkg()}';
107
+ // CommonTenantEntity adds tenant_id + the tenant relation; TenantScopeSubscriber
108
+ // stamps it from the session and refuses cross-tenant writes.
109
+ return `import { Column, Entity } from 'typeorm';
110
+ import { CommonTenantEntity } from '${pkg()}';
108
111
  import { ${n.attributesDtoClass} } from '../dtos/${n.dot}.attributes.dto';
109
112
 
110
113
  @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
-
114
+ export class ${n.entityClass} extends CommonTenantEntity {
116
115
  // TODO: declare the columns ${n.pascal} owns.
117
116
 
118
117
  @Column({ type: 'jsonb' })
119
118
  attributes: ${n.attributesDtoClass};
120
119
 
121
120
  // Relations and methods
122
-
123
- @ManyToOne(() => TenantEntity)
124
- @JoinColumn({ name: 'tenant_id' })
125
- tenant: TenantEntity;
126
121
  }
127
122
  `;
128
123
  }
@@ -140,19 +135,32 @@ function attributesDto(base) {
140
135
  * delegate to the module's service — so the HTTP surface stays readable and
141
136
  * the same operation is reusable from a job, a subscriber or another service.
142
137
  */
143
- function controller(base, { processor = false, listProcessor = false } = {}) {
138
+ function controller(
139
+ base,
140
+ { processor = false, listProcessor = false, enums = {} } = {},
141
+ ) {
144
142
  const n = names(base);
143
+ const a = accessNames(base);
144
+ const scopeEnum = enums.scope || 'ScopeEnum';
145
+ const roleEnum = enums.role || 'RoleEnum';
145
146
  const svcProp = `${n.camel}Service`;
146
147
  const hasPost = processor || listProcessor;
147
148
 
148
149
  // @nestjs/common members, alphabetised the way prettier leaves them.
149
- const nestCommon = ['Controller', 'Get', 'Param'];
150
+ const nestCommon = [
151
+ 'Controller',
152
+ 'Delete',
153
+ 'Get',
154
+ 'Param',
155
+ 'ParseUUIDPipe',
156
+ 'UsePipes',
157
+ ];
150
158
  if (hasPost) nestCommon.push('Body', 'Post');
151
159
  nestCommon.sort();
152
160
 
153
161
  const nestendImports = ['AuthService'];
154
162
  if (listProcessor) nestendImports.push('CommonListFilterDto');
155
- nestendImports.push('CommonParamsDto');
163
+ nestendImports.push('StrictValidationPipe');
156
164
 
157
165
  const imports = [
158
166
  namedImport(nestCommon, '@nestjs/common'),
@@ -160,7 +168,7 @@ function controller(base, { processor = false, listProcessor = false } = {}) {
160
168
  // DocumentBuilder call only registers the scheme (under its default name,
161
169
  // 'bearer') and draws the Authorize button — without the decorator
162
170
  // swagger-ui shows no padlock and never sends the Authorization header.
163
- `import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';`,
171
+ `import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';`,
164
172
  namedImport(nestendImports, pkg()),
165
173
  `import { ${n.serviceClass} } from '../services/${n.dot}.service';`,
166
174
  ];
@@ -170,50 +178,73 @@ function controller(base, { processor = false, listProcessor = false } = {}) {
170
178
  );
171
179
  }
172
180
 
173
- // Tenanted access takes a scope and a role; the B2C model has no roles at
174
- // all, so the same call carries the scope alone.
181
+ // Tenanted access takes the entity's scope and the lowest role that should
182
+ // reach the endpoint (a senior role satisfies a junior one); the B2C model
183
+ // has no roles, so the same call carries the scope alone.
175
184
  const tenanted = current().tenantScoped;
176
- const gate = (indent) =>
177
- tenanted
178
- ? `await this.authService.validateAccess(\n${indent} 'add-scope',\n${indent} 'add-role',\n${indent})`
179
- : `await this.authService.validateAccess('add-scope')`;
185
+ imports.push(
186
+ `import { ${scopeEnum} } from '../../global/enums/scope.enum';`,
187
+ );
188
+ if (tenanted)
189
+ imports.push(`import { ${roleEnum} } from '../../global/enums/role.enum';`);
190
+ const scopeRef = `${scopeEnum}.${a.scopeKey}`;
191
+ const gate = (indent, level) => {
192
+ if (!tenanted) {
193
+ const call = `await this.authService.validateAccess(${scopeRef})`;
194
+ return `${indent}const { user } = ${call};`.length <= 80
195
+ ? call
196
+ : `await this.authService.validateAccess(\n${indent} ${scopeRef},\n${indent})`;
197
+ }
198
+ const roleRef = `${roleEnum}.${a.roles[level].key}`;
199
+ const call = `await this.authService.validateAccess(${scopeRef}, ${roleRef})`;
200
+ return `${indent}const { user } = ${call};`.length <= 80
201
+ ? call
202
+ : `await this.authService.validateAccess(\n${indent} ${scopeRef},\n${indent} ${roleRef},\n${indent})`;
203
+ };
180
204
 
181
205
  const methods = [
182
206
  ` @Get(':id')
183
- async get${n.pascal}(@Param() params: CommonParamsDto) {
184
- const { user } = ${gate(' ')};
207
+ @ApiOperation({ summary: 'One ${n.kebab} by id.' })
208
+ async get${n.pascal}(@Param('id', ParseUUIDPipe) id: string) {
209
+ const { user } = ${gate(' ', 'reader')};
185
210
 
186
- return this.${svcProp}.get(user, params.id);
211
+ return this.${svcProp}.get(user, id);
187
212
  }`,
188
213
  ];
189
214
 
190
215
  if (processor) {
191
216
  methods.push(` @Post()
217
+ @ApiOperation({ summary: 'Create a ${n.kebab}, or update it when an id is sent.' })
192
218
  async upsert${n.pascal}(@Body() body: ${n.dtoClass}) {
193
- ${tenanted ? `await this.authService.validateAccess('add-scope', 'add-role');` : `await this.authService.validateAccess('add-scope');`}
219
+ const { user } = ${gate(' ', 'manager')};
194
220
 
195
- return this.${svcProp}.upsert(body);
221
+ return this.${svcProp}.upsert(user, body);
196
222
  }`);
197
223
  }
198
224
 
199
225
  if (listProcessor) {
200
226
  methods.push(` @Post('list')
227
+ @ApiOperation({ summary: 'List, search and filter ${n.kebab}.' })
201
228
  async list(@Body() body: CommonListFilterDto) {
202
- const { user } = ${gate(' ')};
229
+ const { user } = ${gate(' ', 'reader')};
203
230
 
204
231
  return this.${svcProp}.list(user, body);
205
232
  }`);
206
233
  }
207
234
 
208
- const gateTodo = tenanted
209
- ? `// TODO: replace 'add-scope' / 'add-role' with this module's scope and role.`
210
- : `// 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.`;
235
+ methods.push(` @Delete(':id')
236
+ @ApiOperation({ summary: 'Delete one ${n.kebab} by id.' })
237
+ async remove${n.pascal}(@Param('id', ParseUUIDPipe) id: string) {
238
+ const { user } = ${gate(' ', 'admin')};
239
+
240
+ return this.${svcProp}.remove(user, id);
241
+ }`);
211
242
 
212
243
  return `${imports.join('\n')}
213
244
 
214
- ${gateTodo}
215
245
  @ApiTags('${n.kebab}')
216
246
  @ApiBearerAuth()
247
+ @UsePipes(StrictValidationPipe)
217
248
  @Controller('api/v1/${n.kebab}')
218
249
  export class ${n.controllerClass} {
219
250
  constructor(
@@ -285,11 +316,19 @@ function service(base, { processor = false, listProcessor = false } = {}) {
285
316
  return ${n.camel};
286
317
  }`);
287
318
 
319
+ // Soft delete, through the same ownership-scoped lookup as get().
320
+ members.push(` async remove(user: UserEntity, id: string) {
321
+ const ${n.camel} = await this.get(user, id);
322
+ await ${n.entityClass}.softRemove(${n.camel});
323
+
324
+ return { id, deleted: true, deleted_by: user.id };
325
+ }`);
326
+
288
327
  if (processor) {
289
328
  // Insert or update: CommonDataDto carries an optional `id`, and the data
290
329
  // 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);
330
+ members.push(` async upsert(user: UserEntity, data: ${n.dtoClass}) {
331
+ return new ${n.processorClass}(user).process(data);
293
332
  }`);
294
333
  }
295
334
 
@@ -329,12 +368,13 @@ ${members.join('\n\n')}
329
368
  */
330
369
  function bareController(base, routePath) {
331
370
  const n = names(base);
332
- return `import { Controller, Get } from '@nestjs/common';
371
+ return `import { Controller, Get, UsePipes } from '@nestjs/common';
333
372
  import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
334
- import { AuthService } from '${pkg()}';
373
+ import { AuthService, StrictValidationPipe } from '${pkg()}';
335
374
 
336
375
  @ApiTags('${routePath}')
337
376
  @ApiBearerAuth()
377
+ @UsePipes(StrictValidationPipe)
338
378
  @Controller('api/v1/${routePath}')
339
379
  export class ${n.controllerClass} {
340
380
  constructor(private readonly authService: AuthService) {}
@@ -353,12 +393,13 @@ export class ${n.controllerClass} {
353
393
  */
354
394
  function bareExternalController(base, routePath) {
355
395
  const n = names(base);
356
- return `import { Controller, Get } from '@nestjs/common';
357
- import { ApiSecurity, ApiTags } from '@nestjs/swagger';
358
- import { ApiKeyService } from '${pkg()}';
396
+ return `import { Controller, Get, UsePipes } from '@nestjs/common';
397
+ import { ApiHeader, ApiTags } from '@nestjs/swagger';
398
+ import { ApiKeyService, StrictValidationPipe } from '${pkg()}';
359
399
 
360
400
  @ApiTags('external-${routePath} (Api key)')
361
- @ApiSecurity('api-key')
401
+ @ApiHeader({ name: 'x-api-key', required: true })
402
+ @UsePipes(StrictValidationPipe)
362
403
  @Controller('api/e/v1/${routePath}')
363
404
  export class ${n.externalControllerClass} {
364
405
  constructor(private readonly apiKeyService: ApiKeyService) {}
@@ -414,13 +455,14 @@ export class ${n.dtoClass} extends CommonDataDto {
414
455
  `;
415
456
  }
416
457
 
417
- function processor(base, { external = false } = {}) {
458
+ function processor(base, { external = false, withEntity = false } = {}) {
418
459
  const n = names(base);
419
460
  // An external processor sits one level deeper (external/libraries/), so its
420
461
  // reach back into the module's dtos/ gains a segment.
421
462
  const up = external ? '../..' : '..';
422
463
  const cls = external ? n.externalProcessorClass : n.processorClass;
423
- return `import { CommonDataProcessor } from '${pkg()}';
464
+ if (external) {
465
+ return `import { CommonDataProcessor } from '${pkg()}';
424
466
  import { ${n.dtoClass} } from '${up}/dtos/add.${n.dot}.data.dto';
425
467
 
426
468
  export class ${cls} extends CommonDataProcessor {
@@ -443,6 +485,80 @@ export class ${cls} extends CommonDataProcessor {
443
485
  // TODO: persist ${n.pascal} using this.payload.
444
486
  }
445
487
  }
488
+ `;
489
+ }
490
+
491
+ // Edits load the caller's own row: another owner's id reads as not found.
492
+ const owner = current().tenantScoped
493
+ ? 'tenant_id: this.user.organization_id'
494
+ : 'created_by: this.user.id';
495
+ const label = toWords(base).join(' ');
496
+ const entityImport = withEntity
497
+ ? `\nimport { ${n.entityClass} } from '${up}/entities/${n.dot}.entity';`
498
+ : '';
499
+ const existingField = withEntity
500
+ ? `\n private existing: ${n.entityClass} | null = null;`
501
+ : '';
502
+ const load = withEntity
503
+ ? ` if (this.payload.id) {
504
+ this.existing = await ${n.entityClass}.findOne({
505
+ where: { id: this.payload.id, ${owner} },
506
+ });
507
+ if (!this.existing) this.setColumnError('id', 'Invalid ${label} id');
508
+ }
509
+
510
+ `
511
+ : ` // TODO: on edit (payload.id), load the row scoped to the caller
512
+ // ({ id, ${owner} }) and record an 'id' error when it is not found.
513
+
514
+ `;
515
+ const set = withEntity
516
+ ? ` private set(): Promise<${n.entityClass}> {
517
+ const ${n.camel} = this.existing ?? ${n.entityClass}.create();
518
+ // TODO: assign fields from this.payload explicitly — never spread the DTO.
519
+
520
+ return ${n.camel}.save();
521
+ }`
522
+ : ` private set(): void {
523
+ // TODO: persist ${n.pascal} from this.payload, assigning fields explicitly.
524
+ }`;
525
+ return `import { CommonDataProcessor, UserEntity } from '${pkg()}';
526
+ import { ${n.dtoClass} } from '${up}/dtos/add.${n.dot}.data.dto';${entityImport}
527
+
528
+ export class ${cls} extends CommonDataProcessor {
529
+ protected payload: ${n.dtoClass};${existingField}
530
+
531
+ constructor(private readonly user: UserEntity) {
532
+ super();
533
+ }
534
+
535
+ ${
536
+ withEntity
537
+ ? ` async process(data: ${n.dtoClass}) {
538
+ this.payload = data;
539
+
540
+ await this.validate();
541
+
542
+ return this.set();
543
+ }
544
+
545
+ private async validate() {`
546
+ : ` process(data: ${n.dtoClass}) {
547
+ this.payload = data;
548
+
549
+ this.validate();
550
+
551
+ return this.set();
552
+ }
553
+
554
+ private validate(): void {`
555
+ }
556
+ ${load} // TODO: collect validation errors via this.addErrors({ column: 'message' }).
557
+ this.raiseExceptionOnValidationFailure();
558
+ }
559
+
560
+ ${set}
561
+ }
446
562
  `;
447
563
  }
448
564
 
@@ -543,6 +659,9 @@ export class ${n.subscriberClass} extends CommonSubscriber<${n.entityClass}> {
543
659
  datasource.subscribers.push(this);
544
660
  }
545
661
 
662
+ // Jobs receive only \`id\` unless the columns they need are listed here.
663
+ protected readonly snapshotColumns: (keyof ${n.entityClass} & string)[] = [];
664
+
546
665
  listenTo() {
547
666
  return ${n.entityClass};
548
667
  }
@@ -699,6 +818,33 @@ export const EntityMaps = {
699
818
  `;
700
819
  }
701
820
 
821
+ /** Fresh src/global/enums/scope.enum.ts (first entry included). */
822
+ function scopeEnumFile(base) {
823
+ const a = accessNames(base);
824
+ return `/** Scope identifiers (sys_scopes.identifier) the app checks. */
825
+ export enum ScopeEnum {
826
+ ${a.scopeKey} = '${a.scopeValue}',
827
+ }
828
+ `;
829
+ }
830
+
831
+ /** Fresh src/global/enums/role.enum.ts (the entity's default roles included). */
832
+ function roleEnumFile(base) {
833
+ const a = accessNames(base);
834
+ const members = Object.values(a.roles)
835
+ .map((role) => ` ${role.key} = '${role.value}',`)
836
+ .join('\n');
837
+ return `/**
838
+ * Tenant role identifiers (sys_roles.identifier) the app checks. Each scope's
839
+ * defaults rank admin (priority 1) > manager (2) > reader (3) — create the
840
+ * catalogue rows with those priorities under the matching scope.
841
+ */
842
+ export enum RoleEnum {
843
+ ${members}
844
+ }
845
+ `;
846
+ }
847
+
702
848
  /** A freshly scaffolded module before any es6.classes content exists. */
703
849
  function emptyEs6Classes() {
704
850
  return `export const es6Classes = {
@@ -739,6 +885,8 @@ export class ${pascal}Module {}
739
885
 
740
886
  module.exports = {
741
887
  names,
888
+ scopeEnumFile,
889
+ roleEnumFile,
742
890
  entity,
743
891
  attributesDto,
744
892
  controller,