@ftisindia/create-app 0.1.5 → 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.
Files changed (162) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +25 -0
  3. package/template/README.md +51 -0
  4. package/template/_gitignore +6 -0
  5. package/template/_package.json +6 -0
  6. package/template/docs/FORMS.md +169 -0
  7. package/template/docs/FORMS_CHECKLIST.md +61 -0
  8. package/template/docs/REPORTS.md +246 -0
  9. package/template/docs/REPORTS_CHECKLIST.md +97 -0
  10. package/template/prisma/migrations/20260612000000_add_form_builder/migration.sql +147 -0
  11. package/template/prisma/migrations/20260613000000_add_report_builder/migration.sql +129 -0
  12. package/template/prisma/schema.prisma +285 -0
  13. package/template/scripts/export-openapi.ts +85 -0
  14. package/template/scripts/gen-form.mjs +149 -0
  15. package/template/scripts/push-form.ts +124 -0
  16. package/template/src/app.module.ts +29 -8
  17. package/template/src/common/dto/membership-response.dto.ts +1 -0
  18. package/template/src/common/dto/role-summary.dto.ts +3 -3
  19. package/template/src/common/dto/user-summary.dto.ts +3 -3
  20. package/template/src/config/env.validation.ts +25 -0
  21. package/template/src/config/forms.config.ts +12 -0
  22. package/template/src/config/index.ts +2 -0
  23. package/template/src/config/openapi.ts +12 -0
  24. package/template/src/config/reports-secret.ts +15 -0
  25. package/template/src/config/reports.config.ts +16 -0
  26. package/template/src/main.ts +3 -12
  27. package/template/src/modules/access-control/dto/access-control-response.dto.ts +3 -0
  28. package/template/src/modules/access-control/dto/current-access-control-response.dto.ts +5 -1
  29. package/template/src/modules/access-control/types/permission-key.ts +27 -0
  30. package/template/src/modules/access-control/types/route-permission-registry.ts +183 -0
  31. package/template/src/modules/audit/dto/audit-response.dto.ts +7 -3
  32. package/template/src/modules/auth/auth.module.ts +3 -1
  33. package/template/src/modules/auth/dto/auth-response.dto.ts +1 -1
  34. package/template/src/modules/forms/application/services/file-gc.service.ts +85 -0
  35. package/template/src/modules/forms/application/services/forms-definitions.service.ts +137 -0
  36. package/template/src/modules/forms/application/services/forms-error.mapper.ts +64 -0
  37. package/template/src/modules/forms/application/services/forms-export.service.ts +210 -0
  38. package/template/src/modules/forms/application/services/forms-files.service.ts +164 -0
  39. package/template/src/modules/forms/application/services/forms-public.service.ts +49 -0
  40. package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +53 -0
  41. package/template/src/modules/forms/application/services/forms-submissions.service.ts +103 -0
  42. package/template/src/modules/forms/application/services/handlers/authenticate.action.ts +37 -0
  43. package/template/src/modules/forms/application/services/handlers/logging-email.handler.ts +22 -0
  44. package/template/src/modules/forms/application/services/handlers/send-confirmation-email.action.ts +40 -0
  45. package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +41 -0
  46. package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +109 -0
  47. package/template/src/modules/forms/dto/create-form-definition.dto.ts +12 -0
  48. package/template/src/modules/forms/dto/data-source-response.dto.ts +19 -0
  49. package/template/src/modules/forms/dto/export-submissions-query.dto.ts +33 -0
  50. package/template/src/modules/forms/dto/file-upload-response.dto.ts +24 -0
  51. package/template/src/modules/forms/dto/form-definition-response.dto.ts +50 -0
  52. package/template/src/modules/forms/dto/form-render-response.dto.ts +17 -0
  53. package/template/src/modules/forms/dto/list-form-definitions-query.dto.ts +10 -0
  54. package/template/src/modules/forms/dto/list-submissions-query.dto.ts +10 -0
  55. package/template/src/modules/forms/dto/public-submit-form.dto.ts +24 -0
  56. package/template/src/modules/forms/dto/set-public-access.dto.ts +8 -0
  57. package/template/src/modules/forms/dto/submission-response.dto.ts +99 -0
  58. package/template/src/modules/forms/dto/submit-form.dto.ts +50 -0
  59. package/template/src/modules/forms/dto/update-form-definition.dto.ts +12 -0
  60. package/template/src/modules/forms/dto/upload-file-query.dto.ts +33 -0
  61. package/template/src/modules/forms/dto/validate-submission.dto.ts +22 -0
  62. package/template/src/modules/forms/examples/abstract-submission.form.json +80 -0
  63. package/template/src/modules/forms/examples/login.form.json +24 -0
  64. package/template/src/modules/forms/examples/registration.form.json +44 -0
  65. package/template/src/modules/forms/forms.module.ts +226 -0
  66. package/template/src/modules/forms/forms.tokens.ts +6 -0
  67. package/template/src/modules/forms/infrastructure/audit-sink.adapter.ts +30 -0
  68. package/template/src/modules/forms/infrastructure/casl-forms-authorization.ts +31 -0
  69. package/template/src/modules/forms/infrastructure/prisma-tx-runner.ts +17 -0
  70. package/template/src/modules/forms/infrastructure/registry/form-extension.decorators.ts +17 -0
  71. package/template/src/modules/forms/infrastructure/registry/registry-bootstrap.service.ts +82 -0
  72. package/template/src/modules/forms/infrastructure/request-forms-context.ts +60 -0
  73. package/template/src/modules/forms/infrastructure/schema-check/forms-schema-check.service.ts +76 -0
  74. package/template/src/modules/forms/infrastructure/storage/local-disk-storage.adapter.ts +43 -0
  75. package/template/src/modules/forms/infrastructure/stores/index.ts +5 -0
  76. package/template/src/modules/forms/infrastructure/stores/prisma-action-log.store.ts +37 -0
  77. package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +108 -0
  78. package/template/src/modules/forms/infrastructure/stores/prisma-form-definition.store.ts +147 -0
  79. package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +133 -0
  80. package/template/src/modules/forms/infrastructure/stores/prisma-submission.store.ts +164 -0
  81. package/template/src/modules/forms/presentation/forms-data-sources.controller.ts +58 -0
  82. package/template/src/modules/forms/presentation/forms-definitions.controller.ts +191 -0
  83. package/template/src/modules/forms/presentation/forms-files.controller.ts +79 -0
  84. package/template/src/modules/forms/presentation/forms-submissions.controller.ts +154 -0
  85. package/template/src/modules/forms/presentation/forms-upload.interceptor.ts +33 -0
  86. package/template/src/modules/forms/presentation/public-forms.controller.ts +51 -0
  87. package/template/src/modules/invitations/dto/invitation-response.dto.ts +4 -0
  88. package/template/src/modules/organisations/dto/organisation-response.dto.ts +1 -0
  89. package/template/src/modules/reports/application/services/reports-actions.service.ts +54 -0
  90. package/template/src/modules/reports/application/services/reports-definitions.service.ts +66 -0
  91. package/template/src/modules/reports/application/services/reports-error.mapper.ts +97 -0
  92. package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +124 -0
  93. package/template/src/modules/reports/application/services/reports-exports.service.ts +74 -0
  94. package/template/src/modules/reports/application/services/reports-queries.service.ts +35 -0
  95. package/template/src/modules/reports/application/services/reports-settings-reader.service.ts +49 -0
  96. package/template/src/modules/reports/application/services/reports-views.service.ts +79 -0
  97. package/template/src/modules/reports/dto/action-result-response.dto.ts +21 -0
  98. package/template/src/modules/reports/dto/create-report-definition.dto.ts +86 -0
  99. package/template/src/modules/reports/dto/create-saved-view.dto.ts +26 -0
  100. package/template/src/modules/reports/dto/execute-action.dto.ts +71 -0
  101. package/template/src/modules/reports/dto/export-job-response.dto.ts +60 -0
  102. package/template/src/modules/reports/dto/export-request.dto.ts +34 -0
  103. package/template/src/modules/reports/dto/list-reports-query.dto.ts +10 -0
  104. package/template/src/modules/reports/dto/list-views-query.dto.ts +17 -0
  105. package/template/src/modules/reports/dto/prepare-action-response.dto.ts +14 -0
  106. package/template/src/modules/reports/dto/prepare-action.dto.ts +27 -0
  107. package/template/src/modules/reports/dto/query-response.dto.ts +64 -0
  108. package/template/src/modules/reports/dto/query-spec.dto.ts +120 -0
  109. package/template/src/modules/reports/dto/report-definition-response.dto.ts +64 -0
  110. package/template/src/modules/reports/dto/report-meta-query.dto.ts +16 -0
  111. package/template/src/modules/reports/dto/report-meta-response.dto.ts +113 -0
  112. package/template/src/modules/reports/dto/saved-view-response.dto.ts +66 -0
  113. package/template/src/modules/reports/dto/update-report-definition.dto.ts +9 -0
  114. package/template/src/modules/reports/dto/update-saved-view.dto.ts +27 -0
  115. package/template/src/modules/reports/examples/abstract-review-board.report.json +54 -0
  116. package/template/src/modules/reports/examples/org-members.report.json +55 -0
  117. package/template/src/modules/reports/infrastructure/audit-sink.adapter.ts +31 -0
  118. package/template/src/modules/reports/infrastructure/casl-reports-authorization.ts +39 -0
  119. package/template/src/modules/reports/infrastructure/forms-adapter/form-report-source.adapter.ts +292 -0
  120. package/template/src/modules/reports/infrastructure/forms-adapter/form-row-actions.ts +171 -0
  121. package/template/src/modules/reports/infrastructure/forms-adapter/forms-bridge-bootstrap.service.ts +32 -0
  122. package/template/src/modules/reports/infrastructure/prisma-catalog.adapter.ts +95 -0
  123. package/template/src/modules/reports/infrastructure/prisma-query-executor.ts +103 -0
  124. package/template/src/modules/reports/infrastructure/prisma-snapshot-runner.ts +47 -0
  125. package/template/src/modules/reports/infrastructure/prisma-tx-runner.ts +18 -0
  126. package/template/src/modules/reports/infrastructure/registry/registry-bootstrap.service.ts +61 -0
  127. package/template/src/modules/reports/infrastructure/registry/report-extension.decorators.ts +14 -0
  128. package/template/src/modules/reports/infrastructure/reports-job-queue.adapter.ts +28 -0
  129. package/template/src/modules/reports/infrastructure/request-reports-context.ts +42 -0
  130. package/template/src/modules/reports/infrastructure/schema-check/reports-schema-check.service.ts +116 -0
  131. package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +79 -0
  132. package/template/src/modules/reports/infrastructure/stores/index.ts +5 -0
  133. package/template/src/modules/reports/infrastructure/stores/prisma-bulk-action-run.store.ts +89 -0
  134. package/template/src/modules/reports/infrastructure/stores/prisma-export-job.store.ts +93 -0
  135. package/template/src/modules/reports/infrastructure/stores/prisma-report-definition.store.ts +171 -0
  136. package/template/src/modules/reports/infrastructure/stores/prisma-row-tag.store.ts +110 -0
  137. package/template/src/modules/reports/infrastructure/stores/prisma-saved-view.store.ts +144 -0
  138. package/template/src/modules/reports/presentation/reports-actions.controller.ts +83 -0
  139. package/template/src/modules/reports/presentation/reports-definitions.controller.ts +156 -0
  140. package/template/src/modules/reports/presentation/reports-export-jobs.controller.ts +61 -0
  141. package/template/src/modules/reports/presentation/reports-export.controller.ts +76 -0
  142. package/template/src/modules/reports/presentation/reports-query.controller.ts +52 -0
  143. package/template/src/modules/reports/presentation/reports-views.controller.ts +140 -0
  144. package/template/src/modules/reports/reports-forms.module.ts +33 -0
  145. package/template/src/modules/reports/reports.module.ts +335 -0
  146. package/template/src/modules/reports/reports.tokens.ts +11 -0
  147. package/template/src/modules/reports/sources/org-members.source.ts +112 -0
  148. package/template/src/modules/settings/types/setting-definitions.ts +94 -0
  149. package/template/test/forms-definitions.e2e-spec.ts +394 -0
  150. package/template/test/forms-export.e2e-spec.ts +390 -0
  151. package/template/test/forms-files.e2e-spec.ts +345 -0
  152. package/template/test/forms-outbox.e2e-spec.ts +309 -0
  153. package/template/test/forms-permission-sync.spec.ts +27 -0
  154. package/template/test/forms-public.e2e-spec.ts +269 -0
  155. package/template/test/forms-schema-check.e2e-spec.ts +65 -0
  156. package/template/test/forms-submissions.e2e-spec.ts +500 -0
  157. package/template/test/forms-webhooks.e2e-spec.ts +261 -0
  158. package/template/test/reports-advanced.e2e-spec.ts +368 -0
  159. package/template/test/reports-permission-sync.spec.ts +30 -0
  160. package/template/test/reports-query.e2e-spec.ts +350 -0
  161. package/template/test/reports-tiers.e2e-spec.ts +257 -0
  162. package/template/test/route-registry.validator.spec.ts +22 -0
