@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ftisindia/create-app",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "One-command scaffolder for the Phase 1 NestJS foundation starter.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,10 @@ AUTH_GOOGLE_ENABLED=false
13
13
  AUTH_GOOGLE_CLIENT_ID=
14
14
  AUTH_GOOGLE_CLIENT_SECRET=
15
15
  AUTH_GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/callback
16
+ # The backend redirects the browser here with a one-time ?code= after Google
17
+ # login. When a frontend app consumes this API (for example one scaffolded with
18
+ # @ftisindia/create-frontend), point this at the frontend's login route, e.g.
19
+ # AUTH_GOOGLE_SUCCESS_REDIRECT_URL=http://localhost:4200/login
16
20
  AUTH_GOOGLE_SUCCESS_REDIRECT_URL=http://localhost:3000/auth/google/success
17
21
  AUTH_GOOGLE_ERROR_REDIRECT_URL=http://localhost:3000/auth/google/error
18
22
  # Required when AUTH_GOOGLE_ENABLED=true. Use at least 32 random characters.
@@ -21,6 +25,12 @@ AUTH_FACEBOOK_ENABLED=false
21
25
  AUTH_MOBILE_OTP_ENABLED=false
22
26
  AUTH_MAGIC_LINK_ENABLED=false
23
27
 
28
+ CORS_ENABLED=false
29
+ # Comma-separated exact browser origins, for example:
30
+ # CORS_ORIGINS=http://localhost:5173,https://app.example.com
31
+ CORS_ORIGINS=
32
+ CORS_CREDENTIALS=false
33
+
24
34
  RBAC_CACHE_TTL_SECONDS=60
25
35
  # Path is the only supported mode in this starter phase.
26
36
  ORG_CONTEXT_MODE=path
@@ -34,3 +44,24 @@ TEST_USER_PASSWORD=
34
44
  TEST_USER_DISPLAY_NAME=Starter Owner
35
45
  TEST_ORG_NAME=Demo Organisation
36
46
  TEST_ORG_SLUG=demo
47
+
48
+ # Form builder (@ftisindia/form-builder)
49
+ FORMS_OUTBOX_ENABLED=true
50
+ FORMS_OUTBOX_POLL_MS=5000
51
+ FORMS_FILE_GC_INTERVAL_MS=3600000
52
+ FORMS_FILE_TEMP_TTL_HOURS=24
53
+ FORMS_MAX_UPLOAD_MB=25
54
+ FORMS_FILE_STORAGE_DIR=./var/uploads
55
+ # Set to off only to bypass the boot-time engine schema check (not recommended).
56
+ FORMS_SCHEMA_CHECK=on
57
+
58
+ # Report builder (@ftisindia/report-builder)
59
+ # Boot-time schema check for the engine tables (set off only to bypass temporarily).
60
+ REPORTS_SCHEMA_CHECK=on
61
+ # Optional HMAC secret for report cursors/bulk-action tokens (min 32 chars).
62
+ # When empty, a dedicated key is derived from JWT_SECRET via HKDF.
63
+ REPORTS_TOKEN_SECRET=
64
+ # Reports-owned async export worker + file storage (no forms dependency).
65
+ REPORTS_EXPORT_WORKER_ENABLED=true
66
+ REPORTS_EXPORT_POLL_MS=5000
67
+ REPORTS_EXPORT_STORAGE_DIR=./var/report-exports
@@ -78,6 +78,7 @@ GET /auth/google
78
78
  GET /auth/google/callback
79
79
  GET /auth/me
80
80
  POST /organisations
81
+ GET /organisations/mine
81
82
  GET /organisations/:orgId/memberships/me
82
83
  GET /organisations/:orgId/memberships
83
84
  PATCH /organisations/:orgId/memberships/:membershipId/status
@@ -91,6 +92,7 @@ POST /organisations/:orgId/invitations/:invitationId/revoke
91
92
  POST /organisations/:orgId/invitations/:invitationId/resend
92
93
  POST /invitations/accept
93
94
  POST /invitations/decline
