@ftisindia/create-app 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +28 -0
  3. package/template/README.md +51 -0
  4. package/template/_gitignore +6 -0
  5. package/template/_package.json +10 -1
  6. package/template/docs/FORMS.md +188 -0
  7. package/template/docs/FORMS_CHECKLIST.md +69 -0
  8. package/template/docs/REPORTS.md +255 -0
  9. package/template/docs/REPORTS_CHECKLIST.md +152 -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/migrations/20260616000000_add_form_outbox_claimed_by/migration.sql +5 -0
  13. package/template/prisma/schema.prisma +289 -0
  14. package/template/scripts/export-openapi.ts +85 -0
  15. package/template/scripts/gen-form.mjs +149 -0
  16. package/template/scripts/push-form.ts +124 -0
  17. package/template/src/app.module.ts +30 -8
  18. package/template/src/common/dto/membership-response.dto.ts +1 -0
  19. package/template/src/common/dto/role-summary.dto.ts +3 -3
  20. package/template/src/common/dto/user-summary.dto.ts +3 -3
  21. package/template/src/config/env.validation.ts +28 -0
  22. package/template/src/config/forms.config.ts +13 -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 +18 -0
  27. package/template/src/main.ts +3 -12
  28. package/template/src/modules/access-control/dto/access-control-response.dto.ts +3 -0
  29. package/template/src/modules/access-control/dto/current-access-control-response.dto.ts +5 -1
  30. package/template/src/modules/access-control/types/permission-key.ts +27 -0
  31. package/template/src/modules/access-control/types/route-permission-registry.ts +183 -0
  32. package/template/src/modules/audit/dto/audit-response.dto.ts +7 -3
  33. package/template/src/modules/auth/auth.module.ts +3 -1
  34. package/template/src/modules/auth/dto/auth-response.dto.ts +1 -1
  35. package/template/src/modules/forms/application/services/file-gc.service.ts +85 -0
  36. package/template/src/modules/forms/application/services/forms-definitions.service.ts +137 -0
  37. package/template/src/modules/forms/application/services/forms-error.mapper.ts +64 -0
  38. package/template/src/modules/forms/application/services/forms-export.service.ts +210 -0
  39. package/template/src/modules/forms/application/services/forms-files.service.ts +164 -0
  40. package/template/src/modules/forms/application/services/forms-public.service.ts +49 -0
  41. package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +53 -0
  42. package/template/src/modules/forms/application/services/forms-submissions.service.ts +103 -0
  43. package/template/src/modules/forms/application/services/handlers/authenticate.action.ts +37 -0
  44. package/template/src/modules/forms/application/services/handlers/logging-email.handler.ts +22 -0
  45. package/template/src/modules/forms/application/services/handlers/send-confirmation-email.action.ts +40 -0
  46. package/template/src/modules/forms/application/services/handlers/webhook-delivery.transport.ts +319 -0
  47. package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +89 -0
  48. package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +131 -0
  49. package/template/src/modules/forms/dto/create-form-definition.dto.ts +12 -0
  50. package/template/src/modules/forms/dto/data-source-response.dto.ts +19 -0
  51. package/template/src/modules/forms/dto/export-submissions-query.dto.ts +33 -0
  52. package/template/src/modules/forms/dto/file-upload-response.dto.ts +24 -0
  53. package/template/src/modules/forms/dto/form-definition-response.dto.ts +50 -0
  54. package/template/src/modules/forms/dto/form-render-response.dto.ts +17 -0
  55. package/template/src/modules/forms/dto/list-form-definitions-query.dto.ts +10 -0
  56. package/template/src/modules/forms/dto/list-submissions-query.dto.ts +10 -0
  57. package/template/src/modules/forms/dto/public-submit-form.dto.ts +24 -0
  58. package/template/src/modules/forms/dto/set-public-access.dto.ts +8 -0
  59. package/template/src/modules/forms/dto/submission-response.dto.ts +99 -0
  60. package/template/src/modules/forms/dto/submit-form.dto.ts +50 -0
  61. package/template/src/modules/forms/dto/update-form-definition.dto.ts +12 -0
  62. package/template/src/modules/forms/dto/upload-file-query.dto.ts +33 -0
  63. package/template/src/modules/forms/dto/validate-submission.dto.ts +22 -0
  64. package/template/src/modules/forms/examples/abstract-submission.form.json +80 -0
  65. package/template/src/modules/forms/examples/login.form.json +24 -0
  66. package/template/src/modules/forms/examples/registration.form.json +44 -0
  67. package/template/src/modules/forms/forms.module.ts +228 -0
  68. package/template/src/modules/forms/forms.tokens.ts +6 -0
  69. package/template/src/modules/forms/infrastructure/audit-sink.adapter.ts +30 -0
  70. package/template/src/modules/forms/infrastructure/casl-forms-authorization.ts +31 -0
  71. package/template/src/modules/forms/infrastructure/prisma-tx-runner.ts +17 -0
  72. package/template/src/modules/forms/infrastructure/registry/form-extension.decorators.ts +17 -0
  73. package/template/src/modules/forms/infrastructure/registry/registry-bootstrap.service.ts +82 -0
  74. package/template/src/modules/forms/infrastructure/request-forms-context.ts +60 -0
  75. package/template/src/modules/forms/infrastructure/schema-check/forms-schema-check.service.ts +76 -0
  76. package/template/src/modules/forms/infrastructure/storage/local-disk-storage.adapter.ts +43 -0
  77. package/template/src/modules/forms/infrastructure/stores/index.ts +5 -0
  78. package/template/src/modules/forms/infrastructure/stores/prisma-action-log.store.ts +37 -0
  79. package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +108 -0
  80. package/template/src/modules/forms/infrastructure/stores/prisma-form-definition.store.ts +147 -0
  81. package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +156 -0
  82. package/template/src/modules/forms/infrastructure/stores/prisma-submission.store.ts +164 -0
  83. package/template/src/modules/forms/presentation/forms-data-sources.controller.ts +58 -0
  84. package/template/src/modules/forms/presentation/forms-definitions.controller.ts +191 -0
  85. package/template/src/modules/forms/presentation/forms-files.controller.ts +79 -0
  86. package/template/src/modules/forms/presentation/forms-submissions.controller.ts +154 -0
  87. package/template/src/modules/forms/presentation/forms-upload.interceptor.ts +33 -0
  88. package/template/src/modules/forms/presentation/public-forms.controller.ts +51 -0
  89. package/template/src/modules/invitations/dto/invitation-response.dto.ts +4 -0
  90. package/template/src/modules/organisations/dto/organisation-response.dto.ts +1 -0
  91. package/template/src/modules/reports/application/services/reports-actions.service.ts +54 -0
  92. package/template/src/modules/reports/application/services/reports-definitions.service.ts +66 -0
  93. package/template/src/modules/reports/application/services/reports-error.mapper.ts +97 -0
  94. package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +205 -0
  95. package/template/src/modules/reports/application/services/reports-exports.service.ts +78 -0
  96. package/template/src/modules/reports/application/services/reports-queries.service.ts +35 -0
  97. package/template/src/modules/reports/application/services/reports-settings-reader.service.ts +49 -0
  98. package/template/src/modules/reports/application/services/reports-views.service.ts +79 -0
  99. package/template/src/modules/reports/dto/action-result-response.dto.ts +21 -0
  100. package/template/src/modules/reports/dto/create-report-definition.dto.ts +86 -0
  101. package/template/src/modules/reports/dto/create-saved-view.dto.ts +26 -0
  102. package/template/src/modules/reports/dto/execute-action.dto.ts +71 -0
  103. package/template/src/modules/reports/dto/export-job-response.dto.ts +60 -0
  104. package/template/src/modules/reports/dto/export-request.dto.ts +34 -0
  105. package/template/src/modules/reports/dto/list-reports-query.dto.ts +10 -0
  106. package/template/src/modules/reports/dto/list-views-query.dto.ts +17 -0
  107. package/template/src/modules/reports/dto/prepare-action-response.dto.ts +14 -0
  108. package/template/src/modules/reports/dto/prepare-action.dto.ts +27 -0
  109. package/template/src/modules/reports/dto/query-response.dto.ts +64 -0
  110. package/template/src/modules/reports/dto/query-spec.dto.ts +120 -0
  111. package/template/src/modules/reports/dto/report-definition-response.dto.ts +64 -0
  112. package/template/src/modules/reports/dto/report-meta-query.dto.ts +16 -0
  113. package/template/src/modules/reports/dto/report-meta-response.dto.ts +113 -0
  114. package/template/src/modules/reports/dto/saved-view-response.dto.ts +66 -0
  115. package/template/src/modules/reports/dto/update-report-definition.dto.ts +9 -0
  116. package/template/src/modules/reports/dto/update-saved-view.dto.ts +27 -0
  117. package/template/src/modules/reports/examples/abstract-review-board.report.json +54 -0
  118. package/template/src/modules/reports/examples/org-members.report.json +55 -0
  119. package/template/src/modules/reports/infrastructure/audit-sink.adapter.ts +31 -0
  120. package/template/src/modules/reports/infrastructure/casl-reports-authorization.ts +39 -0
  121. package/template/src/modules/reports/infrastructure/forms-adapter/form-report-source.adapter.ts +292 -0
  122. package/template/src/modules/reports/infrastructure/forms-adapter/form-row-actions.ts +171 -0
  123. package/template/src/modules/reports/infrastructure/forms-adapter/forms-bridge-bootstrap.service.ts +32 -0
  124. package/template/src/modules/reports/infrastructure/prisma-catalog.adapter.ts +95 -0
  125. package/template/src/modules/reports/infrastructure/prisma-query-executor.ts +103 -0
  126. package/template/src/modules/reports/infrastructure/prisma-snapshot-runner.ts +47 -0
  127. package/template/src/modules/reports/infrastructure/prisma-tx-runner.ts +18 -0
  128. package/template/src/modules/reports/infrastructure/registry/registry-bootstrap.service.ts +61 -0
  129. package/template/src/modules/reports/infrastructure/registry/report-extension.decorators.ts +14 -0
  130. package/template/src/modules/reports/infrastructure/reports-job-queue.adapter.ts +28 -0
  131. package/template/src/modules/reports/infrastructure/request-reports-context.ts +42 -0
  132. package/template/src/modules/reports/infrastructure/schema-check/reports-schema-check.service.ts +116 -0
  133. package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +92 -0
  134. package/template/src/modules/reports/infrastructure/stores/index.ts +5 -0
  135. package/template/src/modules/reports/infrastructure/stores/prisma-bulk-action-run.store.ts +89 -0
  136. package/template/src/modules/reports/infrastructure/stores/prisma-export-job.store.ts +93 -0
  137. package/template/src/modules/reports/infrastructure/stores/prisma-report-definition.store.ts +171 -0
  138. package/template/src/modules/reports/infrastructure/stores/prisma-row-tag.store.ts +110 -0
  139. package/template/src/modules/reports/infrastructure/stores/prisma-saved-view.store.ts +144 -0
  140. package/template/src/modules/reports/presentation/reports-actions.controller.ts +83 -0
  141. package/template/src/modules/reports/presentation/reports-definitions.controller.ts +156 -0
  142. package/template/src/modules/reports/presentation/reports-export-jobs.controller.ts +61 -0
  143. package/template/src/modules/reports/presentation/reports-export.controller.ts +76 -0
  144. package/template/src/modules/reports/presentation/reports-query.controller.ts +52 -0
  145. package/template/src/modules/reports/presentation/reports-views.controller.ts +140 -0
  146. package/template/src/modules/reports/reports-forms.module.ts +33 -0
  147. package/template/src/modules/reports/reports.module.ts +335 -0
  148. package/template/src/modules/reports/reports.tokens.ts +11 -0
  149. package/template/src/modules/reports/sources/org-members.source.ts +112 -0
  150. package/template/src/modules/settings/types/setting-definitions.ts +94 -0
  151. package/template/test/forms-captcha.e2e-spec.ts +163 -0
  152. package/template/test/forms-definitions.e2e-spec.ts +394 -0
  153. package/template/test/forms-export.e2e-spec.ts +390 -0
  154. package/template/test/forms-files.e2e-spec.ts +345 -0
  155. package/template/test/forms-outbox.e2e-spec.ts +570 -0
  156. package/template/test/forms-permission-sync.spec.ts +27 -0
  157. package/template/test/forms-public.e2e-spec.ts +293 -0
  158. package/template/test/forms-schema-check.e2e-spec.ts +65 -0
  159. package/template/test/forms-submissions.e2e-spec.ts +500 -0
  160. package/template/test/forms-throttling.e2e-spec.ts +146 -0
  161. package/template/test/forms-webhooks.e2e-spec.ts +403 -0
  162. package/template/test/jest-e2e.json +1 -0
  163. package/template/test/reports-advanced.e2e-spec.ts +381 -0
  164. package/template/test/reports-permission-sync.spec.ts +30 -0
  165. package/template/test/reports-query.e2e-spec.ts +402 -0
  166. package/template/test/reports-tiers.e2e-spec.ts +343 -0
  167. package/template/test/route-registry.validator.spec.ts +22 -0
@@ -7,7 +7,7 @@ class AuditActorResponseDto {
7
7
  })
8
8
  id!: string;
9
9
 
10
- @ApiPropertyOptional({ example: 'Starter Owner', nullable: true })
10
+ @ApiPropertyOptional({ type: String, example: 'Starter Owner', nullable: true })
11
11
  displayName?: string | null;
12
12
  }
13
13
 
@@ -19,6 +19,7 @@ export class AuditLogResponseDto {
19
19
  id!: string;
20
20
 
21
21
  @ApiPropertyOptional({
22
+ type: String,
22
23
  example: '2c67399d-670c-4025-a5fd-1ea9a211891e',
23
24
  format: 'uuid',
24
25
  nullable: true,
@@ -26,6 +27,7 @@ export class AuditLogResponseDto {
26
27
  orgId?: string | null;
27
28
 
28
29
  @ApiPropertyOptional({
30
+ type: String,
29
31
  example: '4a4f0d8a-4bd2-469f-a6a9-3e1cb6a2b456',
30
32
  format: 'uuid',
31
33
  nullable: true,
@@ -39,6 +41,7 @@ export class AuditLogResponseDto {
39
41
  targetType!: string;
40
42
 
41
43
  @ApiPropertyOptional({
44
+ type: String,
42
45
  example: '0a57fb4a-95c6-4f7e-bd5a-f96dbe0599e3',
43
46
  nullable: true,
44
47
  })
@@ -50,10 +53,10 @@ export class AuditLogResponseDto {
50
53
  })
51
54
  metadata?: Record<string, unknown> | null;
52
55
 
53
- @ApiPropertyOptional({ example: '127.0.0.1', nullable: true })
56
+ @ApiPropertyOptional({ type: String, example: '127.0.0.1', nullable: true })
54
57
  ipAddress?: string | null;
55
58
 
56
- @ApiPropertyOptional({ example: 'Mozilla/5.0', nullable: true })
59
+ @ApiPropertyOptional({ type: String, example: 'Mozilla/5.0', nullable: true })
57
60
  userAgent?: string | null;
58
61
 
59
62
  @ApiProperty({ example: '2026-06-01T10:30:00.000Z', format: 'date-time' })
@@ -68,6 +71,7 @@ export class AuditLogListResponseDto {
68
71
  items!: AuditLogResponseDto[];
69
72
 
70
73
  @ApiPropertyOptional({
74
+ type: String,
71
75
  example: 'df6537c4-f58b-452e-a67e-18ec528d0f0f',
72
76
  nullable: true,
73
77
  })
@@ -41,6 +41,8 @@ import { GoogleOAuthExceptionFilter } from './presentation/google-oauth-exceptio
41
41
  PasswordService,
42
42
  TokenService,
43
43
  ],
44
- exports: [JwtAuthGuard, PasswordService],
44
+ // AuthService is exported for the forms module's delegating `authenticate`
45
+ // action (login-as-a-form rides the real auth stack, never reimplements it).
46
+ exports: [JwtAuthGuard, PasswordService, AuthService],
45
47
  })
46
48
  export class AuthModule {}
@@ -14,7 +14,7 @@ export class AuthTokensResponseDto {
14
14
  })
15
15
  refreshToken!: string;
16
16
 
17
- @ApiProperty({ example: 'Bearer' })
17
+ @ApiProperty({ enum: ['Bearer'], example: 'Bearer' })
18
18
  tokenType!: 'Bearer';
19
19
 
20
20
  @ApiProperty({
@@ -0,0 +1,85 @@
1
+ import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
2
+ import { ConfigService } from '@nestjs/config';
3
+ import { isGcEligible } from '@ftisindia/form-builder';
4
+ import { PrismaFileStore } from '../../infrastructure/stores/prisma-file.store';
5
+ import { LocalDiskStorageAdapter } from '../../infrastructure/storage/local-disk-storage.adapter';
6
+
7
+ const GC_BATCH_SIZE = 100;
8
+
9
+ /**
10
+ * Sweeps orphaned TEMPORARY uploads (design doc §10.3): files uploaded but
11
+ * never linked to a submission within FORMS_FILE_TEMP_TTL_HOURS are deleted
12
+ * — bytes first, then the row. LINKED files are exempt by construction.
13
+ * runOnce() makes tests deterministic; the timer mirrors the outbox poller.
14
+ */
15
+ @Injectable()
16
+ export class FileGcService implements OnApplicationBootstrap, OnModuleDestroy {
17
+ private readonly logger = new Logger('FormsFileGc');
18
+ private timer?: NodeJS.Timeout;
19
+ private sweeping = false;
20
+
21
+ constructor(
22
+ private readonly config: ConfigService,
23
+ private readonly files: PrismaFileStore,
24
+ private readonly storage: LocalDiskStorageAdapter,
25
+ ) {}
26
+
27
+ onApplicationBootstrap(): void {
28
+ if (this.config.get<boolean>('forms.outboxEnabled') === false) {
29
+ // Test environments disable background work with one switch.
30
+ return;
31
+ }
32
+ const intervalMs = this.config.get<number>('forms.fileGcIntervalMs') ?? 3_600_000;
33
+ this.timer = setInterval(() => {
34
+ void this.sweep();
35
+ }, intervalMs);
36
+ this.timer.unref?.();
37
+ }
38
+
39
+ onModuleDestroy(): void {
40
+ if (this.timer) {
41
+ clearInterval(this.timer);
42
+ this.timer = undefined;
43
+ }
44
+ }
45
+
46
+ async runOnce(now = new Date()): Promise<number> {
47
+ const ttlMs =
48
+ (this.config.get<number>('forms.fileTempTtlHours') ?? 24) * 60 * 60 * 1000;
49
+ const cutoff = new Date(now.getTime() - ttlMs);
50
+ const candidates = await this.files.listGcEligible(cutoff, GC_BATCH_SIZE);
51
+ let removed = 0;
52
+ for (const file of candidates) {
53
+ if (!isGcEligible(file, now, ttlMs)) {
54
+ continue;
55
+ }
56
+ try {
57
+ await this.storage.delete(file.storageKey);
58
+ await this.files.delete(file.id);
59
+ removed += 1;
60
+ } catch (error) {
61
+ this.logger.warn(
62
+ `Failed to garbage-collect file ${file.id}: ${error instanceof Error ? error.message : String(error)}`,
63
+ );
64
+ }
65
+ }
66
+ return removed;
67
+ }
68
+
69
+ private async sweep(): Promise<void> {
70
+ if (this.sweeping) {
71
+ return;
72
+ }
73
+ this.sweeping = true;
74
+ try {
75
+ const removed = await this.runOnce();
76
+ if (removed > 0) {
77
+ this.logger.log(`Garbage-collected ${removed} orphaned temporary file(s).`);
78
+ }
79
+ } catch (error) {
80
+ this.logger.error('File GC sweep failed.', error instanceof Error ? error.stack : error);
81
+ } finally {
82
+ this.sweeping = false;
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,137 @@
1
+ import { Injectable, NotFoundException } from '@nestjs/common';
2
+ import { DataSourceRegistry, DefinitionService, walkFields } from '@ftisindia/form-builder';
3
+ import type { DataSourceOption, FormDefinition } from '@ftisindia/form-builder';
4
+ import { resolvePageLimit, toPage } from '../../../../common/dto/pagination-query.dto';
5
+ import { RequestContextService } from '../../../request-context/application/services/request-context.service';
6
+ import { CreateFormDefinitionDto } from '../../dto/create-form-definition.dto';
7
+ import { ListFormDefinitionsQueryDto } from '../../dto/list-form-definitions-query.dto';
8
+ import { SetPublicAccessDto } from '../../dto/set-public-access.dto';
9
+ import { UpdateFormDefinitionDto } from '../../dto/update-form-definition.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 DefinitionService: org-scope
15
+ * assertion first, engine call through the FormsContext seam, engine-typed
16
+ * errors mapped onto the app's HTTP envelope. No business logic lives here.
17
+ */
18
+ @Injectable()
19
+ export class FormsDefinitionsService {
20
+ constructor(
21
+ private readonly engine: DefinitionService,
22
+ private readonly dataSources: DataSourceRegistry,
23
+ private readonly formsContext: RequestFormsContext,
24
+ private readonly requestContext: RequestContextService,
25
+ ) {}
26
+
27
+ async list(orgId: string, query: ListFormDefinitionsQueryDto) {
28
+ this.requestContext.assertOrgScope(orgId);
29
+ const limit = resolvePageLimit(query.limit);
30
+
31
+ const records = await withFormsErrorMapping(() =>
32
+ this.engine.list(
33
+ { orgId, status: query.status, cursor: query.cursor, limit: limit + 1 },
34
+ this.formsContext,
35
+ ),
36
+ );
37
+
38
+ return toPage(records, limit);
39
+ }
40
+
41
+ async create(orgId: string, dto: CreateFormDefinitionDto) {
42
+ this.requestContext.assertOrgScope(orgId);
43
+ return withFormsErrorMapping(() =>
44
+ this.engine.createDraft(
45
+ { orgId, definition: dto.definition as unknown as FormDefinition },
46
+ this.formsContext,
47
+ ),
48
+ );
49
+ }
50
+
51
+ async getLatest(orgId: string, formKey: string) {
52
+ this.requestContext.assertOrgScope(orgId);
53
+ return withFormsErrorMapping(() =>
54
+ this.engine.getLatest({ orgId, key: formKey }, this.formsContext),
55
+ );
56
+ }
57
+
58
+ async getVersion(orgId: string, formKey: string, version: number) {
59
+ this.requestContext.assertOrgScope(orgId);
60
+ return withFormsErrorMapping(() =>
61
+ this.engine.getByKeyVersion({ orgId, key: formKey, version }, this.formsContext),
62
+ );
63
+ }
64
+
65
+ async update(orgId: string, formKey: string, version: number, dto: UpdateFormDefinitionDto) {
66
+ this.requestContext.assertOrgScope(orgId);
67
+ return withFormsErrorMapping(() =>
68
+ this.engine.updateDraft(
69
+ {
70
+ orgId,
71
+ key: formKey,
72
+ version,
73
+ definition: dto.definition as unknown as FormDefinition,
74
+ },
75
+ this.formsContext,
76
+ ),
77
+ );
78
+ }
79
+
80
+ async publish(orgId: string, formKey: string, version: number) {
81
+ this.requestContext.assertOrgScope(orgId);
82
+ return withFormsErrorMapping(() =>
83
+ this.engine.publish({ orgId, key: formKey, version }, this.formsContext),
84
+ );
85
+ }
86
+
87
+ async archive(orgId: string, formKey: string, version: number) {
88
+ this.requestContext.assertOrgScope(orgId);
89
+ return withFormsErrorMapping(() =>
90
+ this.engine.archive({ orgId, key: formKey, version }, this.formsContext),
91
+ );
92
+ }
93
+
94
+ async setPublicAccess(orgId: string, formKey: string, version: number, dto: SetPublicAccessDto) {
95
+ this.requestContext.assertOrgScope(orgId);
96
+ return withFormsErrorMapping(() =>
97
+ this.engine.setPublicAccess(
98
+ { orgId, key: formKey, version, access: dto.access },
99
+ this.formsContext,
100
+ ),
101
+ );
102
+ }
103
+
104
+ async render(orgId: string, formKey: string) {
105
+ this.requestContext.assertOrgScope(orgId);
106
+ const result = await withFormsErrorMapping(() =>
107
+ this.engine.getForRender({ orgId, key: formKey }, this.formsContext),
108
+ );
109
+ return { definition: result.definition, options: result.options };
110
+ }
111
+
112
+ async dataSourceOptions(orgId: string, formKey: string, dataSourceKey: string) {
113
+ this.requestContext.assertOrgScope(orgId);
114
+ const render = await withFormsErrorMapping(() =>
115
+ this.engine.getForRender({ orgId, key: formKey }, this.formsContext),
116
+ );
117
+
118
+ const paths: string[] = [];
119
+ walkFields(render.definition.fields, (field, path) => {
120
+ if (field.dataSource?.key === dataSourceKey) {
121
+ paths.push(path.join('.'));
122
+ }
123
+ });
124
+
125
+ if (paths.length === 0) {
126
+ throw new NotFoundException('No field on this form uses the requested data source.');
127
+ }
128
+
129
+ const items: DataSourceOption[] = paths.flatMap((path) => render.options[path] ?? []);
130
+ return { items };
131
+ }
132
+
133
+ listDataSourceKeys(orgId: string) {
134
+ this.requestContext.assertOrgScope(orgId);
135
+ return { items: this.dataSources.keys() };
136
+ }
137
+ }
@@ -0,0 +1,64 @@
1
+ import {
2
+ ConflictException,
3
+ ForbiddenException,
4
+ NotFoundException,
5
+ UnprocessableEntityException,
6
+ } from '@nestjs/common';
7
+ import {
8
+ FormsAuthzDeniedError,
9
+ FormsConflictError,
10
+ FormsDefinitionLintError,
11
+ FormsNotFoundError,
12
+ FormsStateError,
13
+ FormsValidationError,
14
+ } from '@ftisindia/form-builder';
15
+ import { Prisma } from '@prisma/client';
16
+
17
+ /**
18
+ * Maps the engine's typed errors (ecosystem guide §2.1) onto the app's HTTP
19
+ * error envelope. Object bodies survive the global HttpExceptionFilter, which
20
+ * lifts every non-message key into `error.details` — so 422 responses carry
21
+ * the engine's `errors` / `issues` arrays verbatim.
22
+ */
23
+ export function mapFormsError(error: unknown): never {
24
+ if (error instanceof FormsValidationError) {
25
+ throw new UnprocessableEntityException({
26
+ message: 'Validation failed.',
27
+ errors: error.errors,
28
+ });
29
+ }
30
+
31
+ if (error instanceof FormsDefinitionLintError) {
32
+ throw new UnprocessableEntityException({
33
+ message: 'Form definition is invalid.',
34
+ issues: error.issues,
35
+ });
36
+ }
37
+
38
+ if (error instanceof FormsAuthzDeniedError) {
39
+ throw new ForbiddenException(error.message);
40
+ }
41
+
42
+ if (error instanceof FormsNotFoundError) {
43
+ throw new NotFoundException(error.message);
44
+ }
45
+
46
+ if (error instanceof FormsConflictError || error instanceof FormsStateError) {
47
+ throw new ConflictException(error.message);
48
+ }
49
+
50
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
51
+ throw new ConflictException('A form definition with this key and version already exists.');
52
+ }
53
+
54
+ throw error;
55
+ }
56
+
57
+ /** Awaits an engine call and maps any engine-typed failure onto HTTP errors. */
58
+ export async function withFormsErrorMapping<T>(fn: () => Promise<T>): Promise<T> {
59
+ try {
60
+ return await fn();
61
+ } catch (error) {
62
+ mapFormsError(error);
63
+ }
64
+ }
@@ -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
+ }