@ftisindia/create-app 0.1.6 → 0.3.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 +7 -3
- package/template/docs/FORMS.md +87 -18
- package/template/docs/FORMS_CHECKLIST.md +36 -26
- package/template/docs/REPORTS.md +22 -4
- 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/migrations/20260625000000_form_builder_public_uploads/migration.sql +7 -0
- package/template/prisma/schema.prisma +13 -6
- package/template/scripts/push-report.ts +123 -0
- package/template/src/app.module.ts +4 -3
- package/template/src/common/swagger/api-error-responses.ts +8 -2
- 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/data-sources/conference-tracks.data-source.ts +32 -0
- package/template/src/modules/forms/application/services/forms-files.service.ts +143 -39
- package/template/src/modules/forms/application/services/forms-public.service.ts +2 -1
- package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +5 -3
- 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/dto/public-file-upload-response.dto.ts +10 -0
- package/template/src/modules/forms/dto/public-submit-form.dto.ts +9 -0
- package/template/src/modules/forms/forms.module.ts +12 -2
- package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +43 -3
- package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +82 -59
- package/template/src/modules/forms/presentation/public-forms-files.controller.ts +66 -0
- 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/src/modules/settings/types/setting-definitions.ts +4 -0
- package/template/test/forms-captcha.e2e-spec.ts +163 -0
- package/template/test/forms-files.e2e-spec.ts +42 -20
- package/template/test/forms-outbox.e2e-spec.ts +271 -10
- package/template/test/forms-public.e2e-spec.ts +24 -0
- package/template/test/forms-submissions.e2e-spec.ts +2 -11
- 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/template/test/route-registry.validator.spec.ts +4 -2
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
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"format": "prettier --write .",
|
|
11
11
|
"format:check": "prettier --check .",
|
|
12
12
|
"forms:push": "ts-node scripts/push-form.ts",
|
|
13
|
+
"reports:push": "ts-node scripts/push-report.ts",
|
|
13
14
|
"gen:form": "node scripts/gen-form.mjs",
|
|
14
15
|
"gen:module": "node scripts/gen-module.mjs",
|
|
15
16
|
"lint": "eslint .",
|
|
@@ -27,14 +28,14 @@
|
|
|
27
28
|
"setup:ci": "node scripts/setup-local.mjs --yes",
|
|
28
29
|
"test": "jest",
|
|
29
30
|
"test:cov": "jest --coverage",
|
|
30
|
-
"test:e2e": "node scripts/test-db.mjs && jest --config test/jest-e2e.json --runInBand",
|
|
31
|
+
"test:e2e": "node scripts/test-db.mjs && node ./node_modules/jest/bin/jest.js --config test/jest-e2e.json --runInBand",
|
|
31
32
|
"test:watch": "jest --watch",
|
|
32
33
|
"db:reset": "prisma migrate reset"
|
|
33
34
|
},
|
|
34
35
|
"dependencies": {
|
|
35
36
|
"@casl/ability": "6.8.1",
|
|
36
|
-
"@ftisindia/form-builder": "^
|
|
37
|
-
"@ftisindia/report-builder": "^0.
|
|
37
|
+
"@ftisindia/form-builder": "^1.0.0",
|
|
38
|
+
"@ftisindia/report-builder": "^0.3.0",
|
|
38
39
|
"@nestjs/common": "11.1.24",
|
|
39
40
|
"@nestjs/config": "4.0.4",
|
|
40
41
|
"@nestjs/core": "11.1.24",
|
|
@@ -84,6 +85,9 @@
|
|
|
84
85
|
"typescript-eslint": "8.60.0",
|
|
85
86
|
"typescript": "5.9.3"
|
|
86
87
|
},
|
|
88
|
+
"overrides": {
|
|
89
|
+
"js-yaml": "4.2.0"
|
|
90
|
+
},
|
|
87
91
|
"lint-staged": {
|
|
88
92
|
"*.{js,mjs,ts,json,md,yml,yaml}": "prettier --write",
|
|
89
93
|
"*.{js,mjs,ts}": "eslint --fix"
|
package/template/docs/FORMS.md
CHANGED
|
@@ -38,16 +38,25 @@ 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
|
|
|
48
49
|
Implement the engine interface, decorate the provider, list it in a module —
|
|
49
50
|
the registry bootstrap finds it at startup:
|
|
50
51
|
|
|
52
|
+
Definitions can reference a `dataSource.key` only after a matching provider is
|
|
53
|
+
registered. This template ships `ConferenceTracksDataSource` in
|
|
54
|
+
`src/modules/forms/application/services/data-sources/` for the bundled
|
|
55
|
+
`abstract-submission` example (`conference-tracks`). It is intentionally static
|
|
56
|
+
so fresh apps can publish and render the example immediately; replace it with an
|
|
57
|
+
org-scoped Prisma/API lookup before using real production master data. An
|
|
58
|
+
unregistered source fails save/publish lint with `UNKNOWN_DATA_SOURCE`.
|
|
59
|
+
|
|
51
60
|
```ts
|
|
52
61
|
import { FormDataSource } from './modules/forms/infrastructure/registry/form-extension.decorators';
|
|
53
62
|
import type { DataSourceDef, DataSourceContext, DataSourceOption } from '@ftisindia/form-builder';
|
|
@@ -57,11 +66,16 @@ import type { DataSourceDef, DataSourceContext, DataSourceOption } from '@ftisin
|
|
|
57
66
|
export class TrackSource implements DataSourceDef {
|
|
58
67
|
key = 'conference-tracks';
|
|
59
68
|
constructor(private readonly prisma: PrismaService) {}
|
|
60
|
-
async fetch(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
async fetch(
|
|
70
|
+
_params: Record<string, unknown>,
|
|
71
|
+
ctx: DataSourceContext,
|
|
72
|
+
): Promise<DataSourceOption[]> {
|
|
73
|
+
return this.prisma.track
|
|
74
|
+
.findMany({
|
|
75
|
+
where: { orgId: ctx.orgId, active: true },
|
|
76
|
+
select: { id: true, name: true },
|
|
77
|
+
})
|
|
78
|
+
.then((rows) => rows.map((row) => ({ value: row.id, label: row.name })));
|
|
65
79
|
}
|
|
66
80
|
}
|
|
67
81
|
```
|
|
@@ -89,9 +103,51 @@ Flip a published definition with `POST .../public-access { "access": "public" }`
|
|
|
89
103
|
(requires `forms.managePublicAccess`, writes an audit row). Public routes are
|
|
90
104
|
throttled tighter than the app default; the engine enforces the per-IP/day
|
|
91
105
|
cap (`forms.maxSubmissionsPerIpPerDay` setting, per-form override in the
|
|
92
|
-
definition's `settings`) and the captcha seam (`settings.captcha: true`
|
|
106
|
+
definition's `settings`) and the captcha seam (`settings.captcha: true` -
|
|
93
107
|
publish fails closed unless a `CaptchaVerifier` is bound to the
|
|
94
|
-
`FORMS_CAPTCHA_VERIFIER` token). Public
|
|
108
|
+
`FORMS_CAPTCHA_VERIFIER` token). Public file fields are allowed through the
|
|
109
|
+
public upload endpoint: upload first, then submit `{ fileId }` in `data` and the
|
|
110
|
+
returned token in `uploadTokens[fileId]`. Anonymous upload volume is capped by
|
|
111
|
+
`forms.maxPublicUploadsPerIpPerDay`. Like the public submission cap, it depends on a resolvable request IP; configure proxy/IP handling correctly in deployments behind a reverse proxy.
|
|
112
|
+
|
|
113
|
+
A captcha-enabled public form keeps failing closed until the app binds a real
|
|
114
|
+
verifier. Example Turnstile-style adapter:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import { Injectable, Module } from '@nestjs/common';
|
|
118
|
+
import { ConfigService } from '@nestjs/config';
|
|
119
|
+
import type { CaptchaVerifier, FormsContext } from '@ftisindia/form-builder';
|
|
120
|
+
import { FORMS_CAPTCHA_VERIFIER } from './modules/forms/forms.tokens';
|
|
121
|
+
|
|
122
|
+
@Injectable()
|
|
123
|
+
export class TurnstileCaptchaVerifier implements CaptchaVerifier {
|
|
124
|
+
constructor(private readonly config: ConfigService) {}
|
|
125
|
+
|
|
126
|
+
async verify(token: string, _ctx: FormsContext): Promise<boolean> {
|
|
127
|
+
const body = new URLSearchParams({
|
|
128
|
+
secret: this.config.getOrThrow<string>('TURNSTILE_SECRET_KEY'),
|
|
129
|
+
response: token,
|
|
130
|
+
});
|
|
131
|
+
const response = await fetch(
|
|
132
|
+
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
|
133
|
+
{ method: 'POST', body },
|
|
134
|
+
);
|
|
135
|
+
const result = (await response.json()) as { success?: boolean };
|
|
136
|
+
return result.success === true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
@Module({
|
|
141
|
+
providers: [
|
|
142
|
+
{ provide: FORMS_CAPTCHA_VERIFIER, useClass: TurnstileCaptchaVerifier },
|
|
143
|
+
],
|
|
144
|
+
})
|
|
145
|
+
export class AppCaptchaModule {}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
After that binding is present, a definition can opt in with
|
|
149
|
+
`settings.captcha: true`; without the binding, publish fails before the form can
|
|
150
|
+
go public.
|
|
95
151
|
|
|
96
152
|
## Files
|
|
97
153
|
|
|
@@ -119,19 +175,32 @@ A definition can declare webhook destinations that fire when a submission is
|
|
|
119
175
|
- **Reliable by construction:** the job is written to the transactional outbox
|
|
120
176
|
inside the same transaction as the submission, then delivered post-commit
|
|
121
177
|
with retries/backoff (parks as FAILED after max attempts; success writes a
|
|
122
|
-
`forms.outbox.delivered` audit row).
|
|
178
|
+
`forms.outbox.delivered` audit row). The Prisma store claims jobs with
|
|
179
|
+
`FOR UPDATE SKIP LOCKED`, assigns an opaque claim token, heartbeats active
|
|
180
|
+
`PROCESSING` rows with that token, and guards `DONE`/`PENDING`/`FAILED`
|
|
181
|
+
transitions with the same token so stale workers cannot settle reclaimed
|
|
182
|
+
jobs. A duplicate idempotency key is a no-op so retries do not roll back the
|
|
183
|
+
submission. Idempotency key: `<submissionId>:webhook:<index>`, also sent as
|
|
184
|
+
`x-forms-idempotency-key` for receiver-side dedupe.
|
|
123
185
|
- **SSRF-gated:** the destination host must be in the org's audited
|
|
124
186
|
`forms.webhookAllowedHosts` typed setting (default empty = webhooks
|
|
125
187
|
disabled), and the URL must be `https` — `http` is allowed for loopback
|
|
126
188
|
hosts only (local development). Enforced at save AND publish lint
|
|
127
|
-
(`WEBHOOK_HOST_NOT_ALLOWED`, `WEBHOOK_URL_INSECURE`, …).
|
|
189
|
+
(`WEBHOOK_HOST_NOT_ALLOWED`, `WEBHOOK_URL_INSECURE`, …). Delivery does a
|
|
190
|
+
fresh DNS resolution, rejects private/reserved/link-local destinations in
|
|
191
|
+
`NODE_ENV=production`, pins the request to the resolved IP while preserving
|
|
192
|
+
Host/SNI, and refuses redirects instead of following them.
|
|
128
193
|
- **Payload:** `{ event: 'submitted', orgId, formKey, formVersion, submissionId,
|
|
129
|
-
|
|
194
|
+
occurredAt, data? }` — `data` passes sensitive-field redaction; set
|
|
130
195
|
`includeData: false` to send a pure notification.
|
|
131
196
|
- **Signed:** with a `secret`, deliveries carry
|
|
132
|
-
`x-forms-signature: sha256=<hmac-sha256(rawBody)>`. The
|
|
133
|
-
|
|
134
|
-
|
|
197
|
+
`x-forms-signature: sha256=<hmac-sha256(rawBody)>`. The engine computes the
|
|
198
|
+
signature before enqueueing; the outbox payload stores the raw body and
|
|
199
|
+
signature, not the shared secret. Old-format jobs containing `secret` are
|
|
200
|
+
sanitized to the new `{ rawBody, signature }` payload before delivery. The
|
|
201
|
+
secret lives only in the definition
|
|
202
|
+
document (DB) — treat it as a rotatable shared token, not a master
|
|
203
|
+
credential. Max 5 webhooks per definition.
|
|
135
204
|
|
|
136
205
|
## Definitions as code (`gen:form` + `forms:push`)
|
|
137
206
|
|
|
@@ -142,7 +211,7 @@ npm run forms:push -- --file src/modules/forms/definitions/customer-feedback.for
|
|
|
142
211
|
--org demo --user owner@example.com --publish
|
|
143
212
|
```
|
|
144
213
|
|
|
145
|
-
`gen:form` writes `src/modules/forms/definitions/<key>.form.json` and
|
|
214
|
+
Example files under `src/modules/forms/examples/` are reference fixtures; copy or scaffold into `src/modules/forms/definitions/` before pushing. `gen:form` writes `src/modules/forms/definitions/<key>.form.json` and
|
|
146
215
|
`test/<key>.form.spec.ts` (meta-schema + publish-stage lint — fails `npm test`
|
|
147
216
|
the moment the definition would be rejected by the API). `forms:push` loads a
|
|
148
217
|
definition file into an org through the real engine path: it impersonates an
|
|
@@ -1,61 +1,71 @@
|
|
|
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).
|
|
26
|
+
- [x] The bundled `ConferenceTracksDataSource` backs the abstract-submission example; production data sources remain app-provided `@FormDataSource()` providers.
|
|
21
27
|
|
|
22
28
|
## Gating
|
|
23
29
|
|
|
24
|
-
- [
|
|
25
|
-
- [
|
|
30
|
+
- [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.
|
|
31
|
+
- [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
32
|
|
|
27
33
|
## Tenancy
|
|
28
34
|
|
|
29
|
-
- [
|
|
35
|
+
- [x] Cross-org tests: definition fetch, submission read, and file reference across orgs are rejected as not-found.
|
|
30
36
|
|
|
31
37
|
## Transactionality & outbox
|
|
32
38
|
|
|
33
|
-
- [
|
|
34
|
-
- [
|
|
39
|
+
- [x] Phase-2 transactionality proven: an induced `lockEditing` failure rolls back persist and in-tx action logs and enqueued outbox rows.
|
|
40
|
+
- [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.
|
|
41
|
+
- [x] Duplicate outbox enqueue with the same idempotency key is a no-op, not a submission-rolling unique violation.
|
|
42
|
+
- [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.
|
|
43
|
+
- [x] Active `PROCESSING` jobs heartbeat while handlers run; e2e covers both the store primitive and the actual dispatcher wiring with a slow handler.
|
|
35
44
|
|
|
36
45
|
## Public forms
|
|
37
46
|
|
|
38
|
-
- [
|
|
39
|
-
- [
|
|
47
|
+
- [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.
|
|
48
|
+
- [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.
|
|
49
|
+
- [x] Captcha remains fail-closed by default: apps must bind a real `CaptchaVerifier` to `FORMS_CAPTCHA_VERIFIER` before publishing public forms with `settings.captcha: true`.
|
|
40
50
|
|
|
41
51
|
## Files
|
|
42
52
|
|
|
43
|
-
- [
|
|
53
|
+
- [x] Upload -> submit -> `LINKED` flow proven; a foreign user's `fileId` is rejected (IDOR); public uploads require the per-file token returned by the public upload endpoint; GC sweeps orphaned `TEMPORARY` files past TTL.
|
|
44
54
|
|
|
45
55
|
## Webhooks
|
|
46
56
|
|
|
47
|
-
- [
|
|
48
|
-
- [
|
|
49
|
-
- [
|
|
50
|
-
- [
|
|
57
|
+
- [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`).
|
|
58
|
+
- [x] Webhook jobs enqueue inside the submit transaction (idempotency key `<submissionId>:webhook:<i>`), never on draft saves or persist-less pipelines.
|
|
59
|
+
- [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.
|
|
60
|
+
- [x] `forms:push` loads definitions only through the audited service path (real member RBAC, save-time lint, versioning) and never raw DB writes.
|
|
51
61
|
|
|
52
62
|
## Export & redaction
|
|
53
63
|
|
|
54
|
-
- [
|
|
55
|
-
- [
|
|
64
|
+
- [x] `formSubmissions.export` enforced independently of `.read`; every export writes an audit row with form key, filters, row count, and included fields.
|
|
65
|
+
- [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
66
|
|
|
57
67
|
## Envelope & docs
|
|
58
68
|
|
|
59
|
-
- [
|
|
60
|
-
- [
|
|
61
|
-
- [
|
|
69
|
+
- [x] Engine validation errors arrive as `{ error: { code, message, details: { errors: [...] } } }` through the global filter; lint failures carry `details.issues`.
|
|
70
|
+
- [x] Engine-thrown denials and not-founds are indistinguishable in shape from the template's own.
|
|
71
|
+
- [x] Swagger documents all form routes (dynamic payloads as `object`).
|
package/template/docs/REPORTS.md
CHANGED
|
@@ -50,8 +50,17 @@ definition AND again at execute time.
|
|
|
50
50
|
|
|
51
51
|
Example definitions live in `src/modules/reports/examples/` — `org-members`
|
|
52
52
|
(custom source, no form builder anywhere) and `abstract-review-board` (the
|
|
53
|
-
form-backed quick path, indexed tier).
|
|
53
|
+
form-backed quick path, indexed tier).
|
|
54
|
+
|
|
55
|
+
## Definitions as code (`reports:push`)
|
|
54
56
|
|
|
57
|
+
```bash
|
|
58
|
+
npm run reports:push -- --file src/modules/reports/definitions/member-directory.report.json \
|
|
59
|
+
--org demo --user owner@example.com --publish
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`src/modules/reports/definitions/*.report.json` files are source files; they are not loaded automatically at boot. `reports:push` loads a definition into an org through the real service path, so member RBAC, save/publish linting, versioning, and audit rows match the HTTP API. Re-pushing unchanged content is a no-op; changed content creates the next draft version, and `--publish` publishes it.
|
|
63
|
+
|
|
55
64
|
## One contract: the QuerySpec
|
|
56
65
|
|
|
57
66
|
The frontend never builds queries. Every grid interaction posts a declarative
|
|
@@ -150,8 +159,8 @@ the thinking:
|
|
|
150
159
|
against the submission's stamped `formVersion`; `updateStatus` runs the same
|
|
151
160
|
pipeline as the form. Both require `formSubmissions.update`. There is no
|
|
152
161
|
generic UPDATE — the report engine never mutates source rows itself.
|
|
153
|
-
-
|
|
154
|
-
Renamed-field mapping across versions needs rename metadata the form
|
|
162
|
+
- Known limitation: columns resolve against the latest **published** form
|
|
163
|
+
version. Renamed-field mapping across versions needs rename metadata the form
|
|
155
164
|
definition format does not yet carry.
|
|
156
165
|
|
|
157
166
|
## Performance tiers
|
|
@@ -202,6 +211,13 @@ row family's previous `label:*`). Curate the vocabulary with the
|
|
|
202
211
|
streamed into reports-owned org-scoped file storage
|
|
203
212
|
(`REPORTS_EXPORT_STORAGE_DIR`), polled at `GET /reports/exports/:jobId`, and
|
|
204
213
|
downloaded from `GET /reports/exports/:jobId/download`.
|
|
214
|
+
- The default local-disk storage is for a single app instance or for multiple
|
|
215
|
+
instances sharing the same mounted volume. For horizontal scaling without a
|
|
216
|
+
shared volume, bind an S3/GCS-backed `ExportFileSink` so downloads can land on
|
|
217
|
+
any node.
|
|
218
|
+
- Finished async export files are retained for `REPORTS_EXPORT_RETENTION_DAYS`
|
|
219
|
+
(default 7) and swept by the reports export worker. After expiry, the job row
|
|
220
|
+
remains for audit/status history but the file is no longer downloadable.
|
|
205
221
|
- Every export — sync or async — runs inside ONE `REPEATABLE READ` snapshot
|
|
206
222
|
(duration-bounded by `reports.exportMaxSnapshotSeconds`); an export that
|
|
207
223
|
cannot finish in time is rejected with guidance to tighten filters or move
|
|
@@ -232,7 +248,9 @@ Env: `REPORTS_SCHEMA_CHECK` (`on`/`off`); `REPORTS_TOKEN_SECRET` (optional, min
|
|
|
232
248
|
`JWT_SECRET` via HKDF; rotating it invalidates outstanding cursors/tokens —
|
|
233
249
|
clients restart from page one); and the reports-owned async-export worker:
|
|
234
250
|
`REPORTS_EXPORT_WORKER_ENABLED` (default true), `REPORTS_EXPORT_POLL_MS`
|
|
235
|
-
(default 5000), `REPORTS_EXPORT_STORAGE_DIR` (default `./var/report-exports`)
|
|
251
|
+
(default 5000), `REPORTS_EXPORT_STORAGE_DIR` (default `./var/report-exports`),
|
|
252
|
+
`REPORTS_EXPORT_RETENTION_DAYS` (default 7; 0 disables cleanup), and
|
|
253
|
+
`REPORTS_EXPORT_RETENTION_SWEEP_MS` (default 3600000).
|
|
236
254
|
|
|
237
255
|
## Engine upgrades (schema ownership)
|
|
238
256
|
|