@neohm/nh-cli 1.0.0 → 1.1.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
@@ -12,9 +12,10 @@ Every generated artifact matches the conventions of the `nestjs-boilerplate`
12
12
  reference project:
13
13
 
14
14
  - **Filenames** are `dot.separated.lowercase` (no hyphens) — e.g.
15
- `business.client.entity.ts`, `add.business.client.data.dto.ts`.
16
- - **Class names** are `PascalCase` — e.g. `BusinessClientEntity`,
17
- `AddBusinessClientDataDto`.
15
+ `shape.entity.ts`, and a multi-word `RoundShape` becomes
16
+ `round.shape.entity.ts`.
17
+ - **Class names** are `PascalCase` — e.g. `ShapeEntity`,
18
+ `AddShapeDataDto`.
18
19
 
19
20
  ## Installation
20
21
 
@@ -48,36 +49,56 @@ The project root is auto-detected by walking up from the current directory for a
48
49
 
49
50
  ```text
50
51
  $ nh gen
51
- ? Module name (e.g. business): business
52
- ? Entity name (e.g. BusinessClient): BusinessClient
53
- ? Table name (e.g. nh_business_clients): nh_business_clients
54
- ? Create controller (BusinessClientController)? Yes
55
- ? Create DTO (AddBusinessClientDataDto)? Yes
56
- ? Create data processor (BusinessClientDataProcessor)? Yes
57
- ? Create migration (M-AddBusinessClientTable)? Yes
52
+ ? Module name (e.g. shape): shape
53
+ ? Entity name (e.g. Shape): Shape
54
+ ? Table name (e.g. nh_shapes): nh_shapes
55
+ ? Create controller (ShapeController)? Yes
56
+ ? Create DTO (AddShapeDataDto)? Yes
57
+ ? Create data processor (ShapeDataProcessor)? Yes
58
+ ? Create listing processor (ShapeListProcessor)? Yes
59
+ ? Create migration (M-AddShapeTable)? Yes
58
60
  ```
59
61
 
60
- Produces, under `src/business/`:
62
+ Produces, under `src/shape/`:
61
63
 
62
64
  ```text
63
- business/
64
- business.module.ts # BusinessModule (imports CommonModule, QueueModule, ...)
65
+ shape/
66
+ shape.module.ts # ShapeModule (imports CommonModule, QueueModule, ...)
65
67
  es6.classes.ts # barrel of controllers/services/jobs/subscribers
66
- controllers/business.client.controller.ts
67
- dtos/add.business.client.data.dto.ts
68
- entities/business.client.entity.ts
69
- libraries/business.client.data.processor.ts
68
+ controllers/shape.controller.ts
69
+ dtos/add.shape.data.dto.ts
70
+ dtos/shape.list.filter.dto.ts
71
+ entities/shape.entity.ts
72
+ libraries/shape.data.processor.ts
73
+ libraries/shape.list.processor.ts
70
74
  enums/ jobs/ services/ subscribers/
71
75
  ```
72
76
 
73
- plus `src/database/migrations/<timestamp>M-AddBusinessClientTable.ts`, and it
74
- registers `BusinessModule` in `src/app.module.ts`.
77
+ plus `src/database/migrations/<timestamp>M-AddShapeTable.ts`, and it
78
+ registers `ShapeModule` in `src/app.module.ts`.
79
+
80
+ It also registers the entity and its job in the project constant registries —
81
+ `shapeJob: 'shape.job'` in `src/constants/job.constants.ts` (which the
82
+ generated job references as `JobConstants.shapeJob`) and `shape: 'shape'` in
83
+ `src/constants/entity.constants.ts` — and mirrors them in the
84
+ identifier → class map registries: `[JobConstants.shapeJob]: ShapeJob` in
85
+ `src/constants/job.maps.ts` and `[EntityConstants.shape]: ShapeEntity` in
86
+ `src/constants/entity.maps.ts` (imports included). Missing registry files are
87
+ created spreading `baseJobConstants` / `baseEntityConstants` /
88
+ `baseJobMaps` / `baseEntityMaps` from `@neohm/nestend`; existing ones
89
+ (whatever their export is named) just get the entry appended.
75
90
 
76
91
  Generated artifacts extend the `@neohm/nestend` base classes:
77
92
 
78
93
  - Entity → `CommonEntity` (with an empty `attributes` jsonb column)
79
94
  - DTO → `CommonPayloadDto`
80
95
  - Data processor → `CommonDataProcessor` (`process()` → `validate()` → `set()`)
96
+ - List filter DTO → `CommonListFilterDto` (plus a `custom_filter` example field)
97
+ - Listing processor → `CommonListProcessor` (column allowlist config, `id` default sort, and an example custom filter)
98
+
99
+ When both a controller and a listing processor are generated, the controller
100
+ ships with a `POST list` endpoint that runs the listing processor (with
101
+ `SqlService` injected via the constructor).
81
102
  - Migration → `MigrationUtility`
82
103
  - Seed → `SeederUtility`
83
104
 
