@ftisindia/create-app 0.1.4 → 0.1.6

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 (169) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +31 -0
  3. package/template/README.md +61 -0
  4. package/template/_gitignore +6 -0
  5. package/template/_package.json +6 -0
  6. package/template/docs/FORMS.md +169 -0
  7. package/template/docs/FORMS_CHECKLIST.md +61 -0
  8. package/template/docs/REPORTS.md +246 -0
  9. package/template/docs/REPORTS_CHECKLIST.md +97 -0
  10. package/template/prisma/migrations/20260612000000_add_form_builder/migration.sql +147 -0
  11. package/template/prisma/migrations/20260613000000_add_report_builder/migration.sql +129 -0
  12. package/template/prisma/schema.prisma +285 -0
  13. package/template/scripts/export-openapi.ts +85 -0
  14. package/template/scripts/gen-form.mjs +149 -0
  15. package/template/scripts/push-form.ts +124 -0
  16. package/template/src/app.module.ts +29 -8
  17. package/template/src/common/dto/membership-response.dto.ts +1 -0
  18. package/template/src/common/dto/role-summary.dto.ts +3 -3
  19. package/template/src/common/dto/user-summary.dto.ts +3 -3
  20. package/template/src/config/app.config.ts +6 -1
  21. package/template/src/config/env.validation.ts +45 -0
  22. package/template/src/config/forms.config.ts +12 -0
  23. package/template/src/config/index.ts +2 -0
  24. package/template/src/config/openapi.ts +12 -0
  25. package/template/src/config/reports-secret.ts +15 -0
  26. package/template/src/config/reports.config.ts +16 -0
  27. package/template/src/main.ts +16 -12
  28. package/template/src/modules/access-control/access-control.module.ts +2 -1
  29. package/template/src/modules/access-control/dto/access-control-response.dto.ts +3 -0
  30. package/template/src/modules/access-control/dto/current-access-control-response.dto.ts +35 -0
  31. package/template/src/modules/access-control/presentation/current-access-control.controller.ts +40 -0
  32. package/template/src/modules/access-control/types/permission-key.ts +27 -0
  33. package/template/src/modules/access-control/types/route-permission-registry.ts +183 -0
  34. package/template/src/modules/audit/dto/audit-response.dto.ts +7 -3
  35. package/template/src/modules/auth/auth.module.ts +3 -1
  36. package/template/src/modules/auth/dto/auth-response.dto.ts +1 -1
  37. package/template/src/modules/forms/application/services/file-gc.service.ts +85 -0
  38. package/template/src/modules/forms/application/services/forms-definitions.service.ts +137 -0
  39. package/template/src/modules/forms/application/services/forms-error.mapper.ts +64 -0
  40. package/template/src/modules/forms/application/services/forms-export.service.ts +210 -0
  41. package/template/src/modules/forms/application/services/forms-files.service.ts +164 -0
  42. package/template/src/modules/forms/application/services/forms-public.service.ts +49 -0
  43. package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +53 -0
  44. package/template/src/modules/forms/application/services/forms-submissions.service.ts +103 -0
  45. package/template/src/modules/forms/application/services/handlers/authenticate.action.ts +37 -0
  46. package/template/src/modules/forms/application/services/handlers/logging-email.handler.ts +22 -0
  47. package/template/src/modules/forms/application/services/handlers/send-confirmation-email.action.ts +40 -0
  48. package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +41 -0
  49. package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +109 -0
  50. package/template/src/modules/forms/dto/create-form-definition.dto.ts +12 -0
  51. package/template/src/modules/forms/dto/data-source-response.dto.ts +19 -0
  52. package/template/src/modules/forms/dto/export-submissions-query.dto.ts +33 -0
  53. package/template/src/modules/forms/dto/file-upload-response.dto.ts +24 -0
  54. package/template/src/modules/forms/dto/form-definition-response.dto.ts +50 -0
  55. package/template/src/modules/forms/dto/form-render-response.dto.ts +17 -0
  56. package/template/src/modules/forms/dto/list-form-definitions-query.dto.ts +10 -0
  57. package/template/src/modules/forms/dto/list-submissions-query.dto.ts +10 -0
  58. package/template/src/modules/forms/dto/public-submit-form.dto.ts +24 -0
  59. package/template/src/modules/forms/dto/set-public-access.dto.ts +8 -0
  60. package/template/src/modules/forms/dto/submission-response.dto.ts +99 -0
  61. package/template/src/modules/forms/dto/submit-form.dto.ts +50 -0
  62. package/template/src/modules/forms/dto/update-form-definition.dto.ts +12 -0
  63. package/template/src/modules/forms/dto/upload-file-query.dto.ts +33 -0
  64. package/template/src/modules/forms/dto/validate-submission.dto.ts +22 -0
  65. package/template/src/modules/forms/examples/abstract-submission.form.json +80 -0
  66. package/template/src/modules/forms/examples/login.form.json +24 -0
  67. package/template/src/modules/forms/examples/registration.form.json +44 -0
  68. package/template/src/modules/forms/forms.module.ts +226 -0
  69. package/template/src/modules/forms/forms.tokens.ts +6 -0
  70. package/template/src/modules/forms/infrastructure/audit-sink.adapter.ts +30 -0
  71. package/template/src/modules/forms/infrastructure/casl-forms-authorization.ts +31 -0
  72. package/template/src/modules/forms/infrastructure/prisma-tx-runner.ts +17 -0
  73. package/template/src/modules/forms/infrastructure/registry/form-extension.decorators.ts +17 -0
  74. package/template/src/modules/forms/infrastructure/registry/registry-bootstrap.service.ts +82 -0
  75. package/template/src/modules/forms/infrastructure/request-forms-context.ts +60 -0
  76. package/template/src/modules/forms/infrastructure/schema-check/forms-schema-check.service.ts +76 -0
  77. package/template/src/modules/forms/infrastructure/storage/local-disk-storage.adapter.ts +43 -0
  78. package/template/src/modules/forms/infrastructure/stores/index.ts +5 -0
  79. package/template/src/modules/forms/infrastructure/stores/prisma-action-log.store.ts +37 -0
  80. package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +108 -0
  81. package/template/src/modules/forms/infrastructure/stores/prisma-form-definition.store.ts +147 -0
  82. package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +133 -0
  83. package/template/src/modules/forms/infrastructure/stores/prisma-submission.store.ts +164 -0
  84. package/template/src/modules/forms/presentation/forms-data-sources.controller.ts +58 -0
  85. package/template/src/modules/forms/presentation/forms-definitions.controller.ts +191 -0
  86. package/template/src/modules/forms/presentation/forms-files.controller.ts +79 -0
  87. package/template/src/modules/forms/presentation/forms-submissions.controller.ts +154 -0
  88. package/template/src/modules/forms/presentation/forms-upload.interceptor.ts +33 -0
  89. package/template/src/modules/forms/presentation/public-forms.controller.ts +51 -0
  90. package/template/src/modules/invitations/dto/invitation-response.dto.ts +4 -0
  91. package/template/src/modules/organisations/application/services/organisations.service.ts +67 -1
  92. package/template/src/modules/organisations/dto/organisation-response.dto.ts +52 -0
  93. package/template/src/modules/organisations/presentation/organisations.controller.ts +25 -3
  94. package/template/src/modules/reports/application/services/reports-actions.service.ts +54 -0
  95. package/template/src/modules/reports/application/services/reports-definitions.service.ts +66 -0
  96. package/template/src/modules/reports/application/services/reports-error.mapper.ts +97 -0
  97. package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +124 -0
  98. package/template/src/modules/reports/application/services/reports-exports.service.ts +74 -0
  99. package/template/src/modules/reports/application/services/reports-queries.service.ts +35 -0
  100. package/template/src/modules/reports/application/services/reports-settings-reader.service.ts +49 -0
  101. package/template/src/modules/reports/application/services/reports-views.service.ts +79 -0
  102. package/template/src/modules/reports/dto/action-result-response.dto.ts +21 -0
  103. package/template/src/modules/reports/dto/create-report-definition.dto.ts +86 -0
  104. package/template/src/modules/reports/dto/create-saved-view.dto.ts +26 -0
  105. package/template/src/modules/reports/dto/execute-action.dto.ts +71 -0
  106. package/template/src/modules/reports/dto/export-job-response.dto.ts +60 -0
  107. package/template/src/modules/reports/dto/export-request.dto.ts +34 -0
  108. package/template/src/modules/reports/dto/list-reports-query.dto.ts +10 -0
  109. package/template/src/modules/reports/dto/list-views-query.dto.ts +17 -0
  110. package/template/src/modules/reports/dto/prepare-action-response.dto.ts +14 -0
  111. package/template/src/modules/reports/dto/prepare-action.dto.ts +27 -0
  112. package/template/src/modules/reports/dto/query-response.dto.ts +64 -0
  113. package/template/src/modules/reports/dto/query-spec.dto.ts +120 -0
  114. package/template/src/modules/reports/dto/report-definition-response.dto.ts +64 -0
  115. package/template/src/modules/reports/dto/report-meta-query.dto.ts +16 -0
  116. package/template/src/modules/reports/dto/report-meta-response.dto.ts +113 -0
  117. package/template/src/modules/reports/dto/saved-view-response.dto.ts +66 -0
  118. package/template/src/modules/reports/dto/update-report-definition.dto.ts +9 -0
  119. package/template/src/modules/reports/dto/update-saved-view.dto.ts +27 -0
  120. package/template/src/modules/reports/examples/abstract-review-board.report.json +54 -0
  121. package/template/src/modules/reports/examples/org-members.report.json +55 -0
  122. package/template/src/modules/reports/infrastructure/audit-sink.adapter.ts +31 -0
  123. package/template/src/modules/reports/infrastructure/casl-reports-authorization.ts +39 -0
  124. package/template/src/modules/reports/infrastructure/forms-adapter/form-report-source.adapter.ts +292 -0
  125. package/template/src/modules/reports/infrastructure/forms-adapter/form-row-actions.ts +171 -0
  126. package/template/src/modules/reports/infrastructure/forms-adapter/forms-bridge-bootstrap.service.ts +32 -0
  127. package/template/src/modules/reports/infrastructure/prisma-catalog.adapter.ts +95 -0
  128. package/template/src/modules/reports/infrastructure/prisma-query-executor.ts +103 -0
  129. package/template/src/modules/reports/infrastructure/prisma-snapshot-runner.ts +47 -0
  130. package/template/src/modules/reports/infrastructure/prisma-tx-runner.ts +18 -0
  131. package/template/src/modules/reports/infrastructure/registry/registry-bootstrap.service.ts +61 -0
  132. package/template/src/modules/reports/infrastructure/registry/report-extension.decorators.ts +14 -0
  133. package/template/src/modules/reports/infrastructure/reports-job-queue.adapter.ts +28 -0
  134. package/template/src/modules/reports/infrastructure/request-reports-context.ts +42 -0
  135. package/template/src/modules/reports/infrastructure/schema-check/reports-schema-check.service.ts +116 -0
  136. package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +79 -0
  137. package/template/src/modules/reports/infrastructure/stores/index.ts +5 -0
  138. package/template/src/modules/reports/infrastructure/stores/prisma-bulk-action-run.store.ts +89 -0
  139. package/template/src/modules/reports/infrastructure/stores/prisma-export-job.store.ts +93 -0
  140. package/template/src/modules/reports/infrastructure/stores/prisma-report-definition.store.ts +171 -0
  141. package/template/src/modules/reports/infrastructure/stores/prisma-row-tag.store.ts +110 -0
  142. package/template/src/modules/reports/infrastructure/stores/prisma-saved-view.store.ts +144 -0
  143. package/template/src/modules/reports/presentation/reports-actions.controller.ts +83 -0
  144. package/template/src/modules/reports/presentation/reports-definitions.controller.ts +156 -0
  145. package/template/src/modules/reports/presentation/reports-export-jobs.controller.ts +61 -0
  146. package/template/src/modules/reports/presentation/reports-export.controller.ts +76 -0
  147. package/template/src/modules/reports/presentation/reports-query.controller.ts +52 -0
  148. package/template/src/modules/reports/presentation/reports-views.controller.ts +140 -0
  149. package/template/src/modules/reports/reports-forms.module.ts +33 -0
  150. package/template/src/modules/reports/reports.module.ts +335 -0
  151. package/template/src/modules/reports/reports.tokens.ts +11 -0
  152. package/template/src/modules/reports/sources/org-members.source.ts +112 -0
  153. package/template/src/modules/settings/types/setting-definitions.ts +94 -0
  154. package/template/test/forms-definitions.e2e-spec.ts +394 -0
  155. package/template/test/forms-export.e2e-spec.ts +390 -0
  156. package/template/test/forms-files.e2e-spec.ts +345 -0
  157. package/template/test/forms-outbox.e2e-spec.ts +309 -0
  158. package/template/test/forms-permission-sync.spec.ts +27 -0
  159. package/template/test/forms-public.e2e-spec.ts +269 -0
  160. package/template/test/forms-schema-check.e2e-spec.ts +65 -0
  161. package/template/test/forms-submissions.e2e-spec.ts +500 -0
  162. package/template/test/forms-webhooks.e2e-spec.ts +261 -0
  163. package/template/test/frontend-bootstrap.spec.ts +181 -0
  164. package/template/test/reports-advanced.e2e-spec.ts +368 -0
  165. package/template/test/reports-permission-sync.spec.ts +30 -0
  166. package/template/test/reports-query.e2e-spec.ts +350 -0
  167. package/template/test/reports-tiers.e2e-spec.ts +257 -0
  168. package/template/test/route-registry.validator.spec.ts +34 -0
  169. package/template/test/security.e2e-spec.ts +134 -2
