@ftisindia/create-app 0.1.6 → 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 (31) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +3 -0
  3. package/template/_package.json +4 -1
  4. package/template/docs/FORMS.md +34 -15
  5. package/template/docs/FORMS_CHECKLIST.md +34 -26
  6. package/template/docs/REPORTS.md +12 -3
  7. package/template/docs/REPORTS_CHECKLIST.md +150 -95
  8. package/template/prisma/migrations/20260616000000_add_form_outbox_claimed_by/migration.sql +5 -0
  9. package/template/prisma/schema.prisma +5 -1
  10. package/template/src/app.module.ts +4 -3
  11. package/template/src/config/env.validation.ts +3 -0
  12. package/template/src/config/forms.config.ts +1 -0
  13. package/template/src/config/reports.config.ts +2 -0
  14. package/template/src/modules/forms/application/services/handlers/webhook-delivery.transport.ts +319 -0
  15. package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +64 -16
  16. package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +40 -18
  17. package/template/src/modules/forms/forms.module.ts +2 -0
  18. package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +82 -59
  19. package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +81 -0
  20. package/template/src/modules/reports/application/services/reports-exports.service.ts +6 -2
  21. package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +43 -30
  22. package/template/test/forms-captcha.e2e-spec.ts +163 -0
  23. package/template/test/forms-files.e2e-spec.ts +1 -1
  24. package/template/test/forms-outbox.e2e-spec.ts +271 -10
  25. package/template/test/forms-public.e2e-spec.ts +24 -0
  26. package/template/test/forms-throttling.e2e-spec.ts +146 -0
  27. package/template/test/forms-webhooks.e2e-spec.ts +150 -8
  28. package/template/test/jest-e2e.json +1 -0
  29. package/template/test/reports-advanced.e2e-spec.ts +13 -0
  30. package/template/test/reports-query.e2e-spec.ts +52 -0
  31. package/template/test/reports-tiers.e2e-spec.ts +106 -20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ftisindia/create-app",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "description": "One-command scaffolder for the Phase 1 NestJS foundation starter.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48,6 +48,7 @@ TEST_ORG_SLUG=demo
48
48
  # Form builder (@ftisindia/form-builder)
49
49
  FORMS_OUTBOX_ENABLED=true
50
50
  FORMS_OUTBOX_POLL_MS=5000
51
+ FORMS_OUTBOX_HEARTBEAT_MS=60000
51
52
  FORMS_FILE_GC_INTERVAL_MS=3600000
52
53
  FORMS_FILE_TEMP_TTL_HOURS=24
53
54
  FORMS_MAX_UPLOAD_MB=25
@@ -65,3 +66,5 @@ REPORTS_TOKEN_SECRET=
65
66
  REPORTS_EXPORT_WORKER_ENABLED=true
66
67
  REPORTS_EXPORT_POLL_MS=5000
67
68
  REPORTS_EXPORT_STORAGE_DIR=./var/report-exports
69
+ REPORTS_EXPORT_RETENTION_DAYS=7
70
+ REPORTS_EXPORT_RETENTION_SWEEP_MS=3600000
@@ -27,7 +27,7 @@
27
27
  "setup:ci": "node scripts/setup-local.mjs --yes",
28
28
  "test": "jest",
29
29
  "test:cov": "jest --coverage",
30
- "test:e2e": "node scripts/test-db.mjs && jest --config test/jest-e2e.json --runInBand",
30
+ "test:e2e": "node scripts/test-db.mjs && node ./node_modules/jest/bin/jest.js --config test/jest-e2e.json --runInBand",
31
31
  "test:watch": "jest --watch",
32
32
  "db:reset": "prisma migrate reset"
33
33
  },
@@ -84,6 +84,9 @@
84
84
  "typescript-eslint": "8.60.0",
85
85
  "typescript": "5.9.3"
86
86
  },
87
+ "overrides": {
88
+ "js-yaml": "4.2.0"
89
+ },
87
90
  "lint-staged": {
88
91
  "*.{js,mjs,ts,json,md,yml,yaml}": "prettier --write",
89
92
  "*.{js,mjs,ts}": "eslint --fix"
@@ -38,10 +38,11 @@ json-logic rules + data-source membership + file references) → the action
38
38
  pipeline: validation actions, then **all transactional actions in ONE
