@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.
Files changed (169) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +31 -0
  3. package/template/README.md +61 -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/app.config.ts +6 -1
  21. package/template/src/config/env.validation.ts +45 -0
  22. package/template/src/config/forms.config.ts +12 -0
  23. package/template/src/config/index.ts +2 -0
  24. package/template/src/config/openapi.ts +12 -0
  25. package/template/src/config/reports-secret.ts +15 -0
  26. package/template/src/config/reports.config.ts +16 -0
  27. package/template/src/main.ts +16 -12
  28. package/template/src/modules/access-control/access-control.module.ts +2 -1
  29. package/template/src/modules/access-control/dto/access-control-response.dto.ts +3 -0
  30. package/template/src/modules/access-control/dto/current-access-control-response.dto.ts +35 -0
  31. package/template/src/modules/access-control/presentation/current-access-control.controller.ts +40 -0
  32. package/template/src/modules/access-control/types/permission-key.ts +27 -0
  33. package/template/src/modules/access-control/types/route-permission-registry.ts +183 -0
  34. package/template/src/modules/audit/dto/audit-response.dto.ts +7 -3
  35. package/template/src/modules/auth/auth.module.ts +3 -1
  36. package/template/src/modules/auth/dto/auth-response.dto.ts +1 -1
  37. package/template/src/modules/forms/application/services/file-gc.service.ts +85 -0
  38. package/template/src/modules/forms/application/services/forms-definitions.service.ts +137 -0
  39. package/template/src/modules/forms/application/services/forms-error.mapper.ts +64 -0
  40. package/template/src/modules/forms/application/services/forms-export.service.ts +210 -0
  41. package/template/src/modules/forms/application/services/forms-files.service.ts +164 -0
  42. package/template/src/modules/forms/application/services/forms-public.service.ts +49 -0
  43. package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +53 -0
  44. package/template/src/modules/forms/application/services/forms-submissions.service.ts +103 -0
  45. package/template/src/modules/forms/application/services/handlers/authenticate.action.ts +37 -0
  46. package/template/src/modules/forms/application/services/handlers/logging-email.handler.ts +22 -0
  47. package/template/src/modules/forms/application/services/handlers/send-confirmation-email.action.ts +40 -0
  48. package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +41 -0
  49. package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +109 -0
  50. package/template/src/modules/forms/dto/create-form-definition.dto.ts +12 -0
  51. package/template/src/modules/forms/dto/data-source-response.dto.ts +19 -0
  52. package/template/src/modules/forms/dto/export-submissions-query.dto.ts +33 -0
  53. package/template/src/modules/forms/dto/file-upload-response.dto.ts +24 -0
  54. package/template/src/modules/forms/dto/form-definition-response.dto.ts +50 -0
  55. package/template/src/modules/forms/dto/form-render-response.dto.ts +17 -0
  56. package/template/src/modules/forms/dto/list-form-definitions-query.dto.ts +10 -0
  57. package/template/src/modules/forms/dto/list-submissions-query.dto.ts +10 -0
  58. package/template/src/modules/forms/dto/public-submit-form.dto.ts +24 -0
  59. package/template/src/modules/forms/dto/set-public-access.dto.ts +8 -0
  60. package/template/src/modules/forms/dto/submission-response.dto.ts +99 -0
  61. package/template/src/modules/forms/dto/submit-form.dto.ts +50 -0
  62. package/template/src/modules/forms/dto/update-form-definition.dto.ts +12 -0
  63. package/template/src/modules/forms/dto/upload-file-query.dto.ts +33 -0
  64. package/template/src/modules/forms/dto/validate-submission.dto.ts +22 -0
  65. package/template/src/modules/forms/examples/abstract-submission.form.json +80 -0
  66. package/template/src/modules/forms/examples/login.form.json +24 -0
  67. package/template/src/modules/forms/examples/registration.form.json +44 -0
  68. package/template/src/modules/forms/forms.module.ts +226 -0
  69. package/template/src/modules/forms/forms.tokens.ts +6 -0
  70. package/template/src/modules/forms/infrastructure/audit-sink.adapter.ts +30 -0
  71. package/template/src/modules/forms/infrastructure/casl-forms-authorization.ts +31 -0
  72. package/template/src/modules/forms/infrastructure/prisma-tx-runner.ts +17 -0
  73. package/template/src/modules/forms/infrastructure/registry/form-extension.decorators.ts +17 -0
  74. package/template/src/modules/forms/infrastructure/registry/registry-bootstrap.service.ts +82 -0
  75. package/template/src/modules/forms/infrastructure/request-forms-context.ts +60 -0
  76. package/template/src/modules/forms/infrastructure/schema-check/forms-schema-check.service.ts +76 -0
  77. package/template/src/modules/forms/infrastructure/storage/local-disk-storage.adapter.ts +43 -0
  78. package/template/src/modules/forms/infrastructure/stores/index.ts +5 -0
  79. package/template/src/modules/forms/infrastructure/stores/prisma-action-log.store.ts +37 -0
  80. package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +108 -0
  81. package/template/src/modules/forms/infrastructure/stores/prisma-form-definition.store.ts +147 -0
  82. package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +133 -0
  83. package/template/src/modules/forms/infrastructure/stores/prisma-submission.store.ts +164 -0
  84. package/template/src/modules/forms/presentation/forms-data-sources.controller.ts +58 -0
  85. package/template/src/modules/forms/presentation/forms-definitions.controller.ts +191 -0
  86. package/template/src/modules/forms/presentation/forms-files.controller.ts +79 -0
  87. package/template/src/modules/forms/presentation/forms-submissions.controller.ts +154 -0
  88. package/template/src/modules/forms/presentation/forms-upload.interceptor.ts +33 -0
  89. package/template/src/modules/forms/presentation/public-forms.controller.ts +51 -0
  90. package/template/src/modules/invitations/dto/invitation-response.dto.ts +4 -0
  91. package/template/src/modules/organisations/application/services/organisations.service.ts +67 -1
  92. package/template/src/modules/organisations/dto/organisation-response.dto.ts +52 -0
  93. package/template/src/modules/organisations/presentation/organisations.controller.ts +25 -3
  94. package/template/src/modules/reports/application/services/reports-actions.service.ts +54 -0
  95. package/template/src/modules/reports/application/services/reports-definitions.service.ts +66 -0
  96. package/template/src/modules/reports/application/services/reports-error.mapper.ts +97 -0
  97. package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +124 -0
  98. package/template/src/modules/reports/application/services/reports-exports.service.ts +74 -0
  99. package/template/src/modules/reports/application/services/reports-queries.service.ts +35 -0
  100. package/template/src/modules/reports/application/services/reports-settings-reader.service.ts +49 -0
  101. package/template/src/modules/reports/application/services/reports-views.service.ts +79 -0
  102. package/template/src/modules/reports/dto/action-result-response.dto.ts +21 -0
  103. package/template/src/modules/reports/dto/create-report-definition.dto.ts +86 -0
  104. package/template/src/modules/reports/dto/create-saved-view.dto.ts +26 -0
  105. package/template/src/modules/reports/dto/execute-action.dto.ts +71 -0
  106. package/template/src/modules/reports/dto/export-job-response.dto.ts +60 -0
  107. package/template/src/modules/reports/dto/export-request.dto.ts +34 -0
  108. package/template/src/modules/reports/dto/list-reports-query.dto.ts +10 -0
  109. package/template/src/modules/reports/dto/list-views-query.dto.ts +17 -0
  110. package/template/src/modules/reports/dto/prepare-action-response.dto.ts +14 -0
  111. package/template/src/modules/reports/dto/prepare-action.dto.ts +27 -0
  112. package/template/src/modules/reports/dto/query-response.dto.ts +64 -0
  113. package/template/src/modules/reports/dto/query-spec.dto.ts +120 -0
  114. package/template/src/modules/reports/dto/report-definition-response.dto.ts +64 -0
  115. package/template/src/modules/reports/dto/report-meta-query.dto.ts +16 -0
  116. package/template/src/modules/reports/dto/report-meta-response.dto.ts +113 -0
  117. package/template/src/modules/reports/dto/saved-view-response.dto.ts +66 -0
  118. package/template/src/modules/reports/dto/update-report-definition.dto.ts +9 -0
  119. package/template/src/modules/reports/dto/update-saved-view.dto.ts +27 -0
  120. package/template/src/modules/reports/examples/abstract-review-board.report.json +54 -0
  121. package/template/src/modules/reports/examples/org-members.report.json +55 -0
  122. package/template/src/modules/reports/infrastructure/audit-sink.adapter.ts +31 -0
  123. package/template/src/modules/reports/infrastructure/casl-reports-authorization.ts +39 -0
  124. package/template/src/modules/reports/infrastructure/forms-adapter/form-report-source.adapter.ts +292 -0
  125. package/template/src/modules/reports/infrastructure/forms-adapter/form-row-actions.ts +171 -0
  126. package/template/src/modules/reports/infrastructure/forms-adapter/forms-bridge-bootstrap.service.ts +32 -0
  127. package/template/src/modules/reports/infrastructure/prisma-catalog.adapter.ts +95 -0
  128. package/template/src/modules/reports/infrastructure/prisma-query-executor.ts +103 -0
  129. package/template/src/modules/reports/infrastructure/prisma-snapshot-runner.ts +47 -0
  130. package/template/src/modules/reports/infrastructure/prisma-tx-runner.ts +18 -0
  131. package/template/src/modules/reports/infrastructure/registry/registry-bootstrap.service.ts +61 -0
  132. package/template/src/modules/reports/infrastructure/registry/report-extension.decorators.ts +14 -0
  133. package/template/src/modules/reports/infrastructure/reports-job-queue.adapter.ts +28 -0
  134. package/template/src/modules/reports/infrastructure/request-reports-context.ts +42 -0
  135. package/template/src/modules/reports/infrastructure/schema-check/reports-schema-check.service.ts +116 -0
  136. package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +79 -0
  137. package/template/src/modules/reports/infrastructure/stores/index.ts +5 -0
  138. package/template/src/modules/reports/infrastructure/stores/prisma-bulk-action-run.store.ts +89 -0
  139. package/template/src/modules/reports/infrastructure/stores/prisma-export-job.store.ts +93 -0
  140. package/template/src/modules/reports/infrastructure/stores/prisma-report-definition.store.ts +171 -0
  141. package/template/src/modules/reports/infrastructure/stores/prisma-row-tag.store.ts +110 -0
  142. package/template/src/modules/reports/infrastructure/stores/prisma-saved-view.store.ts +144 -0
  143. package/template/src/modules/reports/presentation/reports-actions.controller.ts +83 -0
  144. package/template/src/modules/reports/presentation/reports-definitions.controller.ts +156 -0
  145. package/template/src/modules/reports/presentation/reports-export-jobs.controller.ts +61 -0
  146. package/template/src/modules/reports/presentation/reports-export.controller.ts +76 -0
  147. package/template/src/modules/reports/presentation/reports-query.controller.ts +52 -0
  148. package/template/src/modules/reports/presentation/reports-views.controller.ts +140 -0
  149. package/template/src/modules/reports/reports-forms.module.ts +33 -0
  150. package/template/src/modules/reports/reports.module.ts +335 -0
  151. package/template/src/modules/reports/reports.tokens.ts +11 -0
  152. package/template/src/modules/reports/sources/org-members.source.ts +112 -0
  153. package/template/src/modules/settings/types/setting-definitions.ts +94 -0
  154. package/template/test/forms-definitions.e2e-spec.ts +394 -0
  155. package/template/test/forms-export.e2e-spec.ts +390 -0
  156. package/template/test/forms-files.e2e-spec.ts +345 -0
  157. package/template/test/forms-outbox.e2e-spec.ts +309 -0
  158. package/template/test/forms-permission-sync.spec.ts +27 -0
  159. package/template/test/forms-public.e2e-spec.ts +269 -0
  160. package/template/test/forms-schema-check.e2e-spec.ts +65 -0
  161. package/template/test/forms-submissions.e2e-spec.ts +500 -0
  162. package/template/test/forms-webhooks.e2e-spec.ts +261 -0
  163. package/template/test/frontend-bootstrap.spec.ts +181 -0
  164. package/template/test/reports-advanced.e2e-spec.ts +368 -0
  165. package/template/test/reports-permission-sync.spec.ts +30 -0
  166. package/template/test/reports-query.e2e-spec.ts +350 -0
  167. package/template/test/reports-tiers.e2e-spec.ts +257 -0
  168. package/template/test/route-registry.validator.spec.ts +34 -0
  169. package/template/test/security.e2e-spec.ts +134 -2