@@ -98,7 +119,7 @@ never silently dropped. The operation is idempotent.
98
119
  ```text
99
120
  $ nh check
100
121
  es6.classes barrels:
101
- ok src/business/es6.classes.ts
122
+ ok src/shape/es6.classes.ts
102
123
  update src/test/es6.classes.ts
103
124
  + services: TestService
104
125
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neohm/nh-cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "nh — Command Line Interface for neohm. NestJS projects",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,15 +1,21 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
- const inquirer = require('inquirer');
6
- const chalk = require('chalk');
7
-
8
- const { projectPaths, MODULE_SUBFOLDERS } = require('../lib/paths');
9
- const naming = require('../lib/naming');
10
- const tpl = require('../lib/templates');
11
- const { writeFile, report, log, ensureDir } = require('../lib/fs.utils');
12
- const sync = require('../lib/sync.engine');
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const inquirer = require("inquirer");
6
+ const chalk = require("chalk");
7
+
8
+ const { projectPaths, MODULE_SUBFOLDERS } = require("../lib/paths");
9
+ const naming = require("../lib/naming");
10
+ const tpl = require("../lib/templates");
11
+ const { writeFile, report, log, ensureDir } = require("../lib/fs.utils");
12
+ const sync = require("../lib/sync.engine");
13
+ const {
14
+ ensureConstantEntry,
15
+ ensureMapEntry,
16
+ detectExportName,
17
+ entityConstantsPath,
18
+ } = require("../lib/constants.engine");
13
19
 
14
20
  async function gen() {
15
21
  const paths = projectPaths();
@@ -18,10 +24,10 @@ async function gen() {
18
24
  // 1. Module ---------------------------------------------------------------
19
25
  const { moduleNameRaw } = await inquirer.prompt([
20
26
  {
21
- type: 'input',
22
- name: 'moduleNameRaw',
23
- message: 'Module name (e.g. business):',
24
- validate: (v) => (v && v.trim() ? true : 'Module name is required'),
27
+ type: "input",
28
+ name: "moduleNameRaw",
29
+ message: "Module name:",
30
+ validate: (v) => (v && v.trim() ? true : "Module name is required"),
25
31
  },
26
32
  ]);
27
33
  const moduleDot = naming.toDotCase(moduleNameRaw);
@@ -44,20 +50,16 @@ async function gen() {
44
50
  path.join(moduleDir, `${moduleDot}.module.ts`),
45
51
  paths.root,
46
52
  );
47
- const es6Path = path.join(moduleDir, 'es6.classes.ts');
48
- report(
49
- writeFile(es6Path, tpl.emptyEs6Classes()),
50
- es6Path,
51
- paths.root,
52
- );
53
+ const es6Path = path.join(moduleDir, "es6.classes.ts");
54
+ report(writeFile(es6Path, tpl.emptyEs6Classes()), es6Path, paths.root);
53
55
 
54
56
  // 2. Entity ---------------------------------------------------------------
55
57
  const { entityName } = await inquirer.prompt([
56
58
  {
57
- type: 'input',
58
- name: 'entityName',
59
- message: 'Entity name (e.g. BusinessClient):',
60
- validate: (v) => (v && v.trim() ? true : 'Entity name is required'),
59
+ type: "input",
60
+ name: "entityName",
61
+ message: "Entity name (PascalCase):",
62
+ validate: (v) => (v && v.trim() ? true : "Entity name is required"),
61
63
  },
62
64
  ]);
63
65
  const n = tpl.names(entityName);
@@ -65,66 +67,183 @@ async function gen() {
65
67
  // 3. Table ----------------------------------------------------------------
66
68
  const { tableName } = await inquirer.prompt([
67
69
  {
68
- type: 'input',
69
- name: 'tableName',
70
- message: 'Table name (e.g. nh_business_clients):',
71
- default: `nh_${naming.toWords(entityName).join('_')}s`,
72
- validate: (v) => (v && v.trim() ? true : 'Table name is required'),
70
+ type: "input",
71
+ name: "tableName",
72
+ message: "Table name:",
73
+ default: `nh_${naming.toWords(entityName).join("_")}s`,
74
+ validate: (v) => (v && v.trim() ? true : "Table name is required"),
73
75
  },
74
76
  ]);
75
77
 
76
- const entityPath = path.join(moduleDir, 'entities', n.entityFile);
78
+ // Always generate: attributes DTO, entity, subscriber, job
79
+ const attrDtoPath = path.join(moduleDir, "dtos", n.attributesDtoFile);
80
+ report(writeFile(attrDtoPath, tpl.attributesDto(entityName)), attrDtoPath, paths.root);
81
+
82
+ const entityPath = path.join(moduleDir, "entities", n.entityFile);
77
83
  report(
78
84
  writeFile(entityPath, tpl.entity(entityName, tableName)),
79
85
  entityPath,
80
86
  paths.root,
81
87
  );
82
88
 
83
- // 4-7. Optional artifacts -------------------------------------------------
84
- const answers = await inquirer.prompt([
85
- { type: 'confirm', name: 'controller', message: `Create controller (${n.controllerClass})?`, default: true },
86
- { type: 'confirm', name: 'dto', message: `Create DTO (${n.dtoClass})?`, default: true },
87
- { type: 'confirm', name: 'processor', message: `Create data processor (${n.processorClass})?`, default: true },
88
- { type: 'confirm', name: 'migration', message: `Create migration (M-Add${n.pascal}Table)?`, default: true },
89
- ]);
89
+ const jobPath = path.join(moduleDir, "jobs", n.jobFile);
90
+ report(writeFile(jobPath, tpl.job(entityName)), jobPath, paths.root);
91
+
92
+ const subscriberPath = path.join(moduleDir, "subscribers", n.subscriberFile);
93
+ report(writeFile(subscriberPath, tpl.subscriber(entityName)), subscriberPath, paths.root);
90
94
 
91
- if (answers.controller) {
92
- const p = path.join(moduleDir, 'controllers', n.controllerFile);
93
- report(writeFile(p, tpl.controller(entityName)), p, paths.root);
95
+ // Register the entity + job identifiers in the project constant registries
96
+ // (the generated job references JobConstants.<name>Job, so the entry must exist).
97
+ const jobConstantsPath = path.join(paths.constantsDir, "job.constants.ts");
98
+ const jobEntry = ensureConstantEntry(
99
+ jobConstantsPath,
100
+ tpl.jobConstantsFile(entityName),
101
+ `${n.camel}Job`,
102
+ `${n.dot}.job`,
103
+ );
104
+ if (jobEntry === "failed") {
105
+ log.warn(`Could not update ${path.relative(paths.root, jobConstantsPath)} — add ${n.camel}Job: '${n.dot}.job' manually.`);
106
+ } else {
107
+ report(jobEntry, jobConstantsPath, paths.root);
94
108
  }
95
109
 
96
- // A processor depends on its DTO type, so generate the DTO when either the
97
- // DTO step or the processor step is selected.
98
- if (answers.dto || answers.processor) {
99
- if (!answers.dto) {
100
- log.warn(`DTO ${n.dtoClass} is generated because the data processor depends on it.`);
101
- }
102
- const p = path.join(moduleDir, 'dtos', n.dtoFile);
103
- report(writeFile(p, tpl.dto(entityName)), p, paths.root);
110
+ const entityConstants = entityConstantsPath(paths.constantsDir);
111
+ const entityEntry = ensureConstantEntry(
112
+ entityConstants,
113
+ tpl.entityConstantsFile(entityName),
114
+ n.camel,
115
+ n.dot,
116
+ );
117
+ if (entityEntry === "failed") {
118
+ log.warn(`Could not update ${path.relative(paths.root, entityConstants)} — add ${n.camel}: '${n.dot}' manually.`);
119
+ } else {
120
+ report(entityEntry, entityConstants, paths.root);
104
121
  }
105
122
 
106
- if (answers.processor) {
107
- const p = path.join(moduleDir, 'libraries', n.processorFile);
108
- report(writeFile(p, tpl.processor(entityName)), p, paths.root);
123
+ // ...and mirror them in the identifier → class map registries.
124
+ const jobConstantsRef = {
125
+ exportName: detectExportName(jobConstantsPath) || "JobConstants",
126
+ importPath: "./job.constants",
127
+ };
128
+ const jobMapsPath = path.join(paths.constantsDir, "job.maps.ts");
129
+ const jobMapEntry = ensureMapEntry(
130
+ jobMapsPath,
131
+ tpl.jobMapsFile(entityName, moduleDot, jobConstantsRef),
132
+ `[${jobConstantsRef.exportName}.${n.camel}Job]`,
133
+ n.jobClass,
134
+ [
135
+ { className: n.jobClass, importPath: `../${moduleDot}/jobs/${n.dot}.job` },
136
+ { className: jobConstantsRef.exportName, importPath: jobConstantsRef.importPath },
137
+ ],
138
+ );
139
+ if (jobMapEntry === "failed") {
140
+ log.warn(`Could not update ${path.relative(paths.root, jobMapsPath)} — map ${n.jobClass} manually.`);
141
+ } else {
142
+ report(jobMapEntry, jobMapsPath, paths.root);
109
143
  }
110
144
 
111
- if (answers.migration) {
112
- const ts = Date.now();
113
- const file = `${ts}M-Add${n.pascal}Table.ts`;
114
- const p = path.join(paths.migrationsDir, file);
115
- report(writeFile(p, tpl.genMigration(entityName, tableName, ts)), p, paths.root);
145
+ const entityConstantsRef = {
146
+ exportName: detectExportName(entityConstants) || "EntityConstants",
147
+ importPath: `./${path.basename(entityConstants, ".ts")}`,
148
+ };
149
+ const entityMapsPath = path.join(paths.constantsDir, "entity.maps.ts");
150
+ const entityMapEntry = ensureMapEntry(
151
+ entityMapsPath,
152
+ tpl.entityMapsFile(entityName, moduleDot, entityConstantsRef),
153
+ `[${entityConstantsRef.exportName}.${n.camel}]`,
154
+ n.entityClass,
155
+ [
156
+ { className: n.entityClass, importPath: `../${moduleDot}/entities/${n.dot}.entity` },
157
+ { className: entityConstantsRef.exportName, importPath: entityConstantsRef.importPath },
158
+ ],
159
+ );
160
+ if (entityMapEntry === "failed") {
161
+ log.warn(`Could not update ${path.relative(paths.root, entityMapsPath)} — map ${n.entityClass} manually.`);
162
+ } else {
163
+ report(entityMapEntry, entityMapsPath, paths.root);
164
+ }
165
+
166
+ // 4. Optional: controller -------------------------------------------------
167
+ const { controller } = await inquirer.prompt([
168
+ {
169
+ type: "confirm",
170
+ name: "controller",
171
+ message: `Create controller (${n.controllerClass})?`,
172
+ default: true,
173
+ },
174
+ ]);
175
+
176
+ // (written after the listing-processor prompt — the answer decides whether
177
+ // the controller ships with a POST list endpoint)
178
+
179
+ // 5. Optional: data processor (includes Add*DataDto) ----------------------
180
+ const { processor } = await inquirer.prompt([
181
+ {
182
+ type: "confirm",
183
+ name: "processor",
184
+ message: `Create data processor (${n.processorClass})?`,
185
+ default: true,
186
+ },
187
+ ]);
188
+
189
+ if (processor) {
190
+ const dtoPath = path.join(moduleDir, "dtos", n.dtoFile);
191
+ report(writeFile(dtoPath, tpl.dto(entityName)), dtoPath, paths.root);
192
+
193
+ const procPath = path.join(moduleDir, "libraries", n.processorFile);
194
+ report(writeFile(procPath, tpl.processor(entityName)), procPath, paths.root);
195
+ }
196
+
197
+ // 6. Optional: listing processor (includes *ListFilterDto) -----------------
198
+ const { listProcessor } = await inquirer.prompt([
199
+ {
200
+ type: "confirm",
201
+ name: "listProcessor",
202
+ message: `Create listing processor (${n.listProcessorClass})?`,
203
+ default: true,
204
+ },
205
+ ]);
206
+
207
+ if (listProcessor) {
208
+ const listDtoPath = path.join(moduleDir, "dtos", n.listFilterDtoFile);
209
+ report(
210
+ writeFile(listDtoPath, tpl.listFilterDto(entityName)),
211
+ listDtoPath,
212
+ paths.root,
213
+ );
214
+
215
+ const listProcPath = path.join(moduleDir, "libraries", n.listProcessorFile);
216
+ report(
217
+ writeFile(listProcPath, tpl.listProcessor(entityName, tableName)),
218
+ listProcPath,
219
+ paths.root,
220
+ );
221
+ }
222
+
223
+ if (controller) {
224
+ const p = path.join(moduleDir, "controllers", n.controllerFile);
225
+ report(
226
+ writeFile(p, tpl.controller(entityName, { listProcessor })),
227
+ p,
228
+ paths.root,
229
+ );
116
230
  }
117
231
 
118
232
  // Auto-wire: rebuild this module's barrel and register it in app.module.ts.
119
- log.info('\nWiring imports...');
120
- const mod = sync.scanModule(moduleDir, path.join(moduleDir, `${moduleDot}.module.ts`));
233
+ log.info("\nWiring imports...");
234
+ const mod = sync.scanModule(
235
+ moduleDir,
236
+ path.join(moduleDir, `${moduleDot}.module.ts`),
237
+ );
121
238
  const plan = sync.buildPlan(paths, [mod]);
122
239
  const written = sync.applyPlan(plan);
123
- for (const w of written) report('overwritten', w, paths.root);
124
- if (!written.length) log.info(' (already in sync)');
240
+ for (const w of written) report("overwritten", w, paths.root);
241
+ if (!written.length) log.info(" (already in sync)");
125
242
 
126
243
  log.done(`Module "${moduleDot}" scaffolded.`);
127
- console.log(chalk.dim('Run `nh sync` anytime to re-wire imports after manual edits.'));
244
+ console.log(
245
+ chalk.dim("Run `nh sync` anytime to re-wire imports after manual edits."),
246
+ );
128
247
  }
129
248
 
130
249
  module.exports = gen;
@@ -1,35 +1,39 @@
1
- 'use strict';
1
+ "use strict";
2
2
 
3
- const path = require('path');
4
- const inquirer = require('inquirer');
3
+ const path = require("path");
4
+ const inquirer = require("inquirer");
5
5
 
6
- const { projectPaths } = require('../lib/paths');
7
- const naming = require('../lib/naming');
8
- const tpl = require('../lib/templates');
9
- const { writeFile, report, log } = require('../lib/fs.utils');
6
+ const { projectPaths } = require("../lib/paths");
7
+ const naming = require("../lib/naming");
8
+ const tpl = require("../lib/templates");
9
+ const { writeFile, report, log } = require("../lib/fs.utils");
10
10
 
11
11
  async function migration() {
12
12
  const paths = projectPaths();
13
13
 
14
14
  const { tableName, migrationName } = await inquirer.prompt([
15
15
  {
16
- type: 'input',
17
- name: 'tableName',
18
- message: 'Table name (e.g. nh_business_clients):',
19
- validate: (v) => (v && v.trim() ? true : 'Table name is required'),
16
+ type: "input",
17
+ name: "tableName",
18
+ message: "Table name (e.g. nh_user_details):",
19
+ validate: (v) => (v && v.trim() ? true : "Table name is required"),
20
20
  },
21
21
  {
22
- type: 'input',
23
- name: 'migrationName',
24
- message: 'Migration name (e.g. AddBusinessClientTable):',
25
- validate: (v) => (v && v.trim() ? true : 'Migration name is required'),
22
+ type: "input",
23
+ name: "migrationName",
24
+ message: "Migration name (e.g. AddShapeTable):",
25
+ validate: (v) => (v && v.trim() ? true : "Migration name is required"),
26
26
  },
27
27
  ]);
28
28
 
29
29
  const ts = Date.now();
30
30
  const file = `${ts}M-${naming.toPascalCase(migrationName)}.ts`;
31
31
  const p = path.join(paths.migrationsDir, file);
32
- report(writeFile(p, tpl.migration(migrationName, tableName, ts)), p, paths.root);
32
+ report(
33
+ writeFile(p, tpl.migration(migrationName, tableName, ts)),
34
+ p,
35
+ paths.root,
36
+ );
33
37
  log.done(`Migration created (${file}).`);
34
38
  }
35
39
 
@@ -1,28 +1,28 @@
1
- 'use strict';
1
+ "use strict";
2
2
 
3
- const path = require('path');
4
- const inquirer = require('inquirer');
3
+ const path = require("path");
4
+ const inquirer = require("inquirer");
5
5
 
6
- const { projectPaths } = require('../lib/paths');
7
- const naming = require('../lib/naming');
8
- const tpl = require('../lib/templates');
9
- const { writeFile, report, log } = require('../lib/fs.utils');
6
+ const { projectPaths } = require("../lib/paths");
7
+ const naming = require("../lib/naming");
8
+ const tpl = require("../lib/templates");
9
+ const { writeFile, report, log } = require("../lib/fs.utils");
10
10
 
11
11
  async function seed() {
12
12
  const paths = projectPaths();
13
13
 
14
14
  const { tableName, seedName } = await inquirer.prompt([
15
15
  {
16
- type: 'input',
17
- name: 'tableName',
18
- message: 'Table name (e.g. nh_business_clients):',
19
- validate: (v) => (v && v.trim() ? true : 'Table name is required'),
16
+ type: "input",
17
+ name: "tableName",
18
+ message: "Table name (e.g. nh_user_details):",
19
+ validate: (v) => (v && v.trim() ? true : "Table name is required"),
20
20
  },
21
21
  {
22
- type: 'input',
23
- name: 'seedName',
24
- message: 'Seed name (e.g. SeedBusinessClients):',
25
- validate: (v) => (v && v.trim() ? true : 'Seed name is required'),
22
+ type: "input",
23
+ name: "seedName",
24
+ message: "Seed name (e.g. SeedShapes):",
25
+ validate: (v) => (v && v.trim() ? true : "Seed name is required"),
26
26
  },
27
27
  ]);
28
28
 
@@ -0,0 +1,155 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const { writeFile } = require('./fs.utils');
7
+
8
+ /**
9
+ * Keeps the project-level constant registries (src/constants/job.constants.ts
10
+ * and src/constants/entity.constants.ts) in step with `nh gen`: every
11
+ * generated job references JobConstants.<camel>Job, so the entry has to exist
12
+ * for the project to compile. Missing files are created spreading the
13
+ * corresponding base object from @neohm/nestend; existing files get the new
14
+ * entry appended to their exported object (whatever it is named — older
15
+ * projects use e.g. `entityMap`).
16
+ */
17
+
18
+ /**
19
+ * Ensure `key: 'value',` exists inside the object exported by `filePath`.
20
+ * Creates the file with `freshContent` when absent. Returns a writeFile-style
21
+ * result: 'created' | 'skipped' | 'overwritten' | 'failed'.
22
+ */
23
+ function ensureConstantEntry(filePath, freshContent, key, value) {
24
+ if (!fs.existsSync(filePath)) {
25
+ return writeFile(filePath, freshContent);
26
+ }
27
+
28
+ 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';
53
+
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);
59
+ return 'overwritten';
60
+ }
61
+
62
+ /**
63
+ * Ensure a map entry (`[Constants.key]: ClassName,`) plus the import lines it
64
+ * needs exist in `filePath`. Creates the file with `freshContent` when absent.
65
+ * `imports` is a list of { className, importPath }. Returns 'created' |
66
+ * 'skipped' | 'overwritten' | 'failed'.
67
+ */
68
+ function ensureMapEntry(filePath, freshContent, entryKey, entryValue, imports) {
69
+ if (!fs.existsSync(filePath)) {
70
+ return writeFile(filePath, freshContent);
71
+ }
72
+
73
+ 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';
78
+
79
+ for (const imp of imports) {
80
+ content = ensureImportLine(content, imp.className, imp.importPath);
81
+ }
82
+
83
+ // 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*{/),
87
+ );
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
+ }
99
+ }
100
+ if (close === -1) return 'failed';
101
+
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));
105
+ return 'overwritten';
106
+ }
107
+
108
+ function ensureImportLine(content, className, importPath) {
109
+ if (new RegExp(`from\\s+['"]${escapeRe(importPath)}['"]`).test(content)) {
110
+ return content;
111
+ }
112
+ const importLine = `import { ${className} } from '${importPath}';`;
113
+ const lines = content.split('\n');
114
+ let lastImport = -1;
115
+ for (let i = 0; i < lines.length; i++) {
116
+ if (/^import\b/.test(lines[i]) || /^\s*}\s+from\s+/.test(lines[i])) {
117
+ lastImport = i;
118
+ }
119
+ }
120
+ if (lastImport === -1) return importLine + '\n' + content;
121
+ lines.splice(lastImport + 1, 0, importLine);
122
+ return lines.join('\n');
123
+ }
124
+
125
+ /** Name of the first `export const X = {...}` in the file (null if unreadable). */
126
+ function detectExportName(filePath) {
127
+ if (!fs.existsSync(filePath)) return null;
128
+ const match = fs
129
+ .readFileSync(filePath, 'utf8')
130
+ .match(/export\s+const\s+(\w+)\s*(?::[^=]+)?=\s*{/);
131
+ return match ? match[1] : null;
132
+ }
133
+
134
+ /**
135
+ * Resolve the entity constants file, tolerating the legacy misspelled
136
+ * `enity.constants.ts` some projects carry — an existing registry is updated
137
+ * rather than shadowed by a second file.
138
+ */
139
+ function entityConstantsPath(constantsDir) {
140
+ const legacy = path.join(constantsDir, 'enity.constants.ts');
141
+ const canonical = path.join(constantsDir, 'entity.constants.ts');
142
+ if (!fs.existsSync(canonical) && fs.existsSync(legacy)) return legacy;
143
+ return canonical;
144
+ }
145
+
146
+ function escapeRe(str) {
147
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
148
+ }
149
+
150
+ module.exports = {
151
+ ensureConstantEntry,
152
+ ensureMapEntry,
153
+ detectExportName,
154
+ entityConstantsPath,
155
+ };
package/src/lib/naming.js CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  /**
4
4
  * Naming helpers. Every generated filename follows the dot.separated.lowercase
5
- * convention (e.g. business.client.entity.ts) and every class name is PascalCase
6
- * (e.g. BusinessClientEntity). These helpers are the single source of truth for
5
+ * convention (e.g. round.shape.entity.ts) and every class name is PascalCase
6
+ * (e.g. RoundShapeEntity). These helpers are the single source of truth for
7
7
  * turning a raw user input (PascalCase, kebab-case, snake_case, spaced, or
8
8
  * dotted) into the canonical word list both conventions are derived from.
9
9
  */
@@ -19,25 +19,25 @@ function toWords(input) {
19
19
  .filter(Boolean);
20
20
  }
21
21
 
22
- /** business.client */
22
+ /** round.shape */
23
23
  function toDotCase(input) {
24
24
  return toWords(input).join('.');
25
25
  }
26
26
 
27
- /** BusinessClient */
27
+ /** RoundShape */
28
28
  function toPascalCase(input) {
29
29
  return toWords(input)
30
30
  .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
31
31
  .join('');
32
32
  }
33
33
 
34
- /** businessClient */
34
+ /** roundShape */
35
35
  function toCamelCase(input) {
36
36
  const pascal = toPascalCase(input);
37
37
  return pascal.charAt(0).toLowerCase() + pascal.slice(1);
38
38
  }
39
39
 
40
- /** business-client (used for HTTP route segments / swagger tags) */
40
+ /** round-shape (used for HTTP route segments / swagger tags) */
41
41
  function toKebabCase(input) {
42
42
  return toWords(input).join('-');
43
43
  }
package/src/lib/paths.js CHANGED
@@ -40,6 +40,7 @@ function projectPaths(root = requireProjectRoot()) {
40
40
  srcDir,
41
41
  appModule: path.join(srcDir, 'app.module.ts'),
42
42
  migrationsDir: path.join(srcDir, 'database', 'migrations'),
43
+ constantsDir: path.join(srcDir, 'constants'),
43
44
  moduleDir: (moduleDot) => path.join(srcDir, moduleDot),
44
45
  };
45
46
  }