39
39
  database transaction** (submission, action logs, audit rows, outbox rows
40
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.
41
+ **transactional outbox** (in-process poller with Postgres row-lock claiming,
42
+ idempotent enqueue, claim-token fencing, active-job heartbeat, retries with
43
+ backoff, parks as FAILED after max attempts; delivery writes an audit row). A
44
+ failed email never rolls back a valid submission; a failed transactional step
45
+ rolls back everything.
45
46
 
46
47
  ## Adding behavior (the three registries)
47
48
 
@@ -57,11 +58,16 @@ import type { DataSourceDef, DataSourceContext, DataSourceOption } from '@ftisin
57
58
  export class TrackSource implements DataSourceDef {
58
59
  key = 'conference-tracks';
59
60
  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 })));
61
+ async fetch(
62
+ _params: Record<string, unknown>,
63
+ ctx: DataSourceContext,
64
+ ): Promise<DataSourceOption[]> {
65
+ return this.prisma.track
66
+ .findMany({
67
+ where: { orgId: ctx.orgId, active: true },
68
+ select: { id: true, name: true },
69
+ })
70
+ .then((rows) => rows.map((row) => ({ value: row.id, label: row.name })));
65
71
  }
66
72
  }
67
73
  ```
@@ -119,19 +125,32 @@ A definition can declare webhook destinations that fire when a submission is
119
125
  - **Reliable by construction:** the job is written to the transactional outbox
120
126
  inside the same transaction as the submission, then delivered post-commit
121
127
  with retries/backoff (parks as FAILED after max attempts; success writes a
122
- `forms.outbox.delivered` audit row). Idempotency key: `<submissionId>:webhook:<index>`.
128
+ `forms.outbox.delivered` audit row). The Prisma store claims jobs with
129
+ `FOR UPDATE SKIP LOCKED`, assigns an opaque claim token, heartbeats active
130
+ `PROCESSING` rows with that token, and guards `DONE`/`PENDING`/`FAILED`
131
+ transitions with the same token so stale workers cannot settle reclaimed
132
+ jobs. A duplicate idempotency key is a no-op so retries do not roll back the
133
+ submission. Idempotency key: `<submissionId>:webhook:<index>`, also sent as
134
+ `x-forms-idempotency-key` for receiver-side dedupe.
123
135
  - **SSRF-gated:** the destination host must be in the org's audited
124
136
  `forms.webhookAllowedHosts` typed setting (default empty = webhooks
125
137
  disabled), and the URL must be `https` — `http` is allowed for loopback
126
138
  hosts only (local development). Enforced at save AND publish lint
127
- (`WEBHOOK_HOST_NOT_ALLOWED`, `WEBHOOK_URL_INSECURE`, …).
139
+ (`WEBHOOK_HOST_NOT_ALLOWED`, `WEBHOOK_URL_INSECURE`, …). Delivery does a
140
+ fresh DNS resolution, rejects private/reserved/link-local destinations in
141
+ `NODE_ENV=production`, pins the request to the resolved IP while preserving
142
+ Host/SNI, and refuses redirects instead of following them.
128
143
  - **Payload:** `{ event: 'submitted', orgId, formKey, formVersion, submissionId,
129
- occurredAt, data? }` — `data` passes sensitive-field redaction; set
144
+ occurredAt, data? }` — `data` passes sensitive-field redaction; set
130
145
  `includeData: false` to send a pure notification.
131
146
  - **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.
147
+ `x-forms-signature: sha256=<hmac-sha256(rawBody)>`. The engine computes the
148
+ signature before enqueueing; the outbox payload stores the raw body and
149
+ signature, not the shared secret. Old-format jobs containing `secret` are
150
+ sanitized to the new `{ rawBody, signature }` payload before delivery. The
151
+ secret lives only in the definition
152
+ document (DB) — treat it as a rotatable shared token, not a master
153
+ credential. Max 5 webhooks per definition.
135
154
 
136
155
  ## Definitions as code (`gen:form` + `forms:push`)
