@ftisindia/create-app 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +25 -0
  3. package/template/README.md +51 -0
  4. package/template/_gitignore +6 -0
  5. package/template/_package.json +6 -0
  6. package/template/docs/FORMS.md +169 -0
  7. package/template/docs/FORMS_CHECKLIST.md +61 -0
  8. package/template/docs/REPORTS.md +246 -0
  9. package/template/docs/REPORTS_CHECKLIST.md +97 -0
  10. package/template/prisma/migrations/20260612000000_add_form_builder/migration.sql +147 -0
  11. package/template/prisma/migrations/20260613000000_add_report_builder/migration.sql +129 -0
  12. package/template/prisma/schema.prisma +285 -0
  13. package/template/scripts/export-openapi.ts +85 -0
  14. package/template/scripts/gen-form.mjs +149 -0
  15. package/template/scripts/push-form.ts +124 -0
  16. package/template/src/app.module.ts +29 -8
  17. package/template/src/common/dto/membership-response.dto.ts +1 -0
  18. package/template/src/common/dto/role-summary.dto.ts +3 -3
  19. package/template/src/common/dto/user-summary.dto.ts +3 -3
  20. package/template/src/config/env.validation.ts +25 -0
  21. package/template/src/config/forms.config.ts +12 -0
  22. package/template/src/config/index.ts +2 -0
  23. package/template/src/config/openapi.ts +12 -0
  24. package/template/src/config/reports-secret.ts +15 -0
  25. package/template/src/config/reports.config.ts +16 -0
  26. package/template/src/main.ts +3 -12
  27. package/template/src/modules/access-control/dto/access-control-response.dto.ts +3 -0
  28. package/template/src/modules/access-control/dto/current-access-control-response.dto.ts +5 -1
  29. package/template/src/modules/access-control/types/permission-key.ts +27 -0
  30. package/template/src/modules/access-control/types/route-permission-registry.ts +183 -0
  31. package/template/src/modules/audit/dto/audit-response.dto.ts +7 -3
  32. package/template/src/modules/auth/auth.module.ts +3 -1
  33. package/template/src/modules/auth/dto/auth-response.dto.ts +1 -1
  34. package/template/src/modules/forms/application/services/file-gc.service.ts +85 -0
  35. package/template/src/modules/forms/application/services/forms-definitions.service.ts +137 -0
  36. package/template/src/modules/forms/application/services/forms-error.mapper.ts +64 -0
  37. package/template/src/modules/forms/application/services/forms-export.service.ts +210 -0
  38. package/template/src/modules/forms/application/services/forms-files.service.ts +164 -0
  39. package/template/src/modules/forms/application/services/forms-public.service.ts +49 -0
  40. package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +53 -0
  41. package/template/src/modules/forms/application/services/forms-submissions.service.ts +103 -0
  42. package/template/src/modules/forms/application/services/handlers/authenticate.action.ts +37 -0
  43. package/template/src/modules/forms/application/services/handlers/logging-email.handler.ts +22 -0
  44. package/template/src/modules/forms/application/services/handlers/send-confirmation-email.action.ts +40 -0
  45. package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +41 -0
  46. package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +109 -0
  47. package/template/src/modules/forms/dto/create-form-definition.dto.ts +12 -0
  48. package/template/src/modules/forms/dto/data-source-response.dto.ts +19 -0
  49. package/template/src/modules/forms/dto/export-submissions-query.dto.ts +33 -0
  50. package/template/src/modules/forms/dto/file-upload-response.dto.ts +24 -0
  51. package/template/src/modules/forms/dto/form-definition-response.dto.ts +50 -0
  52. package/template/src/modules/forms/dto/form-render-response.dto.ts +17 -0
  53. package/template/src/modules/forms/dto/list-form-definitions-query.dto.ts +10 -0
  54. package/template/src/modules/forms/dto/list-submissions-query.dto.ts +10 -0
  55. package/template/src/modules/forms/dto/public-submit-form.dto.ts +24 -0
  56. package/template/src/modules/forms/dto/set-public-access.dto.ts +8 -0
  57. package/template/src/modules/forms/dto/submission-response.dto.ts +99 -0
  58. package/template/src/modules/forms/dto/submit-form.dto.ts +50 -0
  59. package/template/src/modules/forms/dto/update-form-definition.dto.ts +12 -0
  60. package/template/src/modules/forms/dto/upload-file-query.dto.ts +33 -0
  61. package/template/src/modules/forms/dto/validate-submission.dto.ts +22 -0
  62. package/template/src/modules/forms/examples/abstract-submission.form.json +80 -0
  63. package/template/src/modules/forms/examples/login.form.json +24 -0
  64. package/template/src/modules/forms/examples/registration.form.json +44 -0
  65. package/template/src/modules/forms/forms.module.ts +226 -0
  66. package/template/src/modules/forms/forms.tokens.ts +6 -0
  67. package/template/src/modules/forms/infrastructure/audit-sink.adapter.ts +30 -0
  68. package/template/src/modules/forms/infrastructure/casl-forms-authorization.ts +31 -0
  69. package/template/src/modules/forms/infrastructure/prisma-tx-runner.ts +17 -0
  70. package/template/src/modules/forms/infrastructure/registry/form-extension.decorators.ts +17 -0
  71. package/template/src/modules/forms/infrastructure/registry/registry-bootstrap.service.ts +82 -0
  72. package/template/src/modules/forms/infrastructure/request-forms-context.ts +60 -0
  73. package/template/src/modules/forms/infrastructure/schema-check/forms-schema-check.service.ts +76 -0
  74. package/template/src/modules/forms/infrastructure/storage/local-disk-storage.adapter.ts +43 -0
  75. package/template/src/modules/forms/infrastructure/stores/index.ts +5 -0
  76. package/template/src/modules/forms/infrastructure/stores/prisma-action-log.store.ts +37 -0
  77. package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +108 -0
  78. package/template/src/modules/forms/infrastructure/stores/prisma-form-definition.store.ts +147 -0
  79. package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +133 -0
  80. package/template/src/modules/forms/infrastructure/stores/prisma-submission.store.ts +164 -0
  81. package/template/src/modules/forms/presentation/forms-data-sources.controller.ts +58 -0
  82. package/template/src/modules/forms/presentation/forms-definitions.controller.ts +191 -0
  83. package/template/src/modules/forms/presentation/forms-files.controller.ts +79 -0
  84. package/template/src/modules/forms/presentation/forms-submissions.controller.ts +154 -0
  85. package/template/src/modules/forms/presentation/forms-upload.interceptor.ts +33 -0
  86. package/template/src/modules/forms/presentation/public-forms.controller.ts +51 -0
  87. package/template/src/modules/invitations/dto/invitation-response.dto.ts +4 -0
  88. package/template/src/modules/organisations/dto/organisation-response.dto.ts +1 -0
  89. package/template/src/modules/reports/application/services/reports-actions.service.ts +54 -0
  90. package/template/src/modules/reports/application/services/reports-definitions.service.ts +66 -0
  91. package/template/src/modules/reports/application/services/reports-error.mapper.ts +97 -0
  92. package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +124 -0
  93. package/template/src/modules/reports/application/services/reports-exports.service.ts +74 -0
  94. package/template/src/modules/reports/application/services/reports-queries.service.ts +35 -0
  95. package/template/src/modules/reports/application/services/reports-settings-reader.service.ts +49 -0
  96. package/template/src/modules/reports/application/services/reports-views.service.ts +79 -0
  97. package/template/src/modules/reports/dto/action-result-response.dto.ts +21 -0
  98. package/template/src/modules/reports/dto/create-report-definition.dto.ts +86 -0
  99. package/template/src/modules/reports/dto/create-saved-view.dto.ts +26 -0
  100. package/template/src/modules/reports/dto/execute-action.dto.ts +71 -0
  101. package/template/src/modules/reports/dto/export-job-response.dto.ts +60 -0
  102. package/template/src/modules/reports/dto/export-request.dto.ts +34 -0
  103. package/template/src/modules/reports/dto/list-reports-query.dto.ts +10 -0
  104. package/template/src/modules/reports/dto/list-views-query.dto.ts +17 -0
  105. package/template/src/modules/reports/dto/prepare-action-response.dto.ts +14 -0
  106. package/template/src/modules/reports/dto/prepare-action.dto.ts +27 -0
  107. package/template/src/modules/reports/dto/query-response.dto.ts +64 -0
  108. package/template/src/modules/reports/dto/query-spec.dto.ts +120 -0
  109. package/template/src/modules/reports/dto/report-definition-response.dto.ts +64 -0
  110. package/template/src/modules/reports/dto/report-meta-query.dto.ts +16 -0
  111. package/template/src/modules/reports/dto/report-meta-response.dto.ts +113 -0
  112. package/template/src/modules/reports/dto/saved-view-response.dto.ts +66 -0
  113. package/template/src/modules/reports/dto/update-report-definition.dto.ts +9 -0
  114. package/template/src/modules/reports/dto/update-saved-view.dto.ts +27 -0
  115. package/template/src/modules/reports/examples/abstract-review-board.report.json +54 -0
  116. package/template/src/modules/reports/examples/org-members.report.json +55 -0
  117. package/template/src/modules/reports/infrastructure/audit-sink.adapter.ts +31 -0
  118. package/template/src/modules/reports/infrastructure/casl-reports-authorization.ts +39 -0
  119. package/template/src/modules/reports/infrastructure/forms-adapter/form-report-source.adapter.ts +292 -0
  120. package/template/src/modules/reports/infrastructure/forms-adapter/form-row-actions.ts +171 -0
  121. package/template/src/modules/reports/infrastructure/forms-adapter/forms-bridge-bootstrap.service.ts +32 -0
  122. package/template/src/modules/reports/infrastructure/prisma-catalog.adapter.ts +95 -0
  123. package/template/src/modules/reports/infrastructure/prisma-query-executor.ts +103 -0
  124. package/template/src/modules/reports/infrastructure/prisma-snapshot-runner.ts +47 -0
  125. package/template/src/modules/reports/infrastructure/prisma-tx-runner.ts +18 -0
  126. package/template/src/modules/reports/infrastructure/registry/registry-bootstrap.service.ts +61 -0
  127. package/template/src/modules/reports/infrastructure/registry/report-extension.decorators.ts +14 -0
  128. package/template/src/modules/reports/infrastructure/reports-job-queue.adapter.ts +28 -0
  129. package/template/src/modules/reports/infrastructure/request-reports-context.ts +42 -0
  130. package/template/src/modules/reports/infrastructure/schema-check/reports-schema-check.service.ts +116 -0
  131. package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +79 -0
  132. package/template/src/modules/reports/infrastructure/stores/index.ts +5 -0
  133. package/template/src/modules/reports/infrastructure/stores/prisma-bulk-action-run.store.ts +89 -0
  134. package/template/src/modules/reports/infrastructure/stores/prisma-export-job.store.ts +93 -0
  135. package/template/src/modules/reports/infrastructure/stores/prisma-report-definition.store.ts +171 -0
  136. package/template/src/modules/reports/infrastructure/stores/prisma-row-tag.store.ts +110 -0
  137. package/template/src/modules/reports/infrastructure/stores/prisma-saved-view.store.ts +144 -0
  138. package/template/src/modules/reports/presentation/reports-actions.controller.ts +83 -0
  139. package/template/src/modules/reports/presentation/reports-definitions.controller.ts +156 -0
  140. package/template/src/modules/reports/presentation/reports-export-jobs.controller.ts +61 -0
  141. package/template/src/modules/reports/presentation/reports-export.controller.ts +76 -0
  142. package/template/src/modules/reports/presentation/reports-query.controller.ts +52 -0
  143. package/template/src/modules/reports/presentation/reports-views.controller.ts +140 -0
  144. package/template/src/modules/reports/reports-forms.module.ts +33 -0
  145. package/template/src/modules/reports/reports.module.ts +335 -0
  146. package/template/src/modules/reports/reports.tokens.ts +11 -0
  147. package/template/src/modules/reports/sources/org-members.source.ts +112 -0
  148. package/template/src/modules/settings/types/setting-definitions.ts +94 -0
  149. package/template/test/forms-definitions.e2e-spec.ts +394 -0
  150. package/template/test/forms-export.e2e-spec.ts +390 -0
  151. package/template/test/forms-files.e2e-spec.ts +345 -0
  152. package/template/test/forms-outbox.e2e-spec.ts +309 -0
  153. package/template/test/forms-permission-sync.spec.ts +27 -0
  154. package/template/test/forms-public.e2e-spec.ts +269 -0
  155. package/template/test/forms-schema-check.e2e-spec.ts +65 -0
  156. package/template/test/forms-submissions.e2e-spec.ts +500 -0
  157. package/template/test/forms-webhooks.e2e-spec.ts +261 -0
  158. package/template/test/reports-advanced.e2e-spec.ts +368 -0
  159. package/template/test/reports-permission-sync.spec.ts +30 -0
  160. package/template/test/reports-query.e2e-spec.ts +350 -0
  161. package/template/test/reports-tiers.e2e-spec.ts +257 -0
  162. package/template/test/route-registry.validator.spec.ts +22 -0
@@ -0,0 +1,37 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { Prisma } from '@prisma/client';
3
+ import type { ActionLogEntry, ActionLogStore, EngineTx } from '@ftisindia/form-builder';
4
+ import { PrismaService } from '../../../../database/prisma/prisma.service';
5
+
6
+ @Injectable()
7
+ export class PrismaActionLogStore implements ActionLogStore {
8
+ constructor(private readonly prisma: PrismaService) {}
9
+
10
+ private client(tx?: EngineTx) {
11
+ return tx ? (tx as unknown as Prisma.TransactionClient) : this.prisma;
12
+ }
13
+
14
+ async write(entry: ActionLogEntry, tx?: EngineTx): Promise<void> {
15
+ await this.client(tx).formActionLog.create({
16
+ data: {
17
+ orgId: entry.orgId,
18
+ submissionId: entry.submissionId ?? null,
19
+ formKey: entry.formKey,
20
+ formVersion: entry.formVersion ?? null,
21
+ action: entry.action,
22
+ status: entry.status,
23
+ ...(entry.input !== undefined
24
+ ? { input: entry.input as unknown as Prisma.InputJsonValue }
25
+ : {}),
26
+ ...(entry.output !== undefined
27
+ ? { output: entry.output as unknown as Prisma.InputJsonValue }
28
+ : {}),
29
+ errorCode: entry.errorCode ?? null,
30
+ errorMessage: entry.errorMessage ?? null,
31
+ actorId: entry.actorId ?? null,
32
+ requestId: entry.requestId ?? null,
33
+ durationMs: entry.durationMs ?? null,
34
+ },
35
+ });
36
+ }
37
+ }
@@ -0,0 +1,108 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { Prisma, UploadedFile as UploadedFileRow } from '@prisma/client';
3
+ import type {
4
+ EngineTx,
5
+ FileStatus,
6
+ FileStore,
7
+ NewUploadedFileRecord,
8
+ UploadedFileRecord,
9
+ } from '@ftisindia/form-builder';
10
+ import { PrismaService } from '../../../../database/prisma/prisma.service';
11
+
12
+ @Injectable()
13
+ export class PrismaFileStore implements FileStore {
14
+ constructor(private readonly prisma: PrismaService) {}
15
+
16
+ private client(tx?: EngineTx) {
17
+ return tx ? (tx as unknown as Prisma.TransactionClient) : this.prisma;
18
+ }
19
+
20
+ async create(record: NewUploadedFileRecord, tx?: EngineTx): Promise<UploadedFileRecord> {
21
+ const row = await this.client(tx).uploadedFile.create({
22
+ data: {
23
+ storageKey: record.storageKey,
24
+ originalName: record.originalName,
25
+ mimeType: record.mimeType,
26
+ size: record.size,
27
+ checksum: record.checksum,
28
+ ownerId: record.ownerId,
29
+ orgId: record.orgId,
30
+ status: record.status,
31
+ },
32
+ });
33
+
34
+ return this.toRecord(row);
35
+ }
36
+
37
+ async findById(orgId: string, id: string, tx?: EngineTx): Promise<UploadedFileRecord | null> {
38
+ const row = await this.client(tx).uploadedFile.findFirst({
39
+ where: { id, orgId },
40
+ });
41
+
42
+ return row ? this.toRecord(row) : null;
43
+ }
44
+
45
+ async updateStatus(
46
+ orgId: string,
47
+ id: string,
48
+ status: FileStatus,
49
+ submissionId?: string,
50
+ tx?: EngineTx,
51
+ ): Promise<UploadedFileRecord> {
52
+ const client = this.client(tx);
53
+
54
+ const existing = await client.uploadedFile.findFirst({
55
+ where: { id, orgId },
56
+ select: { id: true },
57
+ });
58
+
59
+ if (!existing) {
60
+ throw new Error(`Uploaded file ${id} was not found in this organisation.`);
61
+ }
62
+
63
+ const row = await client.uploadedFile.update({
64
+ where: { id: existing.id },
65
+ data: {
66
+ status,
67
+ ...(submissionId !== undefined ? { submissionId } : {}),
68
+ },
69
+ });
70
+
71
+ return this.toRecord(row);
72
+ }
73
+
74
+ async listGcEligible(cutoff: Date, limit: number): Promise<UploadedFileRecord[]> {
75
+ const rows = await this.client().uploadedFile.findMany({
76
+ where: {
77
+ status: 'TEMPORARY',
78
+ createdAt: { lte: cutoff },
79
+ },
80
+ orderBy: { createdAt: 'asc' },
81
+ take: limit,
82
+ });
83
+
84
+ return rows.map((row) => this.toRecord(row));
85
+ }
86
+
87
+ async delete(id: string, tx?: EngineTx): Promise<void> {
88
+ await this.client(tx).uploadedFile.deleteMany({
89
+ where: { id },
90
+ });
91
+ }
92
+
93
+ private toRecord(row: UploadedFileRow): UploadedFileRecord {
94
+ return {
95
+ id: row.id,
96
+ storageKey: row.storageKey,
97
+ originalName: row.originalName,
98
+ mimeType: row.mimeType,
99
+ size: row.size,
100
+ checksum: row.checksum,
101
+ ownerId: row.ownerId,
102
+ orgId: row.orgId,
103
+ status: row.status as FileStatus,
104
+ submissionId: row.submissionId,
105
+ createdAt: row.createdAt,
106
+ };
107
+ }
108
+ }
@@ -0,0 +1,147 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { FormDefinition as FormDefinitionRow, Prisma } from '@prisma/client';
3
+ import type {
4
+ EngineTx,
5
+ FormDefinition,
6
+ FormDefinitionPatch,
7
+ FormDefinitionRecord,
8
+ FormDefinitionStatus,
9
+ FormDefinitionStore,
10
+ NewFormDefinitionRecord,
11
+ } from '@ftisindia/form-builder';
12
+ import { PrismaService } from '../../../../database/prisma/prisma.service';
13
+
14
+ @Injectable()
15
+ export class PrismaFormDefinitionStore implements FormDefinitionStore {
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: NewFormDefinitionRecord, tx?: EngineTx): Promise<FormDefinitionRecord> {
23
+ const row = await this.client(tx).formDefinition.create({
24
+ data: {
25
+ orgId: record.orgId,
26
+ key: record.key,
27
+ version: record.version,
28
+ status: record.status,
29
+ schema: record.schema as unknown as Prisma.InputJsonValue,
30
+ },
31
+ });
32
+
33
+ return this.toRecord(row);
34
+ }
35
+
36
+ async update(
37
+ orgId: string,
38
+ key: string,
39
+ version: number,
40
+ patch: FormDefinitionPatch,
41
+ tx?: EngineTx,
42
+ ): Promise<FormDefinitionRecord> {
43
+ const client = this.client(tx);
44
+
45
+ const existing = await client.formDefinition.findUnique({
46
+ where: { orgId_key_version: { orgId, key, version } },
47
+ select: { id: true },
48
+ });
49
+
50
+ if (!existing) {
51
+ throw new Error(`Form definition ${key} v${version} was not found in this organisation.`);
52
+ }
53
+
54
+ const row = await client.formDefinition.update({
55
+ where: { id: existing.id },
56
+ data: {
57
+ ...(patch.status !== undefined ? { status: patch.status } : {}),
58
+ ...(patch.schema !== undefined
59
+ ? { schema: patch.schema as unknown as Prisma.InputJsonValue }
60
+ : {}),
61
+ },
62
+ });
63
+
64
+ return this.toRecord(row);
65
+ }
66
+
67
+ async findByKeyVersion(
68
+ orgId: string,
69
+ key: string,
70
+ version: number,
71
+ tx?: EngineTx,
72
+ ): Promise<FormDefinitionRecord | null> {
73
+ const row = await this.client(tx).formDefinition.findUnique({
74
+ where: { orgId_key_version: { orgId, key, version } },
75
+ });
76
+
77
+ return row ? this.toRecord(row) : null;
78
+ }
79
+
80
+ async findLatest(
81
+ orgId: string,
82
+ key: string,
83
+ status?: FormDefinitionStatus,
84
+ ): Promise<FormDefinitionRecord | null> {
85
+ const row = await this.client().formDefinition.findFirst({
86
+ where: { orgId, key, ...(status ? { status } : {}) },
87
+ orderBy: { version: 'desc' },
88
+ });
89
+
90
+ return row ? this.toRecord(row) : null;
91
+ }
92
+
93
+ async findAllByKeyStatus(
94
+ orgId: string,
95
+ key: string,
96
+ status: FormDefinitionStatus,
97
+ tx?: EngineTx,
98
+ ): Promise<FormDefinitionRecord[]> {
99
+ const rows = await this.client(tx).formDefinition.findMany({
100
+ where: { orgId, key, status },
101
+ orderBy: { version: 'desc' },
102
+ });
103
+
104
+ return rows.map((row) => this.toRecord(row));
105
+ }
106
+
107
+ async list(
108
+ orgId: string,
109
+ options: { status?: FormDefinitionStatus; cursor?: string; limit: number },
110
+ ): Promise<FormDefinitionRecord[]> {
111
+ const rows = await this.client().formDefinition.findMany({
112
+ where: { orgId, ...(options.status ? { status: options.status } : {}) },
113
+ orderBy: [{ key: 'asc' }, { version: 'desc' }],
114
+ take: options.limit,
115
+ ...(options.cursor
116
+ ? {
117
+ cursor: { id: options.cursor },
118
+ skip: 1,
119
+ }
120
+ : {}),
121
+ });
122
+
123
+ return rows.map((row) => this.toRecord(row));
124
+ }
125
+
126
+ async maxVersion(orgId: string, key: string, tx?: EngineTx): Promise<number> {
127
+ const result = await this.client(tx).formDefinition.aggregate({
128
+ _max: { version: true },
129
+ where: { orgId, key },
130
+ });
131
+
132
+ return result._max.version ?? 0;
133
+ }
134
+
135
+ private toRecord(row: FormDefinitionRow): FormDefinitionRecord {
136
+ return {
137
+ id: row.id,
138
+ orgId: row.orgId,
139
+ key: row.key,
140
+ version: row.version,
141
+ status: row.status as FormDefinitionStatus,
142
+ schema: row.schema as unknown as FormDefinition,
143
+ createdAt: row.createdAt,
144
+ updatedAt: row.updatedAt,
145
+ };
146
+ }
147
+ }
@@ -0,0 +1,133 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { FormOutboxJob as FormOutboxJobRow, Prisma } from '@prisma/client';
3
+ import type {
4
+ EngineTx,
5
+ OutboxJobInput,
6
+ OutboxJobRecord,
7
+ OutboxStatus,
8
+ OutboxStore,
9
+ } from '@ftisindia/form-builder';
10
+ import { PrismaService } from '../../../../database/prisma/prisma.service';
11
+
12
+ const PROCESSING_LEASE_MS = 5 * 60 * 1000;
13
+
14
+ @Injectable()
15
+ export class PrismaOutboxStore implements OutboxStore {
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 enqueue(job: OutboxJobInput, tx: EngineTx): Promise<void> {
23
+ await this.client(tx).formOutboxJob.create({
24
+ data: {
25
+ type: job.type,
26
+ payload: job.payload as unknown as Prisma.InputJsonValue,
27
+ status: 'PENDING',
28
+ attempts: 0,
29
+ maxAttempts: job.maxAttempts ?? 8,
30
+ idempotencyKey: job.idempotencyKey ?? null,
31
+ runAfter: job.runAfter ?? null,
32
+ orgId: job.orgId ?? null,
33
+ actorUserId: job.actorUserId ?? null,
34
+ originRequestId: job.originRequestId ?? null,
35
+ },
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Claims due PENDING jobs and stale PROCESSING jobs by selecting candidates
41
+ * and flipping them to PROCESSING with a guarded updateMany. This is safe
42
+ * for the template's single in-process dispatcher; multi-instance
43
+ * deployments should move the claim to `SELECT ... FOR UPDATE SKIP LOCKED`.
44
+ */
45
+ async claimDue(now: Date, batchSize: number): Promise<OutboxJobRecord[]> {
46
+ const staleProcessingBefore = new Date(now.getTime() - PROCESSING_LEASE_MS);
47
+ const claimableWhere: Prisma.FormOutboxJobWhereInput = {
48
+ OR: [
49
+ {
50
+ status: 'PENDING',
51
+ OR: [{ runAfter: null }, { runAfter: { lte: now } }],
52
+ },
53
+ {
54
+ status: 'PROCESSING',
55
+ updatedAt: { lte: staleProcessingBefore },
56
+ },
57
+ ],
58
+ };
59
+
60
+ const due = await this.client().formOutboxJob.findMany({
61
+ where: claimableWhere,
62
+ orderBy: { createdAt: 'asc' },
63
+ take: batchSize,
64
+ select: { id: true },
65
+ });
66
+
67
+ if (due.length === 0) {
68
+ return [];
69
+ }
70
+
71
+ const ids = due.map((row) => row.id);
72
+
73
+ await this.client().formOutboxJob.updateMany({
74
+ where: { id: { in: ids }, ...claimableWhere },
75
+ data: { status: 'PROCESSING' },
76
+ });
77
+
78
+ const claimed = await this.client().formOutboxJob.findMany({
79
+ where: { id: { in: ids }, status: 'PROCESSING' },
80
+ });
81
+
82
+ return claimed.map((row) => this.toRecord(row));
83
+ }
84
+
85
+ async markDone(id: string): Promise<void> {
86
+ await this.client().formOutboxJob.update({
87
+ where: { id },
88
+ data: { status: 'DONE' },
89
+ });
90
+ }
91
+
92
+ async markRetry(id: string, attempts: number, runAfter: Date, lastError: string): Promise<void> {
93
+ await this.client().formOutboxJob.update({
94
+ where: { id },
95
+ data: {
96
+ status: 'PENDING',
97
+ attempts,
98
+ runAfter,
99
+ lastError,
100
+ },
101
+ });
102
+ }
103
+
104
+ async markFailed(id: string, attempts: number, lastError: string): Promise<void> {
105
+ await this.client().formOutboxJob.update({
106
+ where: { id },
107
+ data: {
108
+ status: 'FAILED',
109
+ attempts,
110
+ lastError,
111
+ },
112
+ });
113
+ }
114
+
115
+ private toRecord(row: FormOutboxJobRow): OutboxJobRecord {
116
+ return {
117
+ id: row.id,
118
+ type: row.type,
119
+ payload: row.payload as unknown as Record<string, unknown>,
120
+ status: row.status as OutboxStatus,
121
+ attempts: row.attempts,
122
+ maxAttempts: row.maxAttempts,
123
+ idempotencyKey: row.idempotencyKey,
124
+ lastError: row.lastError,
125
+ runAfter: row.runAfter,
126
+ orgId: row.orgId,
127
+ actorUserId: row.actorUserId,
128
+ originRequestId: row.originRequestId,
129
+ createdAt: row.createdAt,
130
+ updatedAt: row.updatedAt,
131
+ };
132
+ }
133
+ }
@@ -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
+ }