@ftisindia/create-app 0.1.4 → 0.1.6
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 +31 -0
- package/template/README.md +61 -0
- package/template/_gitignore +6 -0
- package/template/_package.json +6 -0
- package/template/docs/FORMS.md +169 -0
- package/template/docs/FORMS_CHECKLIST.md +61 -0
- package/template/docs/REPORTS.md +246 -0
- package/template/docs/REPORTS_CHECKLIST.md +97 -0
- package/template/prisma/migrations/20260612000000_add_form_builder/migration.sql +147 -0
- package/template/prisma/migrations/20260613000000_add_report_builder/migration.sql +129 -0
- package/template/prisma/schema.prisma +285 -0
- package/template/scripts/export-openapi.ts +85 -0
- package/template/scripts/gen-form.mjs +149 -0
- package/template/scripts/push-form.ts +124 -0
- package/template/src/app.module.ts +29 -8
- package/template/src/common/dto/membership-response.dto.ts +1 -0
- package/template/src/common/dto/role-summary.dto.ts +3 -3
- package/template/src/common/dto/user-summary.dto.ts +3 -3
- package/template/src/config/app.config.ts +6 -1
- package/template/src/config/env.validation.ts +45 -0
- package/template/src/config/forms.config.ts +12 -0
- package/template/src/config/index.ts +2 -0
- package/template/src/config/openapi.ts +12 -0
- package/template/src/config/reports-secret.ts +15 -0
- package/template/src/config/reports.config.ts +16 -0
- package/template/src/main.ts +16 -12
- package/template/src/modules/access-control/access-control.module.ts +2 -1
- package/template/src/modules/access-control/dto/access-control-response.dto.ts +3 -0
- package/template/src/modules/access-control/dto/current-access-control-response.dto.ts +35 -0
- package/template/src/modules/access-control/presentation/current-access-control.controller.ts +40 -0
- package/template/src/modules/access-control/types/permission-key.ts +27 -0
- package/template/src/modules/access-control/types/route-permission-registry.ts +183 -0
- package/template/src/modules/audit/dto/audit-response.dto.ts +7 -3
- package/template/src/modules/auth/auth.module.ts +3 -1
- package/template/src/modules/auth/dto/auth-response.dto.ts +1 -1
- package/template/src/modules/forms/application/services/file-gc.service.ts +85 -0
- package/template/src/modules/forms/application/services/forms-definitions.service.ts +137 -0
- package/template/src/modules/forms/application/services/forms-error.mapper.ts +64 -0
- package/template/src/modules/forms/application/services/forms-export.service.ts +210 -0
- package/template/src/modules/forms/application/services/forms-files.service.ts +164 -0
- package/template/src/modules/forms/application/services/forms-public.service.ts +49 -0
- package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +53 -0
- package/template/src/modules/forms/application/services/forms-submissions.service.ts +103 -0
- package/template/src/modules/forms/application/services/handlers/authenticate.action.ts +37 -0
- package/template/src/modules/forms/application/services/handlers/logging-email.handler.ts +22 -0
- package/template/src/modules/forms/application/services/handlers/send-confirmation-email.action.ts +40 -0
- package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +41 -0
- package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +109 -0
- package/template/src/modules/forms/dto/create-form-definition.dto.ts +12 -0
- package/template/src/modules/forms/dto/data-source-response.dto.ts +19 -0
- package/template/src/modules/forms/dto/export-submissions-query.dto.ts +33 -0
- package/template/src/modules/forms/dto/file-upload-response.dto.ts +24 -0
- package/template/src/modules/forms/dto/form-definition-response.dto.ts +50 -0
- package/template/src/modules/forms/dto/form-render-response.dto.ts +17 -0
- package/template/src/modules/forms/dto/list-form-definitions-query.dto.ts +10 -0
- package/template/src/modules/forms/dto/list-submissions-query.dto.ts +10 -0
- package/template/src/modules/forms/dto/public-submit-form.dto.ts +24 -0
- package/template/src/modules/forms/dto/set-public-access.dto.ts +8 -0
- package/template/src/modules/forms/dto/submission-response.dto.ts +99 -0
- package/template/src/modules/forms/dto/submit-form.dto.ts +50 -0
- package/template/src/modules/forms/dto/update-form-definition.dto.ts +12 -0
- package/template/src/modules/forms/dto/upload-file-query.dto.ts +33 -0
- package/template/src/modules/forms/dto/validate-submission.dto.ts +22 -0
- package/template/src/modules/forms/examples/abstract-submission.form.json +80 -0
- package/template/src/modules/forms/examples/login.form.json +24 -0
- package/template/src/modules/forms/examples/registration.form.json +44 -0
- package/template/src/modules/forms/forms.module.ts +226 -0
- package/template/src/modules/forms/forms.tokens.ts +6 -0
- package/template/src/modules/forms/infrastructure/audit-sink.adapter.ts +30 -0
- package/template/src/modules/forms/infrastructure/casl-forms-authorization.ts +31 -0
- package/template/src/modules/forms/infrastructure/prisma-tx-runner.ts +17 -0
- package/template/src/modules/forms/infrastructure/registry/form-extension.decorators.ts +17 -0
- package/template/src/modules/forms/infrastructure/registry/registry-bootstrap.service.ts +82 -0
- package/template/src/modules/forms/infrastructure/request-forms-context.ts +60 -0
- package/template/src/modules/forms/infrastructure/schema-check/forms-schema-check.service.ts +76 -0
- package/template/src/modules/forms/infrastructure/storage/local-disk-storage.adapter.ts +43 -0
- package/template/src/modules/forms/infrastructure/stores/index.ts +5 -0
- package/template/src/modules/forms/infrastructure/stores/prisma-action-log.store.ts +37 -0
- package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +108 -0
- package/template/src/modules/forms/infrastructure/stores/prisma-form-definition.store.ts +147 -0
- package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +133 -0
- package/template/src/modules/forms/infrastructure/stores/prisma-submission.store.ts +164 -0
- package/template/src/modules/forms/presentation/forms-data-sources.controller.ts +58 -0
- package/template/src/modules/forms/presentation/forms-definitions.controller.ts +191 -0
- package/template/src/modules/forms/presentation/forms-files.controller.ts +79 -0
- package/template/src/modules/forms/presentation/forms-submissions.controller.ts +154 -0
- package/template/src/modules/forms/presentation/forms-upload.interceptor.ts +33 -0
- package/template/src/modules/forms/presentation/public-forms.controller.ts +51 -0
- package/template/src/modules/invitations/dto/invitation-response.dto.ts +4 -0
- package/template/src/modules/organisations/application/services/organisations.service.ts +67 -1
- package/template/src/modules/organisations/dto/organisation-response.dto.ts +52 -0
- package/template/src/modules/organisations/presentation/organisations.controller.ts +25 -3
- package/template/src/modules/reports/application/services/reports-actions.service.ts +54 -0
- package/template/src/modules/reports/application/services/reports-definitions.service.ts +66 -0
- package/template/src/modules/reports/application/services/reports-error.mapper.ts +97 -0
- package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +124 -0
- package/template/src/modules/reports/application/services/reports-exports.service.ts +74 -0
- package/template/src/modules/reports/application/services/reports-queries.service.ts +35 -0
- package/template/src/modules/reports/application/services/reports-settings-reader.service.ts +49 -0
- package/template/src/modules/reports/application/services/reports-views.service.ts +79 -0
- package/template/src/modules/reports/dto/action-result-response.dto.ts +21 -0
- package/template/src/modules/reports/dto/create-report-definition.dto.ts +86 -0
- package/template/src/modules/reports/dto/create-saved-view.dto.ts +26 -0
- package/template/src/modules/reports/dto/execute-action.dto.ts +71 -0
- package/template/src/modules/reports/dto/export-job-response.dto.ts +60 -0
- package/template/src/modules/reports/dto/export-request.dto.ts +34 -0
- package/template/src/modules/reports/dto/list-reports-query.dto.ts +10 -0
- package/template/src/modules/reports/dto/list-views-query.dto.ts +17 -0
- package/template/src/modules/reports/dto/prepare-action-response.dto.ts +14 -0
- package/template/src/modules/reports/dto/prepare-action.dto.ts +27 -0
- package/template/src/modules/reports/dto/query-response.dto.ts +64 -0
- package/template/src/modules/reports/dto/query-spec.dto.ts +120 -0
- package/template/src/modules/reports/dto/report-definition-response.dto.ts +64 -0
- package/template/src/modules/reports/dto/report-meta-query.dto.ts +16 -0
- package/template/src/modules/reports/dto/report-meta-response.dto.ts +113 -0
- package/template/src/modules/reports/dto/saved-view-response.dto.ts +66 -0
- package/template/src/modules/reports/dto/update-report-definition.dto.ts +9 -0
- package/template/src/modules/reports/dto/update-saved-view.dto.ts +27 -0
- package/template/src/modules/reports/examples/abstract-review-board.report.json +54 -0
- package/template/src/modules/reports/examples/org-members.report.json +55 -0
- package/template/src/modules/reports/infrastructure/audit-sink.adapter.ts +31 -0
- package/template/src/modules/reports/infrastructure/casl-reports-authorization.ts +39 -0
- package/template/src/modules/reports/infrastructure/forms-adapter/form-report-source.adapter.ts +292 -0
- package/template/src/modules/reports/infrastructure/forms-adapter/form-row-actions.ts +171 -0
- package/template/src/modules/reports/infrastructure/forms-adapter/forms-bridge-bootstrap.service.ts +32 -0
- package/template/src/modules/reports/infrastructure/prisma-catalog.adapter.ts +95 -0
- package/template/src/modules/reports/infrastructure/prisma-query-executor.ts +103 -0
- package/template/src/modules/reports/infrastructure/prisma-snapshot-runner.ts +47 -0
- package/template/src/modules/reports/infrastructure/prisma-tx-runner.ts +18 -0
- package/template/src/modules/reports/infrastructure/registry/registry-bootstrap.service.ts +61 -0
- package/template/src/modules/reports/infrastructure/registry/report-extension.decorators.ts +14 -0
- package/template/src/modules/reports/infrastructure/reports-job-queue.adapter.ts +28 -0
- package/template/src/modules/reports/infrastructure/request-reports-context.ts +42 -0
- package/template/src/modules/reports/infrastructure/schema-check/reports-schema-check.service.ts +116 -0
- package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +79 -0
- package/template/src/modules/reports/infrastructure/stores/index.ts +5 -0
- package/template/src/modules/reports/infrastructure/stores/prisma-bulk-action-run.store.ts +89 -0
- package/template/src/modules/reports/infrastructure/stores/prisma-export-job.store.ts +93 -0
- package/template/src/modules/reports/infrastructure/stores/prisma-report-definition.store.ts +171 -0
- package/template/src/modules/reports/infrastructure/stores/prisma-row-tag.store.ts +110 -0
- package/template/src/modules/reports/infrastructure/stores/prisma-saved-view.store.ts +144 -0
- package/template/src/modules/reports/presentation/reports-actions.controller.ts +83 -0
- package/template/src/modules/reports/presentation/reports-definitions.controller.ts +156 -0
- package/template/src/modules/reports/presentation/reports-export-jobs.controller.ts +61 -0
- package/template/src/modules/reports/presentation/reports-export.controller.ts +76 -0
- package/template/src/modules/reports/presentation/reports-query.controller.ts +52 -0
- package/template/src/modules/reports/presentation/reports-views.controller.ts +140 -0
- package/template/src/modules/reports/reports-forms.module.ts +33 -0
- package/template/src/modules/reports/reports.module.ts +335 -0
- package/template/src/modules/reports/reports.tokens.ts +11 -0
- package/template/src/modules/reports/sources/org-members.source.ts +112 -0
- package/template/src/modules/settings/types/setting-definitions.ts +94 -0
- package/template/test/forms-definitions.e2e-spec.ts +394 -0
- package/template/test/forms-export.e2e-spec.ts +390 -0
- package/template/test/forms-files.e2e-spec.ts +345 -0
- package/template/test/forms-outbox.e2e-spec.ts +309 -0
- package/template/test/forms-permission-sync.spec.ts +27 -0
- package/template/test/forms-public.e2e-spec.ts +269 -0
- package/template/test/forms-schema-check.e2e-spec.ts +65 -0
- package/template/test/forms-submissions.e2e-spec.ts +500 -0
- package/template/test/forms-webhooks.e2e-spec.ts +261 -0
- package/template/test/frontend-bootstrap.spec.ts +181 -0
- package/template/test/reports-advanced.e2e-spec.ts +368 -0
- package/template/test/reports-permission-sync.spec.ts +30 -0
- package/template/test/reports-query.e2e-spec.ts +350 -0
- package/template/test/reports-tiers.e2e-spec.ts +257 -0
- package/template/test/route-registry.validator.spec.ts +34 -0
- package/template/test/security.e2e-spec.ts +134 -2
package/package.json
CHANGED
package/template/.env.example
CHANGED
|
@@ -13,6 +13,10 @@ AUTH_GOOGLE_ENABLED=false
|
|
|
13
13
|
AUTH_GOOGLE_CLIENT_ID=
|
|
14
14
|
AUTH_GOOGLE_CLIENT_SECRET=
|
|
15
15
|
AUTH_GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/callback
|
|
16
|
+
# The backend redirects the browser here with a one-time ?code= after Google
|
|
17
|
+
# login. When a frontend app consumes this API (for example one scaffolded with
|
|
18
|
+
# @ftisindia/create-frontend), point this at the frontend's login route, e.g.
|
|
19
|
+
# AUTH_GOOGLE_SUCCESS_REDIRECT_URL=http://localhost:4200/login
|
|
16
20
|
AUTH_GOOGLE_SUCCESS_REDIRECT_URL=http://localhost:3000/auth/google/success
|
|
17
21
|
AUTH_GOOGLE_ERROR_REDIRECT_URL=http://localhost:3000/auth/google/error
|
|
18
22
|
# Required when AUTH_GOOGLE_ENABLED=true. Use at least 32 random characters.
|
|
@@ -21,6 +25,12 @@ AUTH_FACEBOOK_ENABLED=false
|
|
|
21
25
|
AUTH_MOBILE_OTP_ENABLED=false
|
|
22
26
|
AUTH_MAGIC_LINK_ENABLED=false
|
|
23
27
|
|
|
28
|
+
CORS_ENABLED=false
|
|
29
|
+
# Comma-separated exact browser origins, for example:
|
|
30
|
+
# CORS_ORIGINS=http://localhost:5173,https://app.example.com
|
|
31
|
+
CORS_ORIGINS=
|
|
32
|
+
CORS_CREDENTIALS=false
|
|
33
|
+
|
|
24
34
|
RBAC_CACHE_TTL_SECONDS=60
|
|
25
35
|
# Path is the only supported mode in this starter phase.
|
|
26
36
|
ORG_CONTEXT_MODE=path
|
|
@@ -34,3 +44,24 @@ TEST_USER_PASSWORD=
|
|
|
34
44
|
TEST_USER_DISPLAY_NAME=Starter Owner
|
|
35
45
|
TEST_ORG_NAME=Demo Organisation
|
|
36
46
|
TEST_ORG_SLUG=demo
|
|
47
|
+
|
|
48
|
+
# Form builder (@ftisindia/form-builder)
|
|
49
|
+
FORMS_OUTBOX_ENABLED=true
|
|
50
|
+
FORMS_OUTBOX_POLL_MS=5000
|
|
51
|
+
FORMS_FILE_GC_INTERVAL_MS=3600000
|
|
52
|
+
FORMS_FILE_TEMP_TTL_HOURS=24
|
|
53
|
+
FORMS_MAX_UPLOAD_MB=25
|
|
54
|
+
FORMS_FILE_STORAGE_DIR=./var/uploads
|
|
55
|
+
# Set to off only to bypass the boot-time engine schema check (not recommended).
|
|
56
|
+
FORMS_SCHEMA_CHECK=on
|
|
57
|
+
|
|
58
|
+
# Report builder (@ftisindia/report-builder)
|
|
59
|
+
# Boot-time schema check for the engine tables (set off only to bypass temporarily).
|
|
60
|
+
REPORTS_SCHEMA_CHECK=on
|
|
61
|
+
# Optional HMAC secret for report cursors/bulk-action tokens (min 32 chars).
|
|
62
|
+
# When empty, a dedicated key is derived from JWT_SECRET via HKDF.
|
|
63
|
+
REPORTS_TOKEN_SECRET=
|
|
64
|
+
# Reports-owned async export worker + file storage (no forms dependency).
|
|
65
|
+
REPORTS_EXPORT_WORKER_ENABLED=true
|
|
66
|
+
REPORTS_EXPORT_POLL_MS=5000
|
|
67
|
+
REPORTS_EXPORT_STORAGE_DIR=./var/report-exports
|
package/template/README.md
CHANGED
|
@@ -78,6 +78,7 @@ GET /auth/google
|
|
|
78
78
|
GET /auth/google/callback
|
|
79
79
|
GET /auth/me
|
|
80
80
|
POST /organisations
|
|
81
|
+
GET /organisations/mine
|
|
81
82
|
GET /organisations/:orgId/memberships/me
|
|
82
83
|
GET /organisations/:orgId/memberships
|
|
83
84
|
PATCH /organisations/:orgId/memberships/:membershipId/status
|
|
@@ -91,6 +92,7 @@ POST /organisations/:orgId/invitations/:invitationId/revoke
|
|
|
91
92
|
POST /organisations/:orgId/invitations/:invitationId/resend
|
|
92
93
|
POST /invitations/accept
|
|
93
94
|
POST /invitations/decline
|
|
95
|
+
GET /organisations/:orgId/access-control/me
|
|
94
96
|
GET /organisations/:orgId/access-control/permissions
|
|
95
97
|
GET /organisations/:orgId/access-control/route-permissions
|
|
96
98
|
GET /organisations/:orgId/access-control/roles
|
|
@@ -107,6 +109,36 @@ GET /organisations/:orgId/sample/status
|
|
|
107
109
|
POST /organisations/:orgId/sample/echo
|
|
108
110
|
```
|
|
109
111
|
|
|
112
|
+
## Frontend Bootstrap
|
|
113
|
+
|
|
114
|
+
After login, call `GET /organisations/mine` to populate the organisation picker,
|
|
115
|
+
store the selected `orgId`, then call
|
|
116
|
+
`GET /organisations/:orgId/access-control/me`. Use the returned
|
|
117
|
+
`permissionKeys` for route, menu, and button visibility in the frontend. Backend
|
|
118
|
+
guards remain the source of truth for access control.
|
|
119
|
+
|
|
120
|
+
A companion frontend starter exists: `npx @ftisindia/create-frontend my-app`
|
|
121
|
+
scaffolds an Angular app already wired to these endpoints.
|
|
122
|
+
|
|
123
|
+
### Google OAuth with a frontend
|
|
124
|
+
|
|
125
|
+
`GET /auth/google/callback` redirects the browser to
|
|
126
|
+
`AUTH_GOOGLE_SUCCESS_REDIRECT_URL` with a one-time `?code=` that the frontend
|
|
127
|
+
must exchange via `POST /auth/oauth/exchange`. The default value points at the
|
|
128
|
+
backend's own origin, so set it to the frontend's login route (for example
|
|
129
|
+
`http://localhost:4200/login`) or the code never reaches the frontend.
|
|
130
|
+
|
|
131
|
+
### Exporting the OpenAPI document
|
|
132
|
+
|
|
133
|
+
Frontend tooling can consume the API contract without a running server:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
npm run export:openapi
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
This writes `docs-json.json` (the same document served at `/docs-json`) to the
|
|
140
|
+
project root. No database connection is required.
|
|
141
|
+
|
|
110
142
|
## Architecture Rules
|
|
111
143
|
|
|
112
144
|
- JWTs contain identity only. Organisation membership, roles, and permissions are read per request.
|
|
@@ -116,6 +148,35 @@ POST /organisations/:orgId/sample/echo
|
|
|
116
148
|
- Mutating business flows should write audit rows.
|
|
117
149
|
- Setup and CI commands are non-destructive. Use `npm run db:reset` only when you explicitly want to reset a local database.
|
|
118
150
|
|
|
151
|
+
## Dynamic Forms
|
|
152
|
+
|
|
153
|
+
The app ships with a definition-driven form engine (`@ftisindia/form-builder`
|
|
154
|
+
plus the `src/modules/forms` glue module). Form definitions are versioned JSON
|
|
155
|
+
documents created over the API; submissions run a validated, transactional
|
|
156
|
+
action pipeline with a reliable outbox for emails/webhooks. Most new forms
|
|
157
|
+
need zero backend code — register a field type, action, or data source only
|
|
158
|
+
when you need genuinely new behavior.
|
|
159
|
+
|
|
160
|
+
- Guide: `docs/FORMS.md` (endpoints, extension story, webhooks, public forms, files, upgrades)
|
|
161
|
+
- Completion standard: `docs/FORMS_CHECKLIST.md`
|
|
162
|
+
- Example definitions: `src/modules/forms/examples/` (registration, abstract submission, login-as-a-form)
|
|
163
|
+
- Definitions as code: `npm run gen:form -- <key>` scaffolds a definition + lint spec; `npm run forms:push` loads it into an org (linted, versioned, audited)
|
|
164
|
+
|
|
165
|
+
## Dynamic Reports
|
|
166
|
+
|
|
167
|
+
The app also ships with a definition-driven report engine
|
|
168
|
+
(`@ftisindia/report-builder` plus the `src/modules/reports` glue module).
|
|
169
|
+
Report definitions are versioned JSON views over registered data sources —
|
|
170
|
+
form submissions are one convenient kind, any table/join is another — with
|
|
171
|
+
filtering, sorting, search, keyset pagination, drift-safe bulk row actions,
|
|
172
|
+
tags, saved views, and snapshot-consistent CSV/XLSX exports through one
|
|
173
|
+
declarative API. Performance is enforced at publish time: unindexed shapes
|
|
174
|
+
fail to publish and the error carries the exact migration SQL to fix them.
|
|
175
|
+
|
|
176
|
+
- Guide: `docs/REPORTS.md` (QuerySpec, sources, tiers, actions, exports, upgrades)
|
|
177
|
+
- Completion standard: `docs/REPORTS_CHECKLIST.md`
|
|
178
|
+
- Example definitions: `src/modules/reports/examples/` (org-members over a custom source, abstract-review-board over a form)
|
|
179
|
+
|
|
119
180
|
## Adding Features
|
|
120
181
|
|
|
121
182
|
Use the sample module as the copy-paste reference:
|
package/template/_gitignore
CHANGED
package/template/_package.json
CHANGED
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
"scripts": {
|
|
7
7
|
"build": "nest build",
|
|
8
8
|
"db:create": "node scripts/db-create.mjs",
|
|
9
|
+
"export:openapi": "ts-node scripts/export-openapi.ts",
|
|
9
10
|
"format": "prettier --write .",
|
|
10
11
|
"format:check": "prettier --check .",
|
|
12
|
+
"forms:push": "ts-node scripts/push-form.ts",
|
|
13
|
+
"gen:form": "node scripts/gen-form.mjs",
|
|
11
14
|
"gen:module": "node scripts/gen-module.mjs",
|
|
12
15
|
"lint": "eslint .",
|
|
13
16
|
"prepare": "husky || true",
|
|
@@ -30,6 +33,8 @@
|
|
|
30
33
|
},
|
|
31
34
|
"dependencies": {
|
|
32
35
|
"@casl/ability": "6.8.1",
|
|
36
|
+
"@ftisindia/form-builder": "^0.1.0",
|
|
37
|
+
"@ftisindia/report-builder": "^0.1.0",
|
|
33
38
|
"@nestjs/common": "11.1.24",
|
|
34
39
|
"@nestjs/config": "4.0.4",
|
|
35
40
|
"@nestjs/core": "11.1.24",
|
|
@@ -56,6 +61,7 @@
|
|
|
56
61
|
"@nestjs/schematics": "11.1.0",
|
|
57
62
|
"@nestjs/testing": "11.1.24",
|
|
58
63
|
"@types/jest": "30.0.0",
|
|
64
|
+
"@types/multer": "1.4.13",
|
|
59
65
|
"@types/node": "20.19.41",
|
|
60
66
|
"@types/passport": "1.0.17",
|
|
61
67
|
"@types/passport-google-oauth20": "2.0.17",
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Forms Module (`@ftisindia/form-builder`)
|
|
2
|
+
|
|
3
|
+
This app ships with a definition-driven dynamic form engine. Form definitions
|
|
4
|
+
are versioned JSON documents in the database; behavior — field types, button
|
|
5
|
+
actions, data sources — is code this app registers. New forms are **data**:
|
|
6
|
+
most ship with zero backend code.
|
|
7
|
+
|
|
8
|
+
- Engine architecture: the `form-builder-design.md` / ecosystem guide pair in
|
|
9
|
+
the starter repository.
|
|
10
|
+
- Completion standard: [`FORMS_CHECKLIST.md`](./FORMS_CHECKLIST.md) on top of
|
|
11
|
+
[`MODULE_COMPLETION_CHECKLIST.md`](./MODULE_COMPLETION_CHECKLIST.md).
|
|
12
|
+
|
|
13
|
+
## The 60-second tour
|
|
14
|
+
|
|
15
|
+
```text
|
|
16
|
+
POST /organisations/:orgId/forms forms.create (new draft version)
|
|
17
|
+
PATCH /organisations/:orgId/forms/:key/versions/:v forms.update (drafts only)
|
|
18
|
+
POST /organisations/:orgId/forms/:key/versions/:v/publish forms.publish (lints, freezes, archives prior)
|
|
19
|
+
POST /organisations/:orgId/forms/:key/versions/:v/public-access forms.managePublicAccess (audited)
|
|
20
|
+
GET /organisations/:orgId/forms/:key/render formSubmissions.create (definition + options)
|
|
21
|
+
POST /organisations/:orgId/forms/:key/submissions formSubmissions.create { mode, data, ... }
|
|
22
|
+
POST /organisations/:orgId/forms/:key/files?field=<name> formSubmissions.create (upload before submit)
|
|
23
|
+
GET /organisations/:orgId/forms/:key/submissions/export formSubmissions.export (audited PII egress)
|
|
24
|
+
GET /public/organisations/:orgId/forms/:key/render|submissions (anonymous, public forms only)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Example definitions live in `src/modules/forms/examples/` — registration
|
|
28
|
+
(plain persist), abstract-submission (nested repeatable groups, data source,
|
|
29
|
+
rules, file upload), and login (public form whose submit action delegates to
|
|
30
|
+
the real `AuthService`).
|
|
31
|
+
|
|
32
|
+
## How it executes
|
|
33
|
+
|
|
34
|
+
A submit runs: coerce → overwrite `source: 'context'` hidden fields from the
|
|
35
|
+
request context (client values for them are always discarded) → lifecycle
|
|
36
|
+
hooks → full validation (JSON Schema in draft or submit mode + bounded
|
|
37
|
+
json-logic rules + data-source membership + file references) → the action
|
|
38
|
+
pipeline: validation actions, then **all transactional actions in ONE
|
|
39
|
+
database transaction** (submission, action logs, audit rows, outbox rows
|
|
40
|
+
commit or roll back together), then post-commit side effects via the
|
|
41
|
+
**transactional outbox** (in-process poller, retries with backoff, parks as
|
|
42
|
+
FAILED after max attempts; delivery writes an audit row). A failed email
|
|
43
|
+
never rolls back a valid submission; a failed transactional step rolls back
|
|
44
|
+
everything.
|
|
45
|
+
|
|
46
|
+
## Adding behavior (the three registries)
|
|
47
|
+
|
|
48
|
+
Implement the engine interface, decorate the provider, list it in a module —
|
|
49
|
+
the registry bootstrap finds it at startup:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { FormDataSource } from './modules/forms/infrastructure/registry/form-extension.decorators';
|
|
53
|
+
import type { DataSourceDef, DataSourceContext, DataSourceOption } from '@ftisindia/form-builder';
|
|
54
|
+
|
|
55
|
+
@Injectable()
|
|
56
|
+
@FormDataSource()
|
|
57
|
+
export class TrackSource implements DataSourceDef {
|
|
58
|
+
key = 'conference-tracks';
|
|
59
|
+
constructor(private readonly prisma: PrismaService) {}
|
|
60
|
+
async fetch(_params: Record<string, unknown>, ctx: DataSourceContext): Promise<DataSourceOption[]> {
|
|
61
|
+
return this.prisma.track.findMany({
|
|
62
|
+
where: { orgId: ctx.orgId, active: true },
|
|
63
|
+
select: { id: true, name: true },
|
|
64
|
+
}).then((rows) => rows.map((row) => ({ value: row.id, label: row.name })));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Same pattern with `@FormFieldType()` (implement `FieldTypeDef`),
|
|
70
|
+
`@FormActionHandler()` (implement `FormAction`), `@FormLifecycleHook()`
|
|
71
|
+
(implement `FormLifecycle`). Builtin actions: `validateAll`, `persist`,
|
|
72
|
+
`persistDraft`, `lockEditing`; shipped app actions: `sendConfirmationEmail`
|
|
73
|
+
(post-commit, enqueues email), `authenticate` (dangerous, delegates to
|
|
74
|
+
`AuthService.login`).
|
|
75
|
+
|
|
76
|
+
## Dangerous actions
|
|
77
|
+
|
|
78
|
+
An action with `dangerous: true` (payments, auth, anything with teeth) is
|
|
79
|
+
triple-gated: wiring it into a definition requires `forms.wireDangerous` AND
|
|
80
|
+
membership in the org's `forms.allowedDangerousActions` typed setting (both
|
|
81
|
+
checked at save/publish); at execute time the allowlist is re-checked. The
|
|
82
|
+
linter also refuses pipelines combining `authenticate` with `persist` —
|
|
83
|
+
credentials are never stored, and dangerous actions' input/output are logged
|
|
84
|
+
as `[REDACTED]`.
|
|
85
|
+
|
|
86
|
+
## Public forms
|
|
87
|
+
|
|
88
|
+
Flip a published definition with `POST .../public-access { "access": "public" }`
|
|
89
|
+
(requires `forms.managePublicAccess`, writes an audit row). Public routes are
|
|
90
|
+
throttled tighter than the app default; the engine enforces the per-IP/day
|
|
91
|
+
cap (`forms.maxSubmissionsPerIpPerDay` setting, per-form override in the
|
|
92
|
+
definition's `settings`) and the captcha seam (`settings.captcha: true` —
|
|
93
|
+
publish fails closed unless a `CaptchaVerifier` is bound to the
|
|
94
|
+
`FORMS_CAPTCHA_VERIFIER` token). Public forms cannot contain file fields.
|
|
95
|
+
|
|
96
|
+
## Files
|
|
97
|
+
|
|
98
|
+
Upload before submit (`POST .../files?field=<name>`); the submission carries
|
|
99
|
+
only `{ "fieldName": { "fileId": "..." } }`. The server sniffs the MIME type
|
|
100
|
+
from content, enforces field + org size caps, computes a sha256, and binds
|
|
101
|
+
the file to uploader AND org. Submit-time linking re-verifies ownership
|
|
102
|
+
in-transaction and promotes TEMPORARY → LINKED; orphans are garbage-collected
|
|
103
|
+
after `FORMS_FILE_TEMP_TTL_HOURS`. Bytes live under `FORMS_FILE_STORAGE_DIR`
|
|
104
|
+
(swap `LocalDiskStorageAdapter` for S3/GCS without engine changes).
|
|
105
|
+
|
|
106
|
+
## Webhooks
|
|
107
|
+
|
|
108
|
+
A definition can declare webhook destinations that fire when a submission is
|
|
109
|
+
**submitted** (never for drafts, never for pipelines without `persist`):
|
|
110
|
+
|
|
111
|
+
```jsonc
|
|
112
|
+
"settings": {
|
|
113
|
+
"webhooks": [
|
|
114
|
+
{ "url": "https://hooks.example.com/forms", "secret": "rotate-me", "includeData": true }
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
- **Reliable by construction:** the job is written to the transactional outbox
|
|
120
|
+
inside the same transaction as the submission, then delivered post-commit
|
|
121
|
+
with retries/backoff (parks as FAILED after max attempts; success writes a
|
|
122
|
+
`forms.outbox.delivered` audit row). Idempotency key: `<submissionId>:webhook:<index>`.
|
|
123
|
+
- **SSRF-gated:** the destination host must be in the org's audited
|
|
124
|
+
`forms.webhookAllowedHosts` typed setting (default empty = webhooks
|
|
125
|
+
disabled), and the URL must be `https` — `http` is allowed for loopback
|
|
126
|
+
hosts only (local development). Enforced at save AND publish lint
|
|
127
|
+
(`WEBHOOK_HOST_NOT_ALLOWED`, `WEBHOOK_URL_INSECURE`, …).
|
|
128
|
+
- **Payload:** `{ event: 'submitted', orgId, formKey, formVersion, submissionId,
|
|
129
|
+
occurredAt, data? }` — `data` passes sensitive-field redaction; set
|
|
130
|
+
`includeData: false` to send a pure notification.
|
|
131
|
+
- **Signed:** with a `secret`, deliveries carry
|
|
132
|
+
`x-forms-signature: sha256=<hmac-sha256(rawBody)>`. The secret lives in the
|
|
133
|
+
definition document (DB) — treat it as a rotatable shared token, not a
|
|
134
|
+
master credential. Max 5 webhooks per definition.
|
|
135
|
+
|
|
136
|
+
## Definitions as code (`gen:form` + `forms:push`)
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
npm run gen:form -- customer-feedback # scaffolds definition + lint spec
|
|
140
|
+
npm test # spec lints the JSON, no DB needed
|
|
141
|
+
npm run forms:push -- --file src/modules/forms/definitions/customer-feedback.form.json \
|
|
142
|
+
--org demo --user owner@example.com --publish
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`gen:form` writes `src/modules/forms/definitions/<key>.form.json` and
|
|
146
|
+
`test/<key>.form.spec.ts` (meta-schema + publish-stage lint — fails `npm test`
|
|
147
|
+
the moment the definition would be rejected by the API). `forms:push` loads a
|
|
148
|
+
definition file into an org through the real engine path: it impersonates an
|
|
149
|
+
existing member (their RBAC applies), so every push is linted, versioned, and
|
|
150
|
+
audited exactly like an HTTP call. Re-pushing an unchanged file is a no-op;
|
|
151
|
+
changed content creates the next version (add `--publish` to go live).
|
|
152
|
+
|
|
153
|
+
## Org settings & env
|
|
154
|
+
|
|
155
|
+
Typed settings: `forms.allowedDangerousActions` (string[]),
|
|
156
|
+
`forms.maxFileSizeMb`, `forms.enableRuleIteration`, `forms.virusScanRequired`
|
|
157
|
+
(seam only — no scanner shipped), `forms.maxSubmissionsPerIpPerDay`.
|
|
158
|
+
Env knobs (see `.env.example`): `FORMS_OUTBOX_ENABLED`, `FORMS_OUTBOX_POLL_MS`,
|
|
159
|
+
`FORMS_FILE_GC_INTERVAL_MS`, `FORMS_FILE_TEMP_TTL_HOURS`, `FORMS_MAX_UPLOAD_MB`,
|
|
160
|
+
`FORMS_FILE_STORAGE_DIR`, `FORMS_SCHEMA_CHECK`.
|
|
161
|
+
|
|
162
|
+
## Engine upgrades (schema ownership)
|
|
163
|
+
|
|
164
|
+
This app owns `schema.prisma` and the migration history. When a new engine
|
|
165
|
+
version changes its data model: read its changelog, copy the updated snippet
|
|
166
|
+
from `node_modules/@ftisindia/form-builder/prisma/forms.prisma` into
|
|
167
|
+
`prisma/schema.prisma`, run `npx prisma migrate dev`. The boot-time schema
|
|
168
|
+
check fails fast with exactly that instruction if the database and engine
|
|
169
|
+
ever disagree (`FORMS_SCHEMA_CHECK=off` is the emergency hatch).
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Forms Module Completion Checklist
|
|
2
|
+
|
|
3
|
+
The forms module must pass `MODULE_COMPLETION_CHECKLIST.md` verbatim, **plus**
|
|
4
|
+
these form-builder-specific items (ecosystem guide §11). Re-verify after any
|
|
5
|
+
engine upgrade or forms-module change.
|
|
6
|
+
|
|
7
|
+
## Boundary
|
|
8
|
+
|
|
9
|
+
- [ ] The engine package (`@ftisindia/form-builder`) compiles and unit-tests **without** the template; only `src/modules/forms` imports template services.
|
|
10
|
+
- [ ] The engine's boundary lint passes: no `@nestjs/*`, `@prisma/*`, `@casl/*`, or template imports anywhere in the engine core; engine errors are engine-typed and mapped to HTTP exceptions only in the glue (`forms-error.mapper.ts`).
|
|
11
|
+
|
|
12
|
+
## Schema ownership
|
|
13
|
+
|
|
14
|
+
- [ ] Boot-time schema check proven both ways: green on a freshly migrated app; clear fail-fast message (naming the engine schema version and the snippet path) on a deliberately stale schema.
|
|
15
|
+
- [ ] `prisma/forms.prisma` in the engine matches the models in the app's `schema.prisma`; upgrades follow copy-snippet → `prisma migrate dev`.
|
|
16
|
+
|
|
17
|
+
## Permissions & registry
|
|
18
|
+
|
|
19
|
+
- [ ] All 11 form permission keys exist in `permissionKeys`, are seeded, and `test/forms-permission-sync.spec.ts` passes (engine ↔ template set equality).
|
|
20
|
+
- [ ] Every protected form route is in `routePermissionRegistry` (the boot-time registry validator passes).
|
|
21
|
+
|
|
22
|
+
## Gating
|
|
23
|
+
|
|
24
|
+
- [ ] Attach-time gating proven by test: lacking `forms.wireDangerous` ⇒ cannot save/publish a definition wiring a `dangerous` action; the `forms.allowedDangerousActions` org setting is respected at save/publish AND at execute time.
|
|
25
|
+
- [ ] `forms.managePublicAccess` gating proven: lacking it ⇒ cannot set or unset `settings.access = 'public'` (including publishing an already-public draft); every flip writes an audit row with previous/next.
|
|
26
|
+
|
|
27
|
+
## Tenancy
|
|
28
|
+
|
|
29
|
+
- [ ] Cross-org tests: definition fetch, submission read, and file reference across orgs are rejected as not-found.
|
|
30
|
+
|
|
31
|
+
## Transactionality & outbox
|
|
32
|
+
|
|
33
|
+
- [ ] Phase-2 transactionality proven: an induced `lockEditing` failure rolls back persist **and** in-tx action logs **and** enqueued outbox rows.
|
|
34
|
+
- [ ] Outbox worker runs inside `requestContext.run({ source: 'worker', jobId, orgId, userId, requestId })`; retries honor backoff; exhausted jobs park as `FAILED`; an audit row (`forms.outbox.delivered`) is written on final success.
|
|
35
|
+
|
|
36
|
+
## Public forms
|
|
37
|
+
|
|
38
|
+
- [ ] Public path: `@Public()` render/submit works without membership; a non-public form 404s on the public routes; org-scoped storage still enforced; permissioned paths unaffected.
|
|
39
|
+
- [ ] Tier-1 abuse baseline active: public routes throttled tighter than the app default; per-IP/day caps read from typed settings (with per-form override) and enforced; IP/UA stamped on anonymous submissions; captcha seam invoked when enabled; captcha-enabled-without-adapter fails at publish, not at submit.
|
|
40
|
+
|
|
41
|
+
## Files
|
|
42
|
+
|
|
43
|
+
- [ ] Upload → submit → `LINKED` flow proven; a foreign user's `fileId` is rejected (IDOR); GC sweeps orphaned `TEMPORARY` files past TTL; public forms with file fields are rejected at publish.
|
|
44
|
+
|
|
45
|
+
## Webhooks
|
|
46
|
+
|
|
47
|
+
- [ ] Webhook destinations are gated by the `forms.webhookAllowedHosts` setting at save AND publish (`WEBHOOK_HOST_NOT_ALLOWED`); https required except loopback (`WEBHOOK_URL_INSECURE`).
|
|
48
|
+
- [ ] Webhook jobs enqueue inside the submit transaction (idempotency key `<submissionId>:webhook:<i>`), never on draft saves or persist-less pipelines.
|
|
49
|
+
- [ ] Deliveries are signed (`x-forms-signature`) when a secret is set; payload data passes sensitive-field redaction; failures retry then park as FAILED.
|
|
50
|
+
- [ ] `forms:push` loads definitions only through the audited service path (real member RBAC, save-time lint, versioning) — never raw DB writes.
|
|
51
|
+
|
|
52
|
+
## Export & redaction
|
|
53
|
+
|
|
54
|
+
- [ ] `formSubmissions.export` enforced independently of `.read`; **every** export writes an audit row with form key, filters, row count, and included fields.
|
|
55
|
+
- [ ] Credential/sensitive fields never appear in `FormActionLog` or `AuditLog` metadata (redaction test); dangerous actions log `[REDACTED]` for input and output.
|
|
56
|
+
|
|
57
|
+
## Envelope & docs
|
|
58
|
+
|
|
59
|
+
- [ ] Engine validation errors arrive as `{ error: { code, message, details: { errors: [...] } } }` through the global filter; lint failures carry `details.issues`.
|
|
60
|
+
- [ ] Engine-thrown denials and not-founds are indistinguishable in shape from the template's own.
|
|
61
|
+
- [ ] Swagger documents all form routes (dynamic payloads as `object`).
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# Reports Module (`@ftisindia/report-builder`)
|
|
2
|
+
|
|
3
|
+
This app ships with a definition-driven report engine. A report is **a
|
|
4
|
+
declared, versioned view over one data source, plus the verbs users may apply
|
|
5
|
+
to it** — filter, sort, search, paginate, export, and act on rows. Report
|
|
6
|
+
definitions are versioned JSON documents in the database; the behaviors they
|
|
7
|
+
reference — sources, row actions — are code this app registers. New reports
|
|
8
|
+
over existing sources are **data**: most ship with zero backend code.
|
|
9
|
+
|
|
10
|
+
- Engine architecture: `report-builder/report-builder-design.md` in the
|
|
11
|
+
starter repository (Rev 2 — the binding spec).
|
|
12
|
+
- Completion standard: [`REPORTS_CHECKLIST.md`](./REPORTS_CHECKLIST.md) on top
|
|
13
|
+
of [`MODULE_COMPLETION_CHECKLIST.md`](./MODULE_COMPLETION_CHECKLIST.md).
|
|
14
|
+
- The engine is **fully usable without the form builder** — the form adapter
|
|
15
|
+
is one optional source provider among many (the standalone guarantee).
|
|
16
|
+
|
|
17
|
+
**Module structure (the standalone guarantee, made structural):**
|
|
18
|
+
`ReportsModule` is self-contained — definitions, queries, views, row actions
|
|
19
|
+
over custom sources, tags, and CSV/XLSX exports (sync + a reports-owned async
|
|
20
|
+
worker and file storage). It imports **no** FormsModule. Form-backed reports
|
|
21
|
+
(`source.kind: "form"`) and the delegated `editSubmission`/`updateStatus` verbs
|
|
22
|
+
live in the **optional** `ReportsFormsModule`, the single artifact aware of
|
|
23
|
+
both engines. Remove its import from `app.module.ts` and reports keeps working
|
|
24
|
+
over custom sources alone.
|
|
25
|
+
|
|
26
|
+
## The 60-second tour
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
GET /organisations/:orgId/reports reports.read list definitions
|
|
30
|
+
POST /organisations/:orgId/reports reports.create create draft
|
|
31
|
+
GET /organisations/:orgId/reports/:key reports.read latest definition
|
|
32
|
+
PATCH /organisations/:orgId/reports/:key reports.update edit draft (published ⇒ opens v+1)
|
|
33
|
+
POST /organisations/:orgId/reports/:key/publish reports.publish lint + freeze a new version
|
|
34
|
+
POST /organisations/:orgId/reports/:key/archive reports.archive archive the definition
|
|
35
|
+
GET /organisations/:orgId/reports/:key/meta?version= reports.read column/operator/action metadata
|
|
36
|
+
POST /organisations/:orgId/reports/:key/query reports.read THE grid endpoint (QuerySpec)
|
|
37
|
+
GET /organisations/:orgId/reports/:key/views reports.read saved views + compatibility
|
|
38
|
+
POST /organisations/:orgId/reports/:key/views reports.read create PERSONAL view
|
|
39
|
+
POST /organisations/:orgId/reports/:key/views/shared reports.update create SHARED view
|
|
40
|
+
POST /organisations/:orgId/reports/:key/actions/:n/prepare reports.read* byFilter token (drift-safe bulk)
|
|
41
|
+
POST /organisations/:orgId/reports/:key/actions/:n reports.read* execute row/bulk action
|
|
42
|
+
POST /organisations/:orgId/reports/:key/export reports.export sync stream or 202 job
|
|
43
|
+
GET /organisations/:orgId/reports/exports/:jobId reports.export async export status
|
|
44
|
+
GET /organisations/:orgId/reports/exports/:jobId/download reports.export download a finished export
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`*` Row actions are additionally gated by their own declared permission keys
|
|
48
|
+
(e.g. `formSubmissions.update`), checked when the action is wired into a
|
|
49
|
+
definition AND again at execute time.
|
|
50
|
+
|
|
51
|
+
Example definitions live in `src/modules/reports/examples/` — `org-members`
|
|
52
|
+
(custom source, no form builder anywhere) and `abstract-review-board` (the
|
|
53
|
+
form-backed quick path, indexed tier).
|
|
54
|
+
|
|
55
|
+
## One contract: the QuerySpec
|
|
56
|
+
|
|
57
|
+
The frontend never builds queries. Every grid interaction posts a declarative
|
|
58
|
+
spec naming declared column ids and verbs; the backend validates, compiles,
|
|
59
|
+
and applies them:
|
|
60
|
+
|
|
61
|
+
```jsonc
|
|
62
|
+
POST /organisations/:orgId/reports/abstract-review-board/query
|
|
63
|
+
{
|
|
64
|
+
"filters": [{ "column": "track", "op": "eq", "value": "trk_ml" }],
|
|
65
|
+
"search": "transformer",
|
|
66
|
+
"sort": [{ "column": "createdAt", "dir": "desc" }],
|
|
67
|
+
"cursor": null, // keyset cursor from the previous page
|
|
68
|
+
"pageSize": 50,
|
|
69
|
+
"count": "none", // "none" | "estimated" | "exact-capped"
|
|
70
|
+
"columns": ["title", "track", "status", "createdAt"]
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Operators are typed per column (`contains` on a number column is a 400, not a
|
|
75
|
+
seq scan). `GET /reports/:key/meta` describes every column, its operators, the
|
|
76
|
+
wired actions, and export formats — a generic grid UI configures itself from
|
|
77
|
+
it.
|
|
78
|
+
|
|
79
|
+
## How a query executes (the performance contract)
|
|
80
|
+
|
|
81
|
+
Slow reports come from four sins; the engine makes them structurally hard:
|
|
82
|
+
|
|
83
|
+
- **No OFFSET, ever.** Pagination is keyset-only; cursors are opaque,
|
|
84
|
+
HMAC-signed, and bound to (report, version, sort, filters) — replay against
|
|
85
|
+
anything else is a 400 telling the client to restart.
|
|
86
|
+
- **Unindexed shapes fail publish.** On the `indexed` tier, every
|
|
87
|
+
sortable/filterable/searchable column must name its backing index; publish
|
|
88
|
+
verifies the index against the catalog (expression, opclass, collation,
|
|
89
|
+
org-leading) AND runs `EXPLAIN` on representative shapes. The failure is
|
|
90
|
+
constructive: the 422 carries the exact migration SQL to apply.
|
|
91
|
+
- **Counts are a choice.** Default `none` (hasMore comes free), `estimated`
|
|
92
|
+
(planner estimate, milliseconds), or `exact-capped` (`10000+` beyond cap).
|
|
93
|
+
- **A statement budget backstops everything** — every report query carries
|
|
94
|
+
`statement_timeout` (`reports.statementTimeoutMs`, default 5s).
|
|
95
|
+
|
|
96
|
+
The compiler builds every statement from code-owned manifests; user input
|
|
97
|
+
never becomes an identifier and values travel only as bind parameters.
|
|
98
|
+
**SQL expressions can exist only in code** — a definition referencing
|
|
99
|
+
`"path": "title' || (SELECT ...)"` is rejected at save.
|
|
100
|
+
|
|
101
|
+
## Custom sources (no form builder required)
|
|
102
|
+
|
|
103
|
+
Register a source in code — one class, one decorator:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { ReportSource } from './modules/reports/infrastructure/registry/report-extension.decorators';
|
|
107
|
+
import type { ReportSourceDef, SourceManifest, SourceQuery } from '@ftisindia/report-builder';
|
|
108
|
+
|
|
109
|
+
@Injectable()
|
|
110
|
+
@ReportSource()
|
|
111
|
+
export class OrgMembersReportSource implements ReportSourceDef {
|
|
112
|
+
key = 'org-members';
|
|
113
|
+
manifest(): SourceManifest { /* columns + types + capabilities + indexes */ }
|
|
114
|
+
baseQuery(): SourceQuery {
|
|
115
|
+
return {
|
|
116
|
+
from: '"Membership" m JOIN "User" u ON u."id" = m."userId"',
|
|
117
|
+
orgColumn: 'm."orgId"',
|
|
118
|
+
primaryTable: 'Membership',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The source declares structure; the compiler owns query construction — a
|
|
125
|
+
source never sees the QuerySpec, so it cannot be injected and cannot bypass
|
|
126
|
+
the allowlist. The engine injects the org predicate first on every statement,
|
|
127
|
+
plus the source's optional `rowScope` (row-level security in code).
|
|
128
|
+
|
|
129
|
+
## Reports from forms (the quick path)
|
|
130
|
+
|
|
131
|
+
Requires the optional `ReportsFormsModule` bridge (imported in `app.module.ts`
|
|
132
|
+
by default in this template). Point a definition at a form and the adapter does
|
|
133
|
+
the thinking:
|
|
134
|
+
|
|
135
|
+
```jsonc
|
|
136
|
+
{ "source": { "kind": "form", "key": "abstract-submission" }, ... }
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
- Fields with `reportable: true` become report columns (labels, types, lookup
|
|
140
|
+
data sources carried over); `indexHint: true` fields are sortable/filterable.
|
|
141
|
+
- **Column-id convention:** a definition column over a form field must use the
|
|
142
|
+
field path (dots → `_`) as its column id; physical submission columns are
|
|
143
|
+
referenced as `$row.status`, `$row.createdAt`, `$row.updatedAt`,
|
|
144
|
+
`$row.createdBy`.
|
|
145
|
+
- On the `indexed` tier the publish lint demands the generated columns +
|
|
146
|
+
indexes (hashed `rb_*` names, immutable expressions, datetimes kept as
|
|
147
|
+
ISO-8601 UTC text + `COLLATE "C"`); the 422's `suggestionSql` is the exact
|
|
148
|
+
migration to apply — run it, publish again.
|
|
149
|
+
- Grid verbs delegate to the form engine: `editSubmission` re-validates
|
|
150
|
+
against the submission's stamped `formVersion`; `updateStatus` runs the same
|
|
151
|
+
pipeline as the form. Both require `formSubmissions.update`. There is no
|
|
152
|
+
generic UPDATE — the report engine never mutates source rows itself.
|
|
153
|
+
- Version note: columns resolve against the latest **published** form version.
|
|
154
|
+
Renamed-field mapping across versions needs rename metadata the form
|
|
155
|
+
definition format does not yet carry.
|
|
156
|
+
|
|
157
|
+
## Performance tiers
|
|
158
|
+
|
|
159
|
+
| Tier | Meaning | Publish gate |
|
|
160
|
+
|---|---|---|
|
|
161
|
+
| `live` | Query raw source/JSONB directly | Source row estimate under `reports.liveTierMaxRows` |
|
|
162
|
+
| `indexed` | Hot columns physical + index-declared | Catalog + EXPLAIN lint pass (suggested SQL on failure) |
|
|
163
|
+
| `materialized` | Reads hit a declared materialized relation | Relation exists; staleness bound declared; `meta.freshAsOf` always set |
|
|
164
|
+
|
|
165
|
+
Tier upgrades are ordinary new versions: v5 `materialized` publishes after its
|
|
166
|
+
migration lands while v4 keeps serving.
|
|
167
|
+
|
|
168
|
+
## Row actions & drift-safe bulk
|
|
169
|
+
|
|
170
|
+
Single rows / explicit lists go through `byIds` (capped at 1,000). Filter-based
|
|
171
|
+
bulk (`byFilter`) is a two-step protocol because rows matching at confirmation
|
|
172
|
+
may differ at execution:
|
|
173
|
+
|
|
174
|
+
1. `POST .../actions/:name/prepare { selection: { byFilter } }` →
|
|
175
|
+
`{ expectedCount, asOf, actionToken }` (HMAC-signed, 5-minute TTL).
|
|
176
|
+
2. `POST .../actions/:name { selection, actionToken, input, idempotencyKey }` —
|
|
177
|
+
the server re-resolves the selection; drift beyond tolerance (0 for
|
|
178
|
+
destructive actions) is a 409 with the current count. The
|
|
179
|
+
`idempotencyKey` is persisted: a retried execute returns the recorded
|
|
180
|
+
outcome instead of re-running.
|
|
181
|
+
|
|
182
|
+
Execution walks rows in keyset order in transactional batches; every batch
|
|
183
|
+
re-applies org + rowScope. Every bulk run writes an audit row with report key,
|
|
184
|
+
version, action, row count, and the filter snapshot.
|
|
185
|
+
|
|
186
|
+
## Tags & labels
|
|
187
|
+
|
|
188
|
+
`manageTags` (built-in action, `reportTags.manage`) annotates rows with
|
|
189
|
+
org-scoped tags without touching the source schema. Rows hydrate `$tags`; the
|
|
190
|
+
virtual `$tags` column filters with `hasTag` / `hasAnyTag` (indexed EXISTS).
|
|
191
|
+
Labels are single-valued tags by convention (`label:approved` replaces the
|
|
192
|
+
row family's previous `label:*`). Curate the vocabulary with the
|
|
193
|
+
`reports.tagVocabulary` setting (empty = free-form).
|
|
194
|
+
|
|
195
|
+
## Exports
|
|
196
|
+
|
|
197
|
+
`POST /reports/:key/export { format: "csv" | "xlsx", spec?, columns? }`:
|
|
198
|
+
|
|
199
|
+
- Up to `reports.maxRowsSync` (default 10k): direct stream with
|
|
200
|
+
`Content-Disposition`. Larger: `202 { job }` — the job is picked up by the
|
|
201
|
+
reports-owned export worker (polling `ReportExportJob`, no forms outbox),
|
|
202
|
+
streamed into reports-owned org-scoped file storage
|
|
203
|
+
(`REPORTS_EXPORT_STORAGE_DIR`), polled at `GET /reports/exports/:jobId`, and
|
|
204
|
+
downloaded from `GET /reports/exports/:jobId/download`.
|
|
205
|
+
- Every export — sync or async — runs inside ONE `REPEATABLE READ` snapshot
|
|
206
|
+
(duration-bounded by `reports.exportMaxSnapshotSeconds`); an export that
|
|
207
|
+
cannot finish in time is rejected with guidance to tighten filters or move
|
|
208
|
+
to the materialized tier.
|
|
209
|
+
- `reports.export` is never implied by read; **every export writes an audit
|
|
210
|
+
row** with report key, version, filter snapshot, row count, and the exact
|
|
211
|
+
column list. Columns marked `exportable: false` are stripped regardless of
|
|
212
|
+
the requested projection.
|
|
213
|
+
|
|
214
|
+
## Saved views
|
|
215
|
+
|
|
216
|
+
Views store the QuerySpec **and the version they were authored against**.
|
|
217
|
+
Listing views returns a `compatibility` verdict per view against the requested
|
|
218
|
+
version — `ok` | `degraded` (names missing/retyped columns) | `incompatible` —
|
|
219
|
+
so a new publish turns stale views into a visible, fixable state instead of a
|
|
220
|
+
silent 500. Personal views need `reports.read`; shared views (no owner) need
|
|
221
|
+
`reports.update`.
|
|
222
|
+
|
|
223
|
+
## Org settings & env
|
|
224
|
+
|
|
225
|
+
Typed settings (audited like all settings): `reports.statementTimeoutMs`
|
|
226
|
+
(5000), `reports.exportMaxSnapshotSeconds` (120), `reports.maxRowsSync`
|
|
227
|
+
(10000), `reports.countCap` (10000), `reports.liveTierMaxRows` (50000),
|
|
228
|
+
`reports.resultCacheTtlMs` (0 = cache off), `reports.tagVocabulary` ([]).
|
|
229
|
+
|
|
230
|
+
Env: `REPORTS_SCHEMA_CHECK` (`on`/`off`); `REPORTS_TOKEN_SECRET` (optional, min
|
|
231
|
+
32 chars — cursor/token HMAC key; when empty a dedicated key is derived from
|
|
232
|
+
`JWT_SECRET` via HKDF; rotating it invalidates outstanding cursors/tokens —
|
|
233
|
+
clients restart from page one); and the reports-owned async-export worker:
|
|
234
|
+
`REPORTS_EXPORT_WORKER_ENABLED` (default true), `REPORTS_EXPORT_POLL_MS`
|
|
235
|
+
(default 5000), `REPORTS_EXPORT_STORAGE_DIR` (default `./var/report-exports`).
|
|
236
|
+
|
|
237
|
+
## Engine upgrades (schema ownership)
|
|
238
|
+
|
|
239
|
+
The app owns `schema.prisma` and the migration history; the engine ships the
|
|
240
|
+
canonical snippet at `@ftisindia/report-builder/prisma/reports.prisma` plus
|
|
241
|
+
`REPORTS_ENGINE_SCHEMA_VERSION`. The boot check verifies tables/columns AND
|
|
242
|
+
the two **partial unique indexes** on `ReportSavedView`
|
|
243
|
+
(`report_view_shared_uq`, `report_view_personal_uq`) — Prisma cannot express
|
|
244
|
+
partial indexes, so they live as raw SQL in the `add_report_builder`
|
|
245
|
+
migration. If a later `prisma migrate dev` proposes `DROP INDEX` for them,
|
|
246
|
+
delete those lines; the boot check fails fast if they ever go missing.
|