@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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +3 -0
  3. package/template/_package.json +7 -3
  4. package/template/docs/FORMS.md +87 -18
  5. package/template/docs/FORMS_CHECKLIST.md +36 -26
  6. package/template/docs/REPORTS.md +22 -4
  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/migrations/20260625000000_form_builder_public_uploads/migration.sql +7 -0
  10. package/template/prisma/schema.prisma +13 -6
  11. package/template/scripts/push-report.ts +123 -0
  12. package/template/src/app.module.ts +4 -3
  13. package/template/src/common/swagger/api-error-responses.ts +8 -2
  14. package/template/src/config/env.validation.ts +3 -0
  15. package/template/src/config/forms.config.ts +1 -0
  16. package/template/src/config/reports.config.ts +2 -0
  17. package/template/src/modules/forms/application/services/data-sources/conference-tracks.data-source.ts +32 -0
  18. package/template/src/modules/forms/application/services/forms-files.service.ts +143 -39
  19. package/template/src/modules/forms/application/services/forms-public.service.ts +2 -1
  20. package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +5 -3
  21. package/template/src/modules/forms/application/services/handlers/webhook-delivery.transport.ts +319 -0
  22. package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +64 -16
  23. package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +40 -18
  24. package/template/src/modules/forms/dto/public-file-upload-response.dto.ts +10 -0
  25. package/template/src/modules/forms/dto/public-submit-form.dto.ts +9 -0
  26. package/template/src/modules/forms/forms.module.ts +12 -2
  27. package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +43 -3
  28. package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +82 -59
  29. package/template/src/modules/forms/presentation/public-forms-files.controller.ts +66 -0
  30. package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +81 -0
  31. package/template/src/modules/reports/application/services/reports-exports.service.ts +6 -2
  32. package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +43 -30
  33. package/template/src/modules/settings/types/setting-definitions.ts +4 -0
  34. package/template/test/forms-captcha.e2e-spec.ts +163 -0
  35. package/template/test/forms-files.e2e-spec.ts +42 -20
  36. package/template/test/forms-outbox.e2e-spec.ts +271 -10
  37. package/template/test/forms-public.e2e-spec.ts +24 -0
  38. package/template/test/forms-submissions.e2e-spec.ts +2 -11
  39. package/template/test/forms-throttling.e2e-spec.ts +146 -0
  40. package/template/test/forms-webhooks.e2e-spec.ts +150 -8
  41. package/template/test/jest-e2e.json +1 -0
  42. package/template/test/reports-advanced.e2e-spec.ts +13 -0
  43. package/template/test/reports-query.e2e-spec.ts +52 -0
  44. package/template/test/reports-tiers.e2e-spec.ts +106 -20
  45. package/template/test/route-registry.validator.spec.ts +4 -2
@@ -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");
@@ -0,0 +1,7 @@
1
+ -- AlterTable
2
+ ALTER TABLE "UploadedFile" ALTER COLUMN "ownerId" DROP NOT NULL,
3
+ ADD COLUMN "claimTokenHash" TEXT,
4
+ ADD COLUMN "uploadedIp" TEXT;
5
+
6
+ -- CreateIndex
7
+ CREATE INDEX "UploadedFile_orgId_uploadedIp_createdAt_idx" ON "UploadedFile"("orgId", "uploadedIp", "createdAt");
@@ -298,7 +298,7 @@ model AuditLog {
298
298
  @@index([createdAt])
299
299
  }
300
300
  // ─────────────────────────────────────────────────────────────────────────────
301
- // @ftisindia/form-builder canonical Prisma model snippet (ENGINE_SCHEMA_VERSION 1)
301
+ // @ftisindia/form-builder - canonical Prisma model snippet (ENGINE_SCHEMA_VERSION 3)
302
302
  //
303
303
  // The APP owns schema.prisma and the migration history (ecosystem guide §10.1).
304
304
  // Copy these models into your app's prisma/schema.prisma verbatim and run