137
156
 
@@ -1,61 +1,69 @@
1
1
  # Forms Module Completion Checklist
2
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.
3
+ The forms module must pass `MODULE_COMPLETION_CHECKLIST.md` verbatim, plus
4
+ these form-builder-specific items from the ecosystem guide. Re-verify after
5
+ any engine upgrade or forms-module change.
6
+
7
+ Last verified on 2026-06-13 against the generated dogfood app (`test-out`) with
8
+ local PostgreSQL `postgres/root`: `npm run dogfood`, generated app
9
+ `npm run build`, `npm run lint`, `npm test`, `npm run test:e2e`; engine
10
+ `npm test`, `npm run lint`, `npm run typecheck`.
6
11
 
7
12
  ## Boundary
8
13
 
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`).
14
+ - [x] The engine package (`@ftisindia/form-builder`) compiles and unit-tests without the template; only `src/modules/forms` imports template services.
15
+ - [x] 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
16
 
12
17
  ## Schema ownership
13
18
 
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`.
19
+ - [x] Boot-time schema check proven both ways: green on a freshly migrated app; clear fail-fast message naming the engine schema version and snippet path on a deliberately stale schema.
20
+ - [x] `prisma/forms.prisma` in the engine matches the models in the app's `schema.prisma`; upgrades follow copy-snippet -> `prisma migrate dev`.
16
21
 
17
22
  ## Permissions & registry
18
23
 
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).
24
+ - [x] All 11 form permission keys exist in `permissionKeys`, are seeded, and `test/forms-permission-sync.spec.ts` passes (engine <-> template set equality).
25
+ - [x] Every protected form route is in `routePermissionRegistry` (the boot-time registry validator passes).
21
26
 
22
27
  ## Gating
23
28
 
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.
29
+ - [x] 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.
30
+ - [x] `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
31
 
27
32
  ## Tenancy
28
33
 
29
- - [ ] Cross-org tests: definition fetch, submission read, and file reference across orgs are rejected as not-found.
34
+ - [x] Cross-org tests: definition fetch, submission read, and file reference across orgs are rejected as not-found.
30
35
 
31
36
  ## Transactionality & outbox
32
37
 
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.
38
+ - [x] Phase-2 transactionality proven: an induced `lockEditing` failure rolls back persist and in-tx action logs and enqueued outbox rows.
39
+ - [x] 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.
40
+ - [x] Duplicate outbox enqueue with the same idempotency key is a no-op, not a submission-rolling unique violation.
41
+ - [x] Claimed jobs carry an opaque `claimedBy` lease token; heartbeat and `DONE`/retry/failed terminal transitions are guarded by that token so stale workers cannot settle reclaimed jobs.
42
+ - [x] Active `PROCESSING` jobs heartbeat while handlers run; e2e covers both the store primitive and the actual dispatcher wiring with a slow handler.
35
43
 
36
44
  ## Public forms
37
45
 
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.
46
+ - [x] 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.
47
+ - [x] 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
48
 
41
49
  ## Files
42
50
 
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.
51
+ - [x] 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
52
 
45
53
  ## Webhooks
46
54
 
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.
55
+ - [x] Webhook destinations are gated by the `forms.webhookAllowedHosts` setting at save AND publish (`WEBHOOK_HOST_NOT_ALLOWED`); https required except loopback (`WEBHOOK_URL_INSECURE`).
56
+ - [x] Webhook jobs enqueue inside the submit transaction (idempotency key `<submissionId>:webhook:<i>`), never on draft saves or persist-less pipelines.
57
+ - [x] Deliveries are signed (`x-forms-signature`) when a secret is set and carry `x-forms-idempotency-key` for receiver-side dedupe; new outbox payloads store the precomputed signature, not the shared secret; legacy secret-bearing payloads are sanitized before delivery; payload data passes sensitive-field redaction; failures retry then park as FAILED.
58
+ - [x] `forms:push` loads definitions only through the audited service path (real member RBAC, save-time lint, versioning) and never raw DB writes.
51
59
 
52
60
  ## Export & redaction
53
61
 
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.
62
+ - [x] `formSubmissions.export` enforced independently of `.read`; every export writes an audit row with form key, filters, row count, and included fields.
63
+ - [x] Credential/sensitive fields never appear in `FormActionLog` or `AuditLog` metadata (redaction test); dangerous actions log `[REDACTED]` for input and output; unknown credential-like keys (`apiKey`, `token`, etc.) are defensively redacted.
56
64
 
57
65
  ## Envelope & docs
58
66
 
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`).
67
+ - [x] Engine validation errors arrive as `{ error: { code, message, details: { errors: [...] } } }` through the global filter; lint failures carry `details.issues`.
68
+ - [x] Engine-thrown denials and not-founds are indistinguishable in shape from the template's own.
69
+ - [x] Swagger documents all form routes (dynamic payloads as `object`).
@@ -150,8 +150,8 @@ the thinking:
150
150
  against the submission's stamped `formVersion`; `updateStatus` runs the same
