@ftisindia/create-app 0.1.5 → 0.2.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 (167) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +28 -0
  3. package/template/README.md +51 -0
  4. package/template/_gitignore +6 -0
  5. package/template/_package.json +10 -1
  6. package/template/docs/FORMS.md +188 -0
  7. package/template/docs/FORMS_CHECKLIST.md +69 -0
  8. package/template/docs/REPORTS.md +255 -0
  9. package/template/docs/REPORTS_CHECKLIST.md +152 -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/migrations/20260616000000_add_form_outbox_claimed_by/migration.sql +5 -0
  13. package/template/prisma/schema.prisma +289 -0
  14. package/template/scripts/export-openapi.ts +85 -0
  15. package/template/scripts/gen-form.mjs +149 -0
  16. package/template/scripts/push-form.ts +124 -0
  17. package/template/src/app.module.ts +30 -8
  18. package/template/src/common/dto/membership-response.dto.ts +1 -0
  19. package/template/src/common/dto/role-summary.dto.ts +3 -3
  20. package/template/src/common/dto/user-summary.dto.ts +3 -3
  21. package/template/src/config/env.validation.ts +28 -0
  22. package/template/src/config/forms.config.ts +13 -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 +18 -0
  27. package/template/src/main.ts +3 -12
  28. package/template/src/modules/access-control/dto/access-control-response.dto.ts +3 -0
  29. package/template/src/modules/access-control/dto/current-access-control-response.dto.ts +5 -1
  30. package/template/src/modules/access-control/types/permission-key.ts +27 -0
  31. package/template/src/modules/access-control/types/route-permission-registry.ts +183 -0
  32. package/template/src/modules/audit/dto/audit-response.dto.ts +7 -3
  33. package/template/src/modules/auth/auth.module.ts +3 -1
  34. package/template/src/modules/auth/dto/auth-response.dto.ts +1 -1
  35. package/template/src/modules/forms/application/services/file-gc.service.ts +85 -0
  36. package/template/src/modules/forms/application/services/forms-definitions.service.ts +137 -0
  37. package/template/src/modules/forms/application/services/forms-error.mapper.ts +64 -0
  38. package/template/src/modules/forms/application/services/forms-export.service.ts +210 -0
  39. package/template/src/modules/forms/application/services/forms-files.service.ts +164 -0
  40. package/template/src/modules/forms/application/services/forms-public.service.ts +49 -0
  41. package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +53 -0
  42. package/template/src/modules/forms/application/services/forms-submissions.service.ts +103 -0
  43. package/template/src/modules/forms/application/services/handlers/authenticate.action.ts +37 -0
  44. package/template/src/modules/forms/application/services/handlers/logging-email.handler.ts +22 -0
  45. package/template/src/modules/forms/application/services/handlers/send-confirmation-email.action.ts +40 -0
  46. package/template/src/modules/forms/application/services/handlers/webhook-delivery.transport.ts +319 -0
  47. package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +89 -0
  48. package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +131 -0
  49. package/template/src/modules/forms/dto/create-form-definition.dto.ts +12 -0
  50. package/template/src/modules/forms/dto/data-source-response.dto.ts +19 -0
  51. package/template/src/modules/forms/dto/export-submissions-query.dto.ts +33 -0
  52. package/template/src/modules/forms/dto/file-upload-response.dto.ts +24 -0
  53. package/template/src/modules/forms/dto/form-definition-response.dto.ts +50 -0
  54. package/template/src/modules/forms/dto/form-render-response.dto.ts +17 -0
  55. package/template/src/modules/forms/dto/list-form-definitions-query.dto.ts +10 -0
  56. package/template/src/modules/forms/dto/list-submissions-query.dto.ts +10 -0
  57. package/template/src/modules/forms/dto/public-submit-form.dto.ts +24 -0
  58. package/template/src/modules/forms/dto/set-public-access.dto.ts +8 -0
  59. package/template/src/modules/forms/dto/submission-response.dto.ts +99 -0
  60. package/template/src/modules/forms/dto/submit-form.dto.ts +50 -0
  61. package/template/src/modules/forms/dto/update-form-definition.dto.ts +12 -0
  62. package/template/src/modules/forms/dto/upload-file-query.dto.ts +33 -0
  63. package/template/src/modules/forms/dto/validate-submission.dto.ts +22 -0
  64. package/template/src/modules/forms/examples/abstract-submission.form.json +80 -0
  65. package/template/src/modules/forms/examples/login.form.json +24 -0
  66. package/template/src/modules/forms/examples/registration.form.json +44 -0
  67. package/template/src/modules/forms/forms.module.ts +228 -0
  68. package/template/src/modules/forms/forms.tokens.ts +6 -0
  69. package/template/src/modules/forms/infrastructure/audit-sink.adapter.ts +30 -0
  70. package/template/src/modules/forms/infrastructure/casl-forms-authorization.ts +31 -0
  71. package/template/src/modules/forms/infrastructure/prisma-tx-runner.ts +17 -0
  72. package/template/src/modules/forms/infrastructure/registry/form-extension.decorators.ts +17 -0
  73. package/template/src/modules/forms/infrastructure/registry/registry-bootstrap.service.ts +82 -0
  74. package/template/src/modules/forms/infrastructure/request-forms-context.ts +60 -0
  75. package/template/src/modules/forms/infrastructure/schema-check/forms-schema-check.service.ts +76 -0
  76. package/template/src/modules/forms/infrastructure/storage/local-disk-storage.adapter.ts +43 -0
  77. package/template/src/modules/forms/infrastructure/stores/index.ts +5 -0
  78. package/template/src/modules/forms/infrastructure/stores/prisma-action-log.store.ts +37 -0
  79. package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +108 -0
  80. package/template/src/modules/forms/infrastructure/stores/prisma-form-definition.store.ts +147 -0
  81. package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +156 -0
  82. package/template/src/modules/forms/infrastructure/stores/prisma-submission.store.ts +164 -0
  83. package/template/src/modules/forms/presentation/forms-data-sources.controller.ts +58 -0
  84. package/template/src/modules/forms/presentation/forms-definitions.controller.ts +191 -0
  85. package/template/src/modules/forms/presentation/forms-files.controller.ts +79 -0
  86. package/template/src/modules/forms/presentation/forms-submissions.controller.ts +154 -0
  87. package/template/src/modules/forms/presentation/forms-upload.interceptor.ts +33 -0
  88. package/template/src/modules/forms/presentation/public-forms.controller.ts +51 -0
  89. package/template/src/modules/invitations/dto/invitation-response.dto.ts +4 -0
  90. package/template/src/modules/organisations/dto/organisation-response.dto.ts +1 -0
  91. package/template/src/modules/reports/application/services/reports-actions.service.ts +54 -0
  92. package/template/src/modules/reports/application/services/reports-definitions.service.ts +66 -0
  93. package/template/src/modules/reports/application/services/reports-error.mapper.ts +97 -0
  94. package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +205 -0
  95. package/template/src/modules/reports/application/services/reports-exports.service.ts +78 -0
  96. package/template/src/modules/reports/application/services/reports-queries.service.ts +35 -0
  97. package/template/src/modules/reports/application/services/reports-settings-reader.service.ts +49 -0
  98. package/template/src/modules/reports/application/services/reports-views.service.ts +79 -0
  99. package/template/src/modules/reports/dto/action-result-response.dto.ts +21 -0
  100. package/template/src/modules/reports/dto/create-report-definition.dto.ts +86 -0
  101. package/template/src/modules/reports/dto/create-saved-view.dto.ts +26 -0
  102. package/template/src/modules/reports/dto/execute-action.dto.ts +71 -0
  103. package/template/src/modules/reports/dto/export-job-response.dto.ts +60 -0
  104. package/template/src/modules/reports/dto/export-request.dto.ts +34 -0
  105. package/template/src/modules/reports/dto/list-reports-query.dto.ts +10 -0
  106. package/template/src/modules/reports/dto/list-views-query.dto.ts +17 -0
  107. package/template/src/modules/reports/dto/prepare-action-response.dto.ts +14 -0
  108. package/template/src/modules/reports/dto/prepare-action.dto.ts +27 -0
  109. package/template/src/modules/reports/dto/query-response.dto.ts +64 -0
  110. package/template/src/modules/reports/dto/query-spec.dto.ts +120 -0
  111. package/template/src/modules/reports/dto/report-definition-response.dto.ts +64 -0
  112. package/template/src/modules/reports/dto/report-meta-query.dto.ts +16 -0
  113. package/template/src/modules/reports/dto/report-meta-response.dto.ts +113 -0
  114. package/template/src/modules/reports/dto/saved-view-response.dto.ts +66 -0
  115. package/template/src/modules/reports/dto/update-report-definition.dto.ts +9 -0
  116. package/template/src/modules/reports/dto/update-saved-view.dto.ts +27 -0
  117. package/template/src/modules/reports/examples/abstract-review-board.report.json +54 -0
  118. package/template/src/modules/reports/examples/org-members.report.json +55 -0
  119. package/template/src/modules/reports/infrastructure/audit-sink.adapter.ts +31 -0
  120. package/template/src/modules/reports/infrastructure/casl-reports-authorization.ts +39 -0
  121. package/template/src/modules/reports/infrastructure/forms-adapter/form-report-source.adapter.ts +292 -0
  122. package/template/src/modules/reports/infrastructure/forms-adapter/form-row-actions.ts +171 -0
  123. package/template/src/modules/reports/infrastructure/forms-adapter/forms-bridge-bootstrap.service.ts +32 -0
  124. package/template/src/modules/reports/infrastructure/prisma-catalog.adapter.ts +95 -0
  125. package/template/src/modules/reports/infrastructure/prisma-query-executor.ts +103 -0
  126. package/template/src/modules/reports/infrastructure/prisma-snapshot-runner.ts +47 -0
  127. package/template/src/modules/reports/infrastructure/prisma-tx-runner.ts +18 -0
  128. package/template/src/modules/reports/infrastructure/registry/registry-bootstrap.service.ts +61 -0
  129. package/template/src/modules/reports/infrastructure/registry/report-extension.decorators.ts +14 -0
  130. package/template/src/modules/reports/infrastructure/reports-job-queue.adapter.ts +28 -0
  131. package/template/src/modules/reports/infrastructure/request-reports-context.ts +42 -0
  132. package/template/src/modules/reports/infrastructure/schema-check/reports-schema-check.service.ts +116 -0
  133. package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +92 -0
  134. package/template/src/modules/reports/infrastructure/stores/index.ts +5 -0
  135. package/template/src/modules/reports/infrastructure/stores/prisma-bulk-action-run.store.ts +89 -0
  136. package/template/src/modules/reports/infrastructure/stores/prisma-export-job.store.ts +93 -0
  137. package/template/src/modules/reports/infrastructure/stores/prisma-report-definition.store.ts +171 -0
  138. package/template/src/modules/reports/infrastructure/stores/prisma-row-tag.store.ts +110 -0
  139. package/template/src/modules/reports/infrastructure/stores/prisma-saved-view.store.ts +144 -0
  140. package/template/src/modules/reports/presentation/reports-actions.controller.ts +83 -0
  141. package/template/src/modules/reports/presentation/reports-definitions.controller.ts +156 -0
  142. package/template/src/modules/reports/presentation/reports-export-jobs.controller.ts +61 -0
  143. package/template/src/modules/reports/presentation/reports-export.controller.ts +76 -0
  144. package/template/src/modules/reports/presentation/reports-query.controller.ts +52 -0
  145. package/template/src/modules/reports/presentation/reports-views.controller.ts +140 -0
  146. package/template/src/modules/reports/reports-forms.module.ts +33 -0
  147. package/template/src/modules/reports/reports.module.ts +335 -0
  148. package/template/src/modules/reports/reports.tokens.ts +11 -0
  149. package/template/src/modules/reports/sources/org-members.source.ts +112 -0
  150. package/template/src/modules/settings/types/setting-definitions.ts +94 -0
  151. package/template/test/forms-captcha.e2e-spec.ts +163 -0
  152. package/template/test/forms-definitions.e2e-spec.ts +394 -0
  153. package/template/test/forms-export.e2e-spec.ts +390 -0
  154. package/template/test/forms-files.e2e-spec.ts +345 -0
  155. package/template/test/forms-outbox.e2e-spec.ts +570 -0
  156. package/template/test/forms-permission-sync.spec.ts +27 -0
  157. package/template/test/forms-public.e2e-spec.ts +293 -0
  158. package/template/test/forms-schema-check.e2e-spec.ts +65 -0
  159. package/template/test/forms-submissions.e2e-spec.ts +500 -0
  160. package/template/test/forms-throttling.e2e-spec.ts +146 -0
  161. package/template/test/forms-webhooks.e2e-spec.ts +403 -0
  162. package/template/test/jest-e2e.json +1 -0
  163. package/template/test/reports-advanced.e2e-spec.ts +381 -0
  164. package/template/test/reports-permission-sync.spec.ts +30 -0
  165. package/template/test/reports-query.e2e-spec.ts +402 -0
  166. package/template/test/reports-tiers.e2e-spec.ts +343 -0
  167. package/template/test/route-registry.validator.spec.ts +22 -0
