@neohm/nh-cli 1.3.1 → 1.4.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
@@ -19,7 +19,7 @@ differ only where the two packages do:
19
19
  | ------------------------ | ------------------------------------------ | ----------------------------------------- |
20
20
  | Import specifier | `@neohm/nestend` | `@neohm/nestendx` |
21
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 |
22
+ | Access check | `validateRole(role)` (scope checked first) | `validateAccess(scope)` — there are no roles |
23
23
  | Generated migration | FK + index on `tenant_id` | no tenant column to key |
24
24
 
25
25
  Everything that does not write package-specific code — `sync`, `check`, `list`,
@@ -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
@@ -136,7 +137,7 @@ Generated controllers run under `@UsePipes(StrictValidationPipe)`, take ids
136
137
  with `ParseUUIDPipe`, and describe every route with `@ApiOperation`.
137
138
 
138
139
  Controllers hold no business logic. They authenticate with
139
- `AuthService.validateAccess()`, then delegate to the module's service — so the
140
+ `AuthService.validateRole()` (`validateAccess()` under nhx), then delegate to the module's service — so the
140
141
  same operation is reusable from a job, a subscriber or another service, and the
141
142
  HTTP surface stays readable.
142
143
 
@@ -147,12 +148,14 @@ answers you already gave:
147
148
  | Service method | Generated when | Controller endpoint |
148
149
  | --- | --- | --- |
149
150
  | `get(user, id)` | always | `GET /api/v1/shape/:id` |
151
+ | `remove(user, id)` | always | `DELETE /api/v1/shape/:id` |
150
152
  | `upsert(user, data)` | data processor: Yes | `POST /api/v1/shape` |
151
153
  | `list(user, data)` | listing processor: Yes | `POST /api/v1/shape/list` |
152
154
 
153
- `get()` is the one operation that needs no processor, so a module that declines
154
- both still gets a useful service with a single read method (and a controller
155
- 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
156
159
  *and* its endpoint together, so the generated module always compiles.
157
160
 
158
161
  `upsert()` rather than `create()` because `CommonDataDto` carries an optional
@@ -168,9 +171,39 @@ is what the tenant is called on `UserEntity`; `tenant_id` is what it is called
168
171
  on the row — the same value on two sides of one relation.) The listing
169
172
  processor applies the same scope as a private predicate.
170
173
 
171
- The generated scope and role identifiers are `'add-scope'` / `'add-role'`
172
- placeholders, flagged with a `TODO` on the controller — replace them with the
173
- `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 role
195
+ carries its scope, so `validateRole()` first checks the organization holds the
196
+ role's scope, then that the caller holds the role or a more senior one:
197
+
198
+ | Endpoint | Gate |
199
+ | --- | --- |
200
+ | `GET :id`, `POST list` | `validateRole(RoleEnum.BILLING_READER)` |
201
+ | `POST` (upsert) | `validateRole(RoleEnum.BILLING_MANAGER)` |
202
+ | `DELETE :id` | `validateRole(RoleEnum.BILLING_ADMIN)` |
203
+
204
+ Under `nhx` the gate is `validateAccess(ScopeEnum.BILLING)`. The enums name the
205
+ identifiers only: create the `sys_scopes` row and the three `sys_roles` rows
206
+ (scope `billing`, priorities 1/2/3) in the back office so the checks can pass.
174
207
 
175
208
  ## Flag generation
176
209
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neohm/nh-cli",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "description": "nh / nhx — Command Line Interface for neohm. NestJS projects",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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([
@@ -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
@@ -134,13 +135,26 @@ function attributesDto(base) {
134
135
  * delegate to the module's service — so the HTTP surface stays readable and
135
136
  * the same operation is reusable from a job, a subscriber or another service.
136
137
  */
137
- function controller(base, { processor = false, listProcessor = false } = {}) {
138
+ function controller(
139
+ base,
140
+ { processor = false, listProcessor = false, enums = {} } = {},
141
+ ) {
138
142
  const n = names(base);
143
+ const a = accessNames(base);
144
+ const scopeEnum = enums.scope || 'ScopeEnum';
145
+ const roleEnum = enums.role || 'RoleEnum';
139
146
  const svcProp = `${n.camel}Service`;
140
147
  const hasPost = processor || listProcessor;
141
148
 
142
149
  // @nestjs/common members, alphabetised the way prettier leaves them.
143
- const nestCommon = ['Controller', 'Get', 'Param', 'ParseUUIDPipe', 'UsePipes'];
150
+ const nestCommon = [
151
+ 'Controller',
152
+ 'Delete',
153
+ 'Get',
154
+ 'Param',
155
+ 'ParseUUIDPipe',
156
+ 'UsePipes',
157
+ ];
144
158
  if (hasPost) nestCommon.push('Body', 'Post');
145
159
  nestCommon.sort();
146
160
 
@@ -164,19 +178,31 @@ function controller(base, { processor = false, listProcessor = false } = {}) {
164
178
  );
165
179
  }
166
180
 
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.
181
+ // Tenanted access names the lowest role that should reach the endpoint: the
182
+ // role carries its scope, so validateRole() checks the organization holds the
183
+ // scope first, then the role (a senior role satisfies a junior one). The B2C
184
+ // model has no roles, so the check is the scope alone.
169
185
  const tenanted = current().tenantScoped;
170
- const gate = (indent) =>
186
+ imports.push(
171
187
  tenanted
172
- ? `await this.authService.validateAccess(\n${indent} 'add-scope',\n${indent} 'add-role',\n${indent})`
173
- : `await this.authService.validateAccess('add-scope')`;
188
+ ? `import { ${roleEnum} } from '../../global/enums/role.enum';`
189
+ : `import { ${scopeEnum} } from '../../global/enums/scope.enum';`,
190
+ );
191
+ const gate = (indent, level) => {
192
+ const [method, arg] = tenanted
193
+ ? ['validateRole', `${roleEnum}.${a.roles[level].key}`]
194
+ : ['validateAccess', `${scopeEnum}.${a.scopeKey}`];
195
+ const call = `await this.authService.${method}(${arg})`;
196
+ return `${indent}const { user } = ${call};`.length <= 80
197
+ ? call
198
+ : `await this.authService.${method}(\n${indent} ${arg},\n${indent})`;
199
+ };
174
200
 
175
201
  const methods = [
176
202
  ` @Get(':id')
177
203
  @ApiOperation({ summary: 'One ${n.kebab} by id.' })
178
204
  async get${n.pascal}(@Param('id', ParseUUIDPipe) id: string) {
179
- const { user } = ${gate(' ')};
205
+ const { user } = ${gate(' ', 'reader')};
180
206
 
181
207
  return this.${svcProp}.get(user, id);
182
208
  }`,
@@ -186,7 +212,7 @@ function controller(base, { processor = false, listProcessor = false } = {}) {
186
212
  methods.push(` @Post()
187
213
  @ApiOperation({ summary: 'Create a ${n.kebab}, or update it when an id is sent.' })
188
214
  async upsert${n.pascal}(@Body() body: ${n.dtoClass}) {
189
- const { user } = ${gate(' ')};
215
+ const { user } = ${gate(' ', 'manager')};
190
216
 
191
217
  return this.${svcProp}.upsert(user, body);
192
218
  }`);
@@ -196,19 +222,22 @@ function controller(base, { processor = false, listProcessor = false } = {}) {
196
222
  methods.push(` @Post('list')
197
223
  @ApiOperation({ summary: 'List, search and filter ${n.kebab}.' })
198
224
  async list(@Body() body: CommonListFilterDto) {
199
- const { user } = ${gate(' ')};
225
+ const { user } = ${gate(' ', 'reader')};
200
226
 
201
227
  return this.${svcProp}.list(user, body);
202
228
  }`);
203
229
  }
204
230
 
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.`;
231
+ methods.push(` @Delete(':id')
232
+ @ApiOperation({ summary: 'Delete one ${n.kebab} by id.' })
233
+ async remove${n.pascal}(@Param('id', ParseUUIDPipe) id: string) {
234
+ const { user } = ${gate(' ', 'admin')};
235
+
236
+ return this.${svcProp}.remove(user, id);
237
+ }`);
208
238
 
209
239
  return `${imports.join('\n')}
210
240
 
211
- ${gateTodo}
212
241
  @ApiTags('${n.kebab}')
213
242
  @ApiBearerAuth()
214
243
  @UsePipes(StrictValidationPipe)
@@ -283,6 +312,14 @@ function service(base, { processor = false, listProcessor = false } = {}) {
283
312
  return ${n.camel};
284
313
  }`);
285
314
 
315
+ // Soft delete, through the same ownership-scoped lookup as get().
316
+ members.push(` async remove(user: UserEntity, id: string) {
317
+ const ${n.camel} = await this.get(user, id);
318
+ await ${n.entityClass}.softRemove(${n.camel});
319
+
320
+ return { id, deleted: true, deleted_by: user.id };
321
+ }`);
322
+
286
323
  if (processor) {
287
324
  // Insert or update: CommonDataDto carries an optional `id`, and the data
288
325
  // processor decides which of the two the payload asks for.
@@ -777,6 +814,33 @@ export const EntityMaps = {
777
814
  `;
778
815
  }
779
816
 
817
+ /** Fresh src/global/enums/scope.enum.ts (first entry included). */
818
+ function scopeEnumFile(base) {
819
+ const a = accessNames(base);
820
+ return `/** Scope identifiers (sys_scopes.identifier) the app checks. */
821
+ export enum ScopeEnum {
822
+ ${a.scopeKey} = '${a.scopeValue}',
823
+ }
824
+ `;
825
+ }
826
+
827
+ /** Fresh src/global/enums/role.enum.ts (the entity's default roles included). */
828
+ function roleEnumFile(base) {
829
+ const a = accessNames(base);
830
+ const members = Object.values(a.roles)
831
+ .map((role) => ` ${role.key} = '${role.value}',`)
832
+ .join('\n');
833
+ return `/**
834
+ * Tenant role identifiers (sys_roles.identifier) the app checks. Each scope's
835
+ * defaults rank admin (priority 1) > manager (2) > reader (3) — create the
836
+ * catalogue rows with those priorities under the matching scope.
837
+ */
838
+ export enum RoleEnum {
839
+ ${members}
840
+ }
841
+ `;
842
+ }
843
+
780
844
  /** A freshly scaffolded module before any es6.classes content exists. */
781
845
  function emptyEs6Classes() {
782
846
  return `export const es6Classes = {
@@ -817,6 +881,8 @@ export class ${pascal}Module {}
817
881
 
818
882
  module.exports = {
819
883
  names,
884
+ scopeEnumFile,
885
+ roleEnumFile,
820
886
  entity,
821
887
  attributesDto,
822
888
  controller,