151
151
  pipeline as the form. Both require `formSubmissions.update`. There is no
152
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
153
+ - Known limitation: columns resolve against the latest **published** form
154
+ version. Renamed-field mapping across versions needs rename metadata the form
155
155
  definition format does not yet carry.
156
156
 
157
157
  ## Performance tiers
@@ -202,6 +202,13 @@ row family's previous `label:*`). Curate the vocabulary with the
202
202
  streamed into reports-owned org-scoped file storage
203
203
  (`REPORTS_EXPORT_STORAGE_DIR`), polled at `GET /reports/exports/:jobId`, and
204
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.
205
212
  - Every export — sync or async — runs inside ONE `REPEATABLE READ` snapshot
206
213
  (duration-bounded by `reports.exportMaxSnapshotSeconds`); an export that
207
214
  cannot finish in time is rejected with guidance to tighten filters or move
@@ -232,7 +239,9 @@ Env: `REPORTS_SCHEMA_CHECK` (`on`/`off`); `REPORTS_TOKEN_SECRET` (optional, min
232
239
  `JWT_SECRET` via HKDF; rotating it invalidates outstanding cursors/tokens —
233
240
  clients restart from page one); and the reports-owned async-export worker:
234
241
  `REPORTS_EXPORT_WORKER_ENABLED` (default true), `REPORTS_EXPORT_POLL_MS`
235
- (default 5000), `REPORTS_EXPORT_STORAGE_DIR` (default `./var/report-exports`).
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).
236
245
 
237
246
  ## Engine upgrades (schema ownership)
238
247
 
@@ -1,97 +1,152 @@
1
1
  # Reports Module Completion Checklist
2
2
 
