@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
@@ -0,0 +1,156 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { Injectable } from '@nestjs/common';
3
+ import { FormOutboxJob as FormOutboxJobRow, Prisma } from '@prisma/client';
4
+ import type {
5
+ EngineTx,
6
+ OutboxJobInput,
7
+ OutboxJobRecord,
8
+ OutboxStatus,
9
+ OutboxStore,
10
+ } from '@ftisindia/form-builder';
11
+ import { PrismaService } from '../../../../database/prisma/prisma.service';
12
+
13
+ const PROCESSING_LEASE_MS = 5 * 60 * 1000;
14
+
15
+ @Injectable()
16
+ export class PrismaOutboxStore implements OutboxStore {
17
+ constructor(private readonly prisma: PrismaService) {}
18
+
19
+ private client(tx?: EngineTx) {
20
+ return tx ? (tx as unknown as Prisma.TransactionClient) : this.prisma;
21
+ }
22
+
23
+ async enqueue(job: OutboxJobInput, tx: EngineTx): Promise<void> {
24
+ await this.client(tx).formOutboxJob.createMany({
25
+ data: [
26
+ {
27
+ type: job.type,
28
+ payload: job.payload as unknown as Prisma.InputJsonValue,
29
+ status: 'PENDING',
30
+ attempts: 0,
31
+ maxAttempts: job.maxAttempts ?? 8,
32
+ idempotencyKey: job.idempotencyKey ?? null,
33
+ runAfter: job.runAfter ?? null,
34
+ orgId: job.orgId ?? null,
35
+ actorUserId: job.actorUserId ?? null,
36
+ originRequestId: job.originRequestId ?? null,
37
+ },
38
+ ],
39
+ skipDuplicates: true,
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Claims due PENDING jobs and stale PROCESSING jobs with row-level locks so
45
+ * multiple app instances can poll without double-delivering the same job.
46
+ */
47
+ async claimDue(now: Date, batchSize: number): Promise<OutboxJobRecord[]> {
48
+ if (batchSize <= 0) {
49
+ return [];
50
+ }
51
+
52
+ const staleProcessingBefore = new Date(now.getTime() - PROCESSING_LEASE_MS);
53
+ const claimedBy = randomUUID();
54
+ const claimed = await this.prisma.$transaction(
55
+ (tx) =>
56
+ tx.$queryRaw<FormOutboxJobRow[]>`
57
+ WITH due AS (
58
+ SELECT "id"
59
+ FROM "FormOutboxJob"
60
+ WHERE (
61
+ "status" = 'PENDING'
62
+ AND ("runAfter" IS NULL OR "runAfter" <= (${now}::timestamptz AT TIME ZONE 'UTC'))
63
+ )
64
+ OR (
65
+ "status" = 'PROCESSING'
66
+ AND "updatedAt" <= (${staleProcessingBefore}::timestamptz AT TIME ZONE 'UTC')
67
+ )
68
+ ORDER BY "createdAt" ASC
69
+ LIMIT ${batchSize}
70
+ FOR UPDATE SKIP LOCKED
71
+ )
72
+ UPDATE "FormOutboxJob" AS job
73
+ SET "status" = 'PROCESSING',
74
+ "claimedBy" = ${claimedBy},
75
+ "updatedAt" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
76
+ FROM due
77
+ WHERE job."id" = due."id"
78
+ RETURNING job.*
79
+ `,
80
+ );
81
+
82
+ return claimed.map((row) => this.toRecord(row));
83
+ }
84
+
85
+ async markDone(id: string, claimedBy: string): Promise<boolean> {
86
+ const result = await this.client().formOutboxJob.updateMany({
87
+ where: { id, status: 'PROCESSING', claimedBy },
88
+ data: { status: 'DONE', claimedBy: null },
89
+ });
90
+ return result.count === 1;
91
+ }
92
+
93
+ async markRetry(
94
+ id: string,
95
+ claimedBy: string,
96
+ attempts: number,
97
+ runAfter: Date,
98
+ lastError: string,
99
+ ): Promise<boolean> {
100
+ const result = await this.client().formOutboxJob.updateMany({
101
+ where: { id, status: 'PROCESSING', claimedBy },
102
+ data: {
103
+ status: 'PENDING',
104
+ claimedBy: null,
105
+ attempts,
106
+ runAfter,
107
+ lastError,
108
+ },
109
+ });
110
+ return result.count === 1;
111
+ }
112
+
113
+ async markFailed(id: string, claimedBy: string, attempts: number, lastError: string): Promise<boolean> {
114
+ const result = await this.client().formOutboxJob.updateMany({
115
+ where: { id, status: 'PROCESSING', claimedBy },
116
+ data: {
117
+ status: 'FAILED',
118
+ claimedBy: null,
119
+ attempts,
120
+ lastError,
121
+ },
122
+ });
123
+ return result.count === 1;
124
+ }
125
+
126
+ async touchProcessing(id: string, claimedBy: string): Promise<boolean> {
127
+ const touched = await this.prisma.$executeRaw`
128
+ UPDATE "FormOutboxJob"
129
+ SET "updatedAt" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
130
+ WHERE "id" = ${id}
131
+ AND "status" = 'PROCESSING'
132
+ AND "claimedBy" = ${claimedBy}
133
+ `;
134
+ return touched === 1;
135
+ }
136
+
137
+ private toRecord(row: FormOutboxJobRow): OutboxJobRecord {
138
+ return {
139
+ id: row.id,
140
+ type: row.type,
141
+ payload: row.payload as unknown as Record<string, unknown>,
142
+ status: row.status as OutboxStatus,
143
+ claimedBy: row.claimedBy,
144
+ attempts: row.attempts,
145
+ maxAttempts: row.maxAttempts,
146
+ idempotencyKey: row.idempotencyKey,
147
+ lastError: row.lastError,
148
+ runAfter: row.runAfter,
149
+ orgId: row.orgId,
150
+ actorUserId: row.actorUserId,
151
+ originRequestId: row.originRequestId,
152
+ createdAt: row.createdAt,
153
+ updatedAt: row.updatedAt,
154
+ };
155
+ }
156
+ }
@@ -0,0 +1,164 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { FormSubmission as FormSubmissionRow, Prisma } from '@prisma/client';
3
+ import type {
4
+ EngineTx,
5
+ NewSubmissionRecord,
6
+ SubmissionListFilters,
7
+ SubmissionPatch,
8
+ SubmissionRecord,
9
+ SubmissionStatus,
10
+ SubmissionStore,
11
+ } from '@ftisindia/form-builder';
12
+ import { PrismaService } from '../../../../database/prisma/prisma.service';
13
+
14
+ @Injectable()
15
+ export class PrismaSubmissionStore implements SubmissionStore {
16
+ constructor(private readonly prisma: PrismaService) {}
17
+
18
+ private client(tx?: EngineTx) {
19
+ return tx ? (tx as unknown as Prisma.TransactionClient) : this.prisma;
20
+ }
21
+
22
+ async create(record: NewSubmissionRecord, tx?: EngineTx): Promise<SubmissionRecord> {
23
+ const row = await this.client(tx).formSubmission.create({
24
+ data: {
25
+ orgId: record.orgId,
26
+ formKey: record.formKey,
27
+ formVersion: record.formVersion,
28
+ data: record.data as unknown as Prisma.InputJsonValue,
29
+ status: record.status,
30
+ createdBy: record.createdBy ?? null,
31
+ ipAddress: record.ipAddress ?? null,
32
+ userAgent: record.userAgent ?? null,
33
+ },
34
+ });
35
+
36
+ return this.toRecord(row);
37
+ }
38
+
39
+ async update(
40
+ orgId: string,
41
+ id: string,
42
+ patch: SubmissionPatch,
43
+ tx?: EngineTx,
44
+ ): Promise<SubmissionRecord> {
45
+ const client = this.client(tx);
46
+
47
+ const existing = await client.formSubmission.findFirst({
48
+ where: { id, orgId },
49
+ select: { id: true },
50
+ });
51
+
52
+ if (!existing) {
53
+ throw new Error(`Form submission ${id} was not found in this organisation.`);
54
+ }
55
+
56
+ const row = await client.formSubmission.update({
57
+ where: { id: existing.id },
58
+ data: {
59
+ ...(patch.data !== undefined
60
+ ? { data: patch.data as unknown as Prisma.InputJsonValue }
61
+ : {}),
62
+ ...(patch.status !== undefined ? { status: patch.status } : {}),
63
+ ...(patch.locked !== undefined ? { locked: patch.locked } : {}),
64
+ },
65
+ });
66
+
67
+ return this.toRecord(row);
68
+ }
69
+
70
+ async findById(orgId: string, id: string, tx?: EngineTx): Promise<SubmissionRecord | null> {
71
+ const row = await this.client(tx).formSubmission.findFirst({
72
+ where: { id, orgId },
73
+ });
74
+
75
+ return row ? this.toRecord(row) : null;
76
+ }
77
+
78
+ async list(
79
+ orgId: string,
80
+ formKey: string,
81
+ filters: SubmissionListFilters,
82
+ ): Promise<SubmissionRecord[]> {
83
+ const rows = await this.client().formSubmission.findMany({
84
+ where: this.filterWhere(orgId, formKey, filters),
85
+ orderBy: { createdAt: 'desc' },
86
+ take: filters.limit,
87
+ ...(filters.cursor
88
+ ? {
89
+ cursor: { id: filters.cursor },
90
+ skip: 1,
91
+ }
92
+ : {}),
93
+ });
94
+
95
+ return rows.map((row) => this.toRecord(row));
96
+ }
97
+
98
+ async countByIpSince(
99
+ orgId: string,
100
+ formKey: string,
101
+ ipAddress: string,
102
+ since: Date,
103
+ ): Promise<number> {
104
+ return this.client().formSubmission.count({
105
+ where: {
106
+ orgId,
107
+ formKey,
108
+ ipAddress,
109
+ createdAt: { gte: since },
110
+ },
111
+ });
112
+ }
113
+
114
+ async listForExport(
115
+ orgId: string,
116
+ formKey: string,
117
+ filters: Omit<SubmissionListFilters, 'cursor' | 'limit'>,
118
+ ): Promise<SubmissionRecord[]> {
119
+ const rows = await this.client().formSubmission.findMany({
120
+ where: this.filterWhere(orgId, formKey, filters),
121
+ orderBy: { createdAt: 'asc' },
122
+ });
123
+
124
+ return rows.map((row) => this.toRecord(row));
125
+ }
126
+
127
+ private filterWhere(
128
+ orgId: string,
129
+ formKey: string,
130
+ filters: Omit<SubmissionListFilters, 'cursor' | 'limit'>,
131
+ ): Prisma.FormSubmissionWhereInput {
132
+ return {
133
+ orgId,
134
+ formKey,
135
+ ...(filters.status ? { status: filters.status } : {}),
136
+ ...(filters.createdBy ? { createdBy: filters.createdBy } : {}),
137
+ ...(filters.createdFrom || filters.createdTo
138
+ ? {
139
+ createdAt: {
140
+ ...(filters.createdFrom ? { gte: filters.createdFrom } : {}),
141
+ ...(filters.createdTo ? { lte: filters.createdTo } : {}),
142
+ },
143
+ }
144
+ : {}),
145
+ };
146
+ }
147
+
148
+ private toRecord(row: FormSubmissionRow): SubmissionRecord {
149
+ return {
150
+ id: row.id,
151
+ orgId: row.orgId,
152
+ formKey: row.formKey,
153
+ formVersion: row.formVersion,
154
+ data: row.data as unknown as Record<string, unknown>,
155
+ status: row.status as SubmissionStatus,
156
+ locked: row.locked,
157
+ createdBy: row.createdBy,
158
+ ipAddress: row.ipAddress,
159
+ userAgent: row.userAgent,
160
+ createdAt: row.createdAt,
161
+ updatedAt: row.updatedAt,
162
+ };
163
+ }
164
+ }
@@ -0,0 +1,58 @@
1
+ import { Controller, Get, Param, UseGuards } from '@nestjs/common';
2
+ import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
3
+ import { ApiProtectedErrorResponses } from '../../../common/swagger/api-error-responses';
4
+ import { PermissionGuard } from '../../access-control/application/services/permission.guard';
5
+ import { RequirePermissions } from '../../access-control/presentation/permissions.decorator';
6
+ import { JwtAuthGuard } from '../../auth/infrastructure/passport/jwt-auth.guard';
7
+ import { OrgScopeGuard } from '../../request-context/presentation/org-scope.guard';
8
+ import { FormsDefinitionsService } from '../application/services/forms-definitions.service';
9
+ import {
10
+ DataSourceKeysResponseDto,
11
+ DataSourceOptionsResponseDto,
12
+ } from '../dto/data-source-response.dto';
13
+
14
+ /**
15
+ * MUST be registered BEFORE FormsDefinitionsController in the module's
16
+ * controllers array: both controllers share the 'organisations/:orgId/forms'
17
+ * base path, and the literal 'data-sources' segment has to win over the
18
+ * ':formKey' parameter route.
19
+ */
20
+ @ApiTags('Forms')
21
+ @ApiBearerAuth()
22
+ @ApiParam({ name: 'orgId', description: 'Organisation ID.', format: 'uuid' })
23
+ @ApiProtectedErrorResponses(404)
24
+ @Controller('organisations/:orgId/forms')
25
+ @UseGuards(JwtAuthGuard, OrgScopeGuard, PermissionGuard)
26
+ export class FormsDataSourcesController {
27
+ constructor(private readonly formsDefinitionsService: FormsDefinitionsService) {}
28
+
29
+ @Get('data-sources')
30
+ @RequirePermissions('formDataSources.manage')
31
+ @ApiOperation({ summary: 'List registered data source keys.' })
32
+ @ApiOkResponse({
33
+ description: 'Registered data source keys.',
34
+ type: DataSourceKeysResponseDto,
35
+ })
36
+ listDataSourceKeys(@Param('orgId') orgId: string) {
37
+ return this.formsDefinitionsService.listDataSourceKeys(orgId);
38
+ }
39
+
40
+ @Get(':formKey/data-sources/:dataSourceKey/options')
41
+ @RequirePermissions('formSubmissions.create')
42
+ @ApiParam({ name: 'formKey', description: 'Form definition key.' })
43
+ @ApiParam({ name: 'dataSourceKey', description: 'Registered data source key.' })
44
+ @ApiOperation({
45
+ summary: 'Resolve options for a data source bound to fields of a published form.',
46
+ })
47
+ @ApiOkResponse({
48
+ description: 'Resolved data-source options.',
49
+ type: DataSourceOptionsResponseDto,
50
+ })
51
+ dataSourceOptions(
52
+ @Param('orgId') orgId: string,
53
+ @Param('formKey') formKey: string,
54
+ @Param('dataSourceKey') dataSourceKey: string,
55
+ ) {
56
+ return this.formsDefinitionsService.dataSourceOptions(orgId, formKey, dataSourceKey);
57
+ }
58
+ }
@@ -0,0 +1,191 @@
1
+ import {
2
+ Body,
3
+ Controller,
4
+ Get,
5
+ HttpCode,
6
+ Param,
7
+ ParseIntPipe,
8
+ Patch,
9
+ Post,
10
+ Query,
11
+ UseGuards,
12
+ } from '@nestjs/common';
13
+ import {
14
+ ApiBearerAuth,
15
+ ApiCreatedResponse,
16
+ ApiOkResponse,
17
+ ApiOperation,
18
+ ApiParam,
19
+ ApiTags,
20
+ ApiUnprocessableEntityResponse,
21
+ } from '@nestjs/swagger';
22
+ import { ErrorResponseDto } from '../../../common/dto/error-response.dto';
23
+ import { ApiProtectedErrorResponses } from '../../../common/swagger/api-error-responses';
24
+ import { PermissionGuard } from '../../access-control/application/services/permission.guard';
25
+ import { RequirePermissions } from '../../access-control/presentation/permissions.decorator';
26
+ import { JwtAuthGuard } from '../../auth/infrastructure/passport/jwt-auth.guard';
27
+ import { OrgScopeGuard } from '../../request-context/presentation/org-scope.guard';
28
+ import { FormsDefinitionsService } from '../application/services/forms-definitions.service';
29
+ import { CreateFormDefinitionDto } from '../dto/create-form-definition.dto';
30
+ import {
31
+ FormDefinitionListResponseDto,
32
+ FormDefinitionResponseDto,
33
+ } from '../dto/form-definition-response.dto';
34
+ import { FormRenderResponseDto } from '../dto/form-render-response.dto';
35
+ import { ListFormDefinitionsQueryDto } from '../dto/list-form-definitions-query.dto';
36
+ import { SetPublicAccessDto } from '../dto/set-public-access.dto';
37
+ import { UpdateFormDefinitionDto } from '../dto/update-form-definition.dto';
38
+
39
+ @ApiTags('Forms')
40
+ @ApiBearerAuth()
41
+ @ApiParam({ name: 'orgId', description: 'Organisation ID.', format: 'uuid' })
42
+ @ApiProtectedErrorResponses(404, 409)
43
+ @ApiUnprocessableEntityResponse({
44
+ description: 'The form definition failed engine validation or linting.',
45
+ type: ErrorResponseDto,
46
+ })
47
+ @Controller('organisations/:orgId/forms')
48
+ @UseGuards(JwtAuthGuard, OrgScopeGuard, PermissionGuard)
49
+ export class FormsDefinitionsController {
50
+ constructor(private readonly formsDefinitionsService: FormsDefinitionsService) {}
51
+
52
+ @Get()
53
+ @RequirePermissions('forms.read')
54
+ @ApiOperation({ summary: 'List form definitions.' })
55
+ @ApiOkResponse({
56
+ description: 'Form definitions.',
57
+ type: FormDefinitionListResponseDto,
58
+ })
59
+ list(@Param('orgId') orgId: string, @Query() query: ListFormDefinitionsQueryDto) {
60
+ return this.formsDefinitionsService.list(orgId, query);
61
+ }
62
+
63
+ @Post()
64
+ @RequirePermissions('forms.create')
65
+ @ApiOperation({ summary: 'Create a new draft form definition version.' })
66
+ @ApiCreatedResponse({
67
+ description: 'Created draft definition.',
68
+ type: FormDefinitionResponseDto,
69
+ })
70
+ create(@Param('orgId') orgId: string, @Body() dto: CreateFormDefinitionDto) {
71
+ return this.formsDefinitionsService.create(orgId, dto);
72
+ }
73
+
74
+ @Get(':formKey')
75
+ @RequirePermissions('forms.read')
76
+ @ApiParam({ name: 'formKey', description: 'Form definition key.' })
77
+ @ApiOperation({ summary: 'Return the latest version of a form definition.' })
78
+ @ApiOkResponse({
79
+ description: 'Latest definition version.',
80
+ type: FormDefinitionResponseDto,
81
+ })
82
+ getLatest(@Param('orgId') orgId: string, @Param('formKey') formKey: string) {
83
+ return this.formsDefinitionsService.getLatest(orgId, formKey);
84
+ }
85
+
86
+ @Get(':formKey/render')
87
+ @RequirePermissions('formSubmissions.create')
88
+ @ApiParam({ name: 'formKey', description: 'Form definition key.' })
89
+ @ApiOperation({
90
+ summary: 'Return the published definition plus resolved data-source options.',
91
+ })
92
+ @ApiOkResponse({
93
+ description: 'Renderable definition.',
94
+ type: FormRenderResponseDto,
95
+ })
96
+ render(@Param('orgId') orgId: string, @Param('formKey') formKey: string) {
97
+ return this.formsDefinitionsService.render(orgId, formKey);
98
+ }
99
+
100
+ @Get(':formKey/versions/:version')
101
+ @RequirePermissions('forms.read')
102
+ @ApiParam({ name: 'formKey', description: 'Form definition key.' })
103
+ @ApiParam({ name: 'version', description: 'Definition version number.', type: Number })
104
+ @ApiOperation({ summary: 'Return a specific form definition version.' })
105
+ @ApiOkResponse({
106
+ description: 'Definition version.',
107
+ type: FormDefinitionResponseDto,
108
+ })
109
+ getVersion(
110
+ @Param('orgId') orgId: string,
111
+ @Param('formKey') formKey: string,
112
+ @Param('version', ParseIntPipe) version: number,
113
+ ) {
114
+ return this.formsDefinitionsService.getVersion(orgId, formKey, version);
115
+ }
116
+
117
+ @Patch(':formKey/versions/:version')
118
+ @RequirePermissions('forms.update')
119
+ @ApiParam({ name: 'formKey', description: 'Form definition key.' })
120
+ @ApiParam({ name: 'version', description: 'Definition version number.', type: Number })
121
+ @ApiOperation({ summary: 'Update a draft form definition version.' })
122
+ @ApiOkResponse({
123
+ description: 'Updated draft definition.',
124
+ type: FormDefinitionResponseDto,
125
+ })
126
+ update(
127
+ @Param('orgId') orgId: string,
128
+ @Param('formKey') formKey: string,
129
+ @Param('version', ParseIntPipe) version: number,
130
+ @Body() dto: UpdateFormDefinitionDto,
131
+ ) {
132
+ return this.formsDefinitionsService.update(orgId, formKey, version, dto);
133
+ }
134
+
135
+ @Post(':formKey/versions/:version/publish')
136
+ @HttpCode(200)
137
+ @RequirePermissions('forms.publish')
138
+ @ApiParam({ name: 'formKey', description: 'Form definition key.' })
139
+ @ApiParam({ name: 'version', description: 'Definition version number.', type: Number })
140
+ @ApiOperation({ summary: 'Publish a draft form definition version.' })
141
+ @ApiOkResponse({
142
+ description: 'Published definition.',
143
+ type: FormDefinitionResponseDto,
144
+ })
145
+ publish(
146
+ @Param('orgId') orgId: string,
147
+ @Param('formKey') formKey: string,
148
+ @Param('version', ParseIntPipe) version: number,
149
+ ) {
150
+ return this.formsDefinitionsService.publish(orgId, formKey, version);
151
+ }
152
+
153
+ @Post(':formKey/versions/:version/archive')
154
+ @HttpCode(200)
155
+ @RequirePermissions('forms.archive')
156
+ @ApiParam({ name: 'formKey', description: 'Form definition key.' })
157
+ @ApiParam({ name: 'version', description: 'Definition version number.', type: Number })
158
+ @ApiOperation({ summary: 'Archive a form definition version.' })
159
+ @ApiOkResponse({
160
+ description: 'Archived definition.',
161
+ type: FormDefinitionResponseDto,
162
+ })
163
+ archive(
164
+ @Param('orgId') orgId: string,
165
+ @Param('formKey') formKey: string,
166
+ @Param('version', ParseIntPipe) version: number,
167
+ ) {
168
+ return this.formsDefinitionsService.archive(orgId, formKey, version);
169
+ }
170
+
171
+ @Post(':formKey/versions/:version/public-access')
172
+ @HttpCode(200)
173
+ @RequirePermissions('forms.managePublicAccess')
174
+ @ApiParam({ name: 'formKey', description: 'Form definition key.' })
175
+ @ApiParam({ name: 'version', description: 'Definition version number.', type: Number })
176
+ @ApiOperation({
177
+ summary: 'Flip the public/private access setting on a form definition version.',
178
+ })
179
+ @ApiOkResponse({
180
+ description: 'Updated definition.',
181
+ type: FormDefinitionResponseDto,
182
+ })
183
+ setPublicAccess(
184
+ @Param('orgId') orgId: string,
185
+ @Param('formKey') formKey: string,
186
+ @Param('version', ParseIntPipe) version: number,
187
+ @Body() dto: SetPublicAccessDto,
188
+ ) {
189
+ return this.formsDefinitionsService.setPublicAccess(orgId, formKey, version, dto);
190
+ }
191
+ }
@@ -0,0 +1,79 @@
1
+ import {
2
+ Controller,
3
+ Get,
4
+ Param,
5
+ ParseUUIDPipe,
6
+ Post,
7
+ Query,
8
+ UploadedFile,
9
+ UseGuards,
10
+ UseInterceptors,
11
+ } from '@nestjs/common';
12
+ import {
13
+ ApiBearerAuth,
14
+ ApiBody,
15
+ ApiConsumes,
16
+ ApiOkResponse,
17
+ ApiOperation,
18
+ ApiParam,
19
+ ApiTags,
20
+ } from '@nestjs/swagger';
21
+ import { ApiProtectedErrorResponses } from '../../../common/swagger/api-error-responses';
22
+ import { CurrentUser } from '../../auth/presentation/current-user.decorator';
23
+ import { JwtAuthGuard } from '../../auth/infrastructure/passport/jwt-auth.guard';
24
+ import { AuthenticatedUser } from '../../auth/types/authenticated-user';
25
+ import { PermissionGuard } from '../../access-control/application/services/permission.guard';
26
+ import { RequirePermissions } from '../../access-control/presentation/permissions.decorator';
27
+ import { OrgScopeGuard } from '../../request-context/presentation/org-scope.guard';
28
+ import { FileUploadResponseDto } from '../dto/file-upload-response.dto';
29
+ import { UploadFileQueryDto } from '../dto/upload-file-query.dto';
30
+ import { FormsFilesService } from '../application/services/forms-files.service';
31
+ import { FormsUploadInterceptor } from './forms-upload.interceptor';
32
+
33
+ @ApiTags('Forms')
34
+ @ApiBearerAuth()
35
+ @ApiParam({ name: 'orgId', description: 'Organisation ID.', format: 'uuid' })
36
+ @ApiParam({ name: 'formKey', description: 'Form definition key.' })
37
+ @ApiProtectedErrorResponses(404, 409)
38
+ @Controller('organisations/:orgId/forms/:formKey/files')
39
+ @UseGuards(JwtAuthGuard, OrgScopeGuard, PermissionGuard)
40
+ export class FormsFilesController {
41
+ constructor(private readonly filesService: FormsFilesService) {}
42
+
43
+ @Post()
44
+ @RequirePermissions('formSubmissions.create')
45
+ @UseInterceptors(FormsUploadInterceptor)
46
+ @ApiConsumes('multipart/form-data')
47
+ @ApiBody({
48
+ schema: {
49
+ type: 'object',
50
+ properties: { file: { type: 'string', format: 'binary' } },
51
+ required: ['file'],
52
+ },
53
+ })
54
+ @ApiOperation({
55
+ summary:
56
+ 'Upload a file for a form field (before submit). The submission then references the returned fileId.',
57
+ })
58
+ @ApiOkResponse({ description: 'Uploaded file reference.', type: FileUploadResponseDto })
59
+ upload(
60
+ @CurrentUser() user: AuthenticatedUser,
61
+ @Param('orgId') orgId: string,
62
+ @Param('formKey') formKey: string,
63
+ @Query() query: UploadFileQueryDto,
64
+ @UploadedFile() file: { originalname: string; size: number; buffer: Buffer } | undefined,
65
+ ) {
66
+ return this.filesService.upload(orgId, formKey, query, file, user);
67
+ }
68
+
69
+ @Get(':fileId')
70
+ @RequirePermissions('formSubmissions.read')
71
+ @ApiOperation({ summary: 'Return metadata for an uploaded file.' })
72
+ @ApiOkResponse({ description: 'Uploaded file metadata.', type: FileUploadResponseDto })
73
+ getMeta(
74
+ @Param('orgId') orgId: string,
75
+ @Param('fileId', ParseUUIDPipe) fileId: string,
76
+ ) {
77
+ return this.filesService.getMeta(orgId, fileId);
78
+ }
79
+ }