@pattern-stack/codegen 0.2.0 → 0.3.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.
Files changed (52) hide show
  1. package/README.md +9 -4
  2. package/dist/src/cli/index.js +136 -128
  3. package/dist/src/cli/index.js.map +1 -1
  4. package/dist/src/index.d.ts +16 -0
  5. package/dist/src/index.js +25 -0
  6. package/dist/src/index.js.map +1 -1
  7. package/package.json +10 -1
  8. package/templates/entity/new/backend/application/commands/create.ejs.t +38 -1
  9. package/templates/entity/new/backend/application/commands/delete.ejs.t +41 -1
  10. package/templates/entity/new/backend/application/commands/update.ejs.t +42 -1
  11. package/templates/entity/new/backend/database/repository.ejs.t +33 -3
  12. package/templates/entity/new/backend/domain/repository-interface.ejs.t +6 -3
  13. package/templates/entity/new/backend/modules/core/module.ejs.t +6 -0
  14. package/templates/entity/new/backend/presentation/controller.ejs.t +32 -10
  15. package/templates/entity/new/clean-lite-ps/controller.ejs.t +72 -11
  16. package/templates/entity/new/clean-lite-ps/entity.ejs.t +16 -2
  17. package/templates/entity/new/clean-lite-ps/index.ejs.t +1 -1
  18. package/templates/entity/new/clean-lite-ps/module.ejs.t +45 -2
  19. package/templates/entity/new/clean-lite-ps/prompt-extension.js +459 -98
  20. package/templates/entity/new/clean-lite-ps/repository.ejs.t +57 -4
  21. package/templates/entity/new/clean-lite-ps/search-controller.ejs.t +50 -0
  22. package/templates/entity/new/clean-lite-ps/service.ejs.t +98 -1
  23. package/templates/entity/new/clean-lite-ps/use-cases/create.ejs.t +150 -0
  24. package/templates/entity/new/clean-lite-ps/use-cases/delete.ejs.t +70 -0
  25. package/templates/entity/new/clean-lite-ps/use-cases/find-by-id-with-fields.ejs.t +19 -0
  26. package/templates/entity/new/clean-lite-ps/use-cases/find-by-id.ejs.t +7 -3
  27. package/templates/entity/new/clean-lite-ps/use-cases/list-with-fields.ejs.t +17 -0
  28. package/templates/entity/new/clean-lite-ps/use-cases/search.ejs.t +63 -0
  29. package/templates/entity/new/clean-lite-ps/use-cases/update.ejs.t +153 -0
  30. package/templates/entity/new/prompt.js +284 -41
  31. package/templates/relationship/new/entity.ejs.t +2 -2
  32. package/templates/relationship/new/prompt.js +3 -7
  33. package/templates/relationship/new/service.ejs.t +1 -1
  34. package/templates/subsystem/bridge/generated-keep.ejs.t +4 -0
  35. package/templates/subsystem/bridge/prompt.js +36 -0
  36. package/templates/subsystem/bridge-config/codegen-config-bridge-block.ejs.t +20 -0
  37. package/templates/subsystem/bridge-config/prompt.js +20 -0
  38. package/templates/subsystem/events/domain-events.schema.ejs.t +81 -0
  39. package/templates/subsystem/events/generated-keep.ejs.t +4 -0
  40. package/templates/subsystem/events/prompt.js +39 -0
  41. package/templates/subsystem/events-config/codegen-config-events-block.ejs.t +26 -0
  42. package/templates/subsystem/events-config/prompt.js +20 -0
  43. package/templates/subsystem/jobs/job-orchestration.schema.ejs.t +221 -0
  44. package/templates/subsystem/jobs/main-hook.ejs.t +11 -0
  45. package/templates/subsystem/jobs/prompt.js +40 -0
  46. package/templates/subsystem/jobs/worker.ejs.t +82 -0
  47. package/templates/subsystem/jobs-config/codegen-config-jobs-block.ejs.t +55 -0
  48. package/templates/subsystem/jobs-config/prompt.js +20 -0
  49. package/templates/subsystem/sync/prompt.js +43 -0
  50. package/templates/subsystem/sync/sync-audit.schema.ejs.t +195 -0
  51. package/templates/subsystem/sync-config/codegen-config-sync-block.ejs.t +29 -0
  52. package/templates/subsystem/sync-config/prompt.js +22 -0
