@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,394 @@
1
+ import { INestApplication, ValidationPipe } from '@nestjs/common';
2
+ import { Test } from '@nestjs/testing';
3
+ import request from 'supertest';
4
+ import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
5
+ import { PrismaService } from '../src/database/prisma/prisma.service';
6
+
7
+ describe('Forms definitions lifecycle (e2e)', () => {
8
+ let app: INestApplication;
9
+ let prisma: PrismaService;
10
+
11
+ beforeAll(async () => {
12
+ process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL;
13
+ process.env.JWT_SECRET ||= 'test-jwt-secret-at-least-16-chars';
14
+ process.env.AUTH_GOOGLE_ENABLED ||= 'false';
15
+ process.env.AUTH_EMAIL_PASSWORD_ENABLED ||= 'true';
16
+ process.env.NODE_ENV = 'test';
17
+ process.env.FORMS_OUTBOX_ENABLED = 'false';
18
+ process.env.FORMS_FILE_STORAGE_DIR = './var/test-uploads';
19
+
20
+ const { AppModule } = await import('../src/app.module');
21
+ const moduleRef = await Test.createTestingModule({
22
+ imports: [AppModule],
23
+ }).compile();
24
+
25
+ app = moduleRef.createNestApplication();
26
+ app.useGlobalFilters(new HttpExceptionFilter());
27
+ app.useGlobalPipes(
28
+ new ValidationPipe({
29
+ forbidNonWhitelisted: true,
30
+ transform: true,
31
+ whitelist: true,
32
+ }),
33
+ );
34
+ await app.init();
35
+ prisma = app.get(PrismaService);
36
+ });
37
+
38
+ afterAll(async () => {
39
+ await app.close();
40
+ });
41
+
42
+ it('creates drafts with incrementing versions for the same key', async () => {
43
+ const { accessToken, orgId } = await createUserAndOrg('def-draft');
44
+ const key = uniqueFormKey('draft');
45
+
46
+ const first = await request(app.getHttpServer())
47
+ .post(`/organisations/${orgId}/forms`)
48
+ .set('Authorization', `Bearer ${accessToken}`)
49
+ .send({ definition: registrationDefinition(key) })
50
+ .expect(201);
51
+
52
+ expect(first.body.status).toBe('DRAFT');
53
+ expect(first.body.version).toBe(1);
54
+ expect(first.body.key).toBe(key);
55
+
56
+ const second = await request(app.getHttpServer())
57
+ .post(`/organisations/${orgId}/forms`)
58
+ .set('Authorization', `Bearer ${accessToken}`)
59
+ .send({ definition: registrationDefinition(key) })
60
+ .expect(201);
61
+
62
+ expect(second.body.status).toBe('DRAFT');
63
+ expect(second.body.version).toBe(2);
64
+ });
65
+
66
+ it('rejects definitions with an unknown field type at save time', async () => {
67
+ const { accessToken, orgId } = await createUserAndOrg('def-lint');
68
+ const definition = registrationDefinition(uniqueFormKey('lint'));
69
+ (definition.fields as Array<Record<string, unknown>>).push({
70
+ type: 'no-such-type',
71
+ name: 'mysteryField',
72
+ });
73
+
74
+ const response = await request(app.getHttpServer())
75
+ .post(`/organisations/${orgId}/forms`)
76
+ .set('Authorization', `Bearer ${accessToken}`)
77
+ .send({ definition })
78
+ .expect(422);
79
+
80
+ const issues = response.body.error.details.issues as Array<{ code: string }>;
81
+ expect(issues.map((issue) => issue.code)).toContain('UNKNOWN_FIELD_TYPE');
82
+ });
83
+
84
+ it('publishes, freezes published versions, auto-archives priors, and audits', async () => {
85
+ const { accessToken, orgId } = await createUserAndOrg('def-publish');
86
+ const key = uniqueFormKey('publish');
87
+
88
+ await request(app.getHttpServer())
89
+ .post(`/organisations/${orgId}/forms`)
90
+ .set('Authorization', `Bearer ${accessToken}`)
91
+ .send({ definition: registrationDefinition(key) })
92
+ .expect(201);
93
+
94
+ const published = await request(app.getHttpServer())
95
+ .post(`/organisations/${orgId}/forms/${key}/versions/1/publish`)
96
+ .set('Authorization', `Bearer ${accessToken}`)
97
+ .expect(200);
98
+ expect(published.body.status).toBe('PUBLISHED');
99
+
100
+ // Published versions are immutable.
101
+ await request(app.getHttpServer())
102
+ .patch(`/organisations/${orgId}/forms/${key}/versions/1`)
103
+ .set('Authorization', `Bearer ${accessToken}`)
104
+ .send({ definition: registrationDefinition(key) })
105
+ .expect(409);
106
+
107
+ // Publish version 2: version 1 must be auto-archived.
108
+ await request(app.getHttpServer())
109
+ .post(`/organisations/${orgId}/forms`)
110
+ .set('Authorization', `Bearer ${accessToken}`)
111
+ .send({ definition: registrationDefinition(key) })
112
+ .expect(201);
113
+ await request(app.getHttpServer())
114
+ .post(`/organisations/${orgId}/forms/${key}/versions/2/publish`)
115
+ .set('Authorization', `Bearer ${accessToken}`)
116
+ .expect(200);
117
+
118
+ const versionOne = await request(app.getHttpServer())
119
+ .get(`/organisations/${orgId}/forms/${key}/versions/1`)
120
+ .set('Authorization', `Bearer ${accessToken}`)
121
+ .expect(200);
122
+ expect(versionOne.body.status).toBe('ARCHIVED');
123
+
124
+ const auditRows = await prisma.auditLog.findMany({
125
+ where: { orgId, action: { in: ['forms.create', 'forms.publish'] } },
126
+ });
127
+ expect(auditRows.some((row) => row.action === 'forms.create')).toBe(true);
128
+ expect(auditRows.some((row) => row.action === 'forms.publish')).toBe(true);
129
+ });
130
+
131
+ it('gates dangerous-action wiring at attach time (permission and allowlist)', async () => {
132
+ const owner = await createUserAndOrg('def-dangerous');
133
+ const member = await createMemberInOrg(owner.orgId, 'def-dangerous-member', [
134
+ 'forms.read',
135
+ 'forms.create',
136
+ 'forms.update',
137
+ 'forms.publish',
138
+ ]);
139
+
140
+ // Member lacks forms.wireDangerous.
141
+ const memberAttempt = await request(app.getHttpServer())
142
+ .post(`/organisations/${owner.orgId}/forms`)
143
+ .set('Authorization', `Bearer ${member.accessToken}`)
144
+ .send({ definition: authenticateDefinition(uniqueFormKey('login-member')) })
145
+ .expect(422);
146
+ const memberIssues = memberAttempt.body.error.details.issues as Array<{ code: string }>;
147
+ expect(memberIssues.map((issue) => issue.code)).toContain('DANGEROUS_NOT_ALLOWED');
148
+
149
+ // Owner passes wireDangerous (manage all) but the org allowlist is empty.
150
+ const ownerAttempt = await request(app.getHttpServer())
151
+ .post(`/organisations/${owner.orgId}/forms`)
152
+ .set('Authorization', `Bearer ${owner.accessToken}`)
153
+ .send({ definition: authenticateDefinition(uniqueFormKey('login-owner')) })
154
+ .expect(422);
155
+ const ownerIssues = ownerAttempt.body.error.details.issues as Array<{ code: string }>;
156
+ expect(ownerIssues.map((issue) => issue.code)).toContain('DANGEROUS_NOT_IN_ALLOWLIST');
157
+ expect(ownerIssues.map((issue) => issue.code)).not.toContain('DANGEROUS_NOT_ALLOWED');
158
+
159
+ // With the org allowlist set, owner create + publish succeeds.
160
+ await setOrgSetting(owner.accessToken, owner.orgId, 'forms.allowedDangerousActions', [
161
+ 'authenticate',
162
+ ]);
163
+ const key = uniqueFormKey('login-allowed');
164
+ await request(app.getHttpServer())
165
+ .post(`/organisations/${owner.orgId}/forms`)
166
+ .set('Authorization', `Bearer ${owner.accessToken}`)
167
+ .send({ definition: authenticateDefinition(key) })
168
+ .expect(201);
169
+ const published = await request(app.getHttpServer())
170
+ .post(`/organisations/${owner.orgId}/forms/${key}/versions/1/publish`)
171
+ .set('Authorization', `Bearer ${owner.accessToken}`)
172
+ .expect(200);
173
+ expect(published.body.status).toBe('PUBLISHED');
174
+ });
175
+
176
+ it('gates public-access flips by forms.managePublicAccess and audits them', async () => {
177
+ const owner = await createUserAndOrg('def-public-access');
178
+ const member = await createMemberInOrg(owner.orgId, 'def-public-member', ['forms.read']);
179
+ const key = uniqueFormKey('flip');
180
+
181
+ await request(app.getHttpServer())
182
+ .post(`/organisations/${owner.orgId}/forms`)
183
+ .set('Authorization', `Bearer ${owner.accessToken}`)
184
+ .send({ definition: registrationDefinition(key) })
185
+ .expect(201);
186
+ await request(app.getHttpServer())
187
+ .post(`/organisations/${owner.orgId}/forms/${key}/versions/1/publish`)
188
+ .set('Authorization', `Bearer ${owner.accessToken}`)
189
+ .expect(200);
190
+
191
+ await request(app.getHttpServer())
192
+ .post(`/organisations/${owner.orgId}/forms/${key}/versions/1/public-access`)
193
+ .set('Authorization', `Bearer ${member.accessToken}`)
194
+ .send({ access: 'public' })
195
+ .expect(403);
196
+
197
+ await request(app.getHttpServer())
198
+ .post(`/organisations/${owner.orgId}/forms/${key}/versions/1/public-access`)
199
+ .set('Authorization', `Bearer ${owner.accessToken}`)
200
+ .send({ access: 'public' })
201
+ .expect(200);
202
+
203
+ const auditRow = await prisma.auditLog.findFirst({
204
+ where: { orgId: owner.orgId, action: 'forms.publicAccess.update' },
205
+ orderBy: { createdAt: 'desc' },
206
+ });
207
+ expect(auditRow).not.toBeNull();
208
+ const metadata = auditRow?.metadata as { previous?: string; next?: string };
209
+ expect(metadata.previous).toBe('private');
210
+ expect(metadata.next).toBe('public');
211
+ });
212
+
213
+ it('denies cross-org definition access (403 cross-tenant, 404 foreign key)', async () => {
214
+ const orgA = await createUserAndOrg('def-cross-a');
215
+ const orgB = await createUserAndOrg('def-cross-b');
216
+ const keyA = uniqueFormKey('cross');
217
+
218
+ await request(app.getHttpServer())
219
+ .post(`/organisations/${orgA.orgId}/forms`)
220
+ .set('Authorization', `Bearer ${orgA.accessToken}`)
221
+ .send({ definition: registrationDefinition(keyA) })
222
+ .expect(201);
223
+
224
+ // Org B's owner against org A's path: not a member, denied outright.
225
+ await request(app.getHttpServer())
226
+ .get(`/organisations/${orgA.orgId}/forms/${keyA}`)
227
+ .set('Authorization', `Bearer ${orgB.accessToken}`)
228
+ .expect(403);
229
+
230
+ // Org A's key under org B's own path: scoped lookup finds nothing.
231
+ await request(app.getHttpServer())
232
+ .get(`/organisations/${orgB.orgId}/forms/${keyA}`)
233
+ .set('Authorization', `Bearer ${orgB.accessToken}`)
234
+ .expect(404);
235
+ });
236
+
237
+ // ── Helpers ────────────────────────────────────────────────────────────────
238
+
239
+ function uniqueSuffix() {
240
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
241
+ }
242
+
243
+ function uniqueFormKey(label: string) {
244
+ return `${label}-${uniqueSuffix()}`.toLowerCase();
245
+ }
246
+
247
+ function registrationDefinition(key: string): Record<string, unknown> {
248
+ return {
249
+ key,
250
+ version: 1,
251
+ title: 'Event registration',
252
+ fields: [
253
+ {
254
+ type: 'text',
255
+ name: 'fullName',
256
+ label: 'Full name',
257
+ validators: { required: true, maxLength: 120 },
258
+ reportable: true,
259
+ },
260
+ {
261
+ type: 'email',
262
+ name: 'email',
263
+ label: 'Email address',
264
+ validators: { required: true },
265
+ reportable: true,
266
+ },
267
+ {
268
+ type: 'select',
269
+ name: 'ticketType',
270
+ label: 'Ticket type',
271
+ validators: { required: true, options: ['standard', 'student', 'vip'] },
272
+ reportable: true,
273
+ },
274
+ {
275
+ type: 'hidden',
276
+ name: 'registeredBy',
277
+ source: 'context',
278
+ path: 'user.id',
279
+ },
280
+ ],
281
+ actions: {
282
+ submit: ['validateAll', 'persist'],
283
+ saveDraft: ['persistDraft'],
284
+ },
285
+ };
286
+ }
287
+
288
+ function authenticateDefinition(key: string): Record<string, unknown> {
289
+ return {
290
+ key,
291
+ version: 1,
292
+ title: 'Sign in',
293
+ fields: [
294
+ { type: 'email', name: 'email', label: 'Email address', validators: { required: true } },
295
+ {
296
+ type: 'password',
297
+ name: 'password',
298
+ label: 'Password',
299
+ validators: { required: true, minLength: 8 },
300
+ },
301
+ ],
302
+ actions: {
303
+ submit: ['authenticate'],
304
+ },
305
+ };
306
+ }
307
+
308
+ async function setOrgSetting(accessToken: string, orgId: string, key: string, value: unknown) {
309
+ await request(app.getHttpServer())
310
+ .patch(`/organisations/${orgId}/settings`)
311
+ .set('Authorization', `Bearer ${accessToken}`)
312
+ .send({ key, value })
313
+ .expect(200);
314
+ }
315
+
316
+ async function createUserAndOrg(label: string) {
317
+ const user = await createUser(label);
318
+ const org = await createOrganisation(user.accessToken, label);
319
+
320
+ return {
321
+ ...user,
322
+ orgId: org.orgId,
323
+ membershipId: org.membershipId,
324
+ };
325
+ }
326
+
327
+ async function createUser(label: string) {
328
+ const suffix = uniqueSuffix();
329
+ const signup = await request(app.getHttpServer())
330
+ .post('/auth/signup')
331
+ .send({
332
+ email: `${label}-${suffix}@example.com`,
333
+ password: 'test-password-123',
334
+ displayName: label,
335
+ })
336
+ .expect(201);
337
+
338
+ return {
339
+ accessToken: signup.body.accessToken as string,
340
+ userId: signup.body.user.id as string,
341
+ };
342
+ }
343
+
344
+ async function createOrganisation(accessToken: string, label: string) {
345
+ const suffix = uniqueSuffix();
346
+ const org = await request(app.getHttpServer())
347
+ .post('/organisations')
348
+ .set('Authorization', `Bearer ${accessToken}`)
349
+ .send({
350
+ name: `${label} ${suffix}`,
351
+ slug: `${label}-${suffix}`,
352
+ })
353
+ .expect(201);
354
+
355
+ return {
356
+ orgId: org.body.organisation.id as string,
357
+ membershipId: org.body.membership.id as string,
358
+ };
359
+ }
360
+
361
+ async function createMemberInOrg(orgId: string, label: string, permissionKeys: string[]) {
362
+ const user = await createUser(label);
363
+ const permissions = await prisma.permission.findMany({
364
+ where: { key: { in: permissionKeys } },
365
+ select: { id: true },
366
+ });
367
+ expect(permissions).toHaveLength(permissionKeys.length);
368
+
369
+ const role = await prisma.role.create({
370
+ data: {
371
+ orgId,
372
+ name: `${label}-${uniqueSuffix()}`,
373
+ permissions: {
374
+ create: permissions.map((permission) => ({ permissionId: permission.id })),
375
+ },
376
+ },
377
+ select: { id: true },
378
+ });
379
+ const membership = await prisma.membership.create({
380
+ data: {
381
+ userId: user.userId,
382
+ orgId,
383
+ roleId: role.id,
384
+ },
385
+ select: { id: true, roleId: true },
386
+ });
387
+
388
+ return {
389
+ ...user,
390
+ membershipId: membership.id,
391
+ roleId: membership.roleId,
392
+ };
393
+ }
394
+ });