@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,171 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { SubmissionService } from '@ftisindia/form-builder';
3
+ import type { SubmissionRecord, SubmissionStatus, SubmitArgs } from '@ftisindia/form-builder';
4
+ import { ReportAuthzDeniedError } from '@ftisindia/report-builder';
5
+ import type {
6
+ ReportActionContext,
7
+ ReportRowActionDef,
8
+ ResolvedRow,
9
+ RowActionKind,
10
+ } from '@ftisindia/report-builder';
11
+ import { withFormsErrorMapping } from '../../../forms/application/services/forms-error.mapper';
12
+ import { RequestFormsContext } from '../../../forms/infrastructure/request-forms-context';
13
+
14
+ /**
15
+ * Form-backed row actions (report design §6.1) — verbs that DELEGATE, never a
16
+ * second write path. The report engine resolves rows and checks org +
17
+ * permissions; these handlers hand each row to the form engine's
18
+ * SubmissionService, so grid verbs run the SAME validation and action pipeline
19
+ * as the form itself. No Prisma access here, ever.
20
+ *
21
+ * Call path mirrors FormsSubmissionsService: the engine is called through the
22
+ * forms context seam (RequestFormsContext — the same AsyncLocalStorage request
23
+ * context the report engine's own seam reads), and every call is wrapped in
24
+ * withFormsErrorMapping so a form validation failure surfaces as the app's
25
+ * 422 envelope with the engine's `errors` array — not an opaque 500.
26
+ *
27
+ * Engine API constraint (read from SubmissionService): the only public update
28
+ * surface is submit/saveDraft with `submissionId`, and the built-in persist
29
+ * action rejects rows that are locked or already SUBMITTED. So edits apply to
30
+ * DRAFT rows; a locked/submitted row surfaces the engine's FormsStateError
31
+ * (409) untouched. The forms engine also runs its OWN transaction per
32
+ * delegated call — its public API takes no external tx — so a multi-row batch
33
+ * is one report-engine transaction wrapping N form-engine transactions, and a
34
+ * failed row stops the batch without rolling back rows already committed by
35
+ * the form engine. Documented seam, not a bug: the alternative would be a
36
+ * second write path (§6).
37
+ *
38
+ * NO generic softDelete is registered: §6.1 makes delete "a status transition
39
+ * the source declares", and the form engine declares no deleted status —
40
+ * SubmissionStatus is DRAFT | SUBMITTED. When the form engine grows one, a
41
+ * delegated action lands here beside these two.
42
+ */
43
+
44
+ /** The submission statuses the form engine exposes (SubmissionStatus). */
45
+ const SUBMISSION_STATUSES: readonly SubmissionStatus[] = ['DRAFT', 'SUBMITTED'];
46
+
47
+ /**
48
+ * `editSubmission` (§6.1 "Edit"): patches a submission through the form
49
+ * engine, re-validating against the submission's STAMPED `formVersion`
50
+ * definition in submit mode — an edit can never be validated against the
51
+ * wrong schema generation. The patch is shallow-merged over the stored data,
52
+ * then the whole document rides the form's own submit pipeline.
53
+ */
54
+ @Injectable()
55
+ export class EditSubmissionRowAction implements ReportRowActionDef {
56
+ readonly name = 'editSubmission';
57
+ readonly kind: RowActionKind = 'transactional';
58
+ readonly requiredPermissions = ['formSubmissions.update'] as const;
59
+ readonly inputSchema = {
60
+ type: 'object',
61
+ properties: { data: { type: 'object' } },
62
+ required: ['data'],
63
+ additionalProperties: false,
64
+ };
65
+
66
+ constructor(
67
+ private readonly submissions: SubmissionService,
68
+ private readonly formsContext: RequestFormsContext,
69
+ ) {}
70
+
71
+ async execute(
72
+ rows: ResolvedRow[],
73
+ input: Record<string, unknown> | undefined,
74
+ actionCtx: ReportActionContext,
75
+ ): Promise<unknown> {
76
+ const orgId = requireOrgId(actionCtx);
77
+ // The action service ajv-validates inputSchema before execute (§6).
78
+ const patch = (input?.data ?? {}) as Record<string, unknown>;
79
+
80
+ let updated = 0;
81
+ for (const row of rows) {
82
+ const record = await this.loadRecord(orgId, row);
83
+ const args: SubmitArgs = {
84
+ orgId,
85
+ formKey: record.formKey,
86
+ // Re-validate against the stamped version, never the latest (§6.1).
87
+ version: record.formVersion,
88
+ data: { ...record.data, ...patch },
89
+ submissionId: record.id,
90
+ };
91
+ await withFormsErrorMapping(() => this.submissions.submit(args, this.formsContext));
92
+ updated += 1;
93
+ }
94
+ return { updated };
95
+ }
96
+
97
+ private async loadRecord(orgId: string, row: ResolvedRow): Promise<SubmissionRecord> {
98
+ // rows carry sourceRef.rowId = the FormSubmission id; the engine's get()
99
+ // re-checks org scope and formSubmissions.read through the forms seam.
100
+ return withFormsErrorMapping(() =>
101
+ this.submissions.get({ orgId, submissionId: row.sourceRef.rowId }, this.formsContext),
102
+ );
103
+ }
104
+ }
105
+
106
+ /**
107
+ * `updateStatus` (§6.1 "Status update"): a delegated transactional action.
108
+ * Status is not patched directly — the form engine couples status to its
109
+ * submit/saveDraft verbs, so SUBMITTED delegates to submit() (full pipeline,
110
+ * stamped-version validation) and DRAFT delegates to saveDraft(). Demoting an
111
+ * already-SUBMITTED row is rejected by the engine's persist action and
112
+ * surfaces as a conflict — the source, not the report layer, owns the legal
113
+ * transitions.
114
+ */
115
+ @Injectable()
116
+ export class UpdateSubmissionStatusRowAction implements ReportRowActionDef {
117
+ readonly name = 'updateStatus';
118
+ readonly kind: RowActionKind = 'transactional';
119
+ readonly requiredPermissions = ['formSubmissions.update'] as const;
120
+ readonly inputSchema = {
121
+ type: 'object',
122
+ properties: { status: { enum: [...SUBMISSION_STATUSES] } },
123
+ required: ['status'],
124
+ additionalProperties: false,
125
+ };
126
+
127
+ constructor(
128
+ private readonly submissions: SubmissionService,
129
+ private readonly formsContext: RequestFormsContext,
130
+ ) {}
131
+
132
+ async execute(
133
+ rows: ResolvedRow[],
134
+ input: Record<string, unknown> | undefined,
135
+ actionCtx: ReportActionContext,
136
+ ): Promise<unknown> {
137
+ const orgId = requireOrgId(actionCtx);
138
+ // ajv-validated against inputSchema before execute (§6).
139
+ const status = input?.status as SubmissionStatus;
140
+
141
+ let updated = 0;
142
+ for (const row of rows) {
143
+ const record = await withFormsErrorMapping(() =>
144
+ this.submissions.get({ orgId, submissionId: row.sourceRef.rowId }, this.formsContext),
145
+ );
146
+ const args: SubmitArgs = {
147
+ orgId,
148
+ formKey: record.formKey,
149
+ version: record.formVersion,
150
+ data: record.data,
151
+ submissionId: record.id,
152
+ };
153
+ await withFormsErrorMapping(() =>
154
+ status === 'SUBMITTED'
155
+ ? this.submissions.submit(args, this.formsContext)
156
+ : this.submissions.saveDraft(args, this.formsContext),
157
+ );
158
+ updated += 1;
159
+ }
160
+ return { updated };
161
+ }
162
+ }
163
+
164
+ /** Mirrors the engine's own manageTags guard: no org scope, no action. */
165
+ function requireOrgId(actionCtx: ReportActionContext): string {
166
+ const orgId = actionCtx.ctx.orgId();
167
+ if (orgId === undefined) {
168
+ throw new ReportAuthzDeniedError('No organisation scope is active.');
169
+ }
170
+ return orgId;
171
+ }
@@ -0,0 +1,32 @@
1
+ import { Injectable, OnModuleInit } from '@nestjs/common';
2
+ import { ReportRowActionRegistry, SourceProviderRegistry } from '@ftisindia/report-builder';
3
+ import { FormReportSourceProvider } from './form-report-source.adapter';
4
+ import {
5
+ EditSubmissionRowAction,
6
+ UpdateSubmissionStatusRowAction,
7
+ } from './form-row-actions';
8
+
9
+ /**
10
+ * The ONLY place the two engines meet (report design §3.2/§13). It registers
11
+ * the form-backed bits into the report engine's shared registries — the
12
+ * 'form' source provider and the delegated editSubmission/updateStatus verbs
13
+ * — only because THIS app ships the form builder. Removing
14
+ * ReportsFormsModule from the app leaves ReportsModule fully functional over
15
+ * custom sources, with no dangling form dependency.
16
+ */
17
+ @Injectable()
18
+ export class ReportsFormsBridgeBootstrap implements OnModuleInit {
19
+ constructor(
20
+ private readonly sourceProviders: SourceProviderRegistry,
21
+ private readonly actions: ReportRowActionRegistry,
22
+ private readonly formSourceProvider: FormReportSourceProvider,
23
+ private readonly editSubmission: EditSubmissionRowAction,
24
+ private readonly updateStatus: UpdateSubmissionStatusRowAction,
25
+ ) {}
26
+
27
+ onModuleInit(): void {
28
+ this.sourceProviders.register(this.formSourceProvider);
29
+ this.actions.register(this.editSubmission);
30
+ this.actions.register(this.updateStatus);
31
+ }
32
+ }
@@ -0,0 +1,95 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import type { CatalogColumn, CatalogIndex, CatalogPort } from '@ftisindia/report-builder';
3
+ import { PrismaService } from '../../../database/prisma/prisma.service';
4
+
5
+ /**
6
+ * Catalog introspection for the publish-time lints (report design §5.2/§8):
7
+ * declared indexes are verified against pg_indexes, generated columns against
8
+ * information_schema, tier caps against the planner's pg_class estimates.
9
+ * Every lookup is scoped to current_schema() so other schemas (extensions,
10
+ * shadow databases) cannot shadow or leak into lint verdicts.
11
+ */
12
+ @Injectable()
13
+ export class PrismaReportsCatalog implements CatalogPort {
14
+ constructor(private readonly prisma: PrismaService) {}
15
+
16
+ async indexesFor(table: string): Promise<CatalogIndex[]> {
17
+ const rows = await this.prisma.$queryRawUnsafe<
18
+ Array<{ indexname: string; tablename: string; indexdef: string }>
19
+ >(
20
+ `SELECT indexname, tablename, indexdef
21
+ FROM pg_indexes
22
+ WHERE schemaname = current_schema()
23
+ AND tablename = $1`,
24
+ table,
25
+ );
26
+
27
+ return rows.map((row) => ({
28
+ name: row.indexname,
29
+ table: row.tablename,
30
+ indexDef: row.indexdef,
31
+ isUnique: row.indexdef.includes('UNIQUE'),
32
+ }));
33
+ }
34
+
35
+ async columnsFor(table: string): Promise<CatalogColumn[]> {
36
+ const rows = await this.prisma.$queryRawUnsafe<
37
+ Array<{ column_name: string; data_type: string; is_generated: string }>
38
+ >(
39
+ `SELECT column_name, data_type, is_generated
40
+ FROM information_schema.columns
41
+ WHERE table_schema = current_schema()
42
+ AND table_name = $1`,
43
+ table,
44
+ );
45
+
46
+ return rows.map((row) => ({
47
+ name: row.column_name,
48
+ dataType: row.data_type,
49
+ isGenerated: row.is_generated === 'ALWAYS',
50
+ }));
51
+ }
52
+
53
+ async hasExtension(name: string): Promise<boolean> {
54
+ const rows = await this.prisma.$queryRawUnsafe<Array<{ found: number }>>(
55
+ `SELECT 1 AS found FROM pg_extension WHERE extname = $1`,
56
+ name,
57
+ );
58
+
59
+ return rows.length > 0;
60
+ }
61
+
62
+ async estimatedRowCount(table: string): Promise<number> {
63
+ const rows = await this.prisma.$queryRawUnsafe<Array<{ reltuples: unknown }>>(
64
+ `SELECT c.reltuples
65
+ FROM pg_class c
66
+ JOIN pg_namespace n ON n.oid = c.relnamespace
67
+ WHERE n.nspname = current_schema()
68
+ AND c.relname = $1
69
+ AND c.relkind IN ('r', 'p')`,
70
+ table,
71
+ );
72
+
73
+ if (rows.length === 0) {
74
+ return 0;
75
+ }
76
+ const estimate = Number(rows[0].reltuples);
77
+ return Number.isFinite(estimate) ? Math.max(0, estimate) : 0;
78
+ }
79
+
80
+ async relationExists(name: string): Promise<boolean> {
81
+ // Tables, views, materialized views, and partitioned tables all count —
82
+ // the materialized tier reads from any of them (report design §8).
83
+ const rows = await this.prisma.$queryRawUnsafe<Array<{ found: number }>>(
84
+ `SELECT 1 AS found
85
+ FROM pg_class c
86
+ JOIN pg_namespace n ON n.oid = c.relnamespace
87
+ WHERE n.nspname = current_schema()
88
+ AND c.relname = $1
89
+ AND c.relkind IN ('r', 'v', 'm', 'p')`,
90
+ name,
91
+ );
92
+
93
+ return rows.length > 0;
94
+ }
95
+ }
@@ -0,0 +1,103 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { Prisma } from '@prisma/client';
3
+ import { ReportQueryBudgetError, type QueryExecutor, type SqlRow } from '@ftisindia/report-builder';
4
+ import { PrismaService } from '../../../database/prisma/prisma.service';
5
+
6
+ /** Hard ceiling on the per-statement budget — matches Prisma's own tx limits. */
7
+ const MAX_STATEMENT_TIMEOUT_MS = 600_000;
8
+
9
+ function assertValidStatementTimeout(ms: number): void {
10
+ if (!Number.isInteger(ms) || ms < 1 || ms > MAX_STATEMENT_TIMEOUT_MS) {
11
+ throw new Error(
12
+ `Invalid report statement timeout: ${ms} (expected an integer between 1 and ${MAX_STATEMENT_TIMEOUT_MS} ms).`,
13
+ );
14
+ }
15
+ }
16
+
17
+ /** Postgres 57014 — 'canceling statement due to statement timeout'. */
18
+ function isStatementTimeout(error: unknown): boolean {
19
+ if (error instanceof Prisma.PrismaClientKnownRequestError) {
20
+ const meta = error.meta as { code?: unknown } | undefined;
21
+ if (meta?.code === '57014') {
22
+ return true;
23
+ }
24
+ }
25
+ const message = error instanceof Error ? error.message : '';
26
+ return message.includes('57014') || message.includes('canceling statement due to statement timeout');
27
+ }
28
+
29
+ /**
30
+ * Runs one compiler-built statement under a SET LOCAL statement_timeout on an
31
+ * open transaction client. SQL identifiers come only from code-owned manifests
32
+ * and every VALUE travels as a positional $1..$n bind; SET LOCAL cannot take
33
+ * binds, so the timeout — a VALIDATED INTEGER — is the one sanctioned
34
+ * interpolation exception (report design §2.1/§5.2).
35
+ *
36
+ * Shared between PrismaQueryExecutor (one-off transactions) and
37
+ * PrismaSnapshotRunner (the export's REPEATABLE READ transaction).
38
+ */
39
+ export async function runBoundedQuery(
40
+ client: Prisma.TransactionClient,
41
+ sql: string,
42
+ params: readonly unknown[],
43
+ opts: { statementTimeoutMs: number },
44
+ ): Promise<SqlRow[]> {
45
+ assertValidStatementTimeout(opts.statementTimeoutMs);
46
+ try {
47
+ await client.$executeRawUnsafe(`SET LOCAL statement_timeout = ${opts.statementTimeoutMs}`);
48
+ return await client.$queryRawUnsafe<SqlRow[]>(sql, ...params);
49
+ } catch (error) {
50
+ if (isStatementTimeout(error)) {
51
+ // The runtime backstop (§5.2): a pathological plan fails fast, typed.
52
+ throw new ReportQueryBudgetError();
53
+ }
54
+ throw error;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * EXPLAIN (FORMAT JSON) for the plan lint and estimated counts (§5.2/§5.4).
60
+ * Postgres returns one row shaped [{ "QUERY PLAN": <document> }]; drivers may
61
+ * surface the json column already parsed or as a string — the engine expects
62
+ * the parsed array-root document either way.
63
+ */
64
+ export async function runExplain(
65
+ client: Prisma.TransactionClient,
66
+ sql: string,
67
+ params: readonly unknown[],
68
+ ): Promise<unknown> {
69
+ const rows = await client.$queryRawUnsafe<Array<Record<string, unknown>>>(
70
+ `EXPLAIN (FORMAT JSON) ${sql}`,
71
+ ...params,
72
+ );
73
+ const plan = rows[0]?.['QUERY PLAN'];
74
+ return typeof plan === 'string' ? (JSON.parse(plan) as unknown) : plan;
75
+ }
76
+
77
+ /**
78
+ * The engine's SQL seam (report design §5) over Prisma. Each call runs inside
79
+ * its own short transaction because SET LOCAL is transaction-scoped — outside
80
+ * one it would be a silent no-op and the statement budget would not exist.
81
+ */
82
+ @Injectable()
83
+ export class PrismaQueryExecutor implements QueryExecutor {
84
+ constructor(private readonly prisma: PrismaService) {}
85
+
86
+ query(
87
+ sql: string,
88
+ params: readonly unknown[],
89
+ opts: { statementTimeoutMs: number },
90
+ ): Promise<SqlRow[]> {
91
+ assertValidStatementTimeout(opts.statementTimeoutMs);
92
+ return this.prisma.$transaction((tx) => runBoundedQuery(tx, sql, params, opts), {
93
+ // Keep Prisma's transaction window above the statement budget so the
94
+ // budget — not Prisma's 5s interactive-tx default — is what fires.
95
+ timeout: opts.statementTimeoutMs + 1000,
96
+ maxWait: 5000,
97
+ });
98
+ }
99
+
100
+ explain(sql: string, params: readonly unknown[]): Promise<unknown> {
101
+ return this.prisma.$transaction((tx) => runExplain(tx, sql, params));
102
+ }
103
+ }
@@ -0,0 +1,47 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { Prisma } from '@prisma/client';
3
+ import type { QueryExecutor, SnapshotRunner } from '@ftisindia/report-builder';
4
+ import { PrismaService } from '../../../database/prisma/prisma.service';
5
+ import { runBoundedQuery, runExplain } from './prisma-query-executor';
6
+
7
+ /**
8
+ * One REPEATABLE READ transaction per export (report design §9, finding #8):
9
+ * every keyset batch reads the same MVCC snapshot, so rows moving under the
10
+ * export can neither be skipped nor duplicated. The transaction is
11
+ * duration-bounded — an export that cannot finish inside the bound aborts
12
+ * (and the engine maps that to guidance: tighten filters or move the report
13
+ * to the materialized tier).
14
+ */
15
+ @Injectable()
16
+ export class PrismaSnapshotRunner implements SnapshotRunner {
17
+ constructor(private readonly prisma: PrismaService) {}
18
+
19
+ withSnapshot<T>(
20
+ opts: { maxDurationMs: number },
21
+ fn: (exec: QueryExecutor, asOf: Date) => Promise<T>,
22
+ ): Promise<T> {
23
+ return this.prisma.$transaction(
24
+ async (tx) => {
25
+ // Under REPEATABLE READ now() is the transaction (= snapshot) start
26
+ // time — the export's `asOf`, stamped into the job and file metadata.
27
+ const rows = await tx.$queryRawUnsafe<Array<{ now: unknown }>>('SELECT now() AS now');
28
+ const now = rows[0]?.now;
29
+ const asOf = now instanceof Date ? now : new Date();
30
+
31
+ // Executor bound to THIS transaction: same SET LOCAL budget + EXPLAIN
32
+ // logic as PrismaQueryExecutor, valid only while `fn` runs.
33
+ const exec: QueryExecutor = {
34
+ query: (sql, params, queryOpts) => runBoundedQuery(tx, sql, params, queryOpts),
35
+ explain: (sql, params) => runExplain(tx, sql, params),
36
+ };
37
+
38
+ return fn(exec, asOf);
39
+ },
40
+ {
41
+ isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead,
42
+ timeout: opts.maxDurationMs,
43
+ maxWait: 5000,
44
+ },
45
+ );
46
+ }
47
+ }
@@ -0,0 +1,18 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import type { EngineTx, TxRunner } from '@ftisindia/report-builder';
3
+ import { PrismaService } from '../../../database/prisma/prisma.service';
4
+
5
+ /**
6
+ * The engine's transaction seam over PrismaService.$transaction. The opaque
7
+ * EngineTx brand is a Prisma.TransactionClient underneath; the stores cast it
8
+ * back. One TxRunner.run call is the engine's atomicity boundary (export job
9
+ * row + outbox enqueue, bulk-action ledger + audit — report design §6.3/§9).
10
+ */
11
+ @Injectable()
12
+ export class ReportsPrismaTxRunner implements TxRunner {
13
+ constructor(private readonly prisma: PrismaService) {}
14
+
15
+ run<T>(fn: (tx: EngineTx) => Promise<T>): Promise<T> {
16
+ return this.prisma.$transaction((tx) => fn(tx as unknown as EngineTx));
17
+ }
18
+ }
@@ -0,0 +1,61 @@
1
+ import { Injectable, OnModuleInit } from '@nestjs/common';
2
+ import { DiscoveryService, Reflector } from '@nestjs/core';
3
+ import {
4
+ ReportRowActionRegistry,
5
+ ReportSourceRegistry,
6
+ ReportTagService,
7
+ SourceProviderRegistry,
8
+ createCustomSourceProvider,
9
+ createManageTagsAction,
10
+ } from '@ftisindia/report-builder';
11
+ import type { ReportRowActionDef, ReportSourceDef } from '@ftisindia/report-builder';
12
+ import {
13
+ REPORT_ROW_ACTION_METADATA,
14
+ REPORT_SOURCE_METADATA,
15
+ } from './report-extension.decorators';
16
+
17
+ /**
18
+ * Populates the engine registries at startup — WITHOUT any form-builder
19
+ * dependency (the standalone guarantee extends to the glue, report design
20
+ * §3.2): the built-in manageTags action (§7), the 'custom' source provider
21
+ * over the code-owned source registry (the one provider the core ships), and
22
+ * a discovery scan for every app provider decorated with @ReportSource /
23
+ * @ReportRowAction (e.g. the org-members example source).
24
+ *
25
+ * Form-backed sources and the delegated editSubmission/updateStatus verbs are
26
+ * added by the OPTIONAL ReportsFormsModule, which registers the 'form'
27
+ * provider + actions into these same registries only when the app ships forms.
28
+ */
29
+ @Injectable()
30
+ export class ReportsRegistryBootstrapService implements OnModuleInit {
31
+ constructor(
32
+ private readonly discovery: DiscoveryService,
33
+ private readonly reflector: Reflector,
34
+ private readonly sourceRegistry: ReportSourceRegistry,
35
+ private readonly sourceProviders: SourceProviderRegistry,
36
+ private readonly actionRegistry: ReportRowActionRegistry,
37
+ private readonly tagService: ReportTagService,
38
+ ) {}
39
+
40
+ onModuleInit(): void {
41
+ this.actionRegistry.register(createManageTagsAction(this.tagService));
42
+ this.sourceProviders.register(createCustomSourceProvider(this.sourceRegistry));
43
+
44
+ for (const wrapper of this.discovery.getProviders()) {
45
+ const instance: unknown = wrapper.instance;
46
+ if (!instance || typeof instance !== 'object') {
47
+ continue;
48
+ }
49
+ const ctor = instance.constructor as object | undefined;
50
+ if (!ctor || ctor === Object) {
51
+ continue;
52
+ }
53
+ if (this.reflector.get<boolean>(REPORT_SOURCE_METADATA, ctor as never)) {
54
+ this.sourceRegistry.register(instance as ReportSourceDef);
55
+ }
56
+ if (this.reflector.get<boolean>(REPORT_ROW_ACTION_METADATA, ctor as never)) {
57
+ this.actionRegistry.register(instance as ReportRowActionDef);
58
+ }
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,14 @@
1
+ import { SetMetadata } from '@nestjs/common';
2
+
3
+ export const REPORT_SOURCE_METADATA = 'ftis:reports:source';
4
+ export const REPORT_ROW_ACTION_METADATA = 'ftis:reports:row-action';
5
+
6
+ /**
7
+ * The standardized extension story (report design §3.2/§6): implement the
8
+ * engine interface (ReportSourceDef / ReportRowActionDef), decorate the
9
+ * @Injectable class, list it as a provider — ReportsRegistryBootstrapService
10
+ * discovers and registers it at startup. Same three steps as the form
11
+ * registries.
12
+ */
13
+ export const ReportSource = () => SetMetadata(REPORT_SOURCE_METADATA, true);
14
+ export const ReportRowAction = () => SetMetadata(REPORT_ROW_ACTION_METADATA, true);
@@ -0,0 +1,28 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import type { EngineTx, ReportsJobQueue } from '@ftisindia/report-builder';
3
+
4
+ /**
5
+ * The async-export job queue (report design §9). The durable signal IS the
6
+ * `ReportExportJob` row the engine creates in the same transaction — the
7
+ * reports-owned ReportsExportDispatcherService polls that table for PENDING
8
+ * jobs. So `enqueue` is intentionally a no-op: there is no separate queue to
9
+ * write, and reports stays independent of the forms outbox.
10
+ *
11
+ * (An app that prefers an external queue can bind a different ReportsJobQueue
12
+ * — e.g. one that publishes to SQS — without changing the engine.)
13
+ */
14
+ @Injectable()
15
+ export class ReportsJobQueueNoop implements ReportsJobQueue {
16
+ async enqueue(
17
+ _job: {
18
+ type: string;
19
+ payload: Record<string, unknown>;
20
+ orgId: string;
21
+ actorUserId?: string | null;
22
+ idempotencyKey?: string;
23
+ },
24
+ _tx: EngineTx,
25
+ ): Promise<void> {
26
+ // The ReportExportJob row (committed with this tx) is the queue entry.
27
+ }
28
+ }
@@ -0,0 +1,42 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import type { ReportsContext } from '@ftisindia/report-builder';
3
+ import { RequestContextService } from '../../request-context/application/services/request-context.service';
4
+
5
+ /**
6
+ * Binds the engine's ReportsContext seam to the app's AsyncLocalStorage
7
+ * request context (report design §13). Worker contexts (async export jobs)
8
+ * are restored by the forms outbox dispatcher before the engine runs, so the
9
+ * same adapter serves HTTP and worker callers (ecosystem guide §3).
10
+ */
11
+ @Injectable()
12
+ export class RequestReportsContext implements ReportsContext {
13
+ constructor(private readonly ctx: RequestContextService) {}
14
+
15
+ requestId() {
16
+ return this.ctx.getRequestId();
17
+ }
18
+
19
+ source(): 'http' | 'worker' {
20
+ return this.ctx.get()?.source ?? 'worker';
21
+ }
22
+
23
+ userId() {
24
+ return this.ctx.getUserId();
25
+ }
26
+
27
+ orgId() {
28
+ return this.ctx.getOrgId();
29
+ }
30
+
31
+ permissionKeys() {
32
+ return this.ctx.getPermissions();
33
+ }
34
+
35
+ isOwner() {
36
+ return this.ctx.getRbacContext()?.isOwner ?? false;
37
+ }
38
+
39
+ assertOrgScope(orgId: string) {
40
+ this.ctx.assertOrgScope(orgId);
41
+ }
42
+ }