3
- The report builder counts as DONE only when every box below holds, on top of
4
- the generic [`MODULE_COMPLETION_CHECKLIST.md`](./MODULE_COMPLETION_CHECKLIST.md).
5
- Section references are to `report-builder/report-builder-design.md` (Rev 2).
6
-
7
- ## Boundary & the standalone guarantee
8
-
9
- - [ ] The engine package (`@ftisindia/report-builder`) compiles and unit-tests **without** the template AND **without** `@ftisindia/form-builder` installed (§3.2/§13); only `src/modules/reports` imports template services.
10
- - [ ] The engine's boundary lint passes: no `@nestjs/*`, `@prisma/*`, `@casl/*`, `@ftisindia/form-builder`, or template imports anywhere in the core; engine errors are engine-typed and mapped to HTTP exceptions only in the glue (`reports-error.mapper.ts`).
11
- - [ ] **`ReportsModule` imports no FormsModule.** It runs over custom sources with its OWN async-export worker + file storage (no forms outbox / UploadedFile). Form-backed sources and the delegated `editSubmission`/`updateStatus` verbs live in the **optional** `ReportsFormsModule` bridge (`reports-forms.module.ts` + `infrastructure/forms-adapter/`), the ONLY artifact aware of both engines. Dropping the bridge from `app.module.ts` leaves reports fully functional.
12
-
13
- ## Schema ownership
14
-
15
- - [ ] Boot-time schema check proven both ways: green on a freshly migrated app; clear fail-fast message (naming `REPORTS_ENGINE_SCHEMA_VERSION` and the snippet path) on a stale schema.
16
- - [ ] The check also verifies the two `ReportSavedView` partial unique indexes and prints the raw SQL when they are missing (finding #5).
17
- - [ ] `prisma/reports.prisma` in the engine matches the models in the app's `schema.prisma`; upgrades follow copy-snippet → `prisma migrate dev`.
18
-
19
- ## Permissions & registry
20
-
21
- - [ ] All 7 report permission keys (+ `formSubmissions.update`) exist in `permissionKeys`, are seeded, and `test/reports-permission-sync.spec.ts` passes (engine ↔ template set equality).
22
- - [ ] Every report route is in `routePermissionRegistry` (the boot-time registry validator passes); `reports.export` is enforced independently of `reports.read`.
23
- - [ ] Attach-time gating proven: saving/publishing a definition wiring a row action whose `requiredPermissions` the author lacks is rejected (§6.1); the same keys are re-checked at execute time.
24
-
25
- ## The SQL boundary (§2.1)
26
-
27
- - [ ] A definition column carrying an `sql` property is rejected by the meta-schema; SQL-shaped strings in `path`/`columnId` are rejected by the save lint (`SQL_IN_DEFINITION`).
28
- - [ ] `$row.*` paths resolve only against the physical columns the source manifest exposes.
29
- - [ ] Compiled statements carry user values exclusively as bind parameters (compiler snapshot tests assert no value ever lands in SQL text).
30
-
31
- ## Query semantics (§4/§5)
32
-
33
- - [ ] Keyset pagination proven at depth: walking pages by cursor is constant-cost; `OFFSET` appears nowhere; the rowId tiebreaker is always appended.
34
- - [ ] Cursor integrity: tampered cursors, and valid cursors replayed against a different sort/filter/version, are rejected with the restart message.
35
- - [ ] Typed operators: `contains` on a number column is a 400; enum values outside the declared set are a 400.
36
- - [ ] Counts: default none; `estimated` uses the planner; `exact-capped` scans at most cap+1 rows (the subquery-LIMIT shape, finding #2).
37
- - [ ] Org predicate compiles first on every statement; a source with `rowScope` filters rows for the scoped user (cross-org reads return empty/404, never data).
38
- - [ ] The statement budget fires as a typed error (408), not a hung connection.
39
-
40
- ## Tiers & publish lint (§5.2/§8)
41
-
42
- - [ ] `live` tier publish fails when the source exceeds `reports.liveTierMaxRows`.
43
- - [ ] `indexed` tier publish fails on missing generated columns/indexes WITH the exact suggestion SQL (`GENERATED ALWAYS AS`, `COMMENT ON COLUMN`, `CREATE INDEX`, `pg_trgm` extension when searchable); applying the snippet verbatim makes the same publish succeed (§8.1 round-trip proven by test).
44
- - [ ] Plan lint records plan hashes into `compiled` at publish; queries after publish use the physical `rb_*` columns (not raw JSONB).
45
- - [ ] `materialized` tier requires the declared relation to exist; responses carry `meta.freshAsOf`.
46
-
47
- ## Row actions & bulk (§6)
48
-
49
- - [ ] Mutations delegate to the owning domain — grep proves the reports module performs no UPDATE/DELETE on source tables; `editSubmission` re-validates through the form engine against the stamped `formVersion` (an invalid patch is rejected by the FORM engine, not the grid).
50
- - [ ] byFilter without a token is rejected; prepare→execute happy path works; drift beyond tolerance → 409 with `currentCount`; expired token → 410.
51
- - [ ] Idempotency: a retried execute with the same key returns the recorded outcome (`replayed: true`) without re-running.
52
- - [ ] byIds capped; cross-org ids resolve to nothing (tenancy).
53
-
54
- ## Tags (§7)
55
-
56
- - [ ] `manageTags` gated by `reportTags.manage`; tags normalized; the org vocabulary setting enforced when non-empty; `label:*` single-valued replacement works.
57
- - [ ] `$tags` hydration and `hasTag`/`hasAnyTag` filters proven against the `ReportRowTag` table.
58
-
59
- ## Exports (§9)
60
-
61
- - [ ] Sync export streams with `Content-Disposition` and the right MIME; async export creates the job + outbox row in ONE transaction, runs on the worker, lands an org-bound `UploadedFile`, and is pollable.
62
- - [ ] Both run inside one `REPEATABLE READ` snapshot; the duration bound rejects over-long exports with the tier guidance.
63
- - [ ] **Every** export writes an audit row with report key, version, filter snapshot, row count, and the exact column list; `exportable: false` columns never appear even when explicitly requested.
64
- - [ ] XLSX output opens (structurally valid OOXML; CSV carries the formula-injection guard).
65
-
66
- ## Saved views (§4)
67
-
68
- - [ ] Shared-view create/update requires `reports.update`; personal views are invisible to other users; duplicate names rejected per the partial uniques.
69
- - [ ] Publishing a version that drops/retypes a referenced column turns the view `degraded` (named columns), not a 500.
70
-
71
- ## Envelope & docs
72
-
73
- - [ ] Engine error codes map to the documented HTTP statuses through `reports-error.mapper.ts` only; Swagger documents every route (the frontend contract flows from OpenAPI).
74
- - [ ] `docs/REPORTS.md` matches reality; examples in `src/modules/reports/examples/` publish and serve.
75
-
76
- ## Test coverage (current state — keep honest)
77
-
78
- **Engine unit tests** (`packages/report-builder`, vitest): compiler SQL shapes
79
- (keyset OR-ladder, null surrogates, collation, bind ordering), cursor/token
80
- roundtrip + tamper, typed-operator rejection, count subquery, meta-schema +
81
- linter (incl. the §2.1 plain-path manifest validation), generated-column
82
- naming/casting, catalog/plan/tier lint, suggestion-SQL emission, CSV/XLSX,
83
- and all six services over in-memory port fakes.
84
-
85
- **Template e2e** (`test/reports-query.e2e-spec.ts`, `test/reports-advanced.e2e-spec.ts`,
86
- real Postgres over the org-members CUSTOM source): publish→query, keyset
87
- paging, typed filter rejection, meta, permission gating, saved views +
88
- compatibility, tags, sync CSV export; byFilter prepare/execute **token
89
- protocol + drift (409) + idempotent replay**, the reports-owned **async
90
- export worker + download**, **XLSX zip validity**, the **PII-egress audit
91
- row**, **shared-view partial-unique** (409), and **indexed-tier publish
92
- rejection with the `CREATE INDEX` migration snippet**.
93
-
94
- **Not yet covered by e2e (unit-proven only — flag before claiming complete):**
95
- the `materialized` tier (needs a real materialized view), the indexed-tier
96
- publish *success* round-trip after applying the suggested migration, and the
97
- boot schema-check *failure* path (the success path is proven by every boot).
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,5 @@
1
+ -- Add an opaque lease token for outbox claim ownership. Dispatchers must match
2
+ -- this token when heartbeating or settling PROCESSING jobs.
3
+ ALTER TABLE "FormOutboxJob" ADD COLUMN "claimedBy" TEXT;
4
+
5
+ CREATE INDEX "FormOutboxJob_status_claimedBy_idx" ON "FormOutboxJob"("status", "claimedBy");
@@ -416,6 +416,9 @@ model FormOutboxJob {
416
416
  idempotencyKey String? @unique
417
417
  lastError String?
418
418
  runAfter DateTime?
419
+ // Opaque worker lease token. Heartbeats and terminal transitions must match
420
+ // this value so a stale worker cannot finish another worker's reclaimed job.
421
+ claimedBy String?
419
422
 
420
423
  // Worker-context restoration + audit correlation (ecosystem guide §3).
421
424
  orgId String?
@@ -426,6 +429,7 @@ model FormOutboxJob {
426
429
  updatedAt DateTime @updatedAt
427
430
 
428
431
  @@index([status, runAfter])
432
+ @@index([status, claimedBy])
429
433
  }
430
434
 
431
435
  model UploadedFile {
@@ -531,7 +535,7 @@ model ReportExportJob {
531
535
  // REPEATABLE READ snapshot timestamp, set when the job runs (finding #8).
532
536
  asOf DateTime?
533
537
  status ReportExportStatus @default(PENDING)
534
- // UploadedFile id once the async export lands in file storage.
538
+ // Reports-owned storage key once the async export lands in file storage.
535
539
  fileId String?
536
540
  rowCount Int?
537
541
  error String?
@@ -42,9 +42,10 @@ import { SettingsModule } from './modules/settings/settings.module';
42
42
  limit: 100,
43
43
  },
44
44
  ],
45
- // e2e suites create many users/submissions in seconds; rate limiting is
46
- // not what they test. Production/dev behavior is unchanged.
47
- skipIf: () => process.env.NODE_ENV === 'test',
45
+ // Most e2e suites create many users/submissions in seconds; enable
46
+ // throttling only in the dedicated abuse-control suite.
47
+ skipIf: () =>
48
+ process.env.NODE_ENV === 'test' && process.env.E2E_THROTTLE_ENABLED !== 'true',
48
49
  }),
49
50
  PrismaModule,
50
51
  HealthModule,
@@ -68,6 +68,7 @@ export const envSchema = z
68
68
  ORG_CONTEXT_MODE: z.literal('path').default('path'),
69
69
  FORMS_OUTBOX_ENABLED: booleanFromEnv.default(true),
70
70
  FORMS_OUTBOX_POLL_MS: z.coerce.number().int().positive().default(5000),
71
+ FORMS_OUTBOX_HEARTBEAT_MS: z.coerce.number().int().positive().default(60_000),
71
72
  FORMS_FILE_GC_INTERVAL_MS: z.coerce.number().int().positive().default(3_600_000),
72
73
  FORMS_FILE_TEMP_TTL_HOURS: z.coerce.number().int().positive().default(24),
73
74
  FORMS_MAX_UPLOAD_MB: z.coerce.number().int().positive().default(25),
@@ -83,6 +84,8 @@ export const envSchema = z
83
84
  REPORTS_EXPORT_WORKER_ENABLED: booleanFromEnv.default(true),
84
85
  REPORTS_EXPORT_POLL_MS: z.coerce.number().int().positive().default(5000),
85
86
  REPORTS_EXPORT_STORAGE_DIR: z.string().trim().min(1).default('./var/report-exports'),
87
+ REPORTS_EXPORT_RETENTION_DAYS: z.coerce.number().int().nonnegative().default(7),
88
+ REPORTS_EXPORT_RETENTION_SWEEP_MS: z.coerce.number().int().positive().default(3_600_000),
86
89
  })
