@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
|
@@ -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");
|
|
@@ -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
|
|
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
|
-
//
|
|
441
|
-
ownerId
|
|
442
|
-
|
|
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
|
-
//
|
|
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;
|
|
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,
|
|
@@ -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
|
+
}
|