@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.
- package/package.json +1 -1
- package/template/.env.example +3 -0
- package/template/_package.json +4 -1
- package/template/docs/FORMS.md +34 -15
- package/template/docs/FORMS_CHECKLIST.md +34 -26
- package/template/docs/REPORTS.md +12 -3
- package/template/docs/REPORTS_CHECKLIST.md +150 -95
- package/template/prisma/migrations/20260616000000_add_form_outbox_claimed_by/migration.sql +5 -0
- package/template/prisma/schema.prisma +5 -1
- package/template/src/app.module.ts +4 -3
- package/template/src/config/env.validation.ts +3 -0
- package/template/src/config/forms.config.ts +1 -0
- package/template/src/config/reports.config.ts +2 -0
- package/template/src/modules/forms/application/services/handlers/webhook-delivery.transport.ts +319 -0
- package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +64 -16
- package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +40 -18
- package/template/src/modules/forms/forms.module.ts +2 -0
- package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +82 -59
- package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +81 -0
- package/template/src/modules/reports/application/services/reports-exports.service.ts +6 -2
- package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +43 -30
- package/template/test/forms-captcha.e2e-spec.ts +163 -0
- package/template/test/forms-files.e2e-spec.ts +1 -1
- package/template/test/forms-outbox.e2e-spec.ts +271 -10
- package/template/test/forms-public.e2e-spec.ts +24 -0
- package/template/test/forms-throttling.e2e-spec.ts +146 -0
- package/template/test/forms-webhooks.e2e-spec.ts +150 -8
- package/template/test/jest-e2e.json +1 -0
- package/template/test/reports-advanced.e2e-spec.ts +13 -0
- package/template/test/reports-query.e2e-spec.ts +52 -0
- package/template/test/reports-tiers.e2e-spec.ts +106 -20
package/package.json
CHANGED
package/template/.env.example
CHANGED
|
@@ -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
|
package/template/_package.json
CHANGED
|
@@ -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"
|
package/template/docs/FORMS.md
CHANGED
|
@@ -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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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).
|
|
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
|
-
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
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,
|
|
4
|
-
these form-builder-specific items
|
|
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
|
-
- [
|
|
10
|
-
- [
|
|
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
|
-
- [
|
|
15
|
-
- [
|
|
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
|
-
- [
|
|
20
|
-
- [
|
|
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
|
-
- [
|
|
25
|
-
- [
|
|
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
|
-
- [
|
|
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
|
-
- [
|
|
34
|
-
- [
|
|
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
|
-
- [
|
|
39
|
-
- [
|
|
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
|
-
- [
|
|
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
|
-
- [
|
|
48
|
-
- [
|
|
49
|
-
- [
|
|
50
|
-
- [
|
|
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
|
-
- [
|
|
55
|
-
- [
|
|
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
|
-
- [
|
|
60
|
-
- [
|
|
61
|
-
- [
|
|
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`).
|
package/template/docs/REPORTS.md
CHANGED
|
@@ -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
|
-
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
## Boundary
|
|
8
|
-
|
|
9
|
-
- [
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
- [
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
##
|
|
20
|
-
|
|
21
|
-
- [
|
|
22
|
-
|
|
23
|
-
- [
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
- [
|
|
34
|
-
|
|
35
|
-
- [
|
|
36
|
-
|
|
37
|
-
- [
|
|
38
|
-
-
|
|
39
|
-
|
|
40
|
-
##
|
|
41
|
-
|
|
42
|
-
- [
|
|
43
|
-
|
|
44
|
-
-
|
|
45
|
-
- [
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
- [
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
- [
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
- [
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
- [
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
- [
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
//
|
|
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;
|
|
46
|
-
//
|
|
47
|
-
skipIf: () =>
|
|
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
|
}));
|