@@ -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 {
@@ -437,9 +441,11 @@ model UploadedFile {
437
441
  size Int
438
442
  // sha256 hex.
439
443
  checksum String
440
- // Two-factor ownership: uploader identity AND tenant scope (§10).
441
- ownerId String
442
- orgId String
444
+ // Authenticated uploads have ownerId; anonymous public uploads have a claim token.
445
+ ownerId String?
446
+ claimTokenHash String?
447
+ uploadedIp String?
448
+ orgId String
443
449
 
444
450
  status FileStatus @default(TEMPORARY)
445
451
  submissionId String?
@@ -447,7 +453,8 @@ model UploadedFile {
447
453
  createdAt DateTime @default(now())
448
454
 
449
455
  @@index([orgId, status])
450
- @@index([ownerId, status])
456
+ @@index([ownerId, status])
457
+ @@index([orgId, uploadedIp, createdAt])
451
458
  @@index([status, createdAt])
452
459
  }
453
460
 
@@ -531,7 +538,7 @@ model ReportExportJob {
531
538
  // REPEATABLE READ snapshot timestamp, set when the job runs (finding #8).
532
539
  asOf DateTime?
533
540
  status ReportExportStatus @default(PENDING)
534
- // UploadedFile id once the async export lands in file storage.
541
+ // Reports-owned storage key once the async export lands in file storage.
535
542
  fileId String?
536
543
  rowCount Int?
537
544
  error String?
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Push a report-definition JSON file into an organisation through the real
3
+ * report engine path: member RBAC, save/publish linting, versioning, and audit
4
+ * rows all stay intact.
5
+ *
6
+ * npm run reports:push -- --file src/modules/reports/definitions/my-report.report.json \
7
+ * --org <org-id-or-slug> --user <member-email> [--publish]
8
+ *
9
+ * Idempotent: if the file matches the latest stored version's authored content,
10
+ * nothing new is created. With --publish, a matching DRAFT is published in place.
11
+ * Runs under ts-node because it bootstraps Nest.
12
+ */
13
+ import { readFileSync } from 'node:fs';
14
+ import { NestFactory } from '@nestjs/core';
15
+ import type { ReportDefinition } from '@ftisindia/report-builder';
16
+ import { AppModule } from '../src/app.module';
17
+ import { PrismaService } from '../src/database/prisma/prisma.service';
18
+ import { RbacCacheService } from '../src/modules/access-control/application/services/rbac-cache.service';
19
+ import { RequestContextService } from '../src/modules/request-context/application/services/request-context.service';
20
+ import { ReportsDefinitionsService } from '../src/modules/reports/application/services/reports-definitions.service';
21
+ import { CreateReportDefinitionDto } from '../src/modules/reports/dto/create-report-definition.dto';
22
+
23
+ function argValue(flag: string): string | undefined {
24
+ const index = process.argv.indexOf(flag);
25
+ return index !== -1 ? process.argv[index + 1] : undefined;
26
+ }
27
+
28
+ function stableStringify(value: unknown): string {
29
+ if (Array.isArray(value)) {
30
+ return `[${value.map(stableStringify).join(',')}]`;
31
+ }
32
+ if (value && typeof value === 'object') {
33
+ const entries = Object.entries(value as Record<string, unknown>)
34
+ .filter(([, v]) => v !== undefined)
35
+ .sort(([a], [b]) => a.localeCompare(b))
36
+ .map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`);
37
+ return `{${entries.join(',')}}`;
38
+ }
39
+ return JSON.stringify(value);
40
+ }
41
+
42
+ /** Version/status are managed by the engine - compare authored content only. */
43
+ function normalized(schema: ReportDefinition): string {
44
+ const { version: _version, status: _status, ...rest } = schema;
45
+ return stableStringify(rest);
46
+ }
47
+
48
+ async function main() {
49
+ const file = argValue('--file');
50
+ const orgRef = argValue('--org');
51
+ const userEmail = argValue('--user');
52
+ const publish = process.argv.includes('--publish');
53
+ if (!file || !orgRef || !userEmail) {
54
+ throw new Error(
55
+ 'Usage: npm run reports:push -- --file <path> --org <org-id-or-slug> --user <member-email> [--publish]',
56
+ );
57
+ }
58
+
59
+ const definition = JSON.parse(readFileSync(file, 'utf8')) as ReportDefinition;
60
+ if (!definition.key) {
61
+ throw new Error(`${file} does not look like a report definition (missing "key").`);
62
+ }
63
+
64
+ const app = await NestFactory.createApplicationContext(AppModule, {
65
+ logger: ['error', 'warn'],
66
+ });
67
+ try {
68
+ const prisma = app.get(PrismaService);
69
+ const user = await prisma.user.findFirst({ where: { email: userEmail } });
70
+ if (!user) {
71
+ throw new Error(`No user found with email "${userEmail}".`);
72
+ }
73
+ const org = await prisma.organisation.findFirst({
74
+ where: { OR: [{ id: orgRef }, { slug: orgRef }] },
75
+ });
76
+ if (!org) {
77
+ throw new Error(`No organisation found with id or slug "${orgRef}".`);
78
+ }
79
+
80
+ const rbac = await app.get(RbacCacheService).getContext(user.id, org.id);
81
+ const requestContext = app.get(RequestContextService);
82
+ const definitions = app.get(ReportsDefinitionsService);
83
+
84
+ await requestContext.run(
85
+ { source: 'worker', orgId: org.id, userId: user.id, rbac },
86
+ async () => {
87
+ const latest = await prisma.reportDefinition.findFirst({
88
+ where: { orgId: org.id, key: definition.key },
89
+ orderBy: { version: 'desc' },
90
+ });
91
+
92
+ if (latest && normalized(latest.schema as unknown as ReportDefinition) === normalized(definition)) {
93
+ if (publish && latest.status === 'DRAFT') {
94
+ await definitions.publish(org.id, definition.key);
95
+ console.log(`Unchanged content - published existing draft v${latest.version}.`);
96
+ } else {
97
+ console.log(
98
+ `No changes - "${definition.key}" v${latest.version} (${latest.status}) already matches ${file}.`,
99
+ );
100
+ }
101
+ return;
102
+ }
103
+
104
+ const created = await definitions.create(
105
+ org.id,
106
+ definition as unknown as CreateReportDefinitionDto,
107
+ );
108
+ console.log(`Created "${created.key}" v${created.version} (DRAFT).`);
109
+ if (publish) {
110
+ await definitions.publish(org.id, created.key);
111
+ console.log(`Published "${created.key}" v${created.version}.`);
112
+ }
113
+ },
114
+ );
115
+ } finally {
116
+ await app.close();
117
+ }
118
+ }
119
+
120
+ main().catch((error) => {
121
+ console.error(error instanceof Error ? error.message : error);
122
+ process.exit(1);
123
+ });
@@ -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,
@@ -6,13 +6,15 @@ import {
6
6
  ApiGoneResponse,
7
7
  ApiInternalServerErrorResponse,
8
8
  ApiNotFoundResponse,
9
+ ApiPayloadTooLargeResponse,
9
10
  ApiServiceUnavailableResponse,
10
11
  ApiTooManyRequestsResponse,
11
12
  ApiUnauthorizedResponse,
13
+ ApiUnsupportedMediaTypeResponse,
12
14
  } from '@nestjs/swagger';
13
15
  import { ErrorResponseDto } from '../dto/error-response.dto';
14
16
 
15
- type ApiErrorStatus = 400 | 401 | 403 | 404 | 409 | 410 | 429 | 500 | 503;
17
+ type ApiErrorStatus = 400 | 401 | 403 | 404 | 409 | 410 | 413 | 415 | 429 | 500 | 503;
16
18
 
17
19
  const errorResponseFactories = {
18
20
  400: ApiBadRequestResponse,
@@ -21,6 +23,8 @@ const errorResponseFactories = {
21
23
  404: ApiNotFoundResponse,
22
24
  409: ApiConflictResponse,
23
25
  410: ApiGoneResponse,
26
+ 413: ApiPayloadTooLargeResponse,
27
+ 415: ApiUnsupportedMediaTypeResponse,
24
28
  429: ApiTooManyRequestsResponse,
25
29
  500: ApiInternalServerErrorResponse,
26
30
  503: ApiServiceUnavailableResponse,
@@ -33,6 +37,8 @@ const descriptions: Record<ApiErrorStatus, string> = {
33
37
  404: 'The requested resource was not found.',
34
38
  409: 'The request conflicts with current resource state.',
35
39
  410: 'The resource is no longer available.',
40
+ 413: 'The request payload is too large.',
41
+ 415: 'The uploaded media type is not supported.',
36
42
  429: 'Too many requests.',
37
43
  500: 'Unexpected server error.',
38
44
  503: 'The service is temporarily unavailable.',
@@ -51,4 +57,4 @@ export function ApiErrorResponses(...statuses: ApiErrorStatus[]) {
51
57
 
52
58
  export function ApiProtectedErrorResponses(...extraStatuses: ApiErrorStatus[]) {
53
59
  return ApiErrorResponses(400, 401, 403, ...extraStatuses);
54
- }
60
+ }
@@ -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
  }));
@@ -0,0 +1,32 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import type {
3
+ DataSourceContext,
4
+ DataSourceDef,
5
+ DataSourceOption,
6
+ } from '@ftisindia/form-builder';
7
+ import { FormDataSource } from '../../../infrastructure/registry/form-extension.decorators';
8
+
9
+ /**
10
+ * Example data source for src/modules/forms/examples/abstract-submission.form.json.
11
+ *
12
+ * Real apps should replace this static list with an org-scoped Prisma/API
13
+ * lookup using ctx.orgId. Keeping the shipped example self-contained makes
14
+ * the data-source registry usable immediately after scaffolding.
15
+ */
16
+ @Injectable()
17
+ @FormDataSource()
18
+ export class ConferenceTracksDataSource implements DataSourceDef {
19
+ readonly key = 'conference-tracks';
20
+
21
+ fetch(
22
+ _params: Record<string, unknown>,
23
+ _ctx: DataSourceContext,
24
+ ): Promise<DataSourceOption[]> {
25
+ return Promise.resolve([
26
+ { value: 'clinical-research', label: 'Clinical Research' },
27
+ { value: 'public-health', label: 'Public Health' },
28
+ { value: 'biomedical-engineering', label: 'Biomedical Engineering' },
29
+ { value: 'data-science', label: 'Data Science' },
30
+ ]);
31
+ }
32
+ }