@@ -0,0 +1,116 @@
1
+ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
2
+ import { ConfigService } from '@nestjs/config';
3
+ import { Prisma } from '@prisma/client';
4
+ import {
5
+ EXPECTED_REPORTS_SCHEMA,
6
+ REPORTS_ENGINE_SCHEMA_VERSION,
7
+ REPORTS_PARTIAL_UNIQUE_SQL,
8
+ REPORTS_PRISMA_SNIPPET_PATH,
9
+ REQUIRED_REPORTS_INDEXES,
10
+ } from '@ftisindia/report-builder';
11
+ import { PrismaService } from '../../../../database/prisma/prisma.service';
12
+
13
+ /**
14
+ * Boot-time schema compatibility check (ecosystem guide §10.1): the app owns
15
+ * schema.prisma and migrations; the engine ships the canonical snippet and a
16
+ * tables/columns manifest. On mismatch the app fails fast at startup with an
17
+ * actionable message instead of crashing mid-request with a cryptic Prisma
18
+ * error. Beyond tables/columns, the saved-view name uniques are PARTIAL
19
+ * unique indexes Prisma cannot represent (report design §12, finding #5) —
20
+ * they are verified via pg_indexes so a `migrate dev` regression cannot ship
21
+ * silently. Escape hatch: REPORTS_SCHEMA_CHECK=off.
22
+ */
23
+ @Injectable()
24
+ export class ReportsSchemaCheckService implements OnApplicationBootstrap {
25
+ private readonly logger = new Logger('ReportsSchemaCheck');
26
+
27
+ constructor(
28
+ private readonly prisma: PrismaService,
29
+ private readonly config: ConfigService,
30
+ ) {}
31
+
32
+ async onApplicationBootstrap(): Promise<void> {
33
+ if (this.config.get<string>('reports.schemaCheck') === 'off') {
34
+ this.logger.warn('Reports schema check is disabled (REPORTS_SCHEMA_CHECK=off).');
35
+ return;
36
+ }
37
+ await this.check();
38
+ this.logger.log(
39
+ `Reports schema check passed (engine schema version ${REPORTS_ENGINE_SCHEMA_VERSION}).`,
40
+ );
41
+ }
42
+
43
+ async check(): Promise<void> {
44
+ const tableProblems = await this.missingTablesOrColumns();
45
+ const indexProblems = await this.missingPartialUniques();
46
+ const problems = [...tableProblems, ...indexProblems];
47
+
48
+ if (problems.length === 0) {
49
+ return;
50
+ }
51
+
52
+ const lines = [
53
+ `The database does not match @ftisindia/report-builder engine schema version ${REPORTS_ENGINE_SCHEMA_VERSION}:`,
54
+ ...problems.map((problem) => ` - ${problem}`),
55
+ `Fix: copy the canonical models from "${REPORTS_PRISMA_SNIPPET_PATH}" (in node_modules) into prisma/schema.prisma and run "npx prisma migrate dev".`,
56
+ ];
57
+ if (indexProblems.length > 0) {
58
+ lines.push(
59
+ 'The saved-view name uniques are partial indexes Prisma cannot represent — re-apply them as raw SQL inside a migration:',
60
+ REPORTS_PARTIAL_UNIQUE_SQL,
61
+ );
62
+ }
63
+ lines.push('To bypass temporarily (NOT recommended), set REPORTS_SCHEMA_CHECK=off.');
64
+
65
+ throw new Error(lines.join('\n'));
66
+ }
67
+
68
+ private async missingTablesOrColumns(): Promise<string[]> {
69
+ const tables = EXPECTED_REPORTS_SCHEMA.map((expected) => expected.table);
70
+ const rows = await this.prisma.$queryRaw<Array<{ table_name: string; column_name: string }>>`
71
+ SELECT table_name, column_name
72
+ FROM information_schema.columns
73
+ WHERE table_schema = current_schema()
74
+ AND table_name IN (${Prisma.join(tables)})
75
+ `;
76
+
77
+ const present = new Map<string, Set<string>>();
78
+ for (const row of rows) {
79
+ const columns = present.get(row.table_name) ?? new Set<string>();
80
+ columns.add(row.column_name);
81
+ present.set(row.table_name, columns);
82
+ }
83
+
84
+ const problems: string[] = [];
85
+ for (const expected of EXPECTED_REPORTS_SCHEMA) {
86
+ const columns = present.get(expected.table);
87
+ if (!columns) {
88
+ problems.push(`missing table "${expected.table}"`);
89
+ continue;
90
+ }
91
+ const missingColumns = expected.columns.filter((column) => !columns.has(column));
92
+ if (missingColumns.length > 0) {
93
+ problems.push(
94
+ `table "${expected.table}" is missing column(s): ${missingColumns.join(', ')}`,
95
+ );
96
+ }
97
+ }
98
+
99
+ return problems;
100
+ }
101
+
102
+ private async missingPartialUniques(): Promise<string[]> {
103
+ const names = REQUIRED_REPORTS_INDEXES.map((index) => index.name);
104
+ const rows = await this.prisma.$queryRaw<Array<{ indexname: string }>>`
105
+ SELECT indexname
106
+ FROM pg_indexes
107
+ WHERE schemaname = current_schema()
108
+ AND indexname IN (${Prisma.join(names)})
109
+ `;
110
+
111
+ const present = new Set(rows.map((row) => row.indexname));
112
+ return REQUIRED_REPORTS_INDEXES.filter((index) => !present.has(index.name)).map(
113
+ (index) => `missing index "${index.name}" on "${index.table}" — ${index.description}`,
114
+ );
115
+ }
116
+ }
@@ -0,0 +1,79 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { dirname, join, resolve, sep } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import type { Readable } from 'node:stream';
6
+ import { Injectable } from '@nestjs/common';
7
+ import { ConfigService } from '@nestjs/config';
8
+ import type { ExportFileSink } from '@ftisindia/report-builder';
9
+
10
+ /**
11
+ * Reports-OWNED export file storage (report design §9). The reports module
12
+ * keeps its own org-scoped export directory so it does not depend on the form
13
+ * builder's UploadedFile machinery — the engine core's standalone guarantee
14
+ * (§3.2) extends to the glue: ReportsModule needs no FormsModule.
15
+ *
16
+ * Files land under `<exportStorageDir>/<orgId>/<uuid>.<ext>`; the returned
17
+ * `fileId` is that storage key, stored on the ReportExportJob row and used by
18
+ * the download route. The key is server-generated (no user input), and reads
19
+ * are guarded against escaping the configured directory.
20
+ *
21
+ * Swap this for an S3/GCS-backed ExportFileSink without touching the engine or
22
+ * the rest of the glue.
23
+ */
24
+ @Injectable()
25
+ export class LocalDiskReportExportStorage implements ExportFileSink {
26
+ private readonly baseDir: string;
27
+
28
+ constructor(config: ConfigService) {
29
+ this.baseDir = resolve(config.get<string>('reports.exportStorageDir') ?? './var/report-exports');
30
+ }
31
+
32
+ async putStream(
33
+ opts: { orgId: string; ownerId: string; fileName: string; mimeType: string },
34
+ chunks: AsyncIterable<Uint8Array>,
35
+ ): Promise<{ fileId: string; size: number }> {
36
+ // Collected into one buffer before writing. Bounded by the engine's export
37
+ // caps (row limits + the snapshot duration bound, §9) long before memory
38
+ // becomes a concern.
39
+ const collected: Buffer[] = [];
40
+ for await (const chunk of chunks) {
41
+ collected.push(Buffer.from(chunk));
42
+ }
43
+ const bytes = Buffer.concat(collected);
44
+
45
+ const safeOrg = sanitizeSegment(opts.orgId);
46
+ const storageKey = `${safeOrg}/${randomUUID()}${extensionOf(opts.fileName)}`;
47
+ const absolute = this.resolveKey(storageKey);
48
+ await mkdir(dirname(absolute), { recursive: true });
49
+ await writeFile(absolute, bytes);
50
+
51
+ return { fileId: storageKey, size: bytes.byteLength };
52
+ }
53
+
54
+ /** A readable stream for the download route. Guarded against path traversal. */
55
+ read(storageKey: string): Readable {
56
+ return createReadStream(this.resolveKey(storageKey));
57
+ }
58
+
59
+ /** Resolve a storage key inside the base dir, rejecting any escape. */
60
+ private resolveKey(storageKey: string): string {
61
+ const absolute = resolve(join(this.baseDir, storageKey));
62
+ if (absolute !== this.baseDir && !absolute.startsWith(this.baseDir + sep)) {
63
+ throw new Error('Export storage key resolves outside the configured directory.');
64
+ }
65
+ return absolute;
66
+ }
67
+ }
68
+
69
+ /** Keep path segments to a safe alphabet (uuids / org ids are already safe). */
70
+ function sanitizeSegment(segment: string): string {
71
+ return segment.replace(/[^A-Za-z0-9_-]/g, '_');
72
+ }
73
+
74
+ /** Server-controlled extension: a short alphanumeric suffix, or none. */
75
+ function extensionOf(fileName: string): string {
76
+ const dot = fileName.lastIndexOf('.');
77
+ const extension = dot >= 0 ? fileName.slice(dot) : '';
78
+ return /^\.[A-Za-z0-9]+$/.test(extension) ? extension.toLowerCase() : '';
79
+ }
@@ -0,0 +1,5 @@
1
+ export * from './prisma-bulk-action-run.store';
2
+ export * from './prisma-export-job.store';
3
+ export * from './prisma-report-definition.store';
4
+ export * from './prisma-row-tag.store';
5
+ export * from './prisma-saved-view.store';
@@ -0,0 +1,89 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { Prisma, ReportBulkActionRun as ReportBulkActionRunRow } from '@prisma/client';
3
+ import type {
4
+ ActionRunStatus,
5
+ BulkActionRunRecord,
6
+ BulkActionRunStore,
7
+ NewBulkActionRunRecord,
8
+ } from '@ftisindia/report-builder';
9
+ import { PrismaService } from '../../../../database/prisma/prisma.service';
10
+
11
+ /**
12
+ * The bulk-action idempotency ledger (report design §6.3, finding #7): the
13
+ * (orgId, idempotencyKey) unique makes `begin` an atomic claim — exactly one
14
+ * caller creates the RUNNING row; a retry gets the existing row back and
15
+ * returns the recorded outcome instead of re-running the verb.
16
+ */
17
+ @Injectable()
18
+ export class PrismaBulkActionRunStore implements BulkActionRunStore {
19
+ constructor(private readonly prisma: PrismaService) {}
20
+
21
+ async begin(
22
+ record: NewBulkActionRunRecord,
23
+ ): Promise<{ created: boolean; run: BulkActionRunRecord }> {
24
+ try {
25
+ const row = await this.prisma.reportBulkActionRun.create({
26
+ data: {
27
+ orgId: record.orgId,
28
+ reportKey: record.reportKey,
29
+ reportVersion: record.reportVersion,
30
+ action: record.action,
31
+ idempotencyKey: record.idempotencyKey,
32
+ status: 'RUNNING',
33
+ },
34
+ });
35
+
36
+ return { created: true, run: this.toRecord(row) };
37
+ } catch (error) {
38
+ if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== 'P2002') {
39
+ throw error;
40
+ }
41
+
42
+ const existing = await this.prisma.reportBulkActionRun.findUnique({
43
+ where: {
44
+ orgId_idempotencyKey: { orgId: record.orgId, idempotencyKey: record.idempotencyKey },
45
+ },
46
+ });
47
+
48
+ if (!existing) {
49
+ // The claiming row vanished between the conflict and the lookup —
50
+ // surface the original conflict rather than inventing state.
51
+ throw error;
52
+ }
53
+
54
+ return { created: false, run: this.toRecord(existing) };
55
+ }
56
+ }
57
+
58
+ async complete(
59
+ orgId: string,
60
+ id: string,
61
+ status: 'DONE' | 'FAILED',
62
+ result?: unknown,
63
+ ): Promise<void> {
64
+ await this.prisma.reportBulkActionRun.updateMany({
65
+ where: { id, orgId },
66
+ data: {
67
+ status,
68
+ ...(result !== undefined
69
+ ? { result: result === null ? Prisma.DbNull : (result as Prisma.InputJsonValue) }
70
+ : {}),
71
+ },
72
+ });
73
+ }
74
+
75
+ private toRecord(row: ReportBulkActionRunRow): BulkActionRunRecord {
76
+ return {
77
+ id: row.id,
78
+ orgId: row.orgId,
79
+ reportKey: row.reportKey,
80
+ reportVersion: row.reportVersion,
81
+ action: row.action,
82
+ idempotencyKey: row.idempotencyKey,
83
+ status: row.status as ActionRunStatus,
84
+ result: row.result,
85
+ createdAt: row.createdAt,
86
+ updatedAt: row.updatedAt,
87
+ };
88
+ }
89
+ }
@@ -0,0 +1,93 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { Prisma, ReportExportJob as ReportExportJobRow } from '@prisma/client';
3
+ import type {
4
+ EngineTx,
5
+ ExportJobRecord,
6
+ ExportJobSpec,
7
+ ExportJobStatus,
8
+ ExportJobStore,
9
+ NewExportJobRecord,
10
+ } from '@ftisindia/report-builder';
11
+ import { PrismaService } from '../../../../database/prisma/prisma.service';
12
+
13
+ @Injectable()
14
+ export class PrismaExportJobStore implements ExportJobStore {
15
+ constructor(private readonly prisma: PrismaService) {}
16
+
17
+ private client(tx?: EngineTx) {
18
+ return tx ? (tx as unknown as Prisma.TransactionClient) : this.prisma;
19
+ }
20
+
21
+ async create(record: NewExportJobRecord, tx?: EngineTx): Promise<ExportJobRecord> {
22
+ const row = await this.client(tx).reportExportJob.create({
23
+ data: {
24
+ orgId: record.orgId,
25
+ reportKey: record.reportKey,
26
+ reportVersion: record.reportVersion,
27
+ // The FULL spec {spec, format, columns} — replayable and auditable,
28
+ // never a hash (report design §9, finding #1/#8).
29
+ spec: record.spec as unknown as Prisma.InputJsonValue,
30
+ status: 'PENDING',
31
+ requestedBy: record.requestedBy,
32
+ },
33
+ });
34
+
35
+ return this.toRecord(row);
36
+ }
37
+
38
+ async findById(orgId: string, id: string): Promise<ExportJobRecord | null> {
39
+ const row = await this.client().reportExportJob.findFirst({
40
+ where: { id, orgId },
41
+ });
42
+
43
+ return row ? this.toRecord(row) : null;
44
+ }
45
+
46
+ async markRunning(orgId: string, id: string): Promise<void> {
47
+ await this.client().reportExportJob.updateMany({
48
+ where: { id, orgId },
49
+ data: { status: 'RUNNING' },
50
+ });
51
+ }
52
+
53
+ async markDone(
54
+ orgId: string,
55
+ id: string,
56
+ outcome: { fileId: string; rowCount: number; asOf: Date },
57
+ ): Promise<void> {
58
+ await this.client().reportExportJob.updateMany({
59
+ where: { id, orgId },
60
+ data: {
61
+ status: 'DONE',
62
+ fileId: outcome.fileId,
63
+ rowCount: outcome.rowCount,
64
+ asOf: outcome.asOf,
65
+ },
66
+ });
67
+ }
68
+
69
+ async markFailed(orgId: string, id: string, error: string): Promise<void> {
70
+ await this.client().reportExportJob.updateMany({
71
+ where: { id, orgId },
72
+ data: { status: 'FAILED', error },
73
+ });
74
+ }
75
+
76
+ private toRecord(row: ReportExportJobRow): ExportJobRecord {
77
+ return {
78
+ id: row.id,
79
+ orgId: row.orgId,
80
+ reportKey: row.reportKey,
81
+ reportVersion: row.reportVersion,
82
+ spec: row.spec as unknown as ExportJobSpec,
83
+ asOf: row.asOf,
84
+ status: row.status as ExportJobStatus,
85
+ fileId: row.fileId,
86
+ rowCount: row.rowCount,
87
+ error: row.error,
88
+ requestedBy: row.requestedBy,
89
+ createdAt: row.createdAt,
90
+ updatedAt: row.updatedAt,
91
+ };
92
+ }
93
+ }
@@ -0,0 +1,171 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { Prisma, ReportDefinition as ReportDefinitionRow } from '@prisma/client';
3
+ import {
4
+ ReportConflictError,
5
+ ReportNotFoundError,
6
+ type CompiledDefinitionMeta,
7
+ type EngineTx,
8
+ type NewReportDefinitionRecord,
9
+ type ReportDefinition,
10
+ type ReportDefinitionPatch,
11
+ type ReportDefinitionRecord,
12
+ type ReportDefinitionStatus,
13
+ type ReportDefinitionStore,
14
+ } from '@ftisindia/report-builder';
15
+ import { PrismaService } from '../../../../database/prisma/prisma.service';
16
+
17
+ @Injectable()
18
+ export class PrismaReportDefinitionStore implements ReportDefinitionStore {
19
+ constructor(private readonly prisma: PrismaService) {}
20
+
21
+ private client(tx?: EngineTx) {
22
+ return tx ? (tx as unknown as Prisma.TransactionClient) : this.prisma;
23
+ }
24
+
25
+ async create(record: NewReportDefinitionRecord, tx?: EngineTx): Promise<ReportDefinitionRecord> {
26
+ try {
27
+ const row = await this.client(tx).reportDefinition.create({
28
+ data: {
29
+ orgId: record.orgId,
30
+ key: record.key,
31
+ version: record.version,
32
+ status: record.status,
33
+ schema: record.schema as unknown as Prisma.InputJsonValue,
34
+ },
35
+ });
36
+
37
+ return this.toRecord(row);
38
+ } catch (error) {
39
+ // The (orgId, key, version) unique — a concurrent draft/publish race.
40
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
41
+ throw new ReportConflictError(
42
+ `Report definition "${record.key}" v${record.version} already exists in this organisation.`,
43
+ );
44
+ }
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ async update(
50
+ orgId: string,
51
+ key: string,
52
+ version: number,
53
+ patch: ReportDefinitionPatch,
54
+ tx?: EngineTx,
55
+ ): Promise<ReportDefinitionRecord> {
56
+ const client = this.client(tx);
57
+
58
+ const existing = await client.reportDefinition.findUnique({
59
+ where: { orgId_key_version: { orgId, key, version } },
60
+ select: { id: true },
61
+ });
62
+
63
+ if (!existing) {
64
+ throw new ReportNotFoundError(
65
+ `Report definition "${key}" v${version} was not found in this organisation.`,
66
+ );
67
+ }
68
+
69
+ const row = await client.reportDefinition.update({
70
+ where: { id: existing.id },
71
+ data: {
72
+ ...(patch.status !== undefined ? { status: patch.status } : {}),
73
+ ...(patch.schema !== undefined
74
+ ? { schema: patch.schema as unknown as Prisma.InputJsonValue }
75
+ : {}),
76
+ ...(patch.compiled !== undefined
77
+ ? {
78
+ compiled:
79
+ patch.compiled === null
80
+ ? Prisma.DbNull
81
+ : (patch.compiled as unknown as Prisma.InputJsonValue),
82
+ }
83
+ : {}),
84
+ },
85
+ });
86
+
87
+ return this.toRecord(row);
88
+ }
89
+
90
+ async findByKeyVersion(
91
+ orgId: string,
92
+ key: string,
93
+ version: number,
94
+ tx?: EngineTx,
95
+ ): Promise<ReportDefinitionRecord | null> {
96
+ const row = await this.client(tx).reportDefinition.findUnique({
97
+ where: { orgId_key_version: { orgId, key, version } },
98
+ });
99
+
100
+ return row ? this.toRecord(row) : null;
101
+ }
102
+
103
+ async findLatest(
104
+ orgId: string,
105
+ key: string,
106
+ status?: ReportDefinitionStatus,
107
+ ): Promise<ReportDefinitionRecord | null> {
108
+ const row = await this.client().reportDefinition.findFirst({
109
+ where: { orgId, key, ...(status ? { status } : {}) },
110
+ orderBy: { version: 'desc' },
111
+ });
112
+
113
+ return row ? this.toRecord(row) : null;
114
+ }
115
+
116
+ async findAllByKeyStatus(
117
+ orgId: string,
118
+ key: string,
119
+ status: ReportDefinitionStatus,
120
+ tx?: EngineTx,
121
+ ): Promise<ReportDefinitionRecord[]> {
122
+ const rows = await this.client(tx).reportDefinition.findMany({
123
+ where: { orgId, key, status },
124
+ orderBy: { version: 'desc' },
125
+ });
126
+
127
+ return rows.map((row) => this.toRecord(row));
128
+ }
129
+
130
+ async list(
131
+ orgId: string,
132
+ options: { status?: ReportDefinitionStatus; cursor?: string; limit: number },
133
+ ): Promise<ReportDefinitionRecord[]> {
134
+ const rows = await this.client().reportDefinition.findMany({
135
+ where: { orgId, ...(options.status ? { status: options.status } : {}) },
136
+ orderBy: [{ key: 'asc' }, { version: 'desc' }],
137
+ take: options.limit,
138
+ ...(options.cursor
139
+ ? {
140
+ cursor: { id: options.cursor },
141
+ skip: 1,
142
+ }
143
+ : {}),
144
+ });
145
+
146
+ return rows.map((row) => this.toRecord(row));
147
+ }
148
+
149
+ async maxVersion(orgId: string, key: string, tx?: EngineTx): Promise<number> {
150
+ const result = await this.client(tx).reportDefinition.aggregate({
151
+ _max: { version: true },
152
+ where: { orgId, key },
153
+ });
154
+
155
+ return result._max.version ?? 0;
156
+ }
157
+
158
+ private toRecord(row: ReportDefinitionRow): ReportDefinitionRecord {
159
+ return {
160
+ id: row.id,
161
+ orgId: row.orgId,
162
+ key: row.key,
163
+ version: row.version,
164
+ status: row.status as ReportDefinitionStatus,
165
+ schema: row.schema as unknown as ReportDefinition,
166
+ compiled: row.compiled === null ? null : (row.compiled as unknown as CompiledDefinitionMeta),
167
+ createdAt: row.createdAt,
168
+ updatedAt: row.updatedAt,
169
+ };
170
+ }
171
+ }
@@ -0,0 +1,110 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { Prisma } from '@prisma/client';
3
+ import type { EngineTx, RowTagStore } from '@ftisindia/report-builder';
4
+ import { PrismaService } from '../../../../database/prisma/prisma.service';
5
+
6
+ /**
7
+ * Org-scoped row tags & labels (report design §7): one table, one mechanism.
8
+ * The (orgId, sourceKind, sourceKey, rowId, tag) unique + createMany
9
+ * skipDuplicates make addTags idempotent per link; the engine normalizes tag
10
+ * text before it reaches this store.
11
+ */
12
+ @Injectable()
13
+ export class PrismaRowTagStore implements RowTagStore {
14
+ constructor(private readonly prisma: PrismaService) {}
15
+
16
+ private client(tx?: EngineTx) {
17
+ return tx ? (tx as unknown as Prisma.TransactionClient) : this.prisma;
18
+ }
19
+
20
+ async addTags(
21
+ orgId: string,
22
+ sourceKind: string,
23
+ sourceKey: string,
24
+ rowIds: readonly string[],
25
+ tags: readonly string[],
26
+ createdBy: string,
27
+ tx?: EngineTx,
28
+ ): Promise<number> {
29
+ if (rowIds.length === 0 || tags.length === 0) {
30
+ return 0;
31
+ }
32
+
33
+ const result = await this.client(tx).reportRowTag.createMany({
34
+ data: rowIds.flatMap((rowId) =>
35
+ tags.map((tag) => ({ orgId, sourceKind, sourceKey, rowId, tag, createdBy })),
36
+ ),
37
+ // Existing (org, source, row, tag) links are silently kept — the count
38
+ // returned is the number of NEW links, per the port contract.
39
+ skipDuplicates: true,
40
+ });
41
+
42
+ return result.count;
43
+ }
44
+
45
+ async removeTags(
46
+ orgId: string,
47
+ sourceKind: string,
48
+ sourceKey: string,
49
+ rowIds: readonly string[],
50
+ tags: readonly string[],
51
+ tx?: EngineTx,
52
+ ): Promise<number> {
53
+ if (rowIds.length === 0 || tags.length === 0) {
54
+ return 0;
55
+ }
56
+
57
+ const result = await this.client(tx).reportRowTag.deleteMany({
58
+ where: {
59
+ orgId,
60
+ sourceKind,
61
+ sourceKey,
62
+ rowId: { in: [...rowIds] },
63
+ tag: { in: [...tags] },
64
+ },
65
+ });
66
+
67
+ return result.count;
68
+ }
69
+
70
+ async tagsForRows(
71
+ orgId: string,
72
+ sourceKind: string,
73
+ sourceKey: string,
74
+ rowIds: readonly string[],
75
+ ): Promise<Map<string, string[]>> {
76
+ if (rowIds.length === 0) {
77
+ return new Map();
78
+ }
79
+
80
+ // One indexed lookup per page — (orgId, sourceKind, sourceKey, rowId).
81
+ const rows = await this.client().reportRowTag.findMany({
82
+ where: { orgId, sourceKind, sourceKey, rowId: { in: [...rowIds] } },
83
+ select: { rowId: true, tag: true },
84
+ orderBy: { tag: 'asc' },
85
+ });
86
+
87
+ const tagsByRow = new Map<string, string[]>();
88
+ for (const row of rows) {
89
+ const tags = tagsByRow.get(row.rowId);
90
+ if (tags) {
91
+ tags.push(row.tag);
92
+ } else {
93
+ tagsByRow.set(row.rowId, [row.tag]);
94
+ }
95
+ }
96
+
97
+ return tagsByRow;
98
+ }
99
+
100
+ async listTags(orgId: string, sourceKind: string, sourceKey: string): Promise<string[]> {
101
+ const rows = await this.client().reportRowTag.findMany({
102
+ where: { orgId, sourceKind, sourceKey },
103
+ select: { tag: true },
104
+ distinct: ['tag'],
105
+ orderBy: { tag: 'asc' },
106
+ });
107
+
108
+ return rows.map((row) => row.tag);
109
+ }
110
+ }