@@ -0,0 +1,255 @@
1
+ # Reports Module (`@ftisindia/report-builder`)
2
+
3
+ This app ships with a definition-driven report engine. A report is **a
4
+ declared, versioned view over one data source, plus the verbs users may apply
5
+ to it** — filter, sort, search, paginate, export, and act on rows. Report
6
+ definitions are versioned JSON documents in the database; the behaviors they
7
+ reference — sources, row actions — are code this app registers. New reports
8
+ over existing sources are **data**: most ship with zero backend code.
9
+
10
+ - Engine architecture: `report-builder/report-builder-design.md` in the
11
+ starter repository (Rev 2 — the binding spec).
12
+ - Completion standard: [`REPORTS_CHECKLIST.md`](./REPORTS_CHECKLIST.md) on top
13
+ of [`MODULE_COMPLETION_CHECKLIST.md`](./MODULE_COMPLETION_CHECKLIST.md).
14
+ - The engine is **fully usable without the form builder** — the form adapter
15
+ is one optional source provider among many (the standalone guarantee).
16
+
17
+ **Module structure (the standalone guarantee, made structural):**
18
+ `ReportsModule` is self-contained — definitions, queries, views, row actions
19
+ over custom sources, tags, and CSV/XLSX exports (sync + a reports-owned async
20
+ worker and file storage). It imports **no** FormsModule. Form-backed reports
21
+ (`source.kind: "form"`) and the delegated `editSubmission`/`updateStatus` verbs
22
+ live in the **optional** `ReportsFormsModule`, the single artifact aware of
23
+ both engines. Remove its import from `app.module.ts` and reports keeps working
24
+ over custom sources alone.
25
+
26
+ ## The 60-second tour
27
+
28
+ ```text
29
+ GET /organisations/:orgId/reports reports.read list definitions
30
+ POST /organisations/:orgId/reports reports.create create draft
31
+ GET /organisations/:orgId/reports/:key reports.read latest definition
32
+ PATCH /organisations/:orgId/reports/:key reports.update edit draft (published ⇒ opens v+1)
33
+ POST /organisations/:orgId/reports/:key/publish reports.publish lint + freeze a new version
34
+ POST /organisations/:orgId/reports/:key/archive reports.archive archive the definition
35
+ GET /organisations/:orgId/reports/:key/meta?version= reports.read column/operator/action metadata
36
+ POST /organisations/:orgId/reports/:key/query reports.read THE grid endpoint (QuerySpec)
37
+ GET /organisations/:orgId/reports/:key/views reports.read saved views + compatibility
38
+ POST /organisations/:orgId/reports/:key/views reports.read create PERSONAL view
39
+ POST /organisations/:orgId/reports/:key/views/shared reports.update create SHARED view
40
+ POST /organisations/:orgId/reports/:key/actions/:n/prepare reports.read* byFilter token (drift-safe bulk)
41
+ POST /organisations/:orgId/reports/:key/actions/:n reports.read* execute row/bulk action
42
+ POST /organisations/:orgId/reports/:key/export reports.export sync stream or 202 job
43
+ GET /organisations/:orgId/reports/exports/:jobId reports.export async export status
44
+ GET /organisations/:orgId/reports/exports/:jobId/download reports.export download a finished export
45
+ ```
46
+
47
+ `*` Row actions are additionally gated by their own declared permission keys
48
+ (e.g. `formSubmissions.update`), checked when the action is wired into a
49
+ definition AND again at execute time.
50
+
51
+ Example definitions live in `src/modules/reports/examples/` — `org-members`
52
+ (custom source, no form builder anywhere) and `abstract-review-board` (the
53
+ form-backed quick path, indexed tier).
54
+
55
+ ## One contract: the QuerySpec
56
+
57
+ The frontend never builds queries. Every grid interaction posts a declarative
58
+ spec naming declared column ids and verbs; the backend validates, compiles,
59
+ and applies them:
60
+
61
+ ```jsonc
62
+ POST /organisations/:orgId/reports/abstract-review-board/query
63
+ {
64
+ "filters": [{ "column": "track", "op": "eq", "value": "trk_ml" }],
65
+ "search": "transformer",
66
+ "sort": [{ "column": "createdAt", "dir": "desc" }],
67
+ "cursor": null, // keyset cursor from the previous page
68
+ "pageSize": 50,
69
+ "count": "none", // "none" | "estimated" | "exact-capped"
70
+ "columns": ["title", "track", "status", "createdAt"]
71
+ }
72
+ ```
73
+
74
+ Operators are typed per column (`contains` on a number column is a 400, not a
75
+ seq scan). `GET /reports/:key/meta` describes every column, its operators, the
76
+ wired actions, and export formats — a generic grid UI configures itself from
77
+ it.
78
+
79
+ ## How a query executes (the performance contract)
80
+
81
+ Slow reports come from four sins; the engine makes them structurally hard:
82
+
83
+ - **No OFFSET, ever.** Pagination is keyset-only; cursors are opaque,
84
+ HMAC-signed, and bound to (report, version, sort, filters) — replay against
85
+ anything else is a 400 telling the client to restart.
86
+ - **Unindexed shapes fail publish.** On the `indexed` tier, every
87
+ sortable/filterable/searchable column must name its backing index; publish
88
+ verifies the index against the catalog (expression, opclass, collation,
89
+ org-leading) AND runs `EXPLAIN` on representative shapes. The failure is
90
+ constructive: the 422 carries the exact migration SQL to apply.
91
+ - **Counts are a choice.** Default `none` (hasMore comes free), `estimated`
92
+ (planner estimate, milliseconds), or `exact-capped` (`10000+` beyond cap).
93
+ - **A statement budget backstops everything** — every report query carries
94
+ `statement_timeout` (`reports.statementTimeoutMs`, default 5s).
95
+
96
+ The compiler builds every statement from code-owned manifests; user input
97
+ never becomes an identifier and values travel only as bind parameters.
98
+ **SQL expressions can exist only in code** — a definition referencing
99
+ `"path": "title' || (SELECT ...)"` is rejected at save.
100
+
101
+ ## Custom sources (no form builder required)
102
+
103
+ Register a source in code — one class, one decorator:
104
+
105
+ ```ts
106
+ import { ReportSource } from './modules/reports/infrastructure/registry/report-extension.decorators';
107
+ import type { ReportSourceDef, SourceManifest, SourceQuery } from '@ftisindia/report-builder';
108
+
109
+ @Injectable()
110
+ @ReportSource()
111
+ export class OrgMembersReportSource implements ReportSourceDef {
112
+ key = 'org-members';
113
+ manifest(): SourceManifest { /* columns + types + capabilities + indexes */ }
114
+ baseQuery(): SourceQuery {
115
+ return {
116
+ from: '"Membership" m JOIN "User" u ON u."id" = m."userId"',
117
+ orgColumn: 'm."orgId"',
118
+ primaryTable: 'Membership',
119
+ };
120
+ }
121
+ }
122
+ ```
123
+
124
+ The source declares structure; the compiler owns query construction — a
125
+ source never sees the QuerySpec, so it cannot be injected and cannot bypass
126
+ the allowlist. The engine injects the org predicate first on every statement,
127
+ plus the source's optional `rowScope` (row-level security in code).
128
+
129
+ ## Reports from forms (the quick path)
130
+
131
+ Requires the optional `ReportsFormsModule` bridge (imported in `app.module.ts`
132
+ by default in this template). Point a definition at a form and the adapter does
133
+ the thinking:
134
+
135
+ ```jsonc
136
+ { "source": { "kind": "form", "key": "abstract-submission" }, ... }
137
+ ```
138
+
139
+ - Fields with `reportable: true` become report columns (labels, types, lookup
140
+ data sources carried over); `indexHint: true` fields are sortable/filterable.
141
+ - **Column-id convention:** a definition column over a form field must use the
142
+ field path (dots → `_`) as its column id; physical submission columns are
143
+ referenced as `$row.status`, `$row.createdAt`, `$row.updatedAt`,
144
+ `$row.createdBy`.
145
+ - On the `indexed` tier the publish lint demands the generated columns +
146
+ indexes (hashed `rb_*` names, immutable expressions, datetimes kept as
147
+ ISO-8601 UTC text + `COLLATE "C"`); the 422's `suggestionSql` is the exact
148
+ migration to apply — run it, publish again.
149
+ - Grid verbs delegate to the form engine: `editSubmission` re-validates
150
+ against the submission's stamped `formVersion`; `updateStatus` runs the same
151
+ pipeline as the form. Both require `formSubmissions.update`. There is no
152
+ generic UPDATE — the report engine never mutates source rows itself.
153
+ - Known limitation: columns resolve against the latest **published** form
154
+ version. Renamed-field mapping across versions needs rename metadata the form
155
+ definition format does not yet carry.
156
+
157
+ ## Performance tiers
158
+
159
+ | Tier | Meaning | Publish gate |
160
+ |---|---|---|
161
+ | `live` | Query raw source/JSONB directly | Source row estimate under `reports.liveTierMaxRows` |
162
+ | `indexed` | Hot columns physical + index-declared | Catalog + EXPLAIN lint pass (suggested SQL on failure) |
163
+ | `materialized` | Reads hit a declared materialized relation | Relation exists; staleness bound declared; `meta.freshAsOf` always set |
164
+
165
+ Tier upgrades are ordinary new versions: v5 `materialized` publishes after its
166
+ migration lands while v4 keeps serving.
167
+
168
+ ## Row actions & drift-safe bulk
169
+
170
+ Single rows / explicit lists go through `byIds` (capped at 1,000). Filter-based
171
+ bulk (`byFilter`) is a two-step protocol because rows matching at confirmation
172
+ may differ at execution:
173
+
174
+ 1. `POST .../actions/:name/prepare { selection: { byFilter } }` →
175
+ `{ expectedCount, asOf, actionToken }` (HMAC-signed, 5-minute TTL).
176
+ 2. `POST .../actions/:name { selection, actionToken, input, idempotencyKey }` —
177
+ the server re-resolves the selection; drift beyond tolerance (0 for
178
+ destructive actions) is a 409 with the current count. The
179
+ `idempotencyKey` is persisted: a retried execute returns the recorded
180
+ outcome instead of re-running.
181
+
182
+ Execution walks rows in keyset order in transactional batches; every batch
183
+ re-applies org + rowScope. Every bulk run writes an audit row with report key,
184
+ version, action, row count, and the filter snapshot.
185
+
186
+ ## Tags & labels
187
+
188
+ `manageTags` (built-in action, `reportTags.manage`) annotates rows with
189
+ org-scoped tags without touching the source schema. Rows hydrate `$tags`; the
190
+ virtual `$tags` column filters with `hasTag` / `hasAnyTag` (indexed EXISTS).
191
+ Labels are single-valued tags by convention (`label:approved` replaces the
192
+ row family's previous `label:*`). Curate the vocabulary with the
193
+ `reports.tagVocabulary` setting (empty = free-form).
194
+
195
+ ## Exports
196
+
197
+ `POST /reports/:key/export { format: "csv" | "xlsx", spec?, columns? }`:
198
+
199
+ - Up to `reports.maxRowsSync` (default 10k): direct stream with
200
+ `Content-Disposition`. Larger: `202 { job }` — the job is picked up by the
201
+ reports-owned export worker (polling `ReportExportJob`, no forms outbox),
202
+ streamed into reports-owned org-scoped file storage
203
+ (`REPORTS_EXPORT_STORAGE_DIR`), polled at `GET /reports/exports/:jobId`, and
204
+ downloaded from `GET /reports/exports/:jobId/download`.
205
+ - The default local-disk storage is for a single app instance or for multiple
206
+ instances sharing the same mounted volume. For horizontal scaling without a
207
+ shared volume, bind an S3/GCS-backed `ExportFileSink` so downloads can land on
208
+ any node.
209
+ - Finished async export files are retained for `REPORTS_EXPORT_RETENTION_DAYS`
210
+ (default 7) and swept by the reports export worker. After expiry, the job row
211
+ remains for audit/status history but the file is no longer downloadable.
212
+ - Every export — sync or async — runs inside ONE `REPEATABLE READ` snapshot
213
+ (duration-bounded by `reports.exportMaxSnapshotSeconds`); an export that
214
+ cannot finish in time is rejected with guidance to tighten filters or move
215
+ to the materialized tier.
216
+ - `reports.export` is never implied by read; **every export writes an audit
217
+ row** with report key, version, filter snapshot, row count, and the exact
218
+ column list. Columns marked `exportable: false` are stripped regardless of
219
+ the requested projection.
220
+
221
+ ## Saved views
222
+
223
+ Views store the QuerySpec **and the version they were authored against**.
224
+ Listing views returns a `compatibility` verdict per view against the requested
225
+ version — `ok` | `degraded` (names missing/retyped columns) | `incompatible` —
226
+ so a new publish turns stale views into a visible, fixable state instead of a
227
+ silent 500. Personal views need `reports.read`; shared views (no owner) need
228
+ `reports.update`.
229
+
230
+ ## Org settings & env
231
+
232
+ Typed settings (audited like all settings): `reports.statementTimeoutMs`
233
+ (5000), `reports.exportMaxSnapshotSeconds` (120), `reports.maxRowsSync`
234
+ (10000), `reports.countCap` (10000), `reports.liveTierMaxRows` (50000),
235
+ `reports.resultCacheTtlMs` (0 = cache off), `reports.tagVocabulary` ([]).
236
+
237
+ Env: `REPORTS_SCHEMA_CHECK` (`on`/`off`); `REPORTS_TOKEN_SECRET` (optional, min
238
+ 32 chars — cursor/token HMAC key; when empty a dedicated key is derived from
239
+ `JWT_SECRET` via HKDF; rotating it invalidates outstanding cursors/tokens —
240
+ clients restart from page one); and the reports-owned async-export worker:
241
+ `REPORTS_EXPORT_WORKER_ENABLED` (default true), `REPORTS_EXPORT_POLL_MS`
242
+ (default 5000), `REPORTS_EXPORT_STORAGE_DIR` (default `./var/report-exports`),
243
+ `REPORTS_EXPORT_RETENTION_DAYS` (default 7; 0 disables cleanup), and
244
+ `REPORTS_EXPORT_RETENTION_SWEEP_MS` (default 3600000).
245
+
246
+ ## Engine upgrades (schema ownership)
247
+
248
+ The app owns `schema.prisma` and the migration history; the engine ships the
249
+ canonical snippet at `@ftisindia/report-builder/prisma/reports.prisma` plus
250
+ `REPORTS_ENGINE_SCHEMA_VERSION`. The boot check verifies tables/columns AND
251
+ the two **partial unique indexes** on `ReportSavedView`
252
+ (`report_view_shared_uq`, `report_view_personal_uq`) — Prisma cannot express
253
+ partial indexes, so they live as raw SQL in the `add_report_builder`
254
+ migration. If a later `prisma migrate dev` proposes `DROP INDEX` for them,
255
+ delete those lines; the boot check fails fast if they ever go missing.
@@ -0,0 +1,152 @@
1
+ # Reports Module Completion Checklist
2
+
3
+ This is the current go/no-go record for the reports module on top of the generic
4
+ [`MODULE_COMPLETION_CHECKLIST.md`](./MODULE_COMPLETION_CHECKLIST.md). Section
5
+ references are to `report-builder/report-builder-design.md` Rev 2.
6
+
7
+ ## Boundary and Standalone Guarantee
8
+
9
+ - [x] The engine package (`@ftisindia/report-builder`) compiles and unit-tests
10
+ without the template and without `@ftisindia/form-builder` installed. Proof:
11
+ `.github/workflows/ci.yml` `report-builder-engine`.
12
+ - [x] The engine boundary lint blocks `@nestjs/*`, `@prisma/*`, `@casl/*`,
13
+ `@ftisindia/form-builder`, and template imports from the core; engine errors
14
+ are mapped to HTTP only in `reports-error.mapper.ts`.
15
+ - [x] `ReportsModule` imports no `FormsModule`. Custom-source reports, tags,
16
+ query, row actions, and async exports run from reports-owned glue. Form-backed
17
+ sources and delegated form verbs live only in `ReportsFormsModule`.
18
+
19
+ ## Schema Ownership
20
+
21
+ - [x] Boot schema check passes on a migrated app and fails loudly on stale
22
+ schema. Proof: `test/reports-tiers.e2e-spec.ts`.
23
+ - [x] The check verifies `report_view_shared_uq` and
24
+ `report_view_personal_uq`, and prints the raw SQL when missing.
25
+ - [x] `packages/report-builder/prisma/reports.prisma` matches the template
26
+ Prisma models; app-owned migrations carry the raw partial indexes.
27
+
28
+ ## Permissions and Registry
29
+
30
+ - [x] All report permission keys plus `formSubmissions.update` exist in the
31
+ template and are synced with the engine list. Proof:
32
+ `test/reports-permission-sync.spec.ts`.
33
+ - [x] Every report route is listed in `routePermissionRegistry`; the registry
34
+ validator covers the report controllers.
35
+ - [x] `reports.export` is enforced independently of `reports.read`. Proof:
36
+ `test/reports-query.e2e-spec.ts`.
37
+ - [x] Row actions are checked at attach time and execute time. Proof:
38
+ engine linter/action-service unit tests plus route-level e2e for `manageTags`.
39
+
40
+ ## SQL Boundary
41
+
42
+ - [x] Definition-side SQL is rejected by meta-schema/lint; SQL-shaped paths are
43
+ rejected before SQL compilation. Proof: engine linter unit tests and
44
+ `test/reports-query.e2e-spec.ts`.
45
+ - [x] `$row.*` paths resolve only against manifest-exposed physical columns.
46
+ - [x] User values reach SQL only through bind parameters. Proof:
47
+ `ParamSink`, compiler snapshot tests, and Postgres e2e.
48
+
49
+ ## Query Semantics
50
+
51
+ - [x] Keyset pagination is proven at depth; `OFFSET` is not used; the row-id
52
+ tiebreaker is always appended. Proof: compiler tests and
53
+ `test/reports-query.e2e-spec.ts`.
54
+ - [x] Cursor tamper and replay against changed sort/filter/version are rejected.
55
+ - [x] Typed operators reject invalid combinations before SQL.
56
+ - [x] Counts cover `none`, `estimated`, and the capped subquery `exact-capped`
57
+ shape.
58
+ - [x] Org predicates compile first; cross-org query/export/action attempts are
59
+ denied, and cross-org `byIds` resolve to zero rows. Proof:
60
+ `test/reports-query.e2e-spec.ts`.
61
+ - [x] Statement budgets are applied by `PrismaQueryExecutor` and mapped as typed
62
+ report errors.
63
+
64
+ ## Tiers and Publish Lint
65
+
66
+ - [x] `live` tier publish fails above `reports.liveTierMaxRows`. Proof:
67
+ engine definition/tier-lint tests.
68
+ - [x] `indexed` tier publish fails with migration SQL, and the same draft
69
+ publishes after applying the required indexes. Proof:
70
+ `test/reports-tiers.e2e-spec.ts`.
71
+ - [x] Plan lint records compiled metadata at publish; queries use compiled
72
+ physical columns when present. Proof: engine tier-lint and definition-service
73
+ tests.
74
+ - [x] `materialized` tier requires the declared relation and returns
75
+ `meta.freshAsOf`. Proof: `test/reports-tiers.e2e-spec.ts`.
76
+
77
+ ## Row Actions and Bulk
78
+
79
+ - [x] Reports do not raw-update/delete source tables; mutations delegate to
80
+ registered action handlers.
81
+ - [x] `byFilter` requires a token; prepare/execute works; drift returns 409;
82
+ expired tokens are rejected. Proof: engine action-service tests and
83
+ `test/reports-advanced.e2e-spec.ts`.
84
+ - [x] Idempotency replays recorded outcomes without rerunning the handler.
85
+ - [x] `byIds` is capped and cross-org ids resolve to zero rows. Proof:
86
+ engine action-service tests and `test/reports-query.e2e-spec.ts`.
87
+
88
+ ## Tags
89
+
90
+ - [x] `manageTags` is gated by `reportTags.manage`; tags normalize; curated
91
+ vocabulary and `label:*` replacement are covered by engine tests.
92
+ - [x] `$tags` hydration and `hasTag` filters are covered against the
93
+ `ReportRowTag` table. Proof: `test/reports-query.e2e-spec.ts`.
94
+
95
+ ## Exports
96
+
97
+ - [x] Sync export streams with `Content-Disposition`; async export creates a
98
+ `ReportExportJob`, runs on the reports-owned worker, writes a storage fileId,
99
+ and is pollable/downloadable.
100
+ - [x] Async export storage streams chunks to disk instead of buffering the whole
101
+ export in memory.
102
+ - [x] Local async export files have retention cleanup:
103
+ `REPORTS_EXPORT_RETENTION_DAYS` plus worker sweep.
104
+ - [x] Sync and async exports run in a `REPEATABLE READ` snapshot; over-long
105
+ snapshots map to the tier-guidance export error.
106
+ - [x] Every export writes a PII-egress audit row; `exportable: false` columns are
107
+ stripped even when requested.
108
+ - [x] XLSX output is structurally valid OOXML; CSV output has formula-injection
109
+ guarding.
110
+
111
+ ## Saved Views
112
+
113
+ - [x] Shared-view create/update requires `reports.update`; duplicate shared
114
+ names are rejected by the partial unique index. Proof:
115
+ `test/reports-advanced.e2e-spec.ts`.
116
+ - [x] Personal/shared compatibility reports `ok`, `degraded`, or
117
+ `incompatible` instead of throwing on stale views. Proof: engine view-service
118
+ tests and template e2e.
119
+
120
+ ## Envelope and Docs
121
+
122
+ - [x] Engine error codes map through `reports-error.mapper.ts`; Swagger
123
+ decorators document the report routes.
124
+ - [x] `docs/REPORTS.md` matches current storage topology, retention behavior,
125
+ tier coverage, and the form-field rename limitation.
126
+
127
+ ## Current E2E Coverage
128
+
129
+ Template e2e coverage now includes the basic report lifecycle, keyset paging,
130
+ typed operator rejection, meta, `reports.read` vs `reports.export`, saved views,
131
+ tags, sync CSV export, SQL-boundary rejection, cross-org isolation for query /
132
+ export / `byIds`, byFilter token protocol, drift, idempotent replay, async
133
+ export worker + download + retention cleanup, XLSX validity, PII-egress audit,
134
+ shared-view partial unique enforcement, indexed-tier failure, indexed-tier
135
+ success after applying indexes, materialized tier `freshAsOf`, and boot
136
+ schema-check failure.
137
+
138
+ ## Release and Operations Sign-Off
139
+
140
+ - [x] OPS-2: async local-disk exports no longer buffer the whole file in memory.
141
+ - [x] OPS-3: local async export files have retention cleanup and documented
142
+ retention env vars.
143
+ - [x] LIM-1: form-backed renamed fields are documented as a known limitation
144
+ until form definitions carry rename metadata.
145
+ - [x] OPS-1: deployment topology decision recorded. Single-node/shared-volume is
146
+ supported by the default local adapter; horizontal scaling without shared
147
+ storage requires rebinding `ExportFileSink` to object storage.
148
+ - [ ] PKG-1: deliberate `@ftisindia/report-builder` version/publish decision
149
+ made for go-live, following engine-before-CLI publish order.
150
+ - [ ] PROC-1: release commit/tag remains pending. `npm run sync-template` and
151
+ `npm run dogfood` have been run locally so the scaffolded CLI output carries
152
+ the same coverage.
@@ -0,0 +1,147 @@
1
+ -- CreateEnum
2
+ CREATE TYPE "FormDefinitionStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
3
+
4
+ -- CreateEnum
5
+ CREATE TYPE "SubmissionStatus" AS ENUM ('DRAFT', 'SUBMITTED');
6
+
7
+ -- CreateEnum
8
+ CREATE TYPE "FormActionStatus" AS ENUM ('OK', 'ERROR');
9
+
10
+ -- CreateEnum
11
+ CREATE TYPE "OutboxStatus" AS ENUM ('PENDING', 'PROCESSING', 'DONE', 'FAILED');
12
+
13
+ -- CreateEnum
14
+ CREATE TYPE "FileStatus" AS ENUM ('TEMPORARY', 'SCANNING', 'CLEAN', 'INFECTED', 'LINKED');
15
+
16
+ -- CreateTable
17
+ CREATE TABLE "FormDefinition" (
18
+ "id" TEXT NOT NULL,
19
+ "orgId" TEXT NOT NULL,
20
+ "key" TEXT NOT NULL,
21
+ "version" INTEGER NOT NULL,
22
+ "status" "FormDefinitionStatus" NOT NULL DEFAULT 'DRAFT',
23
+ "schema" JSONB NOT NULL,
24
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
25
+ "updatedAt" TIMESTAMP(3) NOT NULL,
26
+
27
+ CONSTRAINT "FormDefinition_pkey" PRIMARY KEY ("id")
28
+ );
29
+
30
+ -- CreateTable
31
+ CREATE TABLE "FormSubmission" (
32
+ "id" TEXT NOT NULL,
33
+ "orgId" TEXT NOT NULL,
34
+ "formKey" TEXT NOT NULL,
35
+ "formVersion" INTEGER NOT NULL,
36
+ "data" JSONB NOT NULL,
37
+ "status" "SubmissionStatus" NOT NULL DEFAULT 'DRAFT',
38
+ "locked" BOOLEAN NOT NULL DEFAULT false,
39
+ "createdBy" TEXT,
40
+ "ipAddress" TEXT,
41
+ "userAgent" TEXT,
42
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
43
+ "updatedAt" TIMESTAMP(3) NOT NULL,
44
+
45
+ CONSTRAINT "FormSubmission_pkey" PRIMARY KEY ("id")
46
+ );
47
+
48
+ -- CreateTable
49
+ CREATE TABLE "FormActionLog" (
50
+ "id" TEXT NOT NULL,
51
+ "orgId" TEXT NOT NULL,
52
+ "submissionId" TEXT,
53
+ "formKey" TEXT NOT NULL,
54
+ "formVersion" INTEGER,
55
+ "action" TEXT NOT NULL,
56
+ "status" "FormActionStatus" NOT NULL,
57
+ "input" JSONB,
58
+ "output" JSONB,
59
+ "errorCode" TEXT,
60
+ "errorMessage" TEXT,
61
+ "actorId" TEXT,
62
+ "requestId" TEXT,
63
+ "durationMs" INTEGER,
64
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
65
+
66
+ CONSTRAINT "FormActionLog_pkey" PRIMARY KEY ("id")
67
+ );
68
+
69
+ -- CreateTable
70
+ CREATE TABLE "FormOutboxJob" (
71
+ "id" TEXT NOT NULL,
72
+ "type" TEXT NOT NULL,
73
+ "payload" JSONB NOT NULL,
74
+ "status" "OutboxStatus" NOT NULL DEFAULT 'PENDING',
75
+ "attempts" INTEGER NOT NULL DEFAULT 0,
76
+ "maxAttempts" INTEGER NOT NULL DEFAULT 8,
77
+ "idempotencyKey" TEXT,
78
+ "lastError" TEXT,
79
+ "runAfter" TIMESTAMP(3),
80
+ "orgId" TEXT,
81
+ "actorUserId" TEXT,
82
+ "originRequestId" TEXT,
83
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
84
+ "updatedAt" TIMESTAMP(3) NOT NULL,
85
+
86
+ CONSTRAINT "FormOutboxJob_pkey" PRIMARY KEY ("id")
87
+ );
88
+
89
+ -- CreateTable
90
+ CREATE TABLE "UploadedFile" (
91
+ "id" TEXT NOT NULL,
92
+ "storageKey" TEXT NOT NULL,
93
+ "originalName" TEXT NOT NULL,
94
+ "mimeType" TEXT NOT NULL,
95
+ "size" INTEGER NOT NULL,
96
+ "checksum" TEXT NOT NULL,
97
+ "ownerId" TEXT NOT NULL,
98
+ "orgId" TEXT NOT NULL,
99
+ "status" "FileStatus" NOT NULL DEFAULT 'TEMPORARY',
100
+ "submissionId" TEXT,
101
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
102
+
103
+ CONSTRAINT "UploadedFile_pkey" PRIMARY KEY ("id")
104
+ );
105
+
106
+ -- CreateIndex
107
+ CREATE INDEX "FormDefinition_orgId_idx" ON "FormDefinition"("orgId");
108
+
109
+ -- CreateIndex
110
+ CREATE INDEX "FormDefinition_orgId_status_idx" ON "FormDefinition"("orgId", "status");
111
+
112
+ -- CreateIndex
113
+ CREATE UNIQUE INDEX "FormDefinition_orgId_key_version_key" ON "FormDefinition"("orgId", "key", "version");
114
+
115
+ -- CreateIndex
116
+ CREATE INDEX "FormSubmission_orgId_formKey_status_idx" ON "FormSubmission"("orgId", "formKey", "status");
117
+
118
+ -- CreateIndex
119
+ CREATE INDEX "FormSubmission_orgId_createdAt_idx" ON "FormSubmission"("orgId", "createdAt");
120
+
121
+ -- CreateIndex
122
+ CREATE INDEX "FormSubmission_orgId_formKey_ipAddress_createdAt_idx" ON "FormSubmission"("orgId", "formKey", "ipAddress", "createdAt");
123
+
124
+ -- CreateIndex
125
+ CREATE INDEX "FormActionLog_orgId_createdAt_idx" ON "FormActionLog"("orgId", "createdAt");
126
+
127
+ -- CreateIndex
128
+ CREATE INDEX "FormActionLog_orgId_formKey_action_status_idx" ON "FormActionLog"("orgId", "formKey", "action", "status");
129
+
130
+ -- CreateIndex
131
+ CREATE UNIQUE INDEX "FormOutboxJob_idempotencyKey_key" ON "FormOutboxJob"("idempotencyKey");
132
+
133
+ -- CreateIndex
134
+ CREATE INDEX "FormOutboxJob_status_runAfter_idx" ON "FormOutboxJob"("status", "runAfter");
135
+
136
+ -- CreateIndex
137
+ CREATE UNIQUE INDEX "UploadedFile_storageKey_key" ON "UploadedFile"("storageKey");
138
+
139
+ -- CreateIndex
140
+ CREATE INDEX "UploadedFile_orgId_status_idx" ON "UploadedFile"("orgId", "status");
141
+
142
+ -- CreateIndex
143
+ CREATE INDEX "UploadedFile_ownerId_status_idx" ON "UploadedFile"("ownerId", "status");
144
+
145
+ -- CreateIndex
146
+ CREATE INDEX "UploadedFile_status_createdAt_idx" ON "UploadedFile"("status", "createdAt");
147
+