@ftisindia/create-app 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +28 -0
  3. package/template/README.md +51 -0
  4. package/template/_gitignore +6 -0
  5. package/template/_package.json +10 -1
  6. package/template/docs/FORMS.md +188 -0
  7. package/template/docs/FORMS_CHECKLIST.md +69 -0
  8. package/template/docs/REPORTS.md +255 -0
  9. package/template/docs/REPORTS_CHECKLIST.md +152 -0
  10. package/template/prisma/migrations/20260612000000_add_form_builder/migration.sql +147 -0
  11. package/template/prisma/migrations/20260613000000_add_report_builder/migration.sql +129 -0
  12. package/template/prisma/migrations/20260616000000_add_form_outbox_claimed_by/migration.sql +5 -0
  13. package/template/prisma/schema.prisma +289 -0
  14. package/template/scripts/export-openapi.ts +85 -0
  15. package/template/scripts/gen-form.mjs +149 -0
  16. package/template/scripts/push-form.ts +124 -0
  17. package/template/src/app.module.ts +30 -8
  18. package/template/src/common/dto/membership-response.dto.ts +1 -0
  19. package/template/src/common/dto/role-summary.dto.ts +3 -3
  20. package/template/src/common/dto/user-summary.dto.ts +3 -3
  21. package/template/src/config/env.validation.ts +28 -0
  22. package/template/src/config/forms.config.ts +13 -0
  23. package/template/src/config/index.ts +2 -0
  24. package/template/src/config/openapi.ts +12 -0
  25. package/template/src/config/reports-secret.ts +15 -0
  26. package/template/src/config/reports.config.ts +18 -0
  27. package/template/src/main.ts +3 -12
  28. package/template/src/modules/access-control/dto/access-control-response.dto.ts +3 -0
  29. package/template/src/modules/access-control/dto/current-access-control-response.dto.ts +5 -1
  30. package/template/src/modules/access-control/types/permission-key.ts +27 -0
  31. package/template/src/modules/access-control/types/route-permission-registry.ts +183 -0
  32. package/template/src/modules/audit/dto/audit-response.dto.ts +7 -3
  33. package/template/src/modules/auth/auth.module.ts +3 -1
  34. package/template/src/modules/auth/dto/auth-response.dto.ts +1 -1
  35. package/template/src/modules/forms/application/services/file-gc.service.ts +85 -0
  36. package/template/src/modules/forms/application/services/forms-definitions.service.ts +137 -0
  37. package/template/src/modules/forms/application/services/forms-error.mapper.ts +64 -0
  38. package/template/src/modules/forms/application/services/forms-export.service.ts +210 -0
  39. package/template/src/modules/forms/application/services/forms-files.service.ts +164 -0
  40. package/template/src/modules/forms/application/services/forms-public.service.ts +49 -0
  41. package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +53 -0
  42. package/template/src/modules/forms/application/services/forms-submissions.service.ts +103 -0
  43. package/template/src/modules/forms/application/services/handlers/authenticate.action.ts +37 -0
  44. package/template/src/modules/forms/application/services/handlers/logging-email.handler.ts +22 -0
  45. package/template/src/modules/forms/application/services/handlers/send-confirmation-email.action.ts +40 -0
  46. package/template/src/modules/forms/application/services/handlers/webhook-delivery.transport.ts +319 -0
  47. package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +89 -0
  48. package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +131 -0
  49. package/template/src/modules/forms/dto/create-form-definition.dto.ts +12 -0
  50. package/template/src/modules/forms/dto/data-source-response.dto.ts +19 -0
  51. package/template/src/modules/forms/dto/export-submissions-query.dto.ts +33 -0
  52. package/template/src/modules/forms/dto/file-upload-response.dto.ts +24 -0
  53. package/template/src/modules/forms/dto/form-definition-response.dto.ts +50 -0
  54. package/template/src/modules/forms/dto/form-render-response.dto.ts +17 -0
  55. package/template/src/modules/forms/dto/list-form-definitions-query.dto.ts +10 -0
  56. package/template/src/modules/forms/dto/list-submissions-query.dto.ts +10 -0
  57. package/template/src/modules/forms/dto/public-submit-form.dto.ts +24 -0
  58. package/template/src/modules/forms/dto/set-public-access.dto.ts +8 -0
  59. package/template/src/modules/forms/dto/submission-response.dto.ts +99 -0
  60. package/template/src/modules/forms/dto/submit-form.dto.ts +50 -0
  61. package/template/src/modules/forms/dto/update-form-definition.dto.ts +12 -0
  62. package/template/src/modules/forms/dto/upload-file-query.dto.ts +33 -0
  63. package/template/src/modules/forms/dto/validate-submission.dto.ts +22 -0
  64. package/template/src/modules/forms/examples/abstract-submission.form.json +80 -0
  65. package/template/src/modules/forms/examples/login.form.json +24 -0
  66. package/template/src/modules/forms/examples/registration.form.json +44 -0
  67. package/template/src/modules/forms/forms.module.ts +228 -0
  68. package/template/src/modules/forms/forms.tokens.ts +6 -0
  69. package/template/src/modules/forms/infrastructure/audit-sink.adapter.ts +30 -0
  70. package/template/src/modules/forms/infrastructure/casl-forms-authorization.ts +31 -0
  71. package/template/src/modules/forms/infrastructure/prisma-tx-runner.ts +17 -0
  72. package/template/src/modules/forms/infrastructure/registry/form-extension.decorators.ts +17 -0
  73. package/template/src/modules/forms/infrastructure/registry/registry-bootstrap.service.ts +82 -0
  74. package/template/src/modules/forms/infrastructure/request-forms-context.ts +60 -0
  75. package/template/src/modules/forms/infrastructure/schema-check/forms-schema-check.service.ts +76 -0
  76. package/template/src/modules/forms/infrastructure/storage/local-disk-storage.adapter.ts +43 -0
  77. package/template/src/modules/forms/infrastructure/stores/index.ts +5 -0
  78. package/template/src/modules/forms/infrastructure/stores/prisma-action-log.store.ts +37 -0
  79. package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +108 -0
  80. package/template/src/modules/forms/infrastructure/stores/prisma-form-definition.store.ts +147 -0
  81. package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +156 -0
  82. package/template/src/modules/forms/infrastructure/stores/prisma-submission.store.ts +164 -0
  83. package/template/src/modules/forms/presentation/forms-data-sources.controller.ts +58 -0
  84. package/template/src/modules/forms/presentation/forms-definitions.controller.ts +191 -0
  85. package/template/src/modules/forms/presentation/forms-files.controller.ts +79 -0
  86. package/template/src/modules/forms/presentation/forms-submissions.controller.ts +154 -0
  87. package/template/src/modules/forms/presentation/forms-upload.interceptor.ts +33 -0
  88. package/template/src/modules/forms/presentation/public-forms.controller.ts +51 -0
  89. package/template/src/modules/invitations/dto/invitation-response.dto.ts +4 -0
  90. package/template/src/modules/organisations/dto/organisation-response.dto.ts +1 -0
  91. package/template/src/modules/reports/application/services/reports-actions.service.ts +54 -0
  92. package/template/src/modules/reports/application/services/reports-definitions.service.ts +66 -0
  93. package/template/src/modules/reports/application/services/reports-error.mapper.ts +97 -0
  94. package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +205 -0
  95. package/template/src/modules/reports/application/services/reports-exports.service.ts +78 -0
  96. package/template/src/modules/reports/application/services/reports-queries.service.ts +35 -0
  97. package/template/src/modules/reports/application/services/reports-settings-reader.service.ts +49 -0
  98. package/template/src/modules/reports/application/services/reports-views.service.ts +79 -0
  99. package/template/src/modules/reports/dto/action-result-response.dto.ts +21 -0
  100. package/template/src/modules/reports/dto/create-report-definition.dto.ts +86 -0
  101. package/template/src/modules/reports/dto/create-saved-view.dto.ts +26 -0
  102. package/template/src/modules/reports/dto/execute-action.dto.ts +71 -0
  103. package/template/src/modules/reports/dto/export-job-response.dto.ts +60 -0
  104. package/template/src/modules/reports/dto/export-request.dto.ts +34 -0
  105. package/template/src/modules/reports/dto/list-reports-query.dto.ts +10 -0
  106. package/template/src/modules/reports/dto/list-views-query.dto.ts +17 -0
  107. package/template/src/modules/reports/dto/prepare-action-response.dto.ts +14 -0
  108. package/template/src/modules/reports/dto/prepare-action.dto.ts +27 -0
  109. package/template/src/modules/reports/dto/query-response.dto.ts +64 -0
  110. package/template/src/modules/reports/dto/query-spec.dto.ts +120 -0
  111. package/template/src/modules/reports/dto/report-definition-response.dto.ts +64 -0
  112. package/template/src/modules/reports/dto/report-meta-query.dto.ts +16 -0
  113. package/template/src/modules/reports/dto/report-meta-response.dto.ts +113 -0
  114. package/template/src/modules/reports/dto/saved-view-response.dto.ts +66 -0
  115. package/template/src/modules/reports/dto/update-report-definition.dto.ts +9 -0
  116. package/template/src/modules/reports/dto/update-saved-view.dto.ts +27 -0
  117. package/template/src/modules/reports/examples/abstract-review-board.report.json +54 -0
  118. package/template/src/modules/reports/examples/org-members.report.json +55 -0
  119. package/template/src/modules/reports/infrastructure/audit-sink.adapter.ts +31 -0
  120. package/template/src/modules/reports/infrastructure/casl-reports-authorization.ts +39 -0
  121. package/template/src/modules/reports/infrastructure/forms-adapter/form-report-source.adapter.ts +292 -0
  122. package/template/src/modules/reports/infrastructure/forms-adapter/form-row-actions.ts +171 -0
  123. package/template/src/modules/reports/infrastructure/forms-adapter/forms-bridge-bootstrap.service.ts +32 -0
  124. package/template/src/modules/reports/infrastructure/prisma-catalog.adapter.ts +95 -0
  125. package/template/src/modules/reports/infrastructure/prisma-query-executor.ts +103 -0
  126. package/template/src/modules/reports/infrastructure/prisma-snapshot-runner.ts +47 -0
  127. package/template/src/modules/reports/infrastructure/prisma-tx-runner.ts +18 -0
  128. package/template/src/modules/reports/infrastructure/registry/registry-bootstrap.service.ts +61 -0
  129. package/template/src/modules/reports/infrastructure/registry/report-extension.decorators.ts +14 -0
  130. package/template/src/modules/reports/infrastructure/reports-job-queue.adapter.ts +28 -0
  131. package/template/src/modules/reports/infrastructure/request-reports-context.ts +42 -0
  132. package/template/src/modules/reports/infrastructure/schema-check/reports-schema-check.service.ts +116 -0
  133. package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +92 -0
  134. package/template/src/modules/reports/infrastructure/stores/index.ts +5 -0
  135. package/template/src/modules/reports/infrastructure/stores/prisma-bulk-action-run.store.ts +89 -0
  136. package/template/src/modules/reports/infrastructure/stores/prisma-export-job.store.ts +93 -0
  137. package/template/src/modules/reports/infrastructure/stores/prisma-report-definition.store.ts +171 -0
  138. package/template/src/modules/reports/infrastructure/stores/prisma-row-tag.store.ts +110 -0
  139. package/template/src/modules/reports/infrastructure/stores/prisma-saved-view.store.ts +144 -0
  140. package/template/src/modules/reports/presentation/reports-actions.controller.ts +83 -0
  141. package/template/src/modules/reports/presentation/reports-definitions.controller.ts +156 -0
  142. package/template/src/modules/reports/presentation/reports-export-jobs.controller.ts +61 -0
  143. package/template/src/modules/reports/presentation/reports-export.controller.ts +76 -0
  144. package/template/src/modules/reports/presentation/reports-query.controller.ts +52 -0
  145. package/template/src/modules/reports/presentation/reports-views.controller.ts +140 -0
  146. package/template/src/modules/reports/reports-forms.module.ts +33 -0
  147. package/template/src/modules/reports/reports.module.ts +335 -0
  148. package/template/src/modules/reports/reports.tokens.ts +11 -0
  149. package/template/src/modules/reports/sources/org-members.source.ts +112 -0
  150. package/template/src/modules/settings/types/setting-definitions.ts +94 -0
  151. package/template/test/forms-captcha.e2e-spec.ts +163 -0
  152. package/template/test/forms-definitions.e2e-spec.ts +394 -0
  153. package/template/test/forms-export.e2e-spec.ts +390 -0
  154. package/template/test/forms-files.e2e-spec.ts +345 -0
  155. package/template/test/forms-outbox.e2e-spec.ts +570 -0
  156. package/template/test/forms-permission-sync.spec.ts +27 -0
  157. package/template/test/forms-public.e2e-spec.ts +293 -0
  158. package/template/test/forms-schema-check.e2e-spec.ts +65 -0
  159. package/template/test/forms-submissions.e2e-spec.ts +500 -0
  160. package/template/test/forms-throttling.e2e-spec.ts +146 -0
  161. package/template/test/forms-webhooks.e2e-spec.ts +403 -0
  162. package/template/test/jest-e2e.json +1 -0
  163. package/template/test/reports-advanced.e2e-spec.ts +381 -0
  164. package/template/test/reports-permission-sync.spec.ts +30 -0
  165. package/template/test/reports-query.e2e-spec.ts +402 -0
  166. package/template/test/reports-tiers.e2e-spec.ts +343 -0
  167. package/template/test/route-registry.validator.spec.ts +22 -0