@@ -7,10 +7,13 @@ import { Injectable, Inject } from '@nestjs/common';
7
7
  <% if (hasDeclarativeQueries) { -%>
8
8
  import { eq<%= hasMultiFieldQuery ? ', and' : '' %><%= hasOrderedQuery ? ', desc, asc' : '' %> } from 'drizzle-orm';
9
9
  <% } -%>
10
+ <% if (eavValueTable) { -%>
11
+ import { sql } from 'drizzle-orm';
12
+ <% } -%>
10
13
  import { DRIZZLE } from '@shared/constants/tokens';
11
- import type { DrizzleClient } from '@shared/types/drizzle';
14
+ import type { DrizzleClient<% if (eavValueTable) { %>, DrizzleTx<% } %> } from '@shared/types/drizzle';
12
15
  import { <%= repositoryBaseClass %> } from '<%= repositoryBaseImport %>';
13
- <% if (hasTimestamps || hasSoftDelete) { -%>
16
+ <% if (hasTimestamps || hasSoftDelete || hasUserTracking) { -%>
14
17
  import type { BehaviorConfig } from '@shared/base-classes/base-repository';
15
18
  <% } -%>
16
19
  import { <%= entityNamePlural %>, type <%= classNames.entity %> } from './<%= entityName %>.entity';
@@ -18,15 +21,23 @@ import { <%= entityNamePlural %>, type <%= classNames.entity %> } from './<%= en
18
21
  @Injectable()
19
22
  export class <%= classNames.repository %> extends <%= repositoryBaseClass %><<%= classNames.entity %>> {
20
23
  readonly table = <%= entityNamePlural %>;
21
- <% if (hasTimestamps || hasSoftDelete) { -%>
24
+ <% if (hasTimestamps || hasSoftDelete || hasUserTracking) { -%>
22
25
 
23
26
  // Behaviors declared in YAML -> generated as config object
24
27
  protected override readonly behaviors: BehaviorConfig = {
25
28
  timestamps: <%= !!hasTimestamps %>,
26
29
  softDelete: <%= !!hasSoftDelete %>,
27
- userTracking: false,
30
+ userTracking: <%= !!hasUserTracking %>,
28
31
  };
29
32
  <% } -%>
33
+ <% if (hasPatternConfig) { -%>
34
+
35
+ // Per-entity `<%= patternName %>` pattern config (from YAML `config:` block).
36
+ // The pattern's base class declares `protected readonly patternConfig: TConfig`
37
+ // typed via its `configSchema`; this concrete record is read by the base at
38
+ // runtime (identical shape to `behaviors: BehaviorConfig`).
39
+ protected override readonly patternConfig = <%- renderPatternConfigLiteral(patternConfig, ' ', ' ') %> as const;
40
+ <% } -%>
30
41
 
31
42
  constructor(@Inject(DRIZZLE) db: DrizzleClient) {
32
43
  super(db);
@@ -55,6 +66,48 @@ export class <%= classNames.repository %> extends <%= repositoryBaseClass %><<%=
55
66
 
56
67
  // TODO: Add entity-specific query methods here.
57
68
  <% } -%>
69
+ <% if (eavValueTable) { -%>
70
+
71
+ // ═══════════════════════════════════════════════════════════════════════
72
+ // EAV compound writes (task #23) — generated from eav_value_table: true
73
+ // ═══════════════════════════════════════════════════════════════════════
74
+ /**
75
+ * Upsert "current" value rows keyed by the composite unique
76
+ * (entity_type, entity_id, field_definition_id). Used by the service's
77
+ * upsertFieldsTransactional to dual-write a bag of dynamic fields
78
+ * atomically with the owning entity. Inherited upsertMany only
79
+ * supports single-column conflict targets — this override uses
80
+ * Drizzle's `onConflictDoUpdate` with an explicit column list.
81
+ */
82
+ async upsertCurrentValues(
83
+ inputs: Array<Partial<<%= classNames.entity %>>>,
84
+ tx?: DrizzleTx,
85
+ ): Promise<<%= classNames.entity %>[]> {
86
+ if (inputs.length === 0) return [];
87
+ const data = inputs.map((input) =>
88
+ this.withTimestamps(input as Record<string, unknown>, 'create'),
89
+ );
90
+ const runner = this.runner(tx);
91
+ const rows = await runner
92
+ .insert(this.table)
93
+ .values(data as any) // eslint-disable-line @typescript-eslint/no-explicit-any
94
+ .onConflictDoUpdate({
95
+ target: [
96
+ this.table['entityType'],
97
+ this.table['entityId'],
98
+ this.table['fieldDefinitionId'],
99
+ ],
100
+ set: {
101
+ value: sql`excluded.value`,
102
+ userId: sql`excluded.user_id`,
103
+ updatedAt: sql`excluded.updated_at`,
104
+ } as any, // eslint-disable-line @typescript-eslint/no-explicit-any
105
+ })
106
+ .returning();
107
+ return rows as <%= classNames.entity %>[];
108
+ }
109
+ <% } -%>
110
+
58
111
  // Inherited from <%= repositoryBaseClass %>:
59
112
  <%_ repositoryInheritedMethods.forEach(line => { _%>
60
113
  // <%= line %>
@@ -0,0 +1,50 @@
1
+ ---
2
+ to: "<%= typeof clpOutputPaths !== 'undefined' ? clpOutputPaths.searchController : null %>"
3
+ skip_if: "<%= typeof clpOutputPaths === 'undefined' || !clpOutputPaths.searchController %>"
4
+ force: true
5
+ ---
6
+ <% if (hasSearchQuery) { -%>
7
+ import { BadRequestException, Controller, Get, Query } from '@nestjs/common';
8
+ import { z } from 'zod';
9
+ import { PaginationSchema } from '@shared/http/pagination';
10
+ import { <%= searchQuery.useCaseClassName %> } from './use-cases/search-<%= entityNamePlural %>.use-case';
11
+
12
+ const <%= searchQuery.filtersSchemaName %> = z.object({
13
+ <% searchQuery.filters.forEach((f) => { -%>
14
+ <% if (f.isUuid) { -%>
15
+ <%= f.camelName %>: z.string().uuid().optional(),
16
+ <% } else if (f.hasChoices) { -%>
17
+ <%= f.camelName %>: z.enum([<%- f.choices.map((c) => `'${c}'`).join(', ') %>]).optional(),
18
+ <% } else if (f.isBoolean) { -%>
19
+ <%= f.camelName %>: z.coerce.boolean().optional(),
20
+ <% } else if (f.isNumber) { -%>
21
+ <%= f.camelName %>: z.coerce.number().optional(),
22
+ <% } else { -%>
23
+ <%= f.camelName %>: z.string().optional(),
24
+ <% } -%>
25
+ <% }) -%>
26
+ <% if (searchQuery.searchField) { -%>
27
+ search: z.string().optional(),
28
+ <% } -%>
29
+ }).merge(PaginationSchema);
30
+
31
+ function parseOrThrow<S extends z.ZodTypeAny>(schema: S, input: unknown): z.infer<S> {
32
+ const result = schema.safeParse(input);
33
+ if (!result.success) throw new BadRequestException(result.error.flatten());
34
+ return result.data;
35
+ }
36
+
37
+ /**
38
+ * Filtered search controller (task #16) — generated from queries:
39
+ * block in <%= entityName %>.yaml.
40
+ */
41
+ @Controller('<%= entityNamePlural %>')
42
+ export class <%= classNames.searchController %> {
43
+ constructor(private readonly searchUseCase: <%= searchQuery.useCaseClassName %>) {}
44
+
45
+ @Get('search')
46
+ async search(@Query() query: Record<string, unknown>) {
47
+ return this.searchUseCase.execute(parseOrThrow(<%= searchQuery.filtersSchemaName %>, query));
48
+ }
49
+ }
50
+ <% } -%>
@@ -9,18 +9,41 @@ import { EVENT_BUS } from '@shared/constants/tokens';
9
9
  import { <%= serviceBaseClass %> } from '<%= serviceBaseImport %>';
10
10
  import { <%= classNames.repository %> } from './<%= entityName %>.repository';
11
11
  import type { <%= classNames.entity %> } from './<%= entityName %>.entity';
12
+ <% if (eavEnabled) { -%>
13
+ import { FieldValueService } from '../field_values/field_value.service';
14
+ <% } -%>
15
+ <% if (eavValueTable) { -%>
16
+ import { toEavRows, mergeEavRows } from '@shared/eav-helpers';
17
+ import type { DrizzleTx } from '@shared/types/drizzle';
18
+ import { <%= eavDefinitionPascal %>Repository } from '../<%= eavDefinitionEntityPlural %>/<%= eavDefinitionEntity %>.repository';
19
+ <% } -%>
12
20
 
13
21
  @Injectable()
14
22
  export class <%= classNames.service %> extends WithAnalytics(
15
23
  <%= serviceBaseClass %><<%= classNames.repository %>, <%= classNames.entity %>>,
16
24
  ) {
17
25
  protected override readonly entityName = '<%= entityName %>';
26
+ <% if (hasPatternConfig) { -%>
27
+
28
+ // Per-entity `<%= patternName %>` pattern config (from YAML `config:` block).
29
+ // Mirrors the repository-side emission; the pattern's base service reads
30
+ // `this.patternConfig` directly.
31
+ protected override readonly patternConfig = <%- renderPatternConfigLiteral(patternConfig, ' ', ' ') %> as const;
32
+ <% } -%>
18
33
 
19
34
  /** Injected by NestJS when EventsModule is registered. */
20
35
  @Optional() @Inject(EVENT_BUS)
21
36
  protected override eventBus: any = undefined;
22
37
 
23
- constructor(protected readonly repository: <%= classNames.repository %>) {
38
+ constructor(
39
+ protected override readonly repository: <%= classNames.repository %>,
40
+ <% if (eavEnabled) { -%>
41
+ private readonly fieldValues: FieldValueService,
42
+ <% } -%>
43
+ <% if (eavValueTable) { -%>
44
+ private readonly definitionRepo: <%= eavDefinitionPascal %>Repository,
45
+ <% } -%>
46
+ ) {
24
47
  super(repository);
25
48
  }
26
49
 
@@ -31,4 +54,78 @@ export class <%= classNames.service %> extends WithAnalytics(
31
54
  <%_ serviceInheritedMethods.forEach(line => { _%>
32
55
  // <%= line %>
33
56
  <%_ }) _%>
57
+ <% if (eavEnabled) { %>
58
+ /**
59
+ * EAV paired read (ADR-13): fetch the entity and merge dynamic `field_values`
60
+ * into a single `fields` bag. FieldValueService owns the FieldDefinition
61
+ * lookup internally. Use this for frontend detail views, LLM context,
62
+ * exports.
63
+ */
64
+ async findByIdWithFields(
65
+ id: string,
66
+ ): Promise<(<%= classNames.entity %> & { fields: Record<string, unknown> }) | null> {
67
+ const entity = await this.repository.findById(id);
68
+ if (!entity) return null;
69
+ const fields = await this.fieldValues.findMergedByEntity('<%= entityName %>', id);
70
+ return { ...entity, fields };
71
+ }
72
+
73
+ /**
74
+ * EAV paired read (ADR-13): list variant. Fetches all entities then merges
75
+ * each one's EAV fields via FieldValueService. Acceptable for modest result
76
+ * sets; page externally for large collections.
77
+ */
78
+ async listWithFields(): Promise<Array<<%= classNames.entity %> & { fields: Record<string, unknown> }>> {
79
+ const entities = await this.repository.list();
80
+ if (entities.length === 0) return [];
81
+ return Promise.all(
82
+ entities.map(async (entity) => {
83
+ const fields = await this.fieldValues.findMergedByEntity('<%= entityName %>', entity.id);
84
+ return { ...entity, fields };
85
+ }),
86
+ );
87
+ }
88
+ <% } %>
89
+ <% if (eavValueTable) { %>
90
+ /**
91
+ * EAV compound write (task #23) — upserts a bag of dynamic fields onto
92
+ * an owning entity in a single transaction. Resolves field keys to
93
+ * definition ids internally (by reading <%= eavDefinitionPascal %>Repository),
94
+ * so use-cases inject only this service. Unknown keys are skipped; the
95
+ * caller is expected to have created the definitions first (auto-create
96
+ * is a later step).
97
+ */
98
+ async upsertFieldsTransactional(
99
+ entityType: string,
100
+ entityId: string,
101
+ userId: string,
102
+ fields: Record<string, unknown>,
103
+ tx?: DrizzleTx,
104
+ ): Promise<void> {
105
+ if (!fields || Object.keys(fields).length === 0) return;
106
+ const allDefs = await this.definitionRepo.list();
107
+ const defs = allDefs.filter((d) => (d as any).entityType === entityType);
108
+ const defIdByKey = new Map(defs.map((d) => [d.key, d.id]));
109
+ const rows = toEavRows(entityId, entityType, userId, fields, defIdByKey);
110
+ if (rows.length === 0) return;
111
+ await this.repository.upsertCurrentValues(rows as Array<Partial<<%= classNames.entity %>>>, tx);
112
+ }
113
+
114
+ /**
115
+ * EAV paired read (task #23) — returns the current merged `{ key: value }`
116
+ * bag for one owning entity. Resolves definition ids to keys internally.
117
+ */
118
+ async findMergedByEntity(
119
+ entityType: string,
120
+ entityId: string,
121
+ ): Promise<Record<string, unknown>> {
122
+ const [rows, allDefs] = await Promise.all([
123
+ this.repository.findByEntityIdAndType(entityId, entityType),
124
+ this.definitionRepo.list(),
125
+ ]);
126
+ const defs = allDefs.filter((d) => (d as any).entityType === entityType);
127
+ const defsById = new Map(defs.map((d) => [d.id, { key: d.key }]));
128
+ return mergeEavRows(rows as any, defsById);
129
+ }
130
+ <% } %>
34
131
  }
@@ -0,0 +1,150 @@
1
+ ---
2
+ to: "<%= typeof clpOutputPaths !== 'undefined' ? clpOutputPaths.createUseCase : null %>"
3
+ skip_if: "<%= typeof clpOutputPaths === 'undefined' || !clpOutputPaths.createUseCase %>"
4
+ force: true
5
+ ---
6
+ <% if (eavEnabled) { -%>
7
+ import { Injectable, Inject } from '@nestjs/common';
8
+ import { DRIZZLE } from '<%= drizzleTokenImport %>';
9
+ import type { DrizzleClient } from '<%= drizzleTypeImport %>';
10
+ <% if (hasEmits && createEventType) { -%>
11
+ import { TYPED_EVENT_BUS, TypedEventBus } from '<%= eventsTokenImport %>';
12
+ <% } -%>
13
+ import { FieldValueService } from '../../field_values/field_value.service';
14
+ import { <%= classNames.service %> } from '../<%= entityName %>.service';
15
+ import type { <%= classNames.createDto %> } from '../dto/create-<%= entityName %>.dto';
16
+ import type { <%= classNames.entity %> } from '../<%= entityName %>.entity';
17
+
18
+ /**
19
+ * EAV compound-write use case (ADR-13).
20
+ *
21
+ * Splits `{ fields, ...core }` from the DTO and persists both halves in a
22
+ * single transaction: core columns go to the <%= entityName %> table via
23
+ * <%= classNames.service %>, dynamic `fields` go to `field_values` via
24
+ * FieldValueService.upsertFieldsTransactional (which owns the
25
+ * FieldDefinition lookup internally). Atomicity comes from the shared tx;
26
+ * each service still only writes its own domain.
27
+ <% if (hasEmits && createEventType) { -%>
28
+ *
29
+ * EXTENSION POINT (EVT-7): verify payload mapping against
30
+ * events/<%= createEventType.type %>.yaml before shipping.
31
+ <% } -%>
32
+ */
33
+ @Injectable()
34
+ export class <%= classNames.createUseCase %> {
35
+ constructor(
36
+ private readonly <%= entityNamePlural %>: <%= classNames.service %>,
37
+ private readonly fields: FieldValueService,
38
+ @Inject(DRIZZLE) private readonly db: DrizzleClient,
39
+ <% if (hasEmits && createEventType) { -%>
40
+ @Inject(TYPED_EVENT_BUS) private readonly typedEvents: TypedEventBus,
41
+ <% } -%>
42
+ ) {}
43
+
44
+ async execute(
45
+ dto: <%= classNames.createDto %> & { fields?: Record<string, unknown> },
46
+ <%= hasEmits && createEventType ? 'opts' : '_opts' %>?: { actor?: { tenantId?: string | null; userId?: string } },
47
+ ): Promise<<%= classNames.entity %>> {
48
+ return this.db.transaction(async (tx) => {
49
+ const { fields, ...core } = dto;
50
+ const entity = await this.<%= entityNamePlural %>.create(core as <%= classNames.createDto %>, tx);
51
+ if (fields && Object.keys(fields).length > 0) {
52
+ await this.fields.upsertFieldsTransactional(
53
+ '<%= entityName %>',
54
+ entity.id,
55
+ core.userId,
56
+ fields,
57
+ tx,
58
+ );
59
+ }
60
+ <% if (hasEmits && createEventType) { -%>
61
+ // TODO: verify payload mapping against events/<%= createEventType.type %>.yaml
62
+ await this.typedEvents.publish(
63
+ '<%= createEventType.type %>',
64
+ entity.id,
65
+ {
66
+ <% createEventType.payloadMap.forEach((p) => { -%>
67
+ <%= p.camelKey %>: <%- p.expression %>,<% if (p.todo) { %> // TODO: <%= p.todo %><% } %>
68
+
69
+ <% }) -%>
70
+ },
71
+ {
72
+ tx,
73
+ metadata: opts?.actor
74
+ ? { tenantId: opts.actor.tenantId, userId: opts.actor.userId }
75
+ : undefined,
76
+ },
77
+ );
78
+ <% } -%>
79
+ return entity;
80
+ });
81
+ }
82
+ }
83
+ <% } else { -%>
84
+ <% if (hasEmits && createEventType) { -%>
85
+ import { Injectable, Inject } from '@nestjs/common';
86
+ import { DRIZZLE } from '<%= drizzleTokenImport %>';
87
+ import type { DrizzleClient } from '<%= drizzleTypeImport %>';
88
+ import { TYPED_EVENT_BUS, TypedEventBus } from '<%= eventsTokenImport %>';
89
+ import { <%= classNames.service %> } from '../<%= entityName %>.service';
90
+ import type { <%= classNames.createDto %> } from '../dto/create-<%= entityName %>.dto';
91
+ import type { <%= classNames.entity %> } from '../<%= entityName %>.entity';
92
+
93
+ /**
94
+ * EXTENSION POINT (EVT-7): verify payload mapping against
95
+ * events/<%= createEventType.type %>.yaml before shipping.
96
+ */
97
+ @Injectable()
98
+ export class <%= classNames.createUseCase %> {
99
+ constructor(
100
+ private readonly service: <%= classNames.service %>,
101
+ @Inject(DRIZZLE) private readonly db: DrizzleClient,
102
+ @Inject(TYPED_EVENT_BUS) private readonly typedEvents: TypedEventBus,
103
+ ) {}
104
+
105
+ async execute(
106
+ dto: <%= classNames.createDto %>,
107
+ opts?: { actor?: { tenantId?: string | null; userId?: string } },
108
+ ): Promise<<%= classNames.entity %>> {
109
+ return this.db.transaction(async (tx) => {
110
+ const entity = await this.service.create(dto, tx);
111
+ // TODO: verify payload mapping against events/<%= createEventType.type %>.yaml
112
+ await this.typedEvents.publish(
113
+ '<%= createEventType.type %>',
114
+ entity.id,
115
+ {
116
+ <% createEventType.payloadMap.forEach((p) => { -%>
117
+ <%= p.camelKey %>: <%- p.expression %>,<% if (p.todo) { %> // TODO: <%= p.todo %><% } %>
118
+
119
+ <% }) -%>
120
+ },
121
+ {
122
+ tx,
123
+ metadata: opts?.actor
124
+ ? { tenantId: opts.actor.tenantId, userId: opts.actor.userId }
125
+ : undefined,
126
+ },
127
+ );
128
+ return entity;
129
+ });
130
+ }
131
+ }
132
+ <% } else { -%>
133
+ import { Injectable } from '@nestjs/common';
134
+ import { <%= classNames.service %> } from '../<%= entityName %>.service';
135
+ import type { <%= classNames.createDto %> } from '../dto/create-<%= entityName %>.dto';
136
+ import type { <%= classNames.entity %> } from '../<%= entityName %>.entity';
137
+
138
+ @Injectable()
139
+ export class <%= classNames.createUseCase %> {
140
+ constructor(private readonly service: <%= classNames.service %>) {}
141
+
142
+ async execute(
143
+ dto: <%= classNames.createDto %>,
144
+ _opts?: { actor?: { tenantId?: string | null; userId?: string } },
145
+ ): Promise<<%= classNames.entity %>> {
146
+ return this.service.create(dto);
147
+ }
148
+ }
149
+ <% } -%>
150
+ <% } -%>
@@ -0,0 +1,70 @@
1
+ ---
2
+ to: "<%= typeof clpOutputPaths !== 'undefined' ? clpOutputPaths.deleteUseCase : null %>"
3
+ skip_if: "<%= typeof clpOutputPaths === 'undefined' || !clpOutputPaths.deleteUseCase %>"
4
+ force: true
5
+ ---
6
+ <% if (hasEmits && deleteEventType) { -%>
7
+ import { Injectable, Inject, NotFoundException } from '@nestjs/common';
8
+ import { DRIZZLE } from '<%= drizzleTokenImport %>';
9
+ import type { DrizzleClient } from '<%= drizzleTypeImport %>';
10
+ import { TYPED_EVENT_BUS, TypedEventBus } from '<%= eventsTokenImport %>';
11
+ import { <%= classNames.service %> } from '../<%= entityName %>.service';
12
+
13
+ /**
14
+ * EXTENSION POINT (EVT-7): verify payload mapping against
15
+ * events/<%= deleteEventType.type %>.yaml before shipping.
16
+ */
17
+ @Injectable()
18
+ export class <%= classNames.deleteUseCase %> {
19
+ constructor(
20
+ private readonly service: <%= classNames.service %>,
21
+ @Inject(DRIZZLE) private readonly db: DrizzleClient,
22
+ @Inject(TYPED_EVENT_BUS) private readonly typedEvents: TypedEventBus,
23
+ ) {}
24
+
25
+ async execute(
26
+ id: string,
27
+ opts?: { actor?: { tenantId?: string | null; userId?: string } },
28
+ ): Promise<void> {
29
+ return this.db.transaction(async (tx) => {
30
+ const entity = await this.service.findById(id);
31
+ if (!entity) {
32
+ throw new NotFoundException(`<%= classNames.entity %> with id ${id} not found`);
33
+ }
34
+ await this.service.delete(id, tx);
35
+ // TODO: verify payload mapping against events/<%= deleteEventType.type %>.yaml
36
+ await this.typedEvents.publish(
37
+ '<%= deleteEventType.type %>',
38
+ entity.id,
39
+ {
40
+ <% deleteEventType.payloadMap.forEach((p) => { -%>
41
+ <%= p.camelKey %>: <%- p.expression %>,<% if (p.todo) { %> // TODO: <%= p.todo %><% } %>
42
+
43
+ <% }) -%>
44
+ },
45
+ {
46
+ tx,
47
+ metadata: opts?.actor
48
+ ? { tenantId: opts.actor.tenantId, userId: opts.actor.userId }
49
+ : undefined,
50
+ },
51
+ );
52
+ });
53
+ }
54
+ }
55
+ <% } else { -%>
56
+ import { Injectable } from '@nestjs/common';
57
+ import { <%= classNames.service %> } from '../<%= entityName %>.service';
58
+
59
+ @Injectable()
60
+ export class <%= classNames.deleteUseCase %> {
61
+ constructor(private readonly service: <%= classNames.service %>) {}
62
+
63
+ async execute(
64
+ id: string,
65
+ _opts?: { actor?: { tenantId?: string | null; userId?: string } },
66
+ ): Promise<void> {
67
+ return this.service.delete(id);
68
+ }
69
+ }
70
+ <% } -%>
@@ -0,0 +1,19 @@
1
+ ---
2
+ to: "<%= typeof clpOutputPaths !== 'undefined' ? clpOutputPaths.findByIdWithFieldsUseCase : null %>"
3
+ skip_if: "<%= typeof clpOutputPaths === 'undefined' || !clpOutputPaths.findByIdWithFieldsUseCase %>"
4
+ force: true
5
+ ---
6
+ import { Injectable } from '@nestjs/common';
7
+ import { <%= classNames.service %> } from '../<%= entityName %>.service';
8
+ import type { <%= classNames.entity %> } from '../<%= entityName %>.entity';
9
+
10
+ @Injectable()
11
+ export class <%= classNames.findByIdWithFieldsUseCase %> {
12
+ constructor(private readonly service: <%= classNames.service %>) {}
13
+
14
+ async execute(
15
+ id: string,
16
+ ): Promise<(<%= classNames.entity %> & { fields: Record<string, unknown> }) | null> {
17
+ return this.service.findByIdWithFields(id);
18
+ }
19
+ }
@@ -2,7 +2,7 @@
2
2
  to: "<%= typeof clpOutputPaths !== 'undefined' ? clpOutputPaths.findByIdUseCase : null %>"
3
3
  force: true
4
4
  ---
5
- import { Injectable } from '@nestjs/common';
5
+ import { Injectable, NotFoundException } from '@nestjs/common';
6
6
  import { <%= classNames.service %> } from '../<%= entityName %>.service';
7
7
  import type { <%= classNames.entity %> } from '../<%= entityName %>.entity';
8
8
 
@@ -10,7 +10,11 @@ import type { <%= classNames.entity %> } from '../<%= entityName %>.entity';
10
10
  export class <%= classNames.findByIdUseCase %> {
11
11
  constructor(private readonly service: <%= classNames.service %>) {}
12
12
 
13
- async execute(id: string): Promise<<%= classNames.entity %> | null> {
14
- return this.service.findById(id);
13
+ async execute(id: string): Promise<<%= classNames.entity %>> {
14
+ const entity = await this.service.findById(id);
15
+ if (entity === null || entity === undefined) {
16
+ throw new NotFoundException(`<%= classNames.entity %> not found: ${id}`);
17
+ }
18
+ return entity;
15
19
  }
16
20
  }
@@ -0,0 +1,17 @@
1
+ ---
2
+ to: "<%= typeof clpOutputPaths !== 'undefined' ? clpOutputPaths.listWithFieldsUseCase : null %>"
3
+ skip_if: "<%= typeof clpOutputPaths === 'undefined' || !clpOutputPaths.listWithFieldsUseCase %>"
4
+ force: true
5
+ ---
6
+ import { Injectable } from '@nestjs/common';
7
+ import { <%= classNames.service %> } from '../<%= entityName %>.service';
8
+ import type { <%= classNames.entity %> } from '../<%= entityName %>.entity';
9
+
10
+ @Injectable()
11
+ export class <%= classNames.listWithFieldsUseCase %> {
12
+ constructor(private readonly service: <%= classNames.service %>) {}
13
+
14
+ async execute(): Promise<Array<<%= classNames.entity %> & { fields: Record<string, unknown> }>> {
15
+ return this.service.listWithFields();
16
+ }
17
+ }
@@ -0,0 +1,63 @@
1
+ ---
2
+ to: "<%= typeof clpOutputPaths !== 'undefined' ? clpOutputPaths.searchUseCase : null %>"
3
+ skip_if: "<%= typeof clpOutputPaths === 'undefined' || !clpOutputPaths.searchUseCase %>"
4
+ force: true
5
+ ---
6
+ <% if (hasSearchQuery) { -%>
7
+ import { Injectable } from '@nestjs/common';
8
+ import { and, asc, eq<% if (searchQuery.searchField) { %>, ilike<% } %>, type SQL } from 'drizzle-orm';
9
+ import type { Page } from '@shared/http/pagination';
10
+ import { <%= classNames.service %> } from '../<%= entityName %>.service';
11
+ import { <%= entityNamePlural %>, type <%= classNames.entity %> } from '../<%= entityName %>.entity';
12
+
13
+ export interface <%= searchQuery.inputTypeName %> {
14
+ <% searchQuery.filters.forEach((f) => { -%>
15
+ <%= f.camelName %>?: <%- f.hasChoices ? f.choices.map((c) => `'${c}'`).join(' | ') : f.tsType %>;
16
+ <% }) -%>
17
+ <% if (searchQuery.searchField) { -%>
18
+ search?: string;
19
+ <% } -%>
20
+ <% if (searchQuery.paginate) { -%>
21
+ limit: number;
22
+ offset: number;
23
+ <% } -%>
24
+ }
25
+
26
+ /**
27
+ * Filtered search use case (task #16).
28
+ *
29
+ * Composes the entity service's `list` + `count` with filter-AND and
30
+ * an optional ilike search on `<%= searchQuery.searchField ?? 'N/A' %>`.
31
+ * Pagination is enforced at the Zod layer in the controller.
32
+ */
33
+ @Injectable()
34
+ export class <%= searchQuery.useCaseClassName %> {
35
+ constructor(private readonly service: <%= classNames.service %>) {}
36
+
37
+ async execute(input: <%= searchQuery.inputTypeName %>): Promise<Page<<%= classNames.entity %>>> {
38
+ const conditions: SQL[] = [];
39
+ <% searchQuery.filters.forEach((f) => { -%>
40
+ <% if (f.isBoolean) { -%>
41
+ if (input.<%= f.camelName %> !== undefined) conditions.push(eq(<%= entityNamePlural %>.<%= f.camelName %>, input.<%= f.camelName %>));
42
+ <% } else { -%>
43
+ if (input.<%= f.camelName %>) conditions.push(eq(<%= entityNamePlural %>.<%= f.camelName %>, input.<%= f.camelName %>));
44
+ <% } -%>
45
+ <% }) -%>
46
+ <% if (searchQuery.searchField) { -%>
47
+ if (input.search) conditions.push(ilike(<%= entityNamePlural %>.<%= searchQuery.searchFieldCamel %>, `%${input.search}%`));
48
+ <% } -%>
49
+
50
+ const where =
51
+ conditions.length === 0 ? undefined :
52
+ conditions.length === 1 ? conditions[0] :
53
+ and(...conditions);
54
+
55
+ const [items, total] = await Promise.all([
56
+ this.service.list({ where, limit: input.limit, offset: input.offset, orderBy: asc(<%= entityNamePlural %>.createdAt) }),
57
+ this.service.count(where),
58
+ ]);
59
+
60
+ return { items, total, limit: input.limit, offset: input.offset };
61
+ }
62
+ }
63
+ <% } -%>