87
90
  .superRefine((env, ctx) => {
88
91
  if (env.REPORTS_TOKEN_SECRET.length > 0 && env.REPORTS_TOKEN_SECRET.length < 32) {
@@ -4,6 +4,7 @@ import { getEnv } from './env.validation';
4
4
  export default registerAs('forms', () => ({
5
5
  outboxEnabled: getEnv().FORMS_OUTBOX_ENABLED,
6
6
  outboxPollMs: getEnv().FORMS_OUTBOX_POLL_MS,
7
+ outboxHeartbeatMs: getEnv().FORMS_OUTBOX_HEARTBEAT_MS,
7
8
  fileGcIntervalMs: getEnv().FORMS_FILE_GC_INTERVAL_MS,
8
9
  fileTempTtlHours: getEnv().FORMS_FILE_TEMP_TTL_HOURS,
9
10
  maxUploadMb: getEnv().FORMS_MAX_UPLOAD_MB,
@@ -13,4 +13,6 @@ export default registerAs('reports', () => ({
13
13
  exportWorkerEnabled: getEnv().REPORTS_EXPORT_WORKER_ENABLED,
14
14
  exportPollMs: getEnv().REPORTS_EXPORT_POLL_MS,
15
15
  exportStorageDir: getEnv().REPORTS_EXPORT_STORAGE_DIR,
16
+ exportRetentionDays: getEnv().REPORTS_EXPORT_RETENTION_DAYS,
17
+ exportRetentionSweepMs: getEnv().REPORTS_EXPORT_RETENTION_SWEEP_MS,
16
18
  }));