95
+ GET /organisations/:orgId/access-control/me
94
96
  GET /organisations/:orgId/access-control/permissions
95
97
  GET /organisations/:orgId/access-control/route-permissions
96
98
  GET /organisations/:orgId/access-control/roles
@@ -107,6 +109,36 @@ GET /organisations/:orgId/sample/status
107
109
  POST /organisations/:orgId/sample/echo
108
110
  ```
109
111
 
112
+ ## Frontend Bootstrap
113
+
114
+ After login, call `GET /organisations/mine` to populate the organisation picker,
115
+ store the selected `orgId`, then call
116
+ `GET /organisations/:orgId/access-control/me`. Use the returned
117
+ `permissionKeys` for route, menu, and button visibility in the frontend. Backend
118
+ guards remain the source of truth for access control.
119
+
120
+ A companion frontend starter exists: `npx @ftisindia/create-frontend my-app`
121
+ scaffolds an Angular app already wired to these endpoints.
122
+
123
+ ### Google OAuth with a frontend
124
+
125
+ `GET /auth/google/callback` redirects the browser to
126
+ `AUTH_GOOGLE_SUCCESS_REDIRECT_URL` with a one-time `?code=` that the frontend
127
+ must exchange via `POST /auth/oauth/exchange`. The default value points at the
128
+ backend's own origin, so set it to the frontend's login route (for example
129
+ `http://localhost:4200/login`) or the code never reaches the frontend.
130
+
131
+ ### Exporting the OpenAPI document
132
+
133
+ Frontend tooling can consume the API contract without a running server:
134
+
135
+ ```bash
136
+ npm run export:openapi
137
+ ```
138
+
139
+ This writes `docs-json.json` (the same document served at `/docs-json`) to the
140
+ project root. No database connection is required.
141
+
110
142
  ## Architecture Rules
111
143
 
112
144
  - JWTs contain identity only. Organisation membership, roles, and permissions are read per request.
@@ -116,6 +148,35 @@ POST /organisations/:orgId/sample/echo
116
148
  - Mutating business flows should write audit rows.
117
149
  - Setup and CI commands are non-destructive. Use `npm run db:reset` only when you explicitly want to reset a local database.
118
150
 
151
+ ## Dynamic Forms
152
+
153
+ The app ships with a definition-driven form engine (`@ftisindia/form-builder`
154
+ plus the `src/modules/forms` glue module). Form definitions are versioned JSON
155
+ documents created over the API; submissions run a validated, transactional
156
+ action pipeline with a reliable outbox for emails/webhooks. Most new forms
157
+ need zero backend code — register a field type, action, or data source only
158
+ when you need genuinely new behavior.
159
+
160
+ - Guide: `docs/FORMS.md` (endpoints, extension story, webhooks, public forms, files, upgrades)
161
+ - Completion standard: `docs/FORMS_CHECKLIST.md`
162
+ - Example definitions: `src/modules/forms/examples/` (registration, abstract submission, login-as-a-form)
163
+ - Definitions as code: `npm run gen:form -- <key>` scaffolds a definition + lint spec; `npm run forms:push` loads it into an org (linted, versioned, audited)
164
+
165
+ ## Dynamic Reports
166
+
167
+ The app also ships with a definition-driven report engine
168
+ (`@ftisindia/report-builder` plus the `src/modules/reports` glue module).
169
+ Report definitions are versioned JSON views over registered data sources —
170
+ form submissions are one convenient kind, any table/join is another — with
171
+ filtering, sorting, search, keyset pagination, drift-safe bulk row actions,
172
+ tags, saved views, and snapshot-consistent CSV/XLSX exports through one
173
+ declarative API. Performance is enforced at publish time: unindexed shapes
174
+ fail to publish and the error carries the exact migration SQL to fix them.
175
+
176
+ - Guide: `docs/REPORTS.md` (QuerySpec, sources, tiers, actions, exports, upgrades)
177
+ - Completion standard: `docs/REPORTS_CHECKLIST.md`
178
+ - Example definitions: `src/modules/reports/examples/` (org-members over a custom source, abstract-review-board over a form)
179
+
119
180
  ## Adding Features