@@ -0,0 +1,210 @@
1
+ import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
2
+ import { FieldTypeRegistry } from '@ftisindia/form-builder';
3
+ import type { FieldDef, FormDefinition, SubmissionRecord } from '@ftisindia/form-builder';
4
+ import { PrismaService } from '../../../../database/prisma/prisma.service';
5
+ import { AuditService } from '../../../audit/application/services/audit.service';
6
+ import { AuthenticatedUser } from '../../../auth/types/authenticated-user';
7
+ import { RequestContextService } from '../../../request-context/application/services/request-context.service';
8
+ import { ExportSubmissionsQueryDto } from '../../dto/export-submissions-query.dto';
9
+ import { PrismaFormDefinitionStore } from '../../infrastructure/stores/prisma-form-definition.store';
10
+ import { PrismaSubmissionStore } from '../../infrastructure/stores/prisma-submission.store';
11
+
12
+ const BASE_COLUMNS = ['id', 'formVersion', 'status', 'createdBy', 'createdAt'] as const;
13
+
14
+ export interface ExportResult {
15
+ format: 'csv' | 'json';
16
+ columns: string[];
17
+ rows: Array<Record<string, unknown>>;
18
+ csv?: string;
19
+ }
20
+
21
+ /**
22
+ * Submission export — PII egress, treated accordingly (ecosystem guide §7):
23
+ * gated by formSubmissions.export (never implied by .read at the route), and
24
+ * EVERY export writes an audit row recording form key, filters, row count,
25
+ * and included fields — including empty results. Field selection defaults to
26
+ * the definitions' `reportable` manifest (design doc §15), computed
27
+ * version-aware across the versions present in the result set; sensitive and
28
+ * credential-typed fields are refused even when requested explicitly.
29
+ */
30
+ @Injectable()
31
+ export class FormsExportService {
32
+ constructor(
33
+ private readonly definitions: PrismaFormDefinitionStore,
34
+ private readonly submissions: PrismaSubmissionStore,
35
+ private readonly audit: AuditService,
36
+ private readonly prisma: PrismaService,
37
+ private readonly requestContext: RequestContextService,
38
+ private readonly fieldTypes: FieldTypeRegistry,
39
+ ) {}
40
+
41
+ async export(
42
+ orgId: string,
43
+ formKey: string,
44
+ query: ExportSubmissionsQueryDto,
45
+ user: AuthenticatedUser,
46
+ ): Promise<ExportResult> {
47
+ this.requestContext.assertOrgScope(orgId);
48
+
49
+ const latest = await this.definitions.findLatest(orgId, formKey);
50
+ if (!latest) {
51
+ throw new NotFoundException('Form was not found.');
52
+ }
53
+
54
+ const filters = {
55
+ status: query.status,
56
+ createdFrom: query.from ? new Date(query.from) : undefined,
57
+ createdTo: query.to ? new Date(query.to) : undefined,
58
+ };
59
+ const rows = await this.submissions.listForExport(orgId, formKey, filters);
60
+
61
+ const versions = [...new Set([latest.version, ...rows.map((row) => row.formVersion)])];
62
+ const schemas: FormDefinition[] = [latest.schema];
63
+ for (const version of versions) {
64
+ if (version === latest.version) {
65
+ continue;
66
+ }
67
+ const record = await this.definitions.findByKeyVersion(orgId, formKey, version);
68
+ if (record) {
69
+ schemas.push(record.schema);
70
+ }
71
+ }
72
+
73
+ const { reportable, sensitive } = this.fieldManifest(schemas);
74
+ const requested = query.fields
75
+ ? query.fields.split(',').map((field) => field.trim()).filter(Boolean)
76
+ : undefined;
77
+ if (requested) {
78
+ const refused = requested.filter((field) => sensitive.has(field));
79
+ if (refused.length > 0) {
80
+ throw new ForbiddenException(
81
+ `Sensitive fields can never be exported: ${refused.join(', ')}.`,
82
+ );
83
+ }
84
+ }
85
+ const dataColumns = (requested ?? [...reportable]).filter((field) => !sensitive.has(field));
86
+ const columns = [...BASE_COLUMNS, ...dataColumns];
87
+
88
+ const flatRows = rows.map((row) => this.flatten(row, dataColumns));
89
+ const format = query.format ?? 'json';
90
+ const result: ExportResult = { format, columns, rows: flatRows };
91
+ if (format === 'csv') {
92
+ result.csv = this.toCsv(columns, flatRows);
93
+ }
94
+
95
+ await this.audit.write(this.prisma, {
96
+ orgId,
97
+ actorUserId: user.id,
98
+ action: 'formSubmissions.export',
99
+ targetType: 'FormDefinition',
100
+ targetId: latest.id,
101
+ metadata: {
102
+ formKey,
103
+ filters: {
104
+ status: query.status ?? null,
105
+ from: query.from ?? null,
106
+ to: query.to ?? null,
107
+ },
108
+ rowCount: rows.length,
109
+ fields: dataColumns,
110
+ format,
111
+ },
112
+ });
113
+
114
+ return result;
115
+ }
116
+
117
+ /** Version-aware union of reportable field names; sensitive set across ALL versions. */
118
+ private fieldManifest(schemas: FormDefinition[]) {
119
+ const reportable = new Set<string>();
120
+ const sensitive = new Set<string>();
121
+ for (const schema of schemas) {
122
+ this.collectFieldManifest(schema.fields, [], false, reportable, sensitive);
123
+ }
124
+ return { reportable, sensitive };
125
+ }
126
+
127
+ private collectFieldManifest(
128
+ fields: FieldDef[],
129
+ parentPath: string[],
130
+ inheritedSensitive: boolean,
131
+ reportable: Set<string>,
132
+ sensitive: Set<string>,
133
+ ): boolean {
134
+ let subtreeHasSensitive = false;
135
+ for (const field of fields) {
136
+ const path = [...parentPath, field.name];
137
+ const dotted = path.join('.');
138
+ const ownSensitive = inheritedSensitive || this.isSensitiveField(field);
139
+ if (field.reportable === true) {
140
+ reportable.add(dotted);
141
+ }
142
+
143
+ const childHasSensitive =
144
+ field.fields && field.fields.length > 0
145
+ ? this.collectFieldManifest(
146
+ field.fields,
147
+ path,
148
+ ownSensitive,
149
+ reportable,
150
+ sensitive,
151
+ )
152
+ : false;
153
+
154
+ if (ownSensitive || childHasSensitive) {
155
+ sensitive.add(dotted);
156
+ sensitive.add(field.name);
157
+ subtreeHasSensitive = true;
158
+ }
159
+ }
160
+ return subtreeHasSensitive;
161
+ }
162
+
163
+ private isSensitiveField(field: FieldDef): boolean {
164
+ return field.sensitive === true || this.fieldTypes.get(field.type)?.alwaysSensitive === true;
165
+ }
166
+
167
+ private flatten(row: SubmissionRecord, dataColumns: string[]): Record<string, unknown> {
168
+ const flat: Record<string, unknown> = {
169
+ id: row.id,
170
+ formVersion: row.formVersion,
171
+ status: row.status,
172
+ createdBy: row.createdBy ?? null,
173
+ createdAt: row.createdAt.toISOString(),
174
+ };
175
+ for (const column of dataColumns) {
176
+ const value = this.valueAtPath(row.data, column);
177
+ flat[column] =
178
+ value !== null && typeof value === 'object' ? JSON.stringify(value) : (value ?? null);
179
+ }
180
+ return flat;
181
+ }
182
+
183
+ private valueAtPath(data: Record<string, unknown>, dotted: string): unknown {
184
+ let current: unknown = data;
185
+ for (const segment of dotted.split('.')) {
186
+ if (current === null || typeof current !== 'object' || Array.isArray(current)) {
187
+ return undefined;
188
+ }
189
+ current = (current as Record<string, unknown>)[segment];
190
+ }
191
+ return current;
192
+ }
193
+
194
+ private toCsv(columns: string[], rows: Array<Record<string, unknown>>): string {
195
+ const escape = (value: unknown): string => {
196
+ if (value === null || value === undefined) {
197
+ return '';
198
+ }
199
+ // flatten() already stringifies objects; this branch is type-level only.
200
+ const text =
201
+ typeof value === 'object' ? JSON.stringify(value) : String(value as string | number);
202
+ return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
203
+ };
204
+ const lines = [columns.map(escape).join(',')];
205
+ for (const row of rows) {
206
+ lines.push(columns.map((column) => escape(row[column])).join(','));
207
+ }
208
+ return lines.join('\r\n');
209
+ }
210
+ }
@@ -0,0 +1,164 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import {
3
+ BadRequestException,
4
+ ConflictException,
5
+ Injectable,
6
+ NotFoundException,
7
+ PayloadTooLargeException,
8
+ UnsupportedMediaTypeException,
9
+ } from '@nestjs/common';
10
+ import {
11
+ DefaultMagicByteSniffer,
12
+ acceptSatisfied,
13
+ walkFields,
14
+ } from '@ftisindia/form-builder';
15
+ import type { FieldDef, FormDefinitionRecord, UploadedFileRecord } from '@ftisindia/form-builder';
16
+ import { AuthenticatedUser } from '../../../auth/types/authenticated-user';
17
+ import { RequestContextService } from '../../../request-context/application/services/request-context.service';
18
+ import { UploadFileQueryDto } from '../../dto/upload-file-query.dto';
19
+ import { PrismaFileStore } from '../../infrastructure/stores/prisma-file.store';
20
+ import { PrismaFormDefinitionStore } from '../../infrastructure/stores/prisma-form-definition.store';
21
+ import { PrismaSubmissionStore } from '../../infrastructure/stores/prisma-submission.store';
22
+ import { LocalDiskStorageAdapter } from '../../infrastructure/storage/local-disk-storage.adapter';
23
+ import { FormsSettingsReader } from './forms-settings-reader.service';
24
+
25
+ const sniffer = new DefaultMagicByteSniffer();
26
+
27
+ /**
28
+ * Upload-before-submit (design doc §10): bytes are stored here, the form
29
+ * submission later carries only the file id. Every trust decision is
30
+ * server-side — MIME type is sniffed from content (never the client header),
31
+ * size is enforced against both the field's and the org's caps, sha256 is
32
+ * computed, and the record is bound to uploader AND org (two-factor
33
+ * ownership). Files start TEMPORARY; submit-time linking promotes to LINKED;
34
+ * the GC sweeps the rest after the TTL.
35
+ */
36
+ @Injectable()
37
+ export class FormsFilesService {
38
+ constructor(
39
+ private readonly definitions: PrismaFormDefinitionStore,
40
+ private readonly submissions: PrismaSubmissionStore,
41
+ private readonly files: PrismaFileStore,
42
+ private readonly storage: LocalDiskStorageAdapter,
43
+ private readonly settings: FormsSettingsReader,
44
+ private readonly requestContext: RequestContextService,
45
+ ) {}
46
+
47
+ async upload(
48
+ orgId: string,
49
+ formKey: string,
50
+ query: UploadFileQueryDto,
51
+ file: { originalname: string; size: number; buffer: Buffer } | undefined,
52
+ user: AuthenticatedUser,
53
+ ) {
54
+ this.requestContext.assertOrgScope(orgId);
55
+ if (!file || !file.buffer || file.size === 0) {
56
+ throw new BadRequestException('A non-empty file is required (multipart field "file").');
57
+ }
58
+ if (!query.field) {
59
+ throw new BadRequestException('The "field" query parameter is required.');
60
+ }
61
+
62
+ const definition = await this.resolveTargetDefinition(orgId, formKey, query);
63
+ const field = this.findFileField(definition.schema.fields, query.field);
64
+ if (!field) {
65
+ throw new NotFoundException(`Form has no file field named "${query.field}".`);
66
+ }
67
+
68
+ const policy = await this.settings.policyFor(orgId);
69
+ const capMb = Math.min(field.maxSizeMb ?? Infinity, policy.maxFileSizeMb ?? Infinity);
70
+ if (Number.isFinite(capMb) && file.size > capMb * 1024 * 1024) {
71
+ throw new PayloadTooLargeException(`File exceeds the ${capMb} MB limit for this field.`);
72
+ }
73
+
74
+ const sniffed = sniffer.sniff(file.buffer);
75
+ if (!sniffed) {
76
+ throw new UnsupportedMediaTypeException('The file type could not be determined.');
77
+ }
78
+ if (!acceptSatisfied(field.accept, sniffed.mimeType)) {
79
+ throw new UnsupportedMediaTypeException(
80
+ `Detected type "${sniffed.mimeType}" is not accepted for this field.`,
81
+ );
82
+ }
83
+
84
+ const checksum = createHash('sha256').update(file.buffer).digest('hex');
85
+ const storageKey = `${orgId}/${randomUUID()}`;
86
+ await this.storage.put(storageKey, file.buffer);
87
+ const record = await this.files.create({
88
+ storageKey,
89
+ originalName: file.originalname,
90
+ mimeType: sniffed.mimeType,
91
+ size: file.size,
92
+ checksum,
93
+ ownerId: user.id,
94
+ orgId,
95
+ status: 'TEMPORARY',
96
+ });
97
+ return this.toResponse(record);
98
+ }
99
+
100
+ async getMeta(orgId: string, fileId: string) {
101
+ this.requestContext.assertOrgScope(orgId);
102
+ const record = await this.files.findById(orgId, fileId);
103
+ if (!record) {
104
+ throw new NotFoundException('File was not found.');
105
+ }
106
+ return this.toResponse(record);
107
+ }
108
+
109
+ /** Storage keys never leave the server. */
110
+ private toResponse(record: UploadedFileRecord) {
111
+ return {
112
+ fileId: record.id,
113
+ originalName: record.originalName,
114
+ mimeType: record.mimeType,
115
+ size: record.size,
116
+ checksum: record.checksum,
117
+ status: record.status,
118
+ createdAt: record.createdAt,
119
+ };
120
+ }
121
+
122
+ private async resolveTargetDefinition(
123
+ orgId: string,
124
+ formKey: string,
125
+ query: UploadFileQueryDto,
126
+ ): Promise<FormDefinitionRecord> {
127
+ let version = query.version;
128
+
129
+ if (query.submissionId) {
130
+ const submission = await this.submissions.findById(orgId, query.submissionId);
131
+ if (!submission || submission.formKey !== formKey) {
132
+ throw new NotFoundException('Draft submission was not found.');
133
+ }
134
+ if (submission.status !== 'DRAFT') {
135
+ throw new ConflictException('Files can only be uploaded for editable draft submissions.');
136
+ }
137
+ if (version !== undefined && version !== submission.formVersion) {
138
+ throw new ConflictException(
139
+ 'Upload version does not match the draft submission version.',
140
+ );
141
+ }
142
+ version = submission.formVersion;
143
+ }
144
+
145
+ const record =
146
+ version !== undefined
147
+ ? await this.definitions.findByKeyVersion(orgId, formKey, version)
148
+ : await this.definitions.findLatest(orgId, formKey, 'PUBLISHED');
149
+ if (!record || record.status !== 'PUBLISHED') {
150
+ throw new NotFoundException('Form was not found.');
151
+ }
152
+ return record;
153
+ }
154
+
155
+ private findFileField(fields: FieldDef[], name: string): FieldDef | undefined {
156
+ let found: FieldDef | undefined;
157
+ walkFields(fields, (field) => {
158
+ if (!found && field.type === 'file' && field.name === name) {
159
+ found = field;
160
+ }
161
+ });
162
+ return found;
163
+ }
164
+ }
@@ -0,0 +1,49 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { DefinitionService, SubmissionService } from '@ftisindia/form-builder';
3
+ import { RequestContextService } from '../../../request-context/application/services/request-context.service';
4
+ import { PublicSubmitFormDto } from '../../dto/public-submit-form.dto';
5
+ import { RequestFormsContext } from '../../infrastructure/request-forms-context';
6
+ import { withFormsErrorMapping } from './forms-error.mapper';
7
+
8
+ /**
9
+ * The anonymous path (ecosystem guide §5.4). No membership, no permission
10
+ * keys: access is decided per definition (settings.access === 'public',
11
+ * checked dynamically — a private form answers 404, indistinguishable from
12
+ * missing). orgId comes from the route path; the engine enforces captcha and
13
+ * the per-IP/day cap, and stamps ip/ua on the stored submission.
14
+ */
15
+ @Injectable()
16
+ export class FormsPublicService {
17
+ constructor(
18
+ private readonly definitions: DefinitionService,
19
+ private readonly submissions: SubmissionService,
20
+ private readonly formsContext: RequestFormsContext,
21
+ private readonly requestContext: RequestContextService,
22
+ ) {}
23
+
24
+ async render(orgId: string, formKey: string) {
25
+ this.requestContext.merge({ orgId });
26
+ const result = await withFormsErrorMapping(() =>
27
+ this.definitions.getForRender({ orgId, key: formKey, public: true }, this.formsContext),
28
+ );
29
+ return { definition: result.definition, options: result.options };
30
+ }
31
+
32
+ async submit(orgId: string, formKey: string, dto: PublicSubmitFormDto) {
33
+ this.requestContext.merge({ orgId });
34
+ const result = await withFormsErrorMapping(() =>
35
+ this.submissions.submit(
36
+ {
37
+ orgId,
38
+ formKey,
39
+ version: dto.version,
40
+ data: dto.data,
41
+ public: true,
42
+ captchaToken: dto.captchaToken,
43
+ },
44
+ this.formsContext,
45
+ ),
46
+ );
47
+ return { submissionId: result.submissionId ?? null, outputs: result.outputs };
48
+ }
49
+ }
@@ -0,0 +1,53 @@
1
+ import { Inject, Injectable, Optional } from '@nestjs/common';
2
+ import type { CaptchaVerifier, OrgFormsPolicy } from '@ftisindia/form-builder';
3
+ import { PrismaService } from '../../../../database/prisma/prisma.service';
4
+ import { settingDefinitions } from '../../../settings/types/setting-definitions';
5
+ import { FORMS_CAPTCHA_VERIFIER } from '../../forms.tokens';
6
+
7
+ const FORMS_SETTING_KEYS = [
8
+ 'forms.allowedDangerousActions',
9
+ 'forms.maxFileSizeMb',
10
+ 'forms.enableRuleIteration',
11
+ 'forms.virusScanRequired',
12
+ 'forms.maxSubmissionsPerIpPerDay',
13
+ 'forms.webhookAllowedHosts',
14
+ ] as const;
15
+
16
+ /**
17
+ * Reads the org's forms policy from typed settings, falling back to the
18
+ * setting definitions' defaults for unset keys (the SettingsService 404s on
19
+ * unset keys, which is wrong for policy reads). The result feeds every
20
+ * engine call that takes an OrgFormsPolicy — dangerous-action gating, rule
21
+ * iteration, file caps, public-submission caps.
22
+ */
23
+ @Injectable()
24
+ export class FormsSettingsReader {
25
+ constructor(
26
+ private readonly prisma: PrismaService,
27
+ @Optional() @Inject(FORMS_CAPTCHA_VERIFIER) private readonly captcha?: CaptchaVerifier,
28
+ ) {}
29
+
30
+ get captchaVerifier(): CaptchaVerifier | undefined {
31
+ return this.captcha ?? undefined;
32
+ }
33
+
34
+ async policyFor(orgId: string): Promise<OrgFormsPolicy> {
35
+ const rows = await this.prisma.organisationSetting.findMany({
36
+ where: { orgId, key: { in: [...FORMS_SETTING_KEYS] } },
37
+ select: { key: true, value: true },
38
+ });
39
+ const values = new Map(rows.map((row) => [row.key, row.value as unknown]));
40
+ const read = <T>(key: (typeof FORMS_SETTING_KEYS)[number]): T =>
41
+ (values.has(key) ? values.get(key) : settingDefinitions[key].defaultValue) as T;
42
+
43
+ return {
44
+ allowedDangerousActions: read<string[]>('forms.allowedDangerousActions'),
45
+ enableRuleIteration: read<boolean>('forms.enableRuleIteration'),
46
+ maxFileSizeMb: read<number>('forms.maxFileSizeMb'),
47
+ virusScanRequired: read<boolean>('forms.virusScanRequired'),
48
+ maxSubmissionsPerIpPerDay: read<number>('forms.maxSubmissionsPerIpPerDay'),
49
+ webhookAllowedHosts: read<string[]>('forms.webhookAllowedHosts'),
50
+ captchaConfigured: this.captcha != null,
51
+ };
52
+ }
53
+ }
@@ -0,0 +1,103 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { SubmissionService } from '@ftisindia/form-builder';
3
+ import type { SubmissionRecord, SubmitArgs } from '@ftisindia/form-builder';
4
+ import { resolvePageLimit, toPage } from '../../../../common/dto/pagination-query.dto';
5
+ import { AuthenticatedUser } from '../../../auth/types/authenticated-user';
6
+ import { RequestContextService } from '../../../request-context/application/services/request-context.service';
7
+ import { ListSubmissionsQueryDto } from '../../dto/list-submissions-query.dto';
8
+ import { SubmitFormDto } from '../../dto/submit-form.dto';
9
+ import { ValidateSubmissionDto } from '../../dto/validate-submission.dto';
10
+ import { RequestFormsContext } from '../../infrastructure/request-forms-context';
11
+ import { withFormsErrorMapping } from './forms-error.mapper';
12
+
13
+ /**
14
+ * Thin HTTP-facing wrapper over the engine's SubmissionService: org-scope
15
+ * assertion first, engine call through the FormsContext seam, engine-typed
16
+ * errors mapped onto the app's HTTP envelope. The submitter's identity flows
17
+ * through the request context, never through the payload.
18
+ */
19
+ @Injectable()
20
+ export class FormsSubmissionsService {
21
+ constructor(
22
+ private readonly engine: SubmissionService,
23
+ private readonly formsContext: RequestFormsContext,
24
+ private readonly requestContext: RequestContextService,
25
+ ) {}
26
+
27
+ async submit(orgId: string, formKey: string, dto: SubmitFormDto, _user: AuthenticatedUser) {
28
+ this.requestContext.assertOrgScope(orgId);
29
+
30
+ const args: SubmitArgs = {
31
+ orgId,
32
+ formKey,
33
+ version: dto.version,
34
+ data: dto.data,
35
+ button: dto.button,
36
+ submissionId: dto.submissionId,
37
+ };
38
+
39
+ const result = await withFormsErrorMapping(() =>
40
+ dto.mode === 'draft'
41
+ ? this.engine.saveDraft(args, this.formsContext)
42
+ : this.engine.submit(args, this.formsContext),
43
+ );
44
+
45
+ return { submissionId: result.submissionId ?? null, outputs: result.outputs };
46
+ }
47
+
48
+ async validate(orgId: string, formKey: string, dto: ValidateSubmissionDto) {
49
+ this.requestContext.assertOrgScope(orgId);
50
+ return withFormsErrorMapping(() =>
51
+ this.engine.validateOnly(
52
+ { orgId, formKey, version: dto.version, data: dto.data, mode: dto.mode },
53
+ this.formsContext,
54
+ ),
55
+ );
56
+ }
57
+
58
+ async list(orgId: string, formKey: string, query: ListSubmissionsQueryDto) {
59
+ this.requestContext.assertOrgScope(orgId);
60
+ const limit = resolvePageLimit(query.limit);
61
+
62
+ const records = await withFormsErrorMapping(() =>
63
+ this.engine.list(
64
+ {
65
+ orgId,
66
+ formKey,
67
+ filters: { status: query.status, cursor: query.cursor, limit: limit + 1 },
68
+ },
69
+ this.formsContext,
70
+ ),
71
+ );
72
+
73
+ const page = toPage(records, limit);
74
+ return { items: page.items.map((record) => this.toResponse(record)), nextCursor: page.nextCursor };
75
+ }
76
+
77
+ async get(orgId: string, submissionId: string) {
78
+ this.requestContext.assertOrgScope(orgId);
79
+ const record = await withFormsErrorMapping(() =>
80
+ this.engine.get({ orgId, submissionId }, this.formsContext),
81
+ );
82
+ return this.toResponse(record);
83
+ }
84
+
85
+ /**
86
+ * Explicit field map — never serialize the raw record: ipAddress/userAgent
87
+ * are abuse-control telemetry, not data for formSubmissions.read holders.
88
+ */
89
+ private toResponse(record: SubmissionRecord) {
90
+ return {
91
+ id: record.id,
92
+ orgId: record.orgId,
93
+ formKey: record.formKey,
94
+ formVersion: record.formVersion,
95
+ data: record.data,
96
+ status: record.status,
97
+ locked: record.locked,
98
+ createdBy: record.createdBy ?? null,
99
+ createdAt: record.createdAt,
100
+ updatedAt: record.updatedAt,
101
+ };
102
+ }
103
+ }
@@ -0,0 +1,37 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import type { ActionContext, FormAction } from '@ftisindia/form-builder';
3
+ import { FormsValidationError } from '@ftisindia/form-builder';
4
+ import { AuthService } from '../../../../auth/application/services/auth.service';
5
+ import { FormActionHandler } from '../../../infrastructure/registry/form-extension.decorators';
6
+
7
+ /**
8
+ * "Login is just a form whose submit action authenticates" (design doc §13):
9
+ * this action delegates to the template's real AuthService — hashing, token
10
+ * issuance, throttling all stay where they belong. It is `dangerous`, so
11
+ * wiring it into a definition requires forms.wireDangerous AND membership in
12
+ * the org's forms.allowedDangerousActions setting; being dangerous also means
13
+ * its input/output are never written to the action log (token redaction).
14
+ * The definition linter additionally rejects pipelines that combine
15
+ * `authenticate` with `persist` — credentials are never stored.
16
+ */
17
+ @Injectable()
18
+ @FormActionHandler()
19
+ export class AuthenticateActionHandler implements FormAction {
20
+ readonly name = 'authenticate';
21
+ readonly kind = 'transactional' as const;
22
+ readonly dangerous = true;
23
+
24
+ constructor(private readonly auth: AuthService) {}
25
+
26
+ async execute(ctx: ActionContext): Promise<unknown> {
27
+ const { email, password } = ctx.data as { email?: unknown; password?: unknown };
28
+ if (typeof email !== 'string' || typeof password !== 'string' || !email || !password) {
29
+ throw new FormsValidationError([
30
+ { path: 'email', code: 'REQUIRED', message: 'Email and password are required.' },
31
+ ]);
32
+ }
33
+ // Returned opaquely: the pipeline result carries the auth module's token
34
+ // payload to the caller; the action log records only '[REDACTED]'.
35
+ return this.auth.login({ email, password });
36
+ }
37
+ }
@@ -0,0 +1,22 @@
1
+ import { Injectable, Logger } from '@nestjs/common';
2
+ import type { OutboxJobHandler, OutboxJobRecord } from '@ftisindia/form-builder';
3
+
4
+ /**
5
+ * The default email seam: the template ships no mailer, so delivered form
6
+ * emails are logged instead of sent. Replace this handler (same type:
7
+ * 'email') with a real adapter — SES, SMTP, Resend — without touching the
8
+ * engine or the outbox flow; retries/backoff/parking already apply.
9
+ */
10
+ @Injectable()
11
+ export class LoggingEmailHandler implements OutboxJobHandler {
12
+ readonly type = 'email';
13
+ private readonly logger = new Logger('FormsEmailSeam');
14
+
15
+ handle(job: OutboxJobRecord): Promise<void> {
16
+ const { to, template } = job.payload as { to?: unknown; template?: unknown };
17
+ this.logger.log(
18
+ `Email seam (no mailer configured) — would send template "${String(template)}" to "${String(to)}" (job ${job.id}).`,
19
+ );
20
+ return Promise.resolve();
21
+ }
22
+ }
@@ -0,0 +1,40 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import type { ActionContext, FormAction } from '@ftisindia/form-builder';
3
+ import { FormActionHandler } from '../../../infrastructure/registry/form-extension.decorators';
4
+
5
+ /**
6
+ * Post-commit confirmation email. Does NOT send inline: it enqueues an
7
+ * outbox job through ctx.enqueue (written in the same transaction as the
8
+ * submission), and the dispatcher delivers after commit with retries — the
9
+ * design doc §8.3 contract. Expects the form to have an `email` field; when
10
+ * absent the action is a no-op rather than an error, so authors can wire it
11
+ * unconditionally.
12
+ */
13
+ @Injectable()
14
+ @FormActionHandler()
15
+ export class SendConfirmationEmailAction implements FormAction {
16
+ readonly name = 'sendConfirmationEmail';
17
+ readonly kind = 'post-commit' as const;
18
+ readonly dangerous = false;
19
+
20
+ async execute(ctx: ActionContext): Promise<unknown> {
21
+ const to = ctx.data.email;
22
+ if (typeof to !== 'string' || to.length === 0) {
23
+ return { queued: false, reason: 'no email field in submission' };
24
+ }
25
+ await ctx.enqueue({
26
+ type: 'email',
27
+ payload: {
28
+ to,
29
+ template: 'form-submission-received',
30
+ data: {
31
+ formKey: ctx.definitionRecord.key,
32
+ formVersion: ctx.definitionRecord.version,
33
+ submissionId: ctx.submissionId ?? null,
34
+ title: typeof ctx.data.title === 'string' ? ctx.data.title : undefined,
35
+ },
36
+ },
37
+ });
38
+ return { queued: true };
39
+ }
40
+ }