@@ -18,6 +18,8 @@ const path = require('path');
18
18
  const CATEGORIES = [
19
19
  { key: 'controllers', folder: 'controllers' },
20
20
  { key: 'services', folder: 'services' },
21
+ { key: 'libraries', folder: 'libraries' },
22
+ { key: 'dtos', folder: 'dtos' },
21
23
  { key: 'jobs', folder: 'jobs' },
22
24
  { key: 'subscribers', folder: 'subscribers' },
23
25
  ];
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { toDotCase, toPascalCase, toKebabCase } = require('./naming');
3
+ const { toDotCase, toPascalCase, toKebabCase, toCamelCase } = require('./naming');
4
4
 
5
5
  /**
6
6
  * Code templates. All generated code imports shared base classes from the
@@ -10,22 +10,34 @@ const { toDotCase, toPascalCase, toKebabCase } = require('./naming');
10
10
  * across nestjs-boilerplate.
11
11
  */
12
12
 
13
- /** Build the canonical names for a given entity-ish base (e.g. "BusinessClient"). */
13
+ /** Build the canonical names for a given entity-ish base (e.g. "Shape"). */
14
14
  function names(base) {
15
15
  const dot = toDotCase(base);
16
16
  const pascal = toPascalCase(base);
17
+ const camel = toCamelCase(base);
17
18
  return {
18
19
  dot,
19
20
  pascal,
21
+ camel,
20
22
  kebab: toKebabCase(base),
21
23
  entityClass: `${pascal}Entity`,
22
24
  entityFile: `${dot}.entity.ts`,
25
+ attributesDtoClass: `${pascal}AttributesDto`,
26
+ attributesDtoFile: `${dot}.attributes.dto.ts`,
23
27
  controllerClass: `${pascal}Controller`,
24
28
  controllerFile: `${dot}.controller.ts`,
25
29
  dtoClass: `Add${pascal}DataDto`,
26
30
  dtoFile: `add.${dot}.data.dto.ts`,
27
31
  processorClass: `${pascal}DataProcessor`,
28
32
  processorFile: `${dot}.data.processor.ts`,
33
+ listFilterDtoClass: `${pascal}ListFilterDto`,
34
+ listFilterDtoFile: `${dot}.list.filter.dto.ts`,
35
+ listProcessorClass: `${pascal}ListProcessor`,
36
+ listProcessorFile: `${dot}.list.processor.ts`,
37
+ subscriberClass: `${pascal}Subscriber`,
38
+ subscriberFile: `${dot}.subscriber.ts`,
39
+ jobClass: `${pascal}Job`,
40
+ jobFile: `${dot}.job.ts`,
29
41
  };
30
42
  }
31
43
 
@@ -33,19 +45,28 @@ function entity(base, tableName) {
33
45
  const n = names(base);
34
46
  return `import { Column, Entity } from 'typeorm';
35
47
  import { CommonEntity } from '@neohm/nestend';
48
+ import { ${n.attributesDtoClass} } from '../dtos/${n.dot}.attributes.dto';
36
49
 
37
50
  @Entity({ name: '${tableName}' })
38
51
  export class ${n.entityClass} extends CommonEntity {
39
- // TODO: declare columns for ${n.pascal}.
40
52
  @Column({ type: 'jsonb' })
41
- attributes: Record<string, any>;
53
+ attributes: ${n.attributesDtoClass};
42
54
  }
43
55
  `;
44
56
  }
45
57
 
46
- function controller(base) {
58
+ function attributesDto(base) {
47
59
  const n = names(base);
48
- return `import { Controller } from '@nestjs/common';
60
+ return `export class ${n.attributesDtoClass} {
61
+ // TODO: declare attribute fields for ${n.pascal}.
62
+ }
63
+ `;
64
+ }
65
+
66
+ function controller(base, { listProcessor = false } = {}) {
67
+ const n = names(base);
68
+ if (!listProcessor) {
69
+ return `import { Controller } from '@nestjs/common';
49
70
  import { ApiTags } from '@nestjs/swagger';
50
71
 
51
72
  @ApiTags('${n.kebab}')
@@ -53,6 +74,24 @@ import { ApiTags } from '@nestjs/swagger';
53
74
  export class ${n.controllerClass} {
54
75
  constructor() {}
55
76
  }
77
+ `;
78
+ }
79
+ return `import { Body, Controller, Post } from '@nestjs/common';
80
+ import { ApiTags } from '@nestjs/swagger';
81
+ import { SqlService } from '@neohm/nestend';
82
+ import { ${n.listFilterDtoClass} } from '../dtos/${n.dot}.list.filter.dto';
83
+ import { ${n.listProcessorClass} } from '../libraries/${n.dot}.list.processor';
84
+
85
+ @ApiTags('${n.kebab}')
86
+ @Controller('api/v1/${n.kebab}')
87
+ export class ${n.controllerClass} {
88
+ constructor(private readonly sqlService: SqlService) {}
89
+
90
+ @Post('list')
91
+ async list(@Body() body: ${n.listFilterDtoClass}) {
92
+ return new ${n.listProcessorClass}(this.sqlService).process(body);
93
+ }
94
+ }
56
95
  `;
57
96
  }
58
97
 
@@ -83,8 +122,8 @@ export class ${n.processorClass} extends CommonDataProcessor {
83
122
  }
84
123
 
85
124
  private async validate() {
86
- // TODO: collect validation errors via this.addColumnError(...).
87
- this.throwPresentErrors();
125
+ // TODO: collect validation errors via this.addErrors({ column: 'message' }).
126
+ this.throwExceptionOnError();
88
127
  }
89
128
 
90
129
  private async set() {
@@ -94,6 +133,113 @@ export class ${n.processorClass} extends CommonDataProcessor {
94
133
  `;
95
134
  }
96
135
 
136
+ function listFilterDto(base) {
137
+ const n = names(base);
138
+ return `import { CommonListFilterDto } from '@neohm/nestend';
139
+ import { Expose } from 'class-transformer';
140
+ import { IsOptional, IsString } from 'class-validator';
141
+
142
+ export class ${n.listFilterDtoClass} extends CommonListFilterDto {
143
+ @Expose()
144
+ @IsOptional()
145
+ @IsString()
146
+ custom_filter?: string;
147
+ }
148
+ `;
149
+ }
150
+
151
+ function listProcessor(base, tableName) {
152
+ const n = names(base);
153
+ return `import {
154
+ CommonListFilterConfig,
155
+ CommonListProcessor,
156
+ SortDirection,
157
+ SqlService,
158
+ } from '@neohm/nestend';
159
+ import { ${n.listFilterDtoClass} } from '../dtos/${n.dot}.list.filter.dto';
160
+
161
+ export class ${n.listProcessorClass} extends CommonListProcessor {
162
+ protected config: CommonListFilterConfig = {
163
+ query: '${tableName} a',
164
+ columns: [
165
+ { key: 'a.id', identifier: 'id' },
166
+ { key: 'a.attributes', identifier: 'attributes' },
167
+ { key: 'a.created_at', identifier: 'created_at' },
168
+ ],
169
+ defaultSort: { field: 'id', direction: SortDirection.DESC },
170
+ };
171
+
172
+ constructor(protected readonly sqlService: SqlService) {
173
+ super();
174
+ }
175
+
176
+ async process(payload: ${n.listFilterDtoClass}) {
177
+ this.payload = payload;
178
+
179
+ this.applyCustomFilters(payload);
180
+
181
+ return this.handle();
182
+ }
183
+
184
+ private async applyCustomFilters(payload: ${n.listFilterDtoClass}) {
185
+ this.applyCustomFilter(payload.custom_filter);
186
+ }
187
+
188
+ private async applyCustomFilter(custom_filter: string) {
189
+ if (!custom_filter) return;
190
+
191
+ // Example of a hand-rolled condition beyond the config-driven filters.
192
+ // TODO: replace test_column with a real filterable column of ${n.pascal}.
193
+ this.conditions.push(\`test_column ilike '%\${custom_filter}%'\`);
194
+ }
195
+ }
196
+ `;
197
+ }
198
+
199
+ function subscriber(base) {
200
+ const n = names(base);
201
+ return `import { CommonSubscriber } from '@neohm/nestend';
202
+ import { EventSubscriber } from 'typeorm';
203
+ import { ${n.entityClass} } from '../entities/${n.dot}.entity';
204
+ import { DataSource } from 'typeorm';
205
+ import { ${n.jobClass} } from '../jobs/${n.dot}.job';
206
+
207
+ @EventSubscriber()
208
+ export class ${n.subscriberClass} extends CommonSubscriber<${n.entityClass}> {
209
+ constructor(
210
+ protected readonly datasource: DataSource,
211
+ protected readonly entityJob: ${n.jobClass},
212
+ ) {
213
+ super();
214
+ datasource.subscribers.push(this);
215
+ }
216
+
217
+ listenTo() {
218
+ return ${n.entityClass};
219
+ }
220
+ }
221
+ `;
222
+ }
223
+
224
+ function job(base) {
225
+ const n = names(base);
226
+ return `import { CommonJob, QueueService } from '@neohm/nestend';
227
+ import { Injectable } from '@nestjs/common';
228
+ import { JobConstants } from '../../constants/job.constants';
229
+
230
+ @Injectable()
231
+ export class ${n.jobClass} extends CommonJob {
232
+ constructor(protected readonly queueService: QueueService) {
233
+ super(JobConstants.${n.camel}Job);
234
+ }
235
+
236
+ async handle(event: any): Promise<void> {
237
+ return;
238
+ }
239
+ }
240
+ `;
241
+ }
242
+
97
243
  /**
98
244
  * Migration generated as part of `nh gen`, stubbed with the columns the
99
245
  * generated entity carries (rootPrimary + created_by FK from CommonEntity,
@@ -160,11 +306,65 @@ export class ${className} extends SeederUtility {
160
306
  `;
161
307
  }
162
308
 
309
+ /** Fresh src/constants/job.constants.ts (first entry included). */
310
+ function jobConstantsFile(base) {
311
+ const n = names(base);
312
+ return `import { baseJobConstants } from '@neohm/nestend';
313
+
314
+ export const JobConstants = {
315
+ ...baseJobConstants,
316
+ ${n.camel}Job: '${n.dot}.job',
317
+ };
318
+ `;
319
+ }
320
+
321
+ /** Fresh src/constants/entity.constants.ts (first entry included). */
322
+ function entityConstantsFile(base) {
323
+ const n = names(base);
324
+ return `import { baseEntityConstants } from '@neohm/nestend';
325
+
326
+ export const EntityConstants = {
327
+ ...baseEntityConstants,
328
+ ${n.camel}: '${n.dot}',
329
+ };
330
+ `;
331
+ }
332
+
333
+ /** Fresh src/constants/job.maps.ts (first entry included). */
334
+ function jobMapsFile(base, moduleDot, constants) {
335
+ const n = names(base);
336
+ return `import { baseJobMaps } from '@neohm/nestend';
337
+ import { ${n.jobClass} } from '../${moduleDot}/jobs/${n.dot}.job';
338
+ import { ${constants.exportName} } from '${constants.importPath}';
339
+
340
+ export const JobMaps = {
341
+ ...baseJobMaps,
342
+ [${constants.exportName}.${n.camel}Job]: ${n.jobClass},
343
+ };
344
+ `;
345
+ }
346
+
347
+ /** Fresh src/constants/entity.maps.ts (first entry included). */
348
+ function entityMapsFile(base, moduleDot, constants) {
349
+ const n = names(base);
350
+ return `import { baseEntityMaps } from '@neohm/nestend';
351
+ import { ${n.entityClass} } from '../${moduleDot}/entities/${n.dot}.entity';
352
+ import { ${constants.exportName} } from '${constants.importPath}';
353
+
354
+ export const EntityMaps = {
355
+ ...baseEntityMaps,
356
+ [${constants.exportName}.${n.camel}]: ${n.entityClass},
357
+ };
358
+ `;
359
+ }
360
+
163
361
  /** A freshly scaffolded module before any es6.classes content exists. */
164
362
  function emptyEs6Classes() {
165
363
  return `export const es6Classes = {
166
364
  controllers: [],
167
365
  services: [],
366
+ libraries: [],
367
+ dtos: [],
168
368
  jobs: [],
169
369
  subscribers: [],
170
370
  };
@@ -187,6 +387,7 @@ import { es6Classes } from './es6.classes';
187
387
  controllers: [...es6Classes.controllers],
188
388
  providers: [
189
389
  ...es6Classes.services,
390
+ ...es6Classes.libraries,
190
391
  ...es6Classes.jobs,
191
392
  ...es6Classes.subscribers,
192
393
  ],
@@ -198,12 +399,21 @@ export class ${pascal}Module {}
198
399
  module.exports = {
199
400
  names,
200
401
  entity,
402
+ attributesDto,
201
403
  controller,
202
404
  dto,
203
405
  processor,
406
+ listFilterDto,
407
+ listProcessor,
408
+ subscriber,
409
+ job,
204
410
  genMigration,
205
411
  migration,
206
412
  seed,
413
+ jobConstantsFile,
414
+ entityConstantsFile,
415
+ jobMapsFile,
416
+ entityMapsFile,
207
417
  emptyEs6Classes,
208
418
  moduleFile,
209
419
  };