120
181
 
121
182
  Use the sample module as the copy-paste reference:
@@ -5,3 +5,9 @@ coverage/
5
5
  .env
6
6
  .env.*
7
7
  !.env.example
8
+
9
+ # Generated by npm run export:openapi
10
+ docs-json.json
11
+
12
+ # Form-builder local file storage
13
+ var/
@@ -6,8 +6,11 @@
6
6
  "scripts": {
7
7
  "build": "nest build",
8
8
  "db:create": "node scripts/db-create.mjs",
9
+ "export:openapi": "ts-node scripts/export-openapi.ts",
9
10
  "format": "prettier --write .",
10
11
  "format:check": "prettier --check .",
12
+ "forms:push": "ts-node scripts/push-form.ts",
13
+ "gen:form": "node scripts/gen-form.mjs",
11
14
  "gen:module": "node scripts/gen-module.mjs",
12
15
  "lint": "eslint .",
13
16
  "prepare": "husky || true",
@@ -30,6 +33,8 @@
30
33
  },
31
34
  "dependencies": {
32
35
  "@casl/ability": "6.8.1",
36
+ "@ftisindia/form-builder": "^0.1.0",
37
+ "@ftisindia/report-builder": "^0.1.0",
33
38
  "@nestjs/common": "11.1.24",
34
39
  "@nestjs/config": "4.0.4",
35
40
  "@nestjs/core": "11.1.24",
@@ -56,6 +61,7 @@
56
61
  "@nestjs/schematics": "11.1.0",
57
62
  "@nestjs/testing": "11.1.24",
58
63
  "@types/jest": "30.0.0",
64
+ "@types/multer": "1.4.13",
59
65
  "@types/node": "20.19.41",
60
66
  "@types/passport": "1.0.17",
61
67
  "@types/passport-google-oauth20": "2.0.17",
@@ -0,0 +1,169 @@
1
+ # Forms Module (`@ftisindia/form-builder`)
2
+
3
+ This app ships with a definition-driven dynamic form engine. Form definitions
4
+ are versioned JSON documents in the database; behavior — field types, button
5
+ actions, data sources — is code this app registers. New forms are **data**:
6
+ most ship with zero backend code.
7
+
8
+ - Engine architecture: the `form-builder-design.md` / ecosystem guide pair in
9
+ the starter repository.
10
+ - Completion standard: [`FORMS_CHECKLIST.md`](./FORMS_CHECKLIST.md) on top of
11
+ [`MODULE_COMPLETION_CHECKLIST.md`](./MODULE_COMPLETION_CHECKLIST.md).
12
+
13
+ ## The 60-second tour
14
+
15
+ ```text
16
+ POST /organisations/:orgId/forms forms.create (new draft version)
17
+ PATCH /organisations/:orgId/forms/:key/versions/:v forms.update (drafts only)
18
+ POST /organisations/:orgId/forms/:key/versions/:v/publish forms.publish (lints, freezes, archives prior)
19
+ POST /organisations/:orgId/forms/:key/versions/:v/public-access forms.managePublicAccess (audited)
20
+ GET /organisations/:orgId/forms/:key/render formSubmissions.create (definition + options)
21
+ POST /organisations/:orgId/forms/:key/submissions formSubmissions.create { mode, data, ... }
22
+ POST /organisations/:orgId/forms/:key/files?field=<name> formSubmissions.create (upload before submit)
23
+ GET /organisations/:orgId/forms/:key/submissions/export formSubmissions.export (audited PII egress)
24
+ GET /public/organisations/:orgId/forms/:key/render|submissions (anonymous, public forms only)
25
+ ```
26
+
27
+ Example definitions live in `src/modules/forms/examples/` — registration
28
+ (plain persist), abstract-submission (nested repeatable groups, data source,
29
+ rules, file upload), and login (public form whose submit action delegates to
30
+ the real `AuthService`).
31
+
32
+ ## How it executes
33
+
34
+ A submit runs: coerce → overwrite `source: 'context'` hidden fields from the
35
+ request context (client values for them are always discarded) → lifecycle
36
+ hooks → full validation (JSON Schema in draft or submit mode + bounded
37
+ json-logic rules + data-source membership + file references) → the action
38
+ pipeline: validation actions, then **all transactional actions in ONE
39
+ database transaction** (submission, action logs, audit rows, outbox rows
40
+ commit or roll back together), then post-commit side effects via the
41
+ **transactional outbox** (in-process poller, retries with backoff, parks as
42
+ FAILED after max attempts; delivery writes an audit row). A failed email
43
+ never rolls back a valid submission; a failed transactional step rolls back
44
+ everything.
45
+
46
+ ## Adding behavior (the three registries)
47
+
48
+ Implement the engine interface, decorate the provider, list it in a module —
49
+ the registry bootstrap finds it at startup:
50
+
51
+ ```ts
52
+ import { FormDataSource } from './modules/forms/infrastructure/registry/form-extension.decorators';
53
+ import type { DataSourceDef, DataSourceContext, DataSourceOption } from '@ftisindia/form-builder';
54
+
55
+ @Injectable()
56
+ @FormDataSource()
57
+ export class TrackSource implements DataSourceDef {
58
+ key = 'conference-tracks';
59
+ constructor(private readonly prisma: PrismaService) {}
60
+ async fetch(_params: Record<string, unknown>, ctx: DataSourceContext): Promise<DataSourceOption[]> {
61
+ return this.prisma.track.findMany({
62
+ where: { orgId: ctx.orgId, active: true },
63
+ select: { id: true, name: true },
64
+ }).then((rows) => rows.map((row) => ({ value: row.id, label: row.name })));
65
+ }
66
+ }
67
+ ```
68
+
69
+ Same pattern with `@FormFieldType()` (implement `FieldTypeDef`),
70
+ `@FormActionHandler()` (implement `FormAction`), `@FormLifecycleHook()`
71
+ (implement `FormLifecycle`). Builtin actions: `validateAll`, `persist`,
72
+ `persistDraft`, `lockEditing`; shipped app actions: `sendConfirmationEmail`
73
+ (post-commit, enqueues email), `authenticate` (dangerous, delegates to
74
+ `AuthService.login`).
75
+
76
+ ## Dangerous actions
77
+
78
+ An action with `dangerous: true` (payments, auth, anything with teeth) is
79
+ triple-gated: wiring it into a definition requires `forms.wireDangerous` AND
80
+ membership in the org's `forms.allowedDangerousActions` typed setting (both
81
+ checked at save/publish); at execute time the allowlist is re-checked. The
82
+ linter also refuses pipelines combining `authenticate` with `persist` —
83
+ credentials are never stored, and dangerous actions' input/output are logged
84
+ as `[REDACTED]`.
85
+
86
+ ## Public forms
87
+
88
+ Flip a published definition with `POST .../public-access { "access": "public" }`
89
+ (requires `forms.managePublicAccess`, writes an audit row). Public routes are
90
+ throttled tighter than the app default; the engine enforces the per-IP/day
91
+ cap (`forms.maxSubmissionsPerIpPerDay` setting, per-form override in the
92
+ definition's `settings`) and the captcha seam (`settings.captcha: true` —
93
+ publish fails closed unless a `CaptchaVerifier` is bound to the
94
+ `FORMS_CAPTCHA_VERIFIER` token). Public forms cannot contain file fields.
95
+
96
+ ## Files
97
+
98
+ Upload before submit (`POST .../files?field=<name>`); the submission carries
99
+ only `{ "fieldName": { "fileId": "..." } }`. The server sniffs the MIME type
100
+ from content, enforces field + org size caps, computes a sha256, and binds
101
+ the file to uploader AND org. Submit-time linking re-verifies ownership
102
+ in-transaction and promotes TEMPORARY → LINKED; orphans are garbage-collected
103
+ after `FORMS_FILE_TEMP_TTL_HOURS`. Bytes live under `FORMS_FILE_STORAGE_DIR`
104
+ (swap `LocalDiskStorageAdapter` for S3/GCS without engine changes).
105
+
106
+ ## Webhooks
107
+
108
+ A definition can declare webhook destinations that fire when a submission is
109
+ **submitted** (never for drafts, never for pipelines without `persist`):
110
+
111
+ ```jsonc
112
+ "settings": {
113
+ "webhooks": [
114
+ { "url": "https://hooks.example.com/forms", "secret": "rotate-me", "includeData": true }
115
+ ]
116
+ }
117
+ ```
118
+
119
+ - **Reliable by construction:** the job is written to the transactional outbox
120
+ inside the same transaction as the submission, then delivered post-commit
121
+ with retries/backoff (parks as FAILED after max attempts; success writes a
122
+ `forms.outbox.delivered` audit row). Idempotency key: `<submissionId>:webhook:<index>`.
123
+ - **SSRF-gated:** the destination host must be in the org's audited
124
+ `forms.webhookAllowedHosts` typed setting (default empty = webhooks
125
+ disabled), and the URL must be `https` — `http` is allowed for loopback
126
+ hosts only (local development). Enforced at save AND publish lint
127
+ (`WEBHOOK_HOST_NOT_ALLOWED`, `WEBHOOK_URL_INSECURE`, …).
128
+ - **Payload:** `{ event: 'submitted', orgId, formKey, formVersion, submissionId,
129
+ occurredAt, data? }` — `data` passes sensitive-field redaction; set
130
+ `includeData: false` to send a pure notification.
131
+ - **Signed:** with a `secret`, deliveries carry
132
+ `x-forms-signature: sha256=<hmac-sha256(rawBody)>`. The secret lives in the
133
+ definition document (DB) — treat it as a rotatable shared token, not a
134
+ master credential. Max 5 webhooks per definition.
135
+
136
+ ## Definitions as code (`gen:form` + `forms:push`)
137
+
138
+ ```bash
139
+ npm run gen:form -- customer-feedback # scaffolds definition + lint spec
140
+ npm test # spec lints the JSON, no DB needed
141
+ npm run forms:push -- --file src/modules/forms/definitions/customer-feedback.form.json \
142
+ --org demo --user owner@example.com --publish
143
+ ```
144
+
145
+ `gen:form` writes `src/modules/forms/definitions/<key>.form.json` and
146
+ `test/<key>.form.spec.ts` (meta-schema + publish-stage lint — fails `npm test`
147
+ the moment the definition would be rejected by the API). `forms:push` loads a
148
+ definition file into an org through the real engine path: it impersonates an
149
+ existing member (their RBAC applies), so every push is linted, versioned, and
150
+ audited exactly like an HTTP call. Re-pushing an unchanged file is a no-op;
151
+ changed content creates the next version (add `--publish` to go live).
152
+
153
+ ## Org settings & env
154
+
155
+ Typed settings: `forms.allowedDangerousActions` (string[]),
156
+ `forms.maxFileSizeMb`, `forms.enableRuleIteration`, `forms.virusScanRequired`
157
+ (seam only — no scanner shipped), `forms.maxSubmissionsPerIpPerDay`.
158
+ Env knobs (see `.env.example`): `FORMS_OUTBOX_ENABLED`, `FORMS_OUTBOX_POLL_MS`,
159
+ `FORMS_FILE_GC_INTERVAL_MS`, `FORMS_FILE_TEMP_TTL_HOURS`, `FORMS_MAX_UPLOAD_MB`,
160
+ `FORMS_FILE_STORAGE_DIR`, `FORMS_SCHEMA_CHECK`.
161
+
162
+ ## Engine upgrades (schema ownership)
163
+
164
+ This app owns `schema.prisma` and the migration history. When a new engine
165
+ version changes its data model: read its changelog, copy the updated snippet
166
+ from `node_modules/@ftisindia/form-builder/prisma/forms.prisma` into
167
+ `prisma/schema.prisma`, run `npx prisma migrate dev`. The boot-time schema
168
+ check fails fast with exactly that instruction if the database and engine
169
+ ever disagree (`FORMS_SCHEMA_CHECK=off` is the emergency hatch).
@@ -0,0 +1,61 @@
1
+ # Forms Module Completion Checklist
2
+
3
+ The forms module must pass `MODULE_COMPLETION_CHECKLIST.md` verbatim, **plus**
4
+ these form-builder-specific items (ecosystem guide §11). Re-verify after any
5
+ engine upgrade or forms-module change.
6
+
7
+ ## Boundary
8
+
9
+ - [ ] The engine package (`@ftisindia/form-builder`) compiles and unit-tests **without** the template; only `src/modules/forms` imports template services.
10
+ - [ ] The engine's boundary lint passes: no `@nestjs/*`, `@prisma/*`, `@casl/*`, or template imports anywhere in the engine core; engine errors are engine-typed and mapped to HTTP exceptions only in the glue (`forms-error.mapper.ts`).
11
+
12
+ ## Schema ownership
13
+
14
+ - [ ] Boot-time schema check proven both ways: green on a freshly migrated app; clear fail-fast message (naming the engine schema version and the snippet path) on a deliberately stale schema.
15
+ - [ ] `prisma/forms.prisma` in the engine matches the models in the app's `schema.prisma`; upgrades follow copy-snippet → `prisma migrate dev`.
16
+
17
+ ## Permissions & registry
18
+
19
+ - [ ] All 11 form permission keys exist in `permissionKeys`, are seeded, and `test/forms-permission-sync.spec.ts` passes (engine ↔ template set equality).
20
+ - [ ] Every protected form route is in `routePermissionRegistry` (the boot-time registry validator passes).
21
+
22
+ ## Gating
23
+
24
+ - [ ] Attach-time gating proven by test: lacking `forms.wireDangerous` ⇒ cannot save/publish a definition wiring a `dangerous` action; the `forms.allowedDangerousActions` org setting is respected at save/publish AND at execute time.
25
+ - [ ] `forms.managePublicAccess` gating proven: lacking it ⇒ cannot set or unset `settings.access = 'public'` (including publishing an already-public draft); every flip writes an audit row with previous/next.
26
+
27
+ ## Tenancy
28
+
29
+ - [ ] Cross-org tests: definition fetch, submission read, and file reference across orgs are rejected as not-found.
30
+
31
+ ## Transactionality & outbox
32
+
33
+ - [ ] Phase-2 transactionality proven: an induced `lockEditing` failure rolls back persist **and** in-tx action logs **and** enqueued outbox rows.
34
+ - [ ] Outbox worker runs inside `requestContext.run({ source: 'worker', jobId, orgId, userId, requestId })`; retries honor backoff; exhausted jobs park as `FAILED`; an audit row (`forms.outbox.delivered`) is written on final success.
35
+
36
+ ## Public forms
37
+
38
+ - [ ] Public path: `@Public()` render/submit works without membership; a non-public form 404s on the public routes; org-scoped storage still enforced; permissioned paths unaffected.
39
+ - [ ] Tier-1 abuse baseline active: public routes throttled tighter than the app default; per-IP/day caps read from typed settings (with per-form override) and enforced; IP/UA stamped on anonymous submissions; captcha seam invoked when enabled; captcha-enabled-without-adapter fails at publish, not at submit.
40
+
41
+ ## Files
42
+
43
+ - [ ] Upload → submit → `LINKED` flow proven; a foreign user's `fileId` is rejected (IDOR); GC sweeps orphaned `TEMPORARY` files past TTL; public forms with file fields are rejected at publish.
44
+
45
+ ## Webhooks
46
+
47
+ - [ ] Webhook destinations are gated by the `forms.webhookAllowedHosts` setting at save AND publish (`WEBHOOK_HOST_NOT_ALLOWED`); https required except loopback (`WEBHOOK_URL_INSECURE`).
48
+ - [ ] Webhook jobs enqueue inside the submit transaction (idempotency key `<submissionId>:webhook:<i>`), never on draft saves or persist-less pipelines.
49
+ - [ ] Deliveries are signed (`x-forms-signature`) when a secret is set; payload data passes sensitive-field redaction; failures retry then park as FAILED.
50
+ - [ ] `forms:push` loads definitions only through the audited service path (real member RBAC, save-time lint, versioning) — never raw DB writes.
51
+
52
+ ## Export & redaction
53
+
54
+ - [ ] `formSubmissions.export` enforced independently of `.read`; **every** export writes an audit row with form key, filters, row count, and included fields.
55
+ - [ ] Credential/sensitive fields never appear in `FormActionLog` or `AuditLog` metadata (redaction test); dangerous actions log `[REDACTED]` for input and output.
56
+
57
+ ## Envelope & docs
58
+
59
+ - [ ] Engine validation errors arrive as `{ error: { code, message, details: { errors: [...] } } }` through the global filter; lint failures carry `details.issues`.
60
+ - [ ] Engine-thrown denials and not-founds are indistinguishable in shape from the template's own.
61
+ - [ ] Swagger documents all form routes (dynamic payloads as `object`).
@@ -0,0 +1,246 @@
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
+ - Version note: columns resolve against the latest **published** form version.
154
+ 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
+ - Every export — sync or async — runs inside ONE `REPEATABLE READ` snapshot
206
+ (duration-bounded by `reports.exportMaxSnapshotSeconds`); an export that
207
+ cannot finish in time is rejected with guidance to tighten filters or move
208
+ to the materialized tier.
209
+ - `reports.export` is never implied by read; **every export writes an audit
210
+ row** with report key, version, filter snapshot, row count, and the exact
211
+ column list. Columns marked `exportable: false` are stripped regardless of
212
+ the requested projection.
213
+
214
+ ## Saved views
215
+
216
+ Views store the QuerySpec **and the version they were authored against**.
217
+ Listing views returns a `compatibility` verdict per view against the requested
218
+ version — `ok` | `degraded` (names missing/retyped columns) | `incompatible` —
219
+ so a new publish turns stale views into a visible, fixable state instead of a
220
+ silent 500. Personal views need `reports.read`; shared views (no owner) need
221
+ `reports.update`.
222
+
223
+ ## Org settings & env
224
+
225
+ Typed settings (audited like all settings): `reports.statementTimeoutMs`
226
+ (5000), `reports.exportMaxSnapshotSeconds` (120), `reports.maxRowsSync`
227
+ (10000), `reports.countCap` (10000), `reports.liveTierMaxRows` (50000),
228
+ `reports.resultCacheTtlMs` (0 = cache off), `reports.tagVocabulary` ([]).
229
+
230
+ Env: `REPORTS_SCHEMA_CHECK` (`on`/`off`); `REPORTS_TOKEN_SECRET` (optional, min
231
+ 32 chars — cursor/token HMAC key; when empty a dedicated key is derived from
232
+ `JWT_SECRET` via HKDF; rotating it invalidates outstanding cursors/tokens —
233
+ clients restart from page one); and the reports-owned async-export worker:
234
+ `REPORTS_EXPORT_WORKER_ENABLED` (default true), `REPORTS_EXPORT_POLL_MS`
235
+ (default 5000), `REPORTS_EXPORT_STORAGE_DIR` (default `./var/report-exports`).
236
+
237
+ ## Engine upgrades (schema ownership)
238
+
239
+ The app owns `schema.prisma` and the migration history; the engine ships the
240
+ canonical snippet at `@ftisindia/report-builder/prisma/reports.prisma` plus
241
+ `REPORTS_ENGINE_SCHEMA_VERSION`. The boot check verifies tables/columns AND
242
+ the two **partial unique indexes** on `ReportSavedView`
243
+ (`report_view_shared_uq`, `report_view_personal_uq`) — Prisma cannot express
244
+ partial indexes, so they live as raw SQL in the `add_report_builder`
245
+ migration. If a later `prisma migrate dev` proposes `DROP INDEX` for them,
246
+ delete those lines; the boot check fails fast if they ever go missing.