@ftisindia/create-app 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +31 -0
  3. package/template/README.md +61 -0
  4. package/template/_gitignore +6 -0
  5. package/template/_package.json +6 -0
  6. package/template/docs/FORMS.md +169 -0
  7. package/template/docs/FORMS_CHECKLIST.md +61 -0
  8. package/template/docs/REPORTS.md +246 -0
  9. package/template/docs/REPORTS_CHECKLIST.md +97 -0
  10. package/template/prisma/migrations/20260612000000_add_form_builder/migration.sql +147 -0
  11. package/template/prisma/migrations/20260613000000_add_report_builder/migration.sql +129 -0
  12. package/template/prisma/schema.prisma +285 -0
  13. package/template/scripts/export-openapi.ts +85 -0
  14. package/template/scripts/gen-form.mjs +149 -0
  15. package/template/scripts/push-form.ts +124 -0
  16. package/template/src/app.module.ts +29 -8
  17. package/template/src/common/dto/membership-response.dto.ts +1 -0
  18. package/template/src/common/dto/role-summary.dto.ts +3 -3
  19. package/template/src/common/dto/user-summary.dto.ts +3 -3
  20. package/template/src/config/app.config.ts +6 -1
  21. package/template/src/config/env.validation.ts +45 -0
  22. package/template/src/config/forms.config.ts +12 -0
  23. package/template/src/config/index.ts +2 -0
  24. package/template/src/config/openapi.ts +12 -0
  25. package/template/src/config/reports-secret.ts +15 -0
  26. package/template/src/config/reports.config.ts +16 -0
  27. package/template/src/main.ts +16 -12
  28. package/template/src/modules/access-control/access-control.module.ts +2 -1
  29. package/template/src/modules/access-control/dto/access-control-response.dto.ts +3 -0
  30. package/template/src/modules/access-control/dto/current-access-control-response.dto.ts +35 -0
  31. package/template/src/modules/access-control/presentation/current-access-control.controller.ts +40 -0
  32. package/template/src/modules/access-control/types/permission-key.ts +27 -0
  33. package/template/src/modules/access-control/types/route-permission-registry.ts +183 -0
  34. package/template/src/modules/audit/dto/audit-response.dto.ts +7 -3
  35. package/template/src/modules/auth/auth.module.ts +3 -1
  36. package/template/src/modules/auth/dto/auth-response.dto.ts +1 -1
  37. package/template/src/modules/forms/application/services/file-gc.service.ts +85 -0
  38. package/template/src/modules/forms/application/services/forms-definitions.service.ts +137 -0
  39. package/template/src/modules/forms/application/services/forms-error.mapper.ts +64 -0
  40. package/template/src/modules/forms/application/services/forms-export.service.ts +210 -0
  41. package/template/src/modules/forms/application/services/forms-files.service.ts +164 -0
  42. package/template/src/modules/forms/application/services/forms-public.service.ts +49 -0
  43. package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +53 -0
  44. package/template/src/modules/forms/application/services/forms-submissions.service.ts +103 -0
  45. package/template/src/modules/forms/application/services/handlers/authenticate.action.ts +37 -0
  46. package/template/src/modules/forms/application/services/handlers/logging-email.handler.ts +22 -0
  47. package/template/src/modules/forms/application/services/handlers/send-confirmation-email.action.ts +40 -0
  48. package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +41 -0
  49. package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +109 -0
  50. package/template/src/modules/forms/dto/create-form-definition.dto.ts +12 -0
  51. package/template/src/modules/forms/dto/data-source-response.dto.ts +19 -0
  52. package/template/src/modules/forms/dto/export-submissions-query.dto.ts +33 -0
  53. package/template/src/modules/forms/dto/file-upload-response.dto.ts +24 -0
  54. package/template/src/modules/forms/dto/form-definition-response.dto.ts +50 -0
  55. package/template/src/modules/forms/dto/form-render-response.dto.ts +17 -0
  56. package/template/src/modules/forms/dto/list-form-definitions-query.dto.ts +10 -0
  57. package/template/src/modules/forms/dto/list-submissions-query.dto.ts +10 -0
  58. package/template/src/modules/forms/dto/public-submit-form.dto.ts +24 -0
  59. package/template/src/modules/forms/dto/set-public-access.dto.ts +8 -0
  60. package/template/src/modules/forms/dto/submission-response.dto.ts +99 -0
  61. package/template/src/modules/forms/dto/submit-form.dto.ts +50 -0
  62. package/template/src/modules/forms/dto/update-form-definition.dto.ts +12 -0
  63. package/template/src/modules/forms/dto/upload-file-query.dto.ts +33 -0
  64. package/template/src/modules/forms/dto/validate-submission.dto.ts +22 -0
  65. package/template/src/modules/forms/examples/abstract-submission.form.json +80 -0
  66. package/template/src/modules/forms/examples/login.form.json +24 -0
  67. package/template/src/modules/forms/examples/registration.form.json +44 -0
  68. package/template/src/modules/forms/forms.module.ts +226 -0
  69. package/template/src/modules/forms/forms.tokens.ts +6 -0
  70. package/template/src/modules/forms/infrastructure/audit-sink.adapter.ts +30 -0
  71. package/template/src/modules/forms/infrastructure/casl-forms-authorization.ts +31 -0
  72. package/template/src/modules/forms/infrastructure/prisma-tx-runner.ts +17 -0
  73. package/template/src/modules/forms/infrastructure/registry/form-extension.decorators.ts +17 -0
  74. package/template/src/modules/forms/infrastructure/registry/registry-bootstrap.service.ts +82 -0
  75. package/template/src/modules/forms/infrastructure/request-forms-context.ts +60 -0
  76. package/template/src/modules/forms/infrastructure/schema-check/forms-schema-check.service.ts +76 -0
  77. package/template/src/modules/forms/infrastructure/storage/local-disk-storage.adapter.ts +43 -0
  78. package/template/src/modules/forms/infrastructure/stores/index.ts +5 -0
  79. package/template/src/modules/forms/infrastructure/stores/prisma-action-log.store.ts +37 -0
  80. package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +108 -0
  81. package/template/src/modules/forms/infrastructure/stores/prisma-form-definition.store.ts +147 -0
  82. package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +133 -0
  83. package/template/src/modules/forms/infrastructure/stores/prisma-submission.store.ts +164 -0
  84. package/template/src/modules/forms/presentation/forms-data-sources.controller.ts +58 -0
  85. package/template/src/modules/forms/presentation/forms-definitions.controller.ts +191 -0
  86. package/template/src/modules/forms/presentation/forms-files.controller.ts +79 -0
  87. package/template/src/modules/forms/presentation/forms-submissions.controller.ts +154 -0
  88. package/template/src/modules/forms/presentation/forms-upload.interceptor.ts +33 -0
  89. package/template/src/modules/forms/presentation/public-forms.controller.ts +51 -0
  90. package/template/src/modules/invitations/dto/invitation-response.dto.ts +4 -0
  91. package/template/src/modules/organisations/application/services/organisations.service.ts +67 -1
  92. package/template/src/modules/organisations/dto/organisation-response.dto.ts +52 -0
  93. package/template/src/modules/organisations/presentation/organisations.controller.ts +25 -3
  94. package/template/src/modules/reports/application/services/reports-actions.service.ts +54 -0
  95. package/template/src/modules/reports/application/services/reports-definitions.service.ts +66 -0
  96. package/template/src/modules/reports/application/services/reports-error.mapper.ts +97 -0
  97. package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +124 -0
  98. package/template/src/modules/reports/application/services/reports-exports.service.ts +74 -0
  99. package/template/src/modules/reports/application/services/reports-queries.service.ts +35 -0
  100. package/template/src/modules/reports/application/services/reports-settings-reader.service.ts +49 -0
  101. package/template/src/modules/reports/application/services/reports-views.service.ts +79 -0
  102. package/template/src/modules/reports/dto/action-result-response.dto.ts +21 -0
  103. package/template/src/modules/reports/dto/create-report-definition.dto.ts +86 -0
  104. package/template/src/modules/reports/dto/create-saved-view.dto.ts +26 -0
  105. package/template/src/modules/reports/dto/execute-action.dto.ts +71 -0
  106. package/template/src/modules/reports/dto/export-job-response.dto.ts +60 -0
  107. package/template/src/modules/reports/dto/export-request.dto.ts +34 -0
  108. package/template/src/modules/reports/dto/list-reports-query.dto.ts +10 -0
  109. package/template/src/modules/reports/dto/list-views-query.dto.ts +17 -0
  110. package/template/src/modules/reports/dto/prepare-action-response.dto.ts +14 -0
  111. package/template/src/modules/reports/dto/prepare-action.dto.ts +27 -0
  112. package/template/src/modules/reports/dto/query-response.dto.ts +64 -0
  113. package/template/src/modules/reports/dto/query-spec.dto.ts +120 -0
  114. package/template/src/modules/reports/dto/report-definition-response.dto.ts +64 -0
  115. package/template/src/modules/reports/dto/report-meta-query.dto.ts +16 -0
  116. package/template/src/modules/reports/dto/report-meta-response.dto.ts +113 -0
  117. package/template/src/modules/reports/dto/saved-view-response.dto.ts +66 -0
  118. package/template/src/modules/reports/dto/update-report-definition.dto.ts +9 -0
  119. package/template/src/modules/reports/dto/update-saved-view.dto.ts +27 -0
  120. package/template/src/modules/reports/examples/abstract-review-board.report.json +54 -0
  121. package/template/src/modules/reports/examples/org-members.report.json +55 -0
  122. package/template/src/modules/reports/infrastructure/audit-sink.adapter.ts +31 -0
  123. package/template/src/modules/reports/infrastructure/casl-reports-authorization.ts +39 -0
  124. package/template/src/modules/reports/infrastructure/forms-adapter/form-report-source.adapter.ts +292 -0
  125. package/template/src/modules/reports/infrastructure/forms-adapter/form-row-actions.ts +171 -0
  126. package/template/src/modules/reports/infrastructure/forms-adapter/forms-bridge-bootstrap.service.ts +32 -0
  127. package/template/src/modules/reports/infrastructure/prisma-catalog.adapter.ts +95 -0
  128. package/template/src/modules/reports/infrastructure/prisma-query-executor.ts +103 -0
  129. package/template/src/modules/reports/infrastructure/prisma-snapshot-runner.ts +47 -0
  130. package/template/src/modules/reports/infrastructure/prisma-tx-runner.ts +18 -0
  131. package/template/src/modules/reports/infrastructure/registry/registry-bootstrap.service.ts +61 -0
  132. package/template/src/modules/reports/infrastructure/registry/report-extension.decorators.ts +14 -0
  133. package/template/src/modules/reports/infrastructure/reports-job-queue.adapter.ts +28 -0
  134. package/template/src/modules/reports/infrastructure/request-reports-context.ts +42 -0
  135. package/template/src/modules/reports/infrastructure/schema-check/reports-schema-check.service.ts +116 -0
  136. package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +79 -0
  137. package/template/src/modules/reports/infrastructure/stores/index.ts +5 -0
  138. package/template/src/modules/reports/infrastructure/stores/prisma-bulk-action-run.store.ts +89 -0
  139. package/template/src/modules/reports/infrastructure/stores/prisma-export-job.store.ts +93 -0
  140. package/template/src/modules/reports/infrastructure/stores/prisma-report-definition.store.ts +171 -0
  141. package/template/src/modules/reports/infrastructure/stores/prisma-row-tag.store.ts +110 -0
  142. package/template/src/modules/reports/infrastructure/stores/prisma-saved-view.store.ts +144 -0
  143. package/template/src/modules/reports/presentation/reports-actions.controller.ts +83 -0
  144. package/template/src/modules/reports/presentation/reports-definitions.controller.ts +156 -0
  145. package/template/src/modules/reports/presentation/reports-export-jobs.controller.ts +61 -0
  146. package/template/src/modules/reports/presentation/reports-export.controller.ts +76 -0
  147. package/template/src/modules/reports/presentation/reports-query.controller.ts +52 -0
  148. package/template/src/modules/reports/presentation/reports-views.controller.ts +140 -0
  149. package/template/src/modules/reports/reports-forms.module.ts +33 -0
  150. package/template/src/modules/reports/reports.module.ts +335 -0
  151. package/template/src/modules/reports/reports.tokens.ts +11 -0
  152. package/template/src/modules/reports/sources/org-members.source.ts +112 -0
  153. package/template/src/modules/settings/types/setting-definitions.ts +94 -0
  154. package/template/test/forms-definitions.e2e-spec.ts +394 -0
  155. package/template/test/forms-export.e2e-spec.ts +390 -0
  156. package/template/test/forms-files.e2e-spec.ts +345 -0
  157. package/template/test/forms-outbox.e2e-spec.ts +309 -0
  158. package/template/test/forms-permission-sync.spec.ts +27 -0
  159. package/template/test/forms-public.e2e-spec.ts +269 -0
  160. package/template/test/forms-schema-check.e2e-spec.ts +65 -0
  161. package/template/test/forms-submissions.e2e-spec.ts +500 -0
  162. package/template/test/forms-webhooks.e2e-spec.ts +261 -0
  163. package/template/test/frontend-bootstrap.spec.ts +181 -0
  164. package/template/test/reports-advanced.e2e-spec.ts +368 -0
  165. package/template/test/reports-permission-sync.spec.ts +30 -0
  166. package/template/test/reports-query.e2e-spec.ts +350 -0
  167. package/template/test/reports-tiers.e2e-spec.ts +257 -0
  168. package/template/test/route-registry.validator.spec.ts +34 -0
  169. package/template/test/security.e2e-spec.ts +134 -2
@@ -0,0 +1,500 @@
1
+ import { INestApplication, ValidationPipe } from '@nestjs/common';
2
+ import { Test } from '@nestjs/testing';
3
+ import { ActionRegistry, DataSourceRegistry } from '@ftisindia/form-builder';
4
+ import request from 'supertest';
5
+ import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
6
+ import { PrismaService } from '../src/database/prisma/prisma.service';
7
+
8
+ describe('Forms submissions engine flow (e2e)', () => {
9
+ let app: INestApplication;
10
+ let prisma: PrismaService;
11
+ let owner: { accessToken: string; userId: string; orgId: string };
12
+
13
+ beforeAll(async () => {
14
+ process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL;
15
+ process.env.JWT_SECRET ||= 'test-jwt-secret-at-least-16-chars';
16
+ process.env.AUTH_GOOGLE_ENABLED ||= 'false';
17
+ process.env.AUTH_EMAIL_PASSWORD_ENABLED ||= 'true';
18
+ process.env.NODE_ENV = 'test';
19
+ process.env.FORMS_OUTBOX_ENABLED = 'false';
20
+ process.env.FORMS_FILE_STORAGE_DIR = './var/test-uploads';
21
+
22
+ const { AppModule } = await import('../src/app.module');
23
+ const moduleRef = await Test.createTestingModule({
24
+ imports: [AppModule],
25
+ }).compile();
26
+
27
+ app = moduleRef.createNestApplication();
28
+ app.useGlobalFilters(new HttpExceptionFilter());
29
+ app.useGlobalPipes(
30
+ new ValidationPipe({
31
+ forbidNonWhitelisted: true,
32
+ transform: true,
33
+ whitelist: true,
34
+ }),
35
+ );
36
+ await app.init();
37
+ prisma = app.get(PrismaService);
38
+
39
+ // Test-only engine extensions registered at runtime (pass save-time lint).
40
+ const actions = app.get(ActionRegistry);
41
+ actions.register({
42
+ name: 'failTransactional',
43
+ kind: 'transactional',
44
+ execute: () => {
45
+ return Promise.reject(new Error('induced failure'));
46
+ },
47
+ });
48
+ actions.register({
49
+ name: 'echoData',
50
+ kind: 'transactional',
51
+ execute: (ctx) => Promise.resolve({ ...ctx.data }),
52
+ });
53
+
54
+ const dataSources = app.get(DataSourceRegistry);
55
+ dataSources.register({
56
+ key: 'conference-tracks',
57
+ fetch: () => Promise.resolve([
58
+ { value: 'ai', label: 'Artificial Intelligence' },
59
+ { value: 'ml', label: 'Machine Learning' },
60
+ ]),
61
+ });
62
+
63
+ owner = await createUserAndOrg('sub-owner');
64
+ });
65
+
66
+ afterAll(async () => {
67
+ await app.close();
68
+ });
69
+
70
+ it('accepts a partial draft but rejects the same payload on submit (REQUIRED)', async () => {
71
+ const key = await publishDefinition(registrationDefinition(uniqueFormKey('reg-draft')));
72
+
73
+ const draft = await request(app.getHttpServer())
74
+ .post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
75
+ .set('Authorization', `Bearer ${owner.accessToken}`)
76
+ .send({ mode: 'draft', data: { email: 'draft@example.com' } })
77
+ .expect(200);
78
+ expect(draft.body.submissionId).toBeTruthy();
79
+
80
+ const submit = await request(app.getHttpServer())
81
+ .post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
82
+ .set('Authorization', `Bearer ${owner.accessToken}`)
83
+ .send({ mode: 'submit', data: { email: 'draft@example.com' } })
84
+ .expect(422);
85
+
86
+ expect(submit.body.error.code).toBe('UNPROCESSABLE_ENTITY');
87
+ expect(submit.body.error.message).toBe('Validation failed.');
88
+ const errors = submit.body.error.details.errors as Array<{ path: string; code: string }>;
89
+ expect(errors.some((error) => error.path === 'fullName' && error.code === 'REQUIRED')).toBe(
90
+ true,
91
+ );
92
+ });
93
+
94
+ it('rejects mismatched submit mode and button', async () => {
95
+ const key = await publishDefinition(registrationDefinition(uniqueFormKey('reg-mode-button')));
96
+
97
+ await request(app.getHttpServer())
98
+ .post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
99
+ .set('Authorization', `Bearer ${owner.accessToken}`)
100
+ .send({ mode: 'draft', button: 'submit', data: { email: 'draft@example.com' } })
101
+ .expect(409);
102
+
103
+ await request(app.getHttpServer())
104
+ .post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
105
+ .set('Authorization', `Bearer ${owner.accessToken}`)
106
+ .send({
107
+ mode: 'submit',
108
+ button: 'saveDraft',
109
+ data: { fullName: 'Mode Tester', email: 'mode@example.com', ticketType: 'standard' },
110
+ })
111
+ .expect(409);
112
+ });
113
+
114
+ it('rejects edits to drafts captured under another form version', async () => {
115
+ const key = await publishDefinition(registrationDefinition(uniqueFormKey('reg-versioned')));
116
+ const draft = await request(app.getHttpServer())
117
+ .post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
118
+ .set('Authorization', `Bearer ${owner.accessToken}`)
119
+ .send({ mode: 'draft', data: { email: 'old-version@example.com' } })
120
+ .expect(200);
121
+
122
+ const createdV2 = await request(app.getHttpServer())
123
+ .post(`/organisations/${owner.orgId}/forms`)
124
+ .set('Authorization', `Bearer ${owner.accessToken}`)
125
+ .send({ definition: { ...registrationDefinition(key), title: 'Event registration v2' } })
126
+ .expect(201);
127
+ await request(app.getHttpServer())
128
+ .post(
129
+ `/organisations/${owner.orgId}/forms/${key}/versions/${createdV2.body.version as number}/publish`,
130
+ )
131
+ .set('Authorization', `Bearer ${owner.accessToken}`)
132
+ .expect(200);
133
+
134
+ await request(app.getHttpServer())
135
+ .post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
136
+ .set('Authorization', `Bearer ${owner.accessToken}`)
137
+ .send({
138
+ mode: 'draft',
139
+ submissionId: draft.body.submissionId as string,
140
+ data: { email: 'still-old-version@example.com' },
141
+ })
142
+ .expect(409);
143
+ });
144
+
145
+ it('rejects draft ids replayed through another form route', async () => {
146
+ const sourceKey = await publishDefinition(registrationDefinition(uniqueFormKey('reg-source')));
147
+ const targetKey = await publishDefinition(registrationDefinition(uniqueFormKey('reg-target')));
148
+ const draft = await request(app.getHttpServer())
149
+ .post(`/organisations/${owner.orgId}/forms/${sourceKey}/submissions`)
150
+ .set('Authorization', `Bearer ${owner.accessToken}`)
151
+ .send({ mode: 'draft', data: { email: 'source@example.com' } })
152
+ .expect(200);
153
+
154
+ await request(app.getHttpServer())
155
+ .post(`/organisations/${owner.orgId}/forms/${targetKey}/submissions`)
156
+ .set('Authorization', `Bearer ${owner.accessToken}`)
157
+ .send({
158
+ mode: 'draft',
159
+ submissionId: draft.body.submissionId as string,
160
+ data: { email: 'target@example.com' },
161
+ })
162
+ .expect(404);
163
+ });
164
+
165
+ it('stamps context fields server-side even when the client spoofs them', async () => {
166
+ const key = await publishDefinition(registrationDefinition(uniqueFormKey('reg-spoof')));
167
+
168
+ const response = await request(app.getHttpServer())
169
+ .post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
170
+ .set('Authorization', `Bearer ${owner.accessToken}`)
171
+ .send({
172
+ mode: 'submit',
173
+ data: {
174
+ fullName: 'Ada Lovelace',
175
+ email: 'ada@example.com',
176
+ ticketType: 'standard',
177
+ registeredBy: 'spoofed-user-id',
178
+ },
179
+ })
180
+ .expect(200);
181
+
182
+ const submissionId = response.body.submissionId as string;
183
+ expect(submissionId).toBeTruthy();
184
+
185
+ const row = await prisma.formSubmission.findUnique({ where: { id: submissionId } });
186
+ expect(row).not.toBeNull();
187
+ expect(row?.status).toBe('SUBMITTED');
188
+ expect(row?.formVersion).toBe(1);
189
+ const data = row?.data as unknown as { registeredBy?: string };
190
+ expect(data.registeredBy).toBe(owner.userId);
191
+ });
192
+
193
+ it('rejects edits to an already-SUBMITTED submission with 409', async () => {
194
+ const key = await publishDefinition(registrationDefinition(uniqueFormKey('reg-locked')));
195
+
196
+ const submitted = await request(app.getHttpServer())
197
+ .post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
198
+ .set('Authorization', `Bearer ${owner.accessToken}`)
199
+ .send({
200
+ mode: 'submit',
201
+ data: { fullName: 'Grace Hopper', email: 'grace@example.com', ticketType: 'vip' },
202
+ })
203
+ .expect(200);
204
+
205
+ await request(app.getHttpServer())
206
+ .post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
207
+ .set('Authorization', `Bearer ${owner.accessToken}`)
208
+ .send({
209
+ mode: 'draft',
210
+ submissionId: submitted.body.submissionId as string,
211
+ data: { fullName: 'Grace Hopper (edited)' },
212
+ })
213
+ .expect(409);
214
+ });
215
+
216
+ it('enforces json-logic rules with ruleId on violation', async () => {
217
+ const key = await publishDefinition(fundingDefinition(uniqueFormKey('funding')));
218
+
219
+ const blocked = await request(app.getHttpServer())
220
+ .post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
221
+ .set('Authorization', `Bearer ${owner.accessToken}`)
222
+ .send({ mode: 'submit', data: { needsFunding: true, fundingSource: '' } })
223
+ .expect(422);
224
+
225
+ const errors = blocked.body.error.details.errors as Array<{ ruleId?: string }>;
226
+ expect(errors.some((error) => error.ruleId === 'funding-required')).toBe(true);
227
+
228
+ await request(app.getHttpServer())
229
+ .post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
230
+ .set('Authorization', `Bearer ${owner.accessToken}`)
231
+ .send({ mode: 'submit', data: { needsFunding: true, fundingSource: 'NSF grant 42' } })
232
+ .expect(200);
233
+ });
234
+
235
+ it('rejects static select values outside the options enum', async () => {
236
+ const key = await publishDefinition(registrationDefinition(uniqueFormKey('reg-enum')));
237
+
238
+ const response = await request(app.getHttpServer())
239
+ .post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
240
+ .set('Authorization', `Bearer ${owner.accessToken}`)
241
+ .send({
242
+ mode: 'submit',
243
+ data: { fullName: 'Enum Tester', email: 'enum@example.com', ticketType: 'gold' },
244
+ })
245
+ .expect(422);
246
+
247
+ const errors = response.body.error.details.errors as Array<{ path: string; code: string }>;
248
+ expect(errors.some((error) => error.path === 'ticketType' && error.code === 'ENUM')).toBe(true);
249
+ });
250
+
251
+ it('re-validates data-source membership at submit time', async () => {
252
+ const key = await publishDefinition(trackDefinition(uniqueFormKey('tracks')));
253
+
254
+ const rejected = await request(app.getHttpServer())
255
+ .post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
256
+ .set('Authorization', `Bearer ${owner.accessToken}`)
257
+ .send({ mode: 'submit', data: { track: 'quantum' } })
258
+ .expect(422);
259
+
260
+ const errors = rejected.body.error.details.errors as Array<{ code: string }>;
261
+ expect(errors.some((error) => error.code === 'NOT_IN_DATASOURCE')).toBe(true);
262
+
263
+ await request(app.getHttpServer())
264
+ .post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
265
+ .set('Authorization', `Bearer ${owner.accessToken}`)
266
+ .send({ mode: 'submit', data: { track: 'ai' } })
267
+ .expect(200);
268
+ });
269
+
270
+ it('rolls back the whole transaction when a transactional action fails (§8.3)', async () => {
271
+ const key = uniqueFormKey('rollback');
272
+ await publishDefinition({
273
+ ...registrationDefinition(key),
274
+ actions: {
275
+ submit: ['validateAll', 'persist', 'failTransactional', 'sendConfirmationEmail'],
276
+ saveDraft: ['persistDraft'],
277
+ },
278
+ });
279
+
280
+ const before = {
281
+ submissions: await prisma.formSubmission.count(),
282
+ outbox: await prisma.formOutboxJob.count(),
283
+ audit: await prisma.auditLog.count(),
284
+ persistOk: await prisma.formActionLog.count({
285
+ where: { orgId: owner.orgId, formKey: key, action: 'persist', status: 'OK' },
286
+ }),
287
+ };
288
+
289
+ await request(app.getHttpServer())
290
+ .post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
291
+ .set('Authorization', `Bearer ${owner.accessToken}`)
292
+ .send({
293
+ mode: 'submit',
294
+ data: { fullName: 'Roll Back', email: 'rollback@example.com', ticketType: 'student' },
295
+ })
296
+ .expect(500);
297
+
298
+ const after = {
299
+ submissions: await prisma.formSubmission.count(),
300
+ outbox: await prisma.formOutboxJob.count(),
301
+ audit: await prisma.auditLog.count(),
302
+ persistOk: await prisma.formActionLog.count({
303
+ where: { orgId: owner.orgId, formKey: key, action: 'persist', status: 'OK' },
304
+ }),
305
+ };
306
+
307
+ expect(after.submissions).toBe(before.submissions);
308
+ expect(after.outbox).toBe(before.outbox);
309
+ expect(after.persistOk).toBe(before.persistOk);
310
+
311
+ // One best-effort ERROR row for the failing step is allowed (and expected).
312
+ const errorRow = await prisma.formActionLog.findFirst({
313
+ where: { orgId: owner.orgId, formKey: key, action: 'failTransactional' },
314
+ orderBy: { createdAt: 'desc' },
315
+ });
316
+ expect(errorRow).not.toBeNull();
317
+ expect(errorRow?.status).toBe('ERROR');
318
+ });
319
+
320
+ it('redacts sensitive fields in action-log outputs', async () => {
321
+ const key = await publishDefinition(credentialsDefinition(uniqueFormKey('creds')));
322
+
323
+ await request(app.getHttpServer())
324
+ .post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
325
+ .set('Authorization', `Bearer ${owner.accessToken}`)
326
+ .send({ mode: 'submit', data: { username: 'ada', password: 'super-secret-1' } })
327
+ .expect(200);
328
+
329
+ const logRow = await prisma.formActionLog.findFirst({
330
+ where: { orgId: owner.orgId, formKey: key, action: 'echoData', status: 'OK' },
331
+ orderBy: { createdAt: 'desc' },
332
+ });
333
+ expect(logRow).not.toBeNull();
334
+ const output = logRow?.output as unknown as { username?: string; password?: string };
335
+ expect(output.password).toBe('[REDACTED]');
336
+ expect(output.username).toBe('ada');
337
+ });
338
+
339
+ // ── Helpers ────────────────────────────────────────────────────────────────
340
+
341
+ function uniqueSuffix() {
342
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
343
+ }
344
+
345
+ function uniqueFormKey(label: string) {
346
+ return `${label}-${uniqueSuffix()}`.toLowerCase();
347
+ }
348
+
349
+ function registrationDefinition(key: string): Record<string, unknown> {
350
+ return {
351
+ key,
352
+ version: 1,
353
+ title: 'Event registration',
354
+ fields: [
355
+ {
356
+ type: 'text',
357
+ name: 'fullName',
358
+ label: 'Full name',
359
+ validators: { required: true, maxLength: 120 },
360
+ reportable: true,
361
+ },
362
+ {
363
+ type: 'email',
364
+ name: 'email',
365
+ label: 'Email address',
366
+ validators: { required: true },
367
+ reportable: true,
368
+ },
369
+ {
370
+ type: 'select',
371
+ name: 'ticketType',
372
+ label: 'Ticket type',
373
+ validators: { required: true, options: ['standard', 'student', 'vip'] },
374
+ reportable: true,
375
+ },
376
+ {
377
+ type: 'hidden',
378
+ name: 'registeredBy',
379
+ source: 'context',
380
+ path: 'user.id',
381
+ },
382
+ ],
383
+ actions: {
384
+ submit: ['validateAll', 'persist', 'sendConfirmationEmail'],
385
+ saveDraft: ['persistDraft'],
386
+ },
387
+ };
388
+ }
389
+
390
+ function fundingDefinition(key: string): Record<string, unknown> {
391
+ return {
392
+ key,
393
+ version: 1,
394
+ title: 'Funding request',
395
+ fields: [
396
+ { type: 'boolean', name: 'needsFunding', label: 'Requesting funding?' },
397
+ { type: 'text', name: 'fundingSource', label: 'Funding source' },
398
+ ],
399
+ rules: [
400
+ {
401
+ id: 'funding-required',
402
+ enforceOn: ['submit'],
403
+ if: { '==': [{ var: 'data.needsFunding' }, true] },
404
+ then: { require: ['fundingSource'] },
405
+ message: 'Funding source is required when requesting funding.',
406
+ },
407
+ ],
408
+ actions: {
409
+ submit: ['validateAll', 'persist'],
410
+ saveDraft: ['persistDraft'],
411
+ },
412
+ };
413
+ }
414
+
415
+ function trackDefinition(key: string): Record<string, unknown> {
416
+ return {
417
+ key,
418
+ version: 1,
419
+ title: 'Track selection',
420
+ fields: [
421
+ {
422
+ type: 'select',
423
+ name: 'track',
424
+ label: 'Conference track',
425
+ dataSource: { key: 'conference-tracks' },
426
+ validators: { required: true },
427
+ },
428
+ ],
429
+ actions: {
430
+ submit: ['validateAll', 'persist'],
431
+ saveDraft: ['persistDraft'],
432
+ },
433
+ };
434
+ }
435
+
436
+ function credentialsDefinition(key: string): Record<string, unknown> {
437
+ return {
438
+ key,
439
+ version: 1,
440
+ title: 'Credentials echo',
441
+ fields: [
442
+ { type: 'text', name: 'username', label: 'Username', validators: { required: true } },
443
+ {
444
+ type: 'password',
445
+ name: 'password',
446
+ label: 'Password',
447
+ validators: { required: true, minLength: 8 },
448
+ },
449
+ ],
450
+ actions: {
451
+ submit: ['validateAll', 'persist', 'echoData'],
452
+ },
453
+ };
454
+ }
455
+
456
+ async function publishDefinition(definition: Record<string, unknown>): Promise<string> {
457
+ const created = await request(app.getHttpServer())
458
+ .post(`/organisations/${owner.orgId}/forms`)
459
+ .set('Authorization', `Bearer ${owner.accessToken}`)
460
+ .send({ definition })
461
+ .expect(201);
462
+
463
+ await request(app.getHttpServer())
464
+ .post(
465
+ `/organisations/${owner.orgId}/forms/${created.body.key as string}/versions/${created.body.version as number}/publish`,
466
+ )
467
+ .set('Authorization', `Bearer ${owner.accessToken}`)
468
+ .expect(200);
469
+
470
+ return created.body.key as string;
471
+ }
472
+
473
+ async function createUserAndOrg(label: string) {
474
+ const suffix = uniqueSuffix();
475
+ const signup = await request(app.getHttpServer())
476
+ .post('/auth/signup')
477
+ .send({
478
+ email: `${label}-${suffix}@example.com`,
479
+ password: 'test-password-123',
480
+ displayName: label,
481
+ })
482
+ .expect(201);
483
+
484
+ const accessToken = signup.body.accessToken as string;
485
+ const org = await request(app.getHttpServer())
486
+ .post('/organisations')
487
+ .set('Authorization', `Bearer ${accessToken}`)
488
+ .send({
489
+ name: `${label} ${suffix}`,
490
+ slug: `${label}-${suffix}`,
491
+ })
492
+ .expect(201);
493
+
494
+ return {
495
+ accessToken,
496
+ userId: signup.body.user.id as string,
497
+ orgId: org.body.organisation.id as string,
498
+ };
499
+ }
500
+ });