@@ -0,0 +1,54 @@
1
+ {
2
+ "key": "abstract-review-board",
3
+ "version": 1,
4
+ "status": "PUBLISHED",
5
+ "title": "Abstract submissions — review board",
6
+ "description": "Review grid over the abstract-submission form. Column ids follow the form-adapter convention (field path with dots replaced by underscores); $row.* references the physical FormSubmission columns the adapter allowlists. Only indexHint fields are sortable; the indexed tier's publish lint demands their generated columns and indexes.",
7
+ "source": { "kind": "form", "key": "abstract-submission" },
8
+ "columns": [
9
+ {
10
+ "id": "title",
11
+ "header": "Title",
12
+ "path": "title",
13
+ "type": "text",
14
+ "filterable": true
15
+ },
16
+ {
17
+ "id": "track",
18
+ "header": "Track",
19
+ "path": "track",
20
+ "type": "lookup",
21
+ "sortable": true,
22
+ "filterable": true,
23
+ "lookup": { "dataSource": "conference-tracks" }
24
+ },
25
+ {
26
+ "id": "status",
27
+ "header": "Status",
28
+ "path": "$row.status",
29
+ "type": "enum",
30
+ "sortable": true,
31
+ "filterable": true,
32
+ "enum": ["DRAFT", "SUBMITTED"]
33
+ },
34
+ {
35
+ "id": "createdAt",
36
+ "header": "Submitted",
37
+ "path": "$row.createdAt",
38
+ "type": "datetime",
39
+ "sortable": true,
40
+ "filterable": true
41
+ },
42
+ {
43
+ "id": "createdBy",
44
+ "header": "Submitted by",
45
+ "path": "$row.createdBy",
46
+ "type": "text",
47
+ "filterable": true
48
+ }
49
+ ],
50
+ "defaultSort": [{ "column": "createdAt", "dir": "desc" }],
51
+ "rowActions": ["updateStatus", "editSubmission", "manageTags"],
52
+ "export": { "formats": ["csv", "xlsx"] },
53
+ "performanceTier": "indexed"
54
+ }
@@ -0,0 +1,55 @@
1
+ {
2
+ "key": "org-members",
3
+ "version": 1,
4
+ "status": "PUBLISHED",
5
+ "title": "Organisation members",
6
+ "description": "Every membership in the active organisation with user, role, and status — the Phase-1 standalone example: a custom source with no form-builder involvement.",
7
+ "source": { "kind": "custom", "key": "org-members" },
8
+ "columns": [
9
+ {
10
+ "id": "displayName",
11
+ "header": "Name",
12
+ "columnId": "displayName",
13
+ "type": "text",
14
+ "sortable": true,
15
+ "filterable": true,
16
+ "searchable": true
17
+ },
18
+ {
19
+ "id": "email",
20
+ "header": "Email",
21
+ "columnId": "email",
22
+ "type": "text",
23
+ "sortable": true,
24
+ "filterable": true
25
+ },
26
+ {
27
+ "id": "role",
28
+ "header": "Role",
29
+ "columnId": "role",
30
+ "type": "text",
31
+ "sortable": true,
32
+ "filterable": true
33
+ },
34
+ {
35
+ "id": "status",
36
+ "header": "Status",
37
+ "columnId": "status",
38
+ "type": "enum",
39
+ "filterable": true,
40
+ "enum": ["ACTIVE", "SUSPENDED", "REVOKED"]
41
+ },
42
+ {
43
+ "id": "joinedAt",
44
+ "header": "Joined",
45
+ "columnId": "joinedAt",
46
+ "type": "datetime",
47
+ "sortable": true
48
+ }
49
+ ],
50
+ "defaultSort": [{ "column": "joinedAt", "dir": "desc" }],
51
+ "search": { "columns": ["displayName"] },
52
+ "rowActions": ["manageTags"],
53
+ "export": { "formats": ["csv", "xlsx"] },
54
+ "performanceTier": "live"
55
+ }
@@ -0,0 +1,31 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { Prisma } from '@prisma/client';
3
+ import type { AuditEntry, EngineTx, ReportsAuditSink } from '@ftisindia/report-builder';
4
+ import { PrismaService } from '../../../database/prisma/prisma.service';
5
+ import { AuditService } from '../../audit/application/services/audit.service';
6
+
7
+ /**
8
+ * Routes engine audit entries through the template's AuditService. When the
9
+ * engine supplies a transaction, the audit row is written through it so it
10
+ * commits atomically with the change it describes (ecosystem guide §7) —
11
+ * exports and bulk actions are audited as the PII egress they are (§9).
12
+ */
13
+ @Injectable()
14
+ export class PrismaReportsAuditSink implements ReportsAuditSink {
15
+ constructor(
16
+ private readonly audit: AuditService,
17
+ private readonly prisma: PrismaService,
18
+ ) {}
19
+
20
+ async write(entry: AuditEntry, tx?: EngineTx): Promise<void> {
21
+ const client = tx ? (tx as unknown as Prisma.TransactionClient) : this.prisma;
22
+ await this.audit.write(client, {
23
+ orgId: entry.orgId ?? null,
24
+ actorUserId: entry.actorUserId ?? null,
25
+ action: entry.action,
26
+ targetType: entry.targetType,
27
+ targetId: entry.targetId ?? null,
28
+ metadata: entry.metadata as Prisma.InputJsonValue | undefined,
29
+ });
30
+ }
31
+ }
@@ -0,0 +1,39 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import type { ReportsAuthorization, ReportsContext } from '@ftisindia/report-builder';
3
+ import { AbilityFactory } from '../../access-control/application/services/ability.factory';
4
+ import { permissionKeyToRule } from '../../access-control/types/permission-key';
5
+ import { RequestContextService } from '../../request-context/application/services/request-context.service';
6
+
7
+ /**
8
+ * The engine's internal authorization seam over CASL — mirrors
9
+ * PermissionGuard's own logic and fails closed when no RBAC context exists
10
+ * (anonymous/public requests). Route guards remain the first enforcement
11
+ * layer; this is the defense-in-depth second layer (ecosystem guide §5.3).
12
+ *
13
+ * Required keys are plain two-segment strings by design: row actions declare
14
+ * keys from ANY domain (e.g. 'formSubmissions.update'), not just 'reports.*'
15
+ * (report design §6.1) — permissionKeyToRule splits them generically.
16
+ */
17
+ @Injectable()
18
+ export class CaslReportsAuthorization implements ReportsAuthorization {
19
+ constructor(
20
+ private readonly abilities: AbilityFactory,
21
+ private readonly ctx: RequestContextService,
22
+ ) {}
23
+
24
+ can(required: readonly string[], ctx: ReportsContext): boolean {
25
+ // Fail closed: no authenticated user inside an active org ⇒ no capability.
26
+ if (!ctx.userId() || !ctx.orgId()) {
27
+ return false;
28
+ }
29
+ const rbac = this.ctx.getRbacContext();
30
+ if (!rbac) {
31
+ return false;
32
+ }
33
+ const ability = this.abilities.createForContext(rbac);
34
+ return required.every((key) => {
35
+ const { action, subject } = permissionKeyToRule(key);
36
+ return ability.can(action, subject);
37
+ });
38
+ }
39
+ }
@@ -0,0 +1,292 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import {
3
+ generatedColumnComment,
4
+ generatedColumnExpression,
5
+ generatedColumnIndexes,
6
+ generatedColumnName,
7
+ } from '@ftisindia/report-builder';
8
+ import type {
9
+ ColumnType,
10
+ GeneratedIndexPlan,
11
+ IndexSpec,
12
+ ManifestColumn,
13
+ ReportsContext,
14
+ ResolvedSource,
15
+ SourceBinding,
16
+ SourceManifest,
17
+ SourceProvider,
18
+ SourceQuery,
19
+ } from '@ftisindia/report-builder';
20
+ import { walkFields } from '@ftisindia/form-builder';
21
+ import type { FieldDef, FormDefinition } from '@ftisindia/form-builder';
22
+ import { PrismaFormDefinitionStore } from '../../../forms/infrastructure/stores';
23
+
24
+ /**
25
+ * `kind: "form"` source provider (report design §3.1) — the quick path. Maps a
26
+ * published FormDefinition onto the same SourceManifest interface every custom
27
+ * source implements, so the compiler never knows forms exist.
28
+ *
29
+ * THE STANDALONE GUARANTEE, INVERTED (§3.2): the report-builder core contains
30
+ * zero knowledge of the form builder, and this file is the ONLY place the two
31
+ * engines meet. Remove this provider (and the row actions beside it) and the
32
+ * report module still ships full reporting over custom sources.
33
+ *
34
+ * COLUMN-ID CONVENTION: each reportable field becomes one manifest column
35
+ * whose id is the field's dot path with dots replaced by '_' (e.g.
36
+ * `contact.email` → `contact_email`). Definitions over form sources reference
37
+ * fields via plain-path columns, and plain-path allowlist resolution maps by
38
+ * the DEFINITION column's own id — so a definition column over a form source
39
+ * MUST use this id convention (`{ "id": "contact_email", "path":
40
+ * "contact.email" }`) or it will not resolve against the manifest.
41
+ *
42
+ * VERSION NOTE (§3.1): the design's renamed-field `CASE ON "formVersion"` map
43
+ * requires rename metadata that FieldDef does not carry today, so this adapter
44
+ * resolves against the LATEST PUBLISHED definition version only. Submissions
45
+ * stamp `formVersion`, which is exactly what a future version-aware upgrade
46
+ * needs — nothing here forecloses it.
47
+ */
48
+ @Injectable()
49
+ export class FormReportSourceProvider implements SourceProvider {
50
+ readonly kind = 'form';
51
+
52
+ constructor(private readonly definitions: PrismaFormDefinitionStore) {}
53
+
54
+ /**
55
+ * Resolve a `{ kind: 'form', key: <formKey> }` binding to the manifest +
56
+ * base query the compiler consumes. Null (→ ReportNotFoundError upstream)
57
+ * when no org scope is active or the form has no published version.
58
+ */
59
+ async resolve(binding: SourceBinding, ctx: ReportsContext): Promise<ResolvedSource | null> {
60
+ const orgId = ctx.orgId();
61
+ if (orgId === undefined) {
62
+ return null;
63
+ }
64
+ const record = await this.definitions.findLatest(orgId, binding.key, 'PUBLISHED');
65
+ if (record === null) {
66
+ return null;
67
+ }
68
+
69
+ const manifest: SourceManifest = {
70
+ // The submission row id — what tags, row actions, and the keyset
71
+ // tiebreaker bind to (§7).
72
+ rowId: '"id"',
73
+ orgScoped: true,
74
+ columns: [...this.autoColumns(binding.key, record.schema), ...physicalRowColumns()],
75
+ };
76
+
77
+ const baseQuery: SourceQuery = {
78
+ from: '"FormSubmission"',
79
+ orgColumn: '"orgId"',
80
+ primaryTable: 'FormSubmission',
81
+ // Code-owned discriminator — multiple forms share one physical table.
82
+ where: [{ sql: '"formKey" = ?', params: [binding.key] }],
83
+ };
84
+
85
+ return { kind: this.kind, key: binding.key, manifest, baseQuery };
86
+ }
87
+
88
+ /**
89
+ * Auto-columns from form fields (§3.1): every `reportable: true` field with
90
+ * a mappable type becomes a manifest column over the JSONB extraction
91
+ * expression (the live tier reads it directly; the indexed tier's generated
92
+ * column uses the SAME expression, §8.1).
93
+ *
94
+ * Skipped on purpose:
95
+ * - `sensitive` fields — their values never reach action logs, audit
96
+ * metadata, or exports (form engine contract), so they never enter a
97
+ * report manifest either;
98
+ * - file/password fields (and any type outside the map) — files are
99
+ * references, passwords are always-sensitive; neither is tabular data;
100
+ * - repeatable groups and every field inside one — a repeatable group is an
101
+ * array of objects, so one submission row maps to MANY values: a scalar
102
+ * JSONB extraction would return NULL or require flattening the row set,
103
+ * which is a materialization concern (§8), not a column.
104
+ */
105
+ private autoColumns(formKey: string, definition: FormDefinition): ManifestColumn[] {
106
+ const columns: ManifestColumn[] = [];
107
+ const reserved = new Set(physicalRowColumns().map((column) => column.id));
108
+ const repeatableRoots: string[] = [];
109
+
110
+ walkFields(definition.fields, (field, segments) => {
111
+ const path = segments.join('.');
112
+ // walkFields is depth-first pre-order: a repeatable ancestor is always
113
+ // recorded before its children are visited.
114
+ if (repeatableRoots.some((root) => path === root || path.startsWith(`${root}.`))) {
115
+ return;
116
+ }
117
+ if (field.repeatable === true) {
118
+ repeatableRoots.push(path);
119
+ return;
120
+ }
121
+ if (field.reportable !== true || field.sensitive === true) {
122
+ return;
123
+ }
124
+ const type = mapFieldType(field);
125
+ if (type === undefined) {
126
+ return;
127
+ }
128
+ const id = path.replace(/\./g, '_');
129
+ if (reserved.has(id)) {
130
+ // A field named like a physical $row column would make the allowlist
131
+ // ambiguous (§5.1) — the physical column wins; rename the field.
132
+ return;
133
+ }
134
+ columns.push(this.buildColumn(formKey, path, id, type, field));
135
+ });
136
+
137
+ return columns;
138
+ }
139
+
140
+ private buildColumn(
141
+ formKey: string,
142
+ path: string,
143
+ id: string,
144
+ type: ColumnType,
145
+ field: FieldDef,
146
+ ): ManifestColumn {
147
+ const extraction = generatedColumnExpression(path, type);
148
+ const hot = field.indexHint === true;
149
+ const searchable = hot && type === 'text';
150
+ // SORTABILITY needs non-null keys (§5.3): indexHint fields get the
151
+ // COALESCE-wrapped expression — in BOTH the queryable sql and the
152
+ // generated column, so the index is built on exactly what is compared —
153
+ // and are declared non-nullable. Fields without the hint stay un-wrapped,
154
+ // filterable-only, nullable.
155
+ const expression = hot ? `COALESCE(${extraction}, ${nullFallback(type)})` : extraction;
156
+ const name = generatedColumnName(formKey, path, type);
157
+ const plans = generatedColumnIndexes(name, type, { table: 'FormSubmission', searchable });
158
+ const index = plans.length > 0 ? toIndexSpec(plans[0]) : undefined;
159
+ const trgmPlan = plans.find((plan) => plan.using === 'gin');
160
+ const searchIndex = searchable && trgmPlan ? toIndexSpec(trgmPlan) : undefined;
161
+
162
+ return {
163
+ id,
164
+ sql: expression,
165
+ type,
166
+ sortable: hot,
167
+ filterable: true,
168
+ ...(searchable ? { searchable: true, search: { mode: 'trgm' as const } } : {}),
169
+ nullable: !hot,
170
+ // The §8.1 storage trick: dates are canonical ISO-8601 UTC TEXT, so the
171
+ // lookup binding for `select` fields lives in the DEFINITION column
172
+ // (lookup.dataSource); the manifest only types it 'lookup'.
173
+ ...(type === 'datetime' ? { valueKind: 'isoText' as const } : {}),
174
+ // The physical column the indexed tier publishes against (§8.1); the
175
+ // publish lint verifies it exists and suggests the exact ALTER TABLE.
176
+ generated: {
177
+ table: 'FormSubmission',
178
+ name,
179
+ expression,
180
+ comment: generatedColumnComment(formKey, path, type),
181
+ sqlType: generatedSqlType(type),
182
+ },
183
+ ...(index ? { index } : {}),
184
+ ...(searchIndex ? { searchIndex } : {}),
185
+ };
186
+ }
187
+ }
188
+
189
+ /** §3.1 field-type map — anything outside it is skipped, by design. */
190
+ function mapFieldType(field: FieldDef): ColumnType | undefined {
191
+ switch (field.type) {
192
+ case 'text':
193
+ case 'email':
194
+ return 'text';
195
+ case 'number':
196
+ return 'number';
197
+ case 'boolean':
198
+ return 'boolean';
199
+ case 'select':
200
+ return 'lookup';
201
+ case 'date':
202
+ return 'datetime';
203
+ default:
204
+ return undefined;
205
+ }
206
+ }
207
+
208
+ /** Typed COALESCE fallback for indexHint columns (§5.3 non-null sort keys). */
209
+ function nullFallback(type: ColumnType): string {
210
+ switch (type) {
211
+ case 'number':
212
+ return '0';
213
+ case 'boolean':
214
+ return 'false';
215
+ default:
216
+ // text/enum/lookup — and datetime, which is ISO-8601 UTC TEXT (§8.1).
217
+ return "''";
218
+ }
219
+ }
220
+
221
+ /** Stored SQL type of the generated column (the §8.1 casting table). */
222
+ function generatedSqlType(type: ColumnType): string {
223
+ switch (type) {
224
+ case 'number':
225
+ return 'numeric';
226
+ case 'boolean':
227
+ return 'boolean';
228
+ default:
229
+ return 'text';
230
+ }
231
+ }
232
+
233
+ function toIndexSpec(plan: GeneratedIndexPlan): IndexSpec {
234
+ return { name: plan.name, kind: plan.using, table: 'FormSubmission', expr: plan.expr };
235
+ }
236
+
237
+ /**
238
+ * The §2.1 allowlist of PHYSICAL FormSubmission columns, referenced as
239
+ * `$row.<id>` in definitions. Index specs name the indexes the template's
240
+ * Prisma schema already creates — declared capabilities stay honest (§5.2).
241
+ */
242
+ function physicalRowColumns(): ManifestColumn[] {
243
+ return [
244
+ {
245
+ id: 'status',
246
+ sql: '"status"::text',
247
+ type: 'enum',
248
+ filterable: true,
249
+ sortable: true,
250
+ nullable: false,
251
+ index: {
252
+ name: 'FormSubmission_orgId_formKey_status_idx',
253
+ kind: 'btree',
254
+ table: 'FormSubmission',
255
+ expr: '"orgId", "formKey", "status"',
256
+ },
257
+ },
258
+ {
259
+ id: 'createdAt',
260
+ sql: '"createdAt"',
261
+ type: 'datetime',
262
+ valueKind: 'native',
263
+ sortable: true,
264
+ filterable: true,
265
+ nullable: false,
266
+ index: {
267
+ name: 'FormSubmission_orgId_createdAt_idx',
268
+ kind: 'btree',
269
+ table: 'FormSubmission',
270
+ expr: '"orgId", "createdAt"',
271
+ },
272
+ },
273
+ {
274
+ id: 'updatedAt',
275
+ sql: '"updatedAt"',
276
+ type: 'datetime',
277
+ valueKind: 'native',
278
+ sortable: true,
279
+ nullable: false,
280
+ // No index declared: sortable on the live tier only until an app
281
+ // publishes an indexed report over it.
282
+ },
283
+ {
284
+ id: 'createdBy',
285
+ sql: '"createdBy"',
286
+ type: 'text',
287
+ filterable: true,
288
+ // Null for anonymous/public submissions — filterable only.
289
+ nullable: true,
290
+ },
291
+ ];
292
+ }
@@ -0,0 +1,171 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { SubmissionService } from '@ftisindia/form-builder';
3
+ import type { SubmissionRecord, SubmissionStatus, SubmitArgs } from '@ftisindia/form-builder';
4
+ import { ReportAuthzDeniedError } from '@ftisindia/report-builder';
5
+ import type {
6
+ ReportActionContext,
7
+ ReportRowActionDef,
8
+ ResolvedRow,
9
+ RowActionKind,
10
+ } from '@ftisindia/report-builder';
11
+ import { withFormsErrorMapping } from '../../../forms/application/services/forms-error.mapper';
12
+ import { RequestFormsContext } from '../../../forms/infrastructure/request-forms-context';
13
+
14
+ /**
15
+ * Form-backed row actions (report design §6.1) — verbs that DELEGATE, never a
16
+ * second write path. The report engine resolves rows and checks org +
17
+ * permissions; these handlers hand each row to the form engine's
18
+ * SubmissionService, so grid verbs run the SAME validation and action pipeline
19
+ * as the form itself. No Prisma access here, ever.
20
+ *
21
+ * Call path mirrors FormsSubmissionsService: the engine is called through the
22
+ * forms context seam (RequestFormsContext — the same AsyncLocalStorage request
23
+ * context the report engine's own seam reads), and every call is wrapped in
24
+ * withFormsErrorMapping so a form validation failure surfaces as the app's
25
+ * 422 envelope with the engine's `errors` array — not an opaque 500.
26
+ *
27
+ * Engine API constraint (read from SubmissionService): the only public update
28
+ * surface is submit/saveDraft with `submissionId`, and the built-in persist
29
+ * action rejects rows that are locked or already SUBMITTED. So edits apply to
30
+ * DRAFT rows; a locked/submitted row surfaces the engine's FormsStateError
31
+ * (409) untouched. The forms engine also runs its OWN transaction per
32
+ * delegated call — its public API takes no external tx — so a multi-row batch
33
+ * is one report-engine transaction wrapping N form-engine transactions, and a
34
+ * failed row stops the batch without rolling back rows already committed by
35
+ * the form engine. Documented seam, not a bug: the alternative would be a
36
+ * second write path (§6).
37
+ *
38
+ * NO generic softDelete is registered: §6.1 makes delete "a status transition
39
+ * the source declares", and the form engine declares no deleted status —
40
+ * SubmissionStatus is DRAFT | SUBMITTED. When the form engine grows one, a
41
+ * delegated action lands here beside these two.
42
+ */
43
+
44
+ /** The submission statuses the form engine exposes (SubmissionStatus). */
45
+ const SUBMISSION_STATUSES: readonly SubmissionStatus[] = ['DRAFT', 'SUBMITTED'];
46
+
47
+ /**
48
+ * `editSubmission` (§6.1 "Edit"): patches a submission through the form
49
+ * engine, re-validating against the submission's STAMPED `formVersion`
50
+ * definition in submit mode — an edit can never be validated against the
51
+ * wrong schema generation. The patch is shallow-merged over the stored data,
52
+ * then the whole document rides the form's own submit pipeline.
53
+ */
54
+ @Injectable()
55
+ export class EditSubmissionRowAction implements ReportRowActionDef {
56
+ readonly name = 'editSubmission';
57
+ readonly kind: RowActionKind = 'transactional';
58
+ readonly requiredPermissions = ['formSubmissions.update'] as const;
59
+ readonly inputSchema = {
60
+ type: 'object',
61
+ properties: { data: { type: 'object' } },
62
+ required: ['data'],
63
+ additionalProperties: false,
64
+ };
65
+
66
+ constructor(
67
+ private readonly submissions: SubmissionService,
68
+ private readonly formsContext: RequestFormsContext,
69
+ ) {}
70
+
71
+ async execute(
72
+ rows: ResolvedRow[],
73
+ input: Record<string, unknown> | undefined,
74
+ actionCtx: ReportActionContext,
75
+ ): Promise<unknown> {
76
+ const orgId = requireOrgId(actionCtx);
77
+ // The action service ajv-validates inputSchema before execute (§6).
78
+ const patch = (input?.data ?? {}) as Record<string, unknown>;
79
+
80
+ let updated = 0;
81
+ for (const row of rows) {
82
+ const record = await this.loadRecord(orgId, row);
83
+ const args: SubmitArgs = {
84
+ orgId,
85
+ formKey: record.formKey,
86
+ // Re-validate against the stamped version, never the latest (§6.1).
87
+ version: record.formVersion,
88
+ data: { ...record.data, ...patch },
89
+ submissionId: record.id,
90
+ };
91
+ await withFormsErrorMapping(() => this.submissions.submit(args, this.formsContext));
92
+ updated += 1;
93
+ }
94
+ return { updated };
95
+ }
96
+
97
+ private async loadRecord(orgId: string, row: ResolvedRow): Promise<SubmissionRecord> {
98
+ // rows carry sourceRef.rowId = the FormSubmission id; the engine's get()
99
+ // re-checks org scope and formSubmissions.read through the forms seam.
100
+ return withFormsErrorMapping(() =>
101
+ this.submissions.get({ orgId, submissionId: row.sourceRef.rowId }, this.formsContext),
102
+ );
103
+ }
104
+ }
105
+
106
+ /**
107
+ * `updateStatus` (§6.1 "Status update"): a delegated transactional action.
108
+ * Status is not patched directly — the form engine couples status to its
109
+ * submit/saveDraft verbs, so SUBMITTED delegates to submit() (full pipeline,
110
+ * stamped-version validation) and DRAFT delegates to saveDraft(). Demoting an
111
+ * already-SUBMITTED row is rejected by the engine's persist action and
112
+ * surfaces as a conflict — the source, not the report layer, owns the legal
113
+ * transitions.
114
+ */
115
+ @Injectable()
116
+ export class UpdateSubmissionStatusRowAction implements ReportRowActionDef {
117
+ readonly name = 'updateStatus';
118
+ readonly kind: RowActionKind = 'transactional';
119
+ readonly requiredPermissions = ['formSubmissions.update'] as const;
120
+ readonly inputSchema = {
121
+ type: 'object',
122
+ properties: { status: { enum: [...SUBMISSION_STATUSES] } },
123
+ required: ['status'],
124
+ additionalProperties: false,
125
+ };
126
+
127
+ constructor(
128
+ private readonly submissions: SubmissionService,
129
+ private readonly formsContext: RequestFormsContext,
130
+ ) {}
131
+
132
+ async execute(
133
+ rows: ResolvedRow[],
134
+ input: Record<string, unknown> | undefined,
135
+ actionCtx: ReportActionContext,
136
+ ): Promise<unknown> {
137
+ const orgId = requireOrgId(actionCtx);
138
+ // ajv-validated against inputSchema before execute (§6).
139
+ const status = input?.status as SubmissionStatus;
140
+
141
+ let updated = 0;
142
+ for (const row of rows) {
143
+ const record = await withFormsErrorMapping(() =>
144
+ this.submissions.get({ orgId, submissionId: row.sourceRef.rowId }, this.formsContext),
145
+ );
146
+ const args: SubmitArgs = {
147
+ orgId,
148
+ formKey: record.formKey,
149
+ version: record.formVersion,
150
+ data: record.data,
151
+ submissionId: record.id,
152
+ };
153
+ await withFormsErrorMapping(() =>
154
+ status === 'SUBMITTED'
155
+ ? this.submissions.submit(args, this.formsContext)
156
+ : this.submissions.saveDraft(args, this.formsContext),
157
+ );
158
+ updated += 1;
159
+ }
160
+ return { updated };
161
+ }
162
+ }
163
+
164
+ /** Mirrors the engine's own manageTags guard: no org scope, no action. */
165
+ function requireOrgId(actionCtx: ReportActionContext): string {
166
+ const orgId = actionCtx.ctx.orgId();
167
+ if (orgId === undefined) {
168
+ throw new ReportAuthzDeniedError('No organisation scope is active.');
169
+ }
170
+ return orgId;
171
+ }
@@ -0,0 +1,32 @@
1
+ import { Injectable, OnModuleInit } from '@nestjs/common';
2
+ import { ReportRowActionRegistry, SourceProviderRegistry } from '@ftisindia/report-builder';
3
+ import { FormReportSourceProvider } from './form-report-source.adapter';
4
+ import {
5
+ EditSubmissionRowAction,
6
+ UpdateSubmissionStatusRowAction,
7
+ } from './form-row-actions';
8
+
9
+ /**
10
+ * The ONLY place the two engines meet (report design §3.2/§13). It registers
11
+ * the form-backed bits into the report engine's shared registries — the
12
+ * 'form' source provider and the delegated editSubmission/updateStatus verbs
13
+ * — only because THIS app ships the form builder. Removing
14
+ * ReportsFormsModule from the app leaves ReportsModule fully functional over
15
+ * custom sources, with no dangling form dependency.
16
+ */
17
+ @Injectable()
18
+ export class ReportsFormsBridgeBootstrap implements OnModuleInit {
19
+ constructor(
20
+ private readonly sourceProviders: SourceProviderRegistry,
21
+ private readonly actions: ReportRowActionRegistry,
22
+ private readonly formSourceProvider: FormReportSourceProvider,
23
+ private readonly editSubmission: EditSubmissionRowAction,
24
+ private readonly updateStatus: UpdateSubmissionStatusRowAction,
25
+ ) {}
26
+
27
+ onModuleInit(): void {
28
+ this.sourceProviders.register(this.formSourceProvider);
29
+ this.actions.register(this.editSubmission);
30
+ this.actions.register(this.updateStatus);
31
+ }
32
+ }