@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
@@ -126,4 +126,187 @@ export const routePermissionRegistry: RoutePermissionEntry[] = [
126
126
  path: '/organisations/:orgId/sample/echo',
127
127
  permissions: ['organisations.update'],
128
128
  },
129
+ {
130
+ method: 'GET',
131
+ path: '/organisations/:orgId/forms',
132
+ permissions: ['forms.read'],
133
+ },
134
+ {
135
+ method: 'POST',
136
+ path: '/organisations/:orgId/forms',
137
+ permissions: ['forms.create'],
138
+ },
139
+ {
140
+ method: 'GET',
141
+ path: '/organisations/:orgId/forms/:formKey',
142
+ permissions: ['forms.read'],
143
+ },
144
+ {
145
+ method: 'GET',
146
+ path: '/organisations/:orgId/forms/:formKey/render',
147
+ permissions: ['formSubmissions.create'],
148
+ },
149
+ {
150
+ method: 'GET',
151
+ path: '/organisations/:orgId/forms/:formKey/versions/:version',
152
+ permissions: ['forms.read'],
153
+ },
154
+ {
155
+ method: 'PATCH',
156
+ path: '/organisations/:orgId/forms/:formKey/versions/:version',
157
+ permissions: ['forms.update'],
158
+ },
159
+ {
160
+ method: 'POST',
161
+ path: '/organisations/:orgId/forms/:formKey/versions/:version/publish',
162
+ permissions: ['forms.publish'],
163
+ },
164
+ {
165
+ method: 'POST',
166
+ path: '/organisations/:orgId/forms/:formKey/versions/:version/archive',
167
+ permissions: ['forms.archive'],
168
+ },
169
+ {
170
+ method: 'POST',
171
+ path: '/organisations/:orgId/forms/:formKey/versions/:version/public-access',
172
+ permissions: ['forms.managePublicAccess'],
173
+ },
174
+ {
175
+ method: 'POST',
176
+ path: '/organisations/:orgId/forms/:formKey/submissions',
177
+ permissions: ['formSubmissions.create'],
178
+ },
179
+ {
180
+ method: 'POST',
181
+ path: '/organisations/:orgId/forms/:formKey/submissions/validate',
182
+ permissions: ['formSubmissions.create'],
183
+ },
184
+ {
185
+ method: 'GET',
186
+ path: '/organisations/:orgId/forms/:formKey/submissions',
187
+ permissions: ['formSubmissions.read'],
188
+ },
189
+ {
190
+ method: 'GET',
191
+ path: '/organisations/:orgId/forms/:formKey/submissions/:submissionId',
192
+ permissions: ['formSubmissions.read'],
193
+ },
194
+ {
195
+ method: 'GET',
196
+ path: '/organisations/:orgId/forms/data-sources',
197
+ permissions: ['formDataSources.manage'],
198
+ },
199
+ {
200
+ method: 'GET',
201
+ path: '/organisations/:orgId/forms/:formKey/data-sources/:dataSourceKey/options',
202
+ permissions: ['formSubmissions.create'],
203
+ },
204
+ {
205
+ method: 'POST',
206
+ path: '/organisations/:orgId/forms/:formKey/files',
207
+ permissions: ['formSubmissions.create'],
208
+ },
209
+ {
210
+ method: 'GET',
211
+ path: '/organisations/:orgId/forms/:formKey/files/:fileId',
212
+ permissions: ['formSubmissions.read'],
213
+ },
214
+ {
215
+ method: 'GET',
216
+ path: '/organisations/:orgId/forms/:formKey/submissions/export',
217
+ permissions: ['formSubmissions.export'],
218
+ },
219
+ // Report builder (@ftisindia/report-builder) — report design §10. Row
220
+ // actions carry an additional per-action permission check inside the engine
221
+ // (attach-time and execute-time, §6.1); the route gate below is the base.
222
+ {
223
+ method: 'GET',
224
+ path: '/organisations/:orgId/reports',
225
+ permissions: ['reports.read'],
226
+ },
227
+ {
228
+ method: 'POST',
229
+ path: '/organisations/:orgId/reports',
230
+ permissions: ['reports.create'],
231
+ },
232
+ {
233
+ method: 'GET',
234
+ path: '/organisations/:orgId/reports/exports/:jobId',
235
+ permissions: ['reports.export'],
236
+ },
237
+ {
238
+ method: 'GET',
239
+ path: '/organisations/:orgId/reports/exports/:jobId/download',
240
+ permissions: ['reports.export'],
241
+ },
242
+ {
243
+ method: 'GET',
244
+ path: '/organisations/:orgId/reports/:key',
245
+ permissions: ['reports.read'],
246
+ },
247
+ {
248
+ method: 'PATCH',
249
+ path: '/organisations/:orgId/reports/:key',
250
+ permissions: ['reports.update'],
251
+ },
252
+ {
253
+ method: 'POST',
254
+ path: '/organisations/:orgId/reports/:key/publish',
255
+ permissions: ['reports.publish'],
256
+ },
257
+ {
258
+ method: 'POST',
259
+ path: '/organisations/:orgId/reports/:key/archive',
260
+ permissions: ['reports.archive'],
261
+ },
262
+ {
263
+ method: 'GET',
264
+ path: '/organisations/:orgId/reports/:key/meta',
265
+ permissions: ['reports.read'],
266
+ },
267
+ {
268
+ method: 'POST',
269
+ path: '/organisations/:orgId/reports/:key/query',
270
+ permissions: ['reports.read'],
271
+ },
272
+ {
273
+ method: 'GET',
274
+ path: '/organisations/:orgId/reports/:key/views',
275
+ permissions: ['reports.read'],
276
+ },
277
+ {
278
+ method: 'POST',
279
+ path: '/organisations/:orgId/reports/:key/views',
280
+ permissions: ['reports.read'],
281
+ },
282
+ {
283
+ method: 'POST',
284
+ path: '/organisations/:orgId/reports/:key/views/shared',
285
+ permissions: ['reports.update'],
286
+ },
287
+ {
288
+ method: 'PATCH',
289
+ path: '/organisations/:orgId/reports/:key/views/:viewId',
290
+ permissions: ['reports.read'],
291
+ },
292
+ {
293
+ method: 'DELETE',
294
+ path: '/organisations/:orgId/reports/:key/views/:viewId',
295
+ permissions: ['reports.read'],
296
+ },
297
+ {
298
+ method: 'POST',
299
+ path: '/organisations/:orgId/reports/:key/actions/:name/prepare',
300
+ permissions: ['reports.read'],
301
+ },
302
+ {
303
+ method: 'POST',
304
+ path: '/organisations/:orgId/reports/:key/actions/:name',
305
+ permissions: ['reports.read'],
306
+ },
307
+ {
308
+ method: 'POST',
309
+ path: '/organisations/:orgId/reports/:key/export',
310
+ permissions: ['reports.export'],
311
+ },
129
312
  ];
@@ -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
+ }