@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 +52 -14
- package/package.json +2 -2
- package/src/commands/gen.flag.js +7 -1
- package/src/commands/gen.js +13 -3
- package/src/lib/access.js +38 -0
- package/src/lib/constants.engine.js +65 -51
- package/src/lib/paths.js +1 -0
- package/src/lib/registry.js +59 -1
- package/src/lib/source.scan.js +132 -0
- package/src/lib/sync.engine.js +32 -14
- package/src/lib/templates.js +189 -41
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 → `
|
|
124
|
-
relation,
|
|
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
|
-
| `
|
|
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()`
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
`
|
|
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
|
|
219
|
-
with `@
|
|
220
|
-
|
|
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.
|
|
4
|
-
"description": "nh / nhx
|
|
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",
|
package/src/commands/gen.flag.js
CHANGED
|
@@ -283,7 +283,13 @@ async function genFlag(target, { external = false } = {}) {
|
|
|
283
283
|
break;
|
|
284
284
|
|
|
285
285
|
case 'dataProcessor':
|
|
286
|
-
write(
|
|
286
|
+
write(
|
|
287
|
+
dest,
|
|
288
|
+
tpl.processor(rawName, {
|
|
289
|
+
external,
|
|
290
|
+
withEntity: fs.existsSync(path.join(moduleDir, 'entities', n.entityFile)),
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
287
293
|
// The processor's payload DTO lives in the module's dtos/ either way —
|
|
288
294
|
// created here only if it is not already there.
|
|
289
295
|
write(path.join(moduleDir, 'dtos', n.dtoFile), tpl.dto(rawName));
|
package/src/commands/gen.js
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
103
|
-
const
|
|
104
|
-
|
|
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
|
}
|
package/src/lib/registry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|
package/src/lib/sync.engine.js
CHANGED
|
@@ -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
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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) {
|
package/src/lib/templates.js
CHANGED
|
@@ -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
|
-
|
|
107
|
-
|
|
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
|
|
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(
|
|
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 = [
|
|
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('
|
|
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
|
|
174
|
-
//
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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 {
|
|
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
|
-
@
|
|
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
|
-
|
|
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,
|