@@ -0,0 +1,345 @@
1
+ import { INestApplication, ValidationPipe } from '@nestjs/common';
2
+ import { Test } from '@nestjs/testing';
3
+ import { 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
+ import { FileGcService } from '../src/modules/forms/application/services/file-gc.service';
8
+
9
+ const PDF_BYTES = Buffer.from('%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF');
10
+ const PNG_BYTES = Buffer.from([
11
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
12
+ ]);
13
+
14
+ describe('Forms file uploads (e2e)', () => {
15
+ let app: INestApplication;
16
+ let prisma: PrismaService;
17
+ let owner: { accessToken: string; userId: string; orgId: string };
18
+ let formKey: string;
19
+
20
+ beforeAll(async () => {
21
+ process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL;
22
+ process.env.JWT_SECRET ||= 'test-jwt-secret-at-least-16-chars';
23
+ process.env.AUTH_GOOGLE_ENABLED ||= 'false';
24
+ process.env.AUTH_EMAIL_PASSWORD_ENABLED ||= 'true';
25
+ process.env.NODE_ENV = 'test';
26
+ process.env.FORMS_OUTBOX_ENABLED = 'false';
27
+ process.env.FORMS_FILE_STORAGE_DIR = './var/test-uploads';
28
+
29
+ const { AppModule } = await import('../src/app.module');
30
+ const moduleRef = await Test.createTestingModule({
31
+ imports: [AppModule],
32
+ }).compile();
33
+
34
+ app = moduleRef.createNestApplication();
35
+ app.useGlobalFilters(new HttpExceptionFilter());
36
+ app.useGlobalPipes(
37
+ new ValidationPipe({
38
+ forbidNonWhitelisted: true,
39
+ transform: true,
40
+ whitelist: true,
41
+ }),
42
+ );
43
+ await app.init();
44
+ prisma = app.get(PrismaService);
45
+
46
+ // The abstract-like definition binds a select to this source; register the
47
+ // stub BEFORE creating the definition so save-time lint passes.
48
+ app.get(DataSourceRegistry).register({
49
+ key: 'conference-tracks',
50
+ fetch: () => Promise.resolve([
51
+ { value: 'ai', label: 'Artificial Intelligence' },
52
+ { value: 'ml', label: 'Machine Learning' },
53
+ ]),
54
+ });
55
+
56
+ owner = await createUserAndOrg('files-owner');
57
+ formKey = await publishDefinition(abstractDefinition(uniqueFormKey('abstract')));
58
+ });
59
+
60
+ afterAll(async () => {
61
+ await app.close();
62
+ });
63
+
64
+ it('rejects mismatched content (415) and accepts a sniffed PDF', async () => {
65
+ await request(app.getHttpServer())
66
+ .post(`/organisations/${owner.orgId}/forms/${formKey}/files`)
67
+ .query({ field: 'manuscript' })
68
+ .set('Authorization', `Bearer ${owner.accessToken}`)
69
+ .attach('file', PNG_BYTES, 'sneaky.pdf')
70
+ .expect(415);
71
+
72
+ const upload = await request(app.getHttpServer())
73
+ .post(`/organisations/${owner.orgId}/forms/${formKey}/files`)
74
+ .query({ field: 'manuscript' })
75
+ .set('Authorization', `Bearer ${owner.accessToken}`)
76
+ .attach('file', PDF_BYTES, 'manuscript.pdf');
77
+
78
+ expect([200, 201]).toContain(upload.status);
79
+ expect(upload.body.fileId).toBeTruthy();
80
+ expect(upload.body.mimeType).toBe('application/pdf');
81
+ expect(upload.body.status).toBe('TEMPORARY');
82
+ expect(upload.body.checksum).toMatch(/^[0-9a-f]{64}$/);
83
+ });
84
+
85
+ it('validates uploads against the requested version or draft submission stamp', async () => {
86
+ const key = await publishDefinition(abstractDefinition(uniqueFormKey('versioned-file')));
87
+ const draft = await request(app.getHttpServer())
88
+ .post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
89
+ .set('Authorization', `Bearer ${owner.accessToken}`)
90
+ .send({ mode: 'draft', data: { title: 'Versioned upload', track: 'ai' } })
91
+ .expect(200);
92
+
93
+ const createdV2 = await request(app.getHttpServer())
94
+ .post(`/organisations/${owner.orgId}/forms`)
95
+ .set('Authorization', `Bearer ${owner.accessToken}`)
96
+ .send({ definition: abstractDefinition(key, ['image/png']) })
97
+ .expect(201);
98
+ await request(app.getHttpServer())
99
+ .post(
100
+ `/organisations/${owner.orgId}/forms/${key}/versions/${createdV2.body.version as number}/publish`,
101
+ )
102
+ .set('Authorization', `Bearer ${owner.accessToken}`)
103
+ .expect(200);
104
+
105
+ await request(app.getHttpServer())
106
+ .post(`/organisations/${owner.orgId}/forms/${key}/files`)
107
+ .query({ field: 'manuscript' })
108
+ .set('Authorization', `Bearer ${owner.accessToken}`)
109
+ .attach('file', PDF_BYTES, 'manuscript.pdf')
110
+ .expect(415);
111
+
112
+ const pngUpload = await request(app.getHttpServer())
113
+ .post(`/organisations/${owner.orgId}/forms/${key}/files`)
114
+ .query({ field: 'manuscript', version: createdV2.body.version as number })
115
+ .set('Authorization', `Bearer ${owner.accessToken}`)
116
+ .attach('file', PNG_BYTES, 'manuscript.png');
117
+ expect([200, 201]).toContain(pngUpload.status);
118
+
119
+ await request(app.getHttpServer())
120
+ .post(`/organisations/${owner.orgId}/forms/${key}/files`)
121
+ .query({
122
+ field: 'manuscript',
123
+ submissionId: draft.body.submissionId as string,
124
+ version: createdV2.body.version as number,
125
+ })
126
+ .set('Authorization', `Bearer ${owner.accessToken}`)
127
+ .attach('file', PNG_BYTES, 'manuscript.png')
128
+ .expect(409);
129
+ });
130
+
131
+ it('links the referenced file to the submission (TEMPORARY -> LINKED)', async () => {
132
+ const fileId = await uploadPdf(owner.accessToken);
133
+
134
+ const submitted = await request(app.getHttpServer())
135
+ .post(`/organisations/${owner.orgId}/forms/${formKey}/submissions`)
136
+ .set('Authorization', `Bearer ${owner.accessToken}`)
137
+ .send({
138
+ mode: 'submit',
139
+ data: { title: 'On Linking Files', track: 'ai', manuscript: { fileId } },
140
+ })
141
+ .expect(200);
142
+
143
+ const submissionId = submitted.body.submissionId as string;
144
+ expect(submissionId).toBeTruthy();
145
+
146
+ const row = await prisma.uploadedFile.findUnique({ where: { id: fileId } });
147
+ expect(row?.status).toBe('LINKED');
148
+ expect(row?.submissionId).toBe(submissionId);
149
+ });
150
+
151
+ it('rejects another member referencing a file they did not upload (IDOR)', async () => {
152
+ const fileId = await uploadPdf(owner.accessToken);
153
+ const member = await createMemberInOrg(owner.orgId, 'files-member', [
154
+ 'formSubmissions.create',
155
+ ]);
156
+
157
+ const response = await request(app.getHttpServer())
158
+ .post(`/organisations/${owner.orgId}/forms/${formKey}/submissions`)
159
+ .set('Authorization', `Bearer ${member.accessToken}`)
160
+ .send({
161
+ mode: 'submit',
162
+ data: { title: 'Stolen Reference', track: 'ml', manuscript: { fileId } },
163
+ })
164
+ .expect(422);
165
+
166
+ const errors = response.body.error.details.errors as Array<{ code: string; path: string }>;
167
+ expect(errors.some((error) => error.code === 'FILE_REFERENCE')).toBe(true);
168
+
169
+ // The file is untouched.
170
+ const row = await prisma.uploadedFile.findUnique({ where: { id: fileId } });
171
+ expect(row?.status).toBe('TEMPORARY');
172
+ });
173
+
174
+ it('garbage-collects orphaned TEMPORARY uploads past the TTL', async () => {
175
+ const fileId = await uploadPdf(owner.accessToken);
176
+
177
+ await prisma.uploadedFile.update({
178
+ where: { id: fileId },
179
+ data: { createdAt: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000) },
180
+ });
181
+
182
+ const removed = await app.get(FileGcService).runOnce();
183
+ expect(removed).toBeGreaterThanOrEqual(1);
184
+
185
+ const row = await prisma.uploadedFile.findUnique({ where: { id: fileId } });
186
+ expect(row).toBeNull();
187
+ });
188
+
189
+ it('refuses to flip a definition with file fields to public access', async () => {
190
+ const response = await request(app.getHttpServer())
191
+ .post(`/organisations/${owner.orgId}/forms/${formKey}/versions/1/public-access`)
192
+ .set('Authorization', `Bearer ${owner.accessToken}`)
193
+ .send({ access: 'public' })
194
+ .expect(422);
195
+
196
+ const issues = response.body.error.details.issues as Array<{ code: string }>;
197
+ expect(issues.map((issue) => issue.code)).toContain('PUBLIC_FILE_FIELD');
198
+ });
199
+
200
+ // ── Helpers ────────────────────────────────────────────────────────────────
201
+
202
+ function uniqueSuffix() {
203
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
204
+ }
205
+
206
+ function uniqueFormKey(label: string) {
207
+ return `${label}-${uniqueSuffix()}`.toLowerCase();
208
+ }
209
+
210
+ function abstractDefinition(
211
+ key: string,
212
+ accept: string[] = ['application/pdf'],
213
+ ): Record<string, unknown> {
214
+ return {
215
+ key,
216
+ version: 1,
217
+ title: 'Scientific abstract submission',
218
+ fields: [
219
+ {
220
+ type: 'text',
221
+ name: 'title',
222
+ label: 'Abstract title',
223
+ validators: { required: true, maxLength: 200 },
224
+ reportable: true,
225
+ },
226
+ {
227
+ type: 'select',
228
+ name: 'track',
229
+ label: 'Conference track',
230
+ dataSource: { key: 'conference-tracks' },
231
+ validators: { required: true },
232
+ reportable: true,
233
+ },
234
+ {
235
+ type: 'file',
236
+ name: 'manuscript',
237
+ label: 'Full manuscript (PDF)',
238
+ accept,
239
+ maxSizeMb: 5,
240
+ },
241
+ ],
242
+ actions: {
243
+ submit: ['validateAll', 'persist'],
244
+ saveDraft: ['persistDraft'],
245
+ },
246
+ };
247
+ }
248
+
249
+ async function uploadPdf(accessToken: string): Promise<string> {
250
+ const upload = await request(app.getHttpServer())
251
+ .post(`/organisations/${owner.orgId}/forms/${formKey}/files`)
252
+ .query({ field: 'manuscript' })
253
+ .set('Authorization', `Bearer ${accessToken}`)
254
+ .attach('file', PDF_BYTES, 'manuscript.pdf');
255
+
256
+ expect([200, 201]).toContain(upload.status);
257
+ return upload.body.fileId as string;
258
+ }
259
+
260
+ async function publishDefinition(definition: Record<string, unknown>): Promise<string> {
261
+ const created = await request(app.getHttpServer())
262
+ .post(`/organisations/${owner.orgId}/forms`)
263
+ .set('Authorization', `Bearer ${owner.accessToken}`)
264
+ .send({ definition })
265
+ .expect(201);
266
+
267
+ await request(app.getHttpServer())
268
+ .post(
269
+ `/organisations/${owner.orgId}/forms/${created.body.key as string}/versions/${created.body.version as number}/publish`,
270
+ )
271
+ .set('Authorization', `Bearer ${owner.accessToken}`)
272
+ .expect(200);
273
+
274
+ return created.body.key as string;
275
+ }
276
+
277
+ async function createUser(label: string) {
278
+ const suffix = uniqueSuffix();
279
+ const signup = await request(app.getHttpServer())
280
+ .post('/auth/signup')
281
+ .send({
282
+ email: `${label}-${suffix}@example.com`,
283
+ password: 'test-password-123',
284
+ displayName: label,
285
+ })
286
+ .expect(201);
287
+
288
+ return {
289
+ accessToken: signup.body.accessToken as string,
290
+ userId: signup.body.user.id as string,
291
+ };
292
+ }
293
+
294
+ async function createUserAndOrg(label: string) {
295
+ const user = await createUser(label);
296
+ const suffix = uniqueSuffix();
297
+ const org = await request(app.getHttpServer())
298
+ .post('/organisations')
299
+ .set('Authorization', `Bearer ${user.accessToken}`)
300
+ .send({
301
+ name: `${label} ${suffix}`,
302
+ slug: `${label}-${suffix}`,
303
+ })
304
+ .expect(201);
305
+
306
+ return {
307
+ ...user,
308
+ orgId: org.body.organisation.id as string,
309
+ };
310
+ }
311
+
312
+ async function createMemberInOrg(orgId: string, label: string, permissionKeys: string[]) {
313
+ const user = await createUser(label);
314
+ const permissions = await prisma.permission.findMany({
315
+ where: { key: { in: permissionKeys } },
316
+ select: { id: true },
317
+ });
318
+ expect(permissions).toHaveLength(permissionKeys.length);
319
+
320
+ const role = await prisma.role.create({
321
+ data: {
322
+ orgId,
323
+ name: `${label}-${uniqueSuffix()}`,
324
+ permissions: {
325
+ create: permissions.map((permission) => ({ permissionId: permission.id })),
326
+ },
327
+ },
328
+ select: { id: true },
329
+ });
330
+ const membership = await prisma.membership.create({
331
+ data: {
332
+ userId: user.userId,
333
+ orgId,
334
+ roleId: role.id,
335
+ },
336
+ select: { id: true, roleId: true },
337
+ });
338
+
339
+ return {
340
+ ...user,
341
+ membershipId: membership.id,
342
+ roleId: membership.roleId,
343
+ };
344
+ }
345
+ });