@ftisindia/create-app 0.1.6 → 0.3.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 (45) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +3 -0
  3. package/template/_package.json +7 -3
  4. package/template/docs/FORMS.md +87 -18
  5. package/template/docs/FORMS_CHECKLIST.md +36 -26
  6. package/template/docs/REPORTS.md +22 -4
  7. package/template/docs/REPORTS_CHECKLIST.md +150 -95
  8. package/template/prisma/migrations/20260616000000_add_form_outbox_claimed_by/migration.sql +5 -0
  9. package/template/prisma/migrations/20260625000000_form_builder_public_uploads/migration.sql +7 -0
  10. package/template/prisma/schema.prisma +13 -6
  11. package/template/scripts/push-report.ts +123 -0
  12. package/template/src/app.module.ts +4 -3
  13. package/template/src/common/swagger/api-error-responses.ts +8 -2
  14. package/template/src/config/env.validation.ts +3 -0
  15. package/template/src/config/forms.config.ts +1 -0
  16. package/template/src/config/reports.config.ts +2 -0
  17. package/template/src/modules/forms/application/services/data-sources/conference-tracks.data-source.ts +32 -0
  18. package/template/src/modules/forms/application/services/forms-files.service.ts +143 -39
  19. package/template/src/modules/forms/application/services/forms-public.service.ts +2 -1
  20. package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +5 -3
  21. package/template/src/modules/forms/application/services/handlers/webhook-delivery.transport.ts +319 -0
  22. package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +64 -16
  23. package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +40 -18
  24. package/template/src/modules/forms/dto/public-file-upload-response.dto.ts +10 -0
  25. package/template/src/modules/forms/dto/public-submit-form.dto.ts +9 -0
  26. package/template/src/modules/forms/forms.module.ts +12 -2
  27. package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +43 -3
  28. package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +82 -59
  29. package/template/src/modules/forms/presentation/public-forms-files.controller.ts +66 -0
  30. package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +81 -0
  31. package/template/src/modules/reports/application/services/reports-exports.service.ts +6 -2
  32. package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +43 -30
  33. package/template/src/modules/settings/types/setting-definitions.ts +4 -0
  34. package/template/test/forms-captcha.e2e-spec.ts +163 -0
  35. package/template/test/forms-files.e2e-spec.ts +42 -20
  36. package/template/test/forms-outbox.e2e-spec.ts +271 -10
  37. package/template/test/forms-public.e2e-spec.ts +24 -0
  38. package/template/test/forms-submissions.e2e-spec.ts +2 -11
  39. package/template/test/forms-throttling.e2e-spec.ts +146 -0
  40. package/template/test/forms-webhooks.e2e-spec.ts +150 -8
  41. package/template/test/jest-e2e.json +1 -0
  42. package/template/test/reports-advanced.e2e-spec.ts +13 -0
  43. package/template/test/reports-query.e2e-spec.ts +52 -0
  44. package/template/test/reports-tiers.e2e-spec.ts +106 -20
  45. package/template/test/route-registry.validator.spec.ts +4 -2
@@ -8,8 +8,11 @@ import { ConfigService } from '@nestjs/config';
8
8
  import { ReportExportService } from '@ftisindia/report-builder';
9
9
  import { PrismaService } from '../../../../database/prisma/prisma.service';
10
10
  import { RequestContextService } from '../../../request-context/application/services/request-context.service';
11
+ import { LocalDiskReportExportStorage } from '../../infrastructure/storage/local-disk-export-storage.adapter';
11
12
  import { RequestReportsContext } from '../../infrastructure/request-reports-context';
12
13
 
14
+ const RETENTION_BATCH_SIZE = 100;
15
+
13
16
  /**
14
17
  * Reports-OWNED async export worker (report design §9). Instead of riding the
15
18
  * forms transactional outbox, it polls the engine's own `ReportExportJob`
@@ -25,12 +28,15 @@ import { RequestReportsContext } from '../../infrastructure/request-reports-cont
25
28
  export class ReportsExportDispatcherService implements OnApplicationBootstrap, OnModuleDestroy {
26
29
  private readonly logger = new Logger('ReportsExportWorker');
27
30
  private timer?: NodeJS.Timeout;
31
+ private retentionTimer?: NodeJS.Timeout;
28
32
  private ticking = false;
33
+ private sweepingRetention = false;
29
34
 
30
35
  constructor(
31
36
  private readonly config: ConfigService,
32
37
  private readonly prisma: PrismaService,
33
38
  private readonly exports: ReportExportService,
39
+ private readonly storage: LocalDiskReportExportStorage,
34
40
  private readonly requestContext: RequestContextService,
35
41
  private readonly reportsContext: RequestReportsContext,
36
42
  ) {}
@@ -44,11 +50,22 @@ export class ReportsExportDispatcherService implements OnApplicationBootstrap, O
44
50
  void this.tick();
45
51
  }, pollMs);
46
52
  this.timer.unref?.();
53
+
54
+ const retentionSweepMs = this.config.get<number>('reports.exportRetentionSweepMs') ?? 3_600_000;
55
+ this.retentionTimer = setInterval(() => {
56
+ void this.sweepRetention();
57
+ }, retentionSweepMs);
58
+ this.retentionTimer.unref?.();
47
59
  }
48
60
 
49
61
  onModuleDestroy(): void {
50
62
  if (this.timer) {
51
63
  clearInterval(this.timer);
64
+ this.timer = undefined;
65
+ }
66
+ if (this.retentionTimer) {
67
+ clearInterval(this.retentionTimer);
68
+ this.retentionTimer = undefined;
52
69
  }
53
70
  }
54
71
 
@@ -103,6 +120,50 @@ export class ReportsExportDispatcherService implements OnApplicationBootstrap, O
103
120
  return { claimed, done, failed };
104
121
  }
105
122
 
123
+ /** Delete expired local export files and clear their job fileId. */
124
+ async cleanupExpiredFiles(now = new Date()): Promise<number> {
125
+ const retentionDays = this.config.get<number>('reports.exportRetentionDays') ?? 7;
126
+ if (retentionDays <= 0) {
127
+ return 0;
128
+ }
129
+
130
+ const cutoff = new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1000);
131
+ const jobs = await this.prisma.reportExportJob.findMany({
132
+ where: {
133
+ status: 'DONE',
134
+ fileId: { not: null },
135
+ updatedAt: { lt: cutoff },
136
+ },
137
+ orderBy: { updatedAt: 'asc' },
138
+ take: RETENTION_BATCH_SIZE,
139
+ select: { id: true, orgId: true, fileId: true },
140
+ });
141
+
142
+ let removed = 0;
143
+ for (const job of jobs) {
144
+ if (job.fileId === null) {
145
+ continue;
146
+ }
147
+ try {
148
+ await this.storage.delete(job.fileId);
149
+ const result = await this.prisma.reportExportJob.updateMany({
150
+ where: { id: job.id, orgId: job.orgId, fileId: job.fileId },
151
+ data: {
152
+ fileId: null,
153
+ error: `Export file expired after ${retentionDays} day(s).`,
154
+ },
155
+ });
156
+ removed += result.count;
157
+ } catch (error) {
158
+ this.logger.warn(
159
+ `Failed to expire export file for job ${job.id}: ${error instanceof Error ? error.message : String(error)}`,
160
+ );
161
+ }
162
+ }
163
+
164
+ return removed;
165
+ }
166
+
106
167
  private async tick(): Promise<void> {
107
168
  if (this.ticking) {
108
169
  return;
@@ -121,4 +182,24 @@ export class ReportsExportDispatcherService implements OnApplicationBootstrap, O
121
182
  this.ticking = false;
122
183
  }
123
184
  }
185
+
186
+ private async sweepRetention(): Promise<void> {
187
+ if (this.sweepingRetention) {
188
+ return;
189
+ }
190
+ this.sweepingRetention = true;
191
+ try {
192
+ const removed = await this.cleanupExpiredFiles();
193
+ if (removed > 0) {
194
+ this.logger.log(`Expired ${removed} report export file(s).`);
195
+ }
196
+ } catch (error) {
197
+ this.logger.error(
198
+ 'Export retention sweep failed.',
199
+ error instanceof Error ? error.stack : String(error),
200
+ );
201
+ } finally {
202
+ this.sweepingRetention = false;
203
+ }
204
+ }
124
205
  }
@@ -61,12 +61,16 @@ export class ReportsExportsService {
61
61
  const job = await withReportsErrorMapping(() =>
62
62
  this.engine.getJob(orgId, jobId, this.reportsContext),
63
63
  );
64
- if (job.status !== 'DONE' || job.fileId === null) {
64
+ if (job.status !== 'DONE') {
65
65
  throw new NotFoundException('The export is not ready for download.');
66
66
  }
67
+ const fileId = job.fileId;
68
+ if (fileId === null) {
69
+ throw new NotFoundException('The export file is no longer available.');
70
+ }
67
71
  const format = job.spec.format;
68
72
  return {
69
- stream: this.storage.read(job.fileId),
73
+ stream: this.storage.read(fileId),
70
74
  fileName: `${job.reportKey}-v${job.reportVersion}.${format}`,
71
75
  mimeType: FORMAT_MIME[format] ?? 'application/octet-stream',
72
76
  };
@@ -1,25 +1,25 @@
1
- import { createReadStream } from 'node:fs';
2
- import { mkdir, writeFile } from 'node:fs/promises';
3
- import { dirname, join, resolve, sep } from 'node:path';
4
1
  import { randomUUID } from 'node:crypto';
5
- import type { Readable } from 'node:stream';
2
+ import { createReadStream, createWriteStream } from 'node:fs';
3
+ import { mkdir, rm } from 'node:fs/promises';
4
+ import { dirname, join, resolve, sep } from 'node:path';
5
+ import { Readable } from 'node:stream';
6
+ import { pipeline } from 'node:stream/promises';
6
7
  import { Injectable } from '@nestjs/common';
7
8
  import { ConfigService } from '@nestjs/config';
8
9
  import type { ExportFileSink } from '@ftisindia/report-builder';
9
10
 
10
11
  /**
11
- * Reports-OWNED export file storage (report design §9). The reports module
12
- * keeps its own org-scoped export directory so it does not depend on the form
13
- * builder's UploadedFile machinery the engine core's standalone guarantee
14
- * (§3.2) extends to the glue: ReportsModule needs no FormsModule.
15
- *
16
- * Files land under `<exportStorageDir>/<orgId>/<uuid>.<ext>`; the returned
17
- * `fileId` is that storage key, stored on the ReportExportJob row and used by
18
- * the download route. The key is server-generated (no user input), and reads
19
- * are guarded against escaping the configured directory.
12
+ * Reports-owned export file storage. Files land under
13
+ * `<exportStorageDir>/<orgId>/<uuid>.<ext>`; the returned fileId is that
14
+ * storage key, stored on the ReportExportJob row and used by the download
15
+ * route. The key is server-generated, and reads/deletes are guarded against
16
+ * escaping the configured directory.
20
17
  *
21
- * Swap this for an S3/GCS-backed ExportFileSink without touching the engine or
22
- * the rest of the glue.
18
+ * Operational boundary: this local adapter is correct for a single app
19
+ * instance or for multiple instances sharing the same mounted volume. A
20
+ * horizontally scaled deployment without shared storage must rebind this
21
+ * provider to an object-storage adapter so any node can download a file
22
+ * written by any worker.
23
23
  */
24
24
  @Injectable()
25
25
  export class LocalDiskReportExportStorage implements ExportFileSink {
@@ -33,30 +33,35 @@ export class LocalDiskReportExportStorage implements ExportFileSink {
33
33
  opts: { orgId: string; ownerId: string; fileName: string; mimeType: string },
34
34
  chunks: AsyncIterable<Uint8Array>,
35
35
  ): Promise<{ fileId: string; size: number }> {
36
- // Collected into one buffer before writing. Bounded by the engine's export
37
- // caps (row limits + the snapshot duration bound, §9) long before memory
38
- // becomes a concern.
39
- const collected: Buffer[] = [];
40
- for await (const chunk of chunks) {
41
- collected.push(Buffer.from(chunk));
42
- }
43
- const bytes = Buffer.concat(collected);
44
-
45
36
  const safeOrg = sanitizeSegment(opts.orgId);
46
37
  const storageKey = `${safeOrg}/${randomUUID()}${extensionOf(opts.fileName)}`;
47
38
  const absolute = this.resolveKey(storageKey);
48
39
  await mkdir(dirname(absolute), { recursive: true });
49
- await writeFile(absolute, bytes);
50
40
 
51
- return { fileId: storageKey, size: bytes.byteLength };
41
+ let size = 0;
42
+ try {
43
+ await pipeline(
44
+ Readable.from(countingChunks(chunks, (byteLength) => {
45
+ size += byteLength;
46
+ })),
47
+ createWriteStream(absolute),
48
+ );
49
+ } catch (error) {
50
+ await rm(absolute, { force: true });
51
+ throw error;
52
+ }
53
+
54
+ return { fileId: storageKey, size };
52
55
  }
53
56
 
54
- /** A readable stream for the download route. Guarded against path traversal. */
55
57
  read(storageKey: string): Readable {
56
58
  return createReadStream(this.resolveKey(storageKey));
57
59
  }
58
60
 
59
- /** Resolve a storage key inside the base dir, rejecting any escape. */
61
+ async delete(storageKey: string): Promise<void> {
62
+ await rm(this.resolveKey(storageKey), { force: true });
63
+ }
64
+
60
65
  private resolveKey(storageKey: string): string {
61
66
  const absolute = resolve(join(this.baseDir, storageKey));
62
67
  if (absolute !== this.baseDir && !absolute.startsWith(this.baseDir + sep)) {
@@ -66,14 +71,22 @@ export class LocalDiskReportExportStorage implements ExportFileSink {
66
71
  }
67
72
  }
68
73
 
69
- /** Keep path segments to a safe alphabet (uuids / org ids are already safe). */
70
74
  function sanitizeSegment(segment: string): string {
71
75
  return segment.replace(/[^A-Za-z0-9_-]/g, '_');
72
76
  }
73
77
 
74
- /** Server-controlled extension: a short alphanumeric suffix, or none. */
75
78
  function extensionOf(fileName: string): string {
76
79
  const dot = fileName.lastIndexOf('.');
77
80
  const extension = dot >= 0 ? fileName.slice(dot) : '';
78
81
  return /^\.[A-Za-z0-9]+$/.test(extension) ? extension.toLowerCase() : '';
79
82
  }
83
+
84
+ async function* countingChunks(
85
+ chunks: AsyncIterable<Uint8Array>,
86
+ onChunk: (byteLength: number) => void,
87
+ ): AsyncGenerator<Uint8Array> {
88
+ for await (const chunk of chunks) {
89
+ onChunk(chunk.byteLength);
90
+ yield chunk;
91
+ }
92
+ }
@@ -57,6 +57,10 @@ export const settingDefinitions = {
57
57
  defaultValue: 50,
58
58
  parse: parsePositiveInt('forms.maxSubmissionsPerIpPerDay', 100000),
59
59
  },
60
+ 'forms.maxPublicUploadsPerIpPerDay': {
61
+ defaultValue: 100,
62
+ parse: parsePositiveInt('forms.maxPublicUploadsPerIpPerDay', 100000),
63
+ },
60
64
  // SSRF gate for definition webhooks: empty list = webhooks disabled until
61
65
  // an admin explicitly allows destination hosts (changes are audited).
62
66
  'forms.webhookAllowedHosts': {
@@ -0,0 +1,163 @@
1
+ import { INestApplication, ValidationPipe } from '@nestjs/common';
2
+ import { Test } from '@nestjs/testing';
3
+ import type { CaptchaVerifier } from '@ftisindia/form-builder';
4
+ import request from 'supertest';
5
+ import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
6
+ import { FORMS_CAPTCHA_VERIFIER } from '../src/modules/forms/forms.tokens';
7
+
8
+ describe('Forms captcha seam (e2e)', () => {
9
+ let app: INestApplication;
10
+ let owner: { accessToken: string; orgId: string };
11
+ let formKey: string;
12
+ const seenTokens: string[] = [];
13
+
14
+ beforeAll(async () => {
15
+ process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL;
16
+ process.env.JWT_SECRET ||= 'test-jwt-secret-at-least-16-chars';
17
+ process.env.AUTH_GOOGLE_ENABLED ||= 'false';
18
+ process.env.AUTH_EMAIL_PASSWORD_ENABLED ||= 'true';
19
+ process.env.NODE_ENV = 'test';
20
+ process.env.FORMS_OUTBOX_ENABLED = 'false';
21
+
22
+ const captcha: CaptchaVerifier = {
23
+ verify: (token) => {
24
+ seenTokens.push(token);
25
+ return Promise.resolve(token === 'valid-token');
26
+ },
27
+ };
28
+
29
+ const { AppModule } = await import('../src/app.module');
30
+ const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
31
+ .overrideProvider(FORMS_CAPTCHA_VERIFIER)
32
+ .useValue(captcha)
33
+ .compile();
34
+
35
+ app = moduleRef.createNestApplication();
36
+ app.useGlobalFilters(new HttpExceptionFilter());
37
+ app.useGlobalPipes(
38
+ new ValidationPipe({
39
+ forbidNonWhitelisted: true,
40
+ transform: true,
41
+ whitelist: true,
42
+ }),
43
+ );
44
+ await app.init();
45
+
46
+ owner = await createUserAndOrg('captcha-owner');
47
+ formKey = await publishDefinition(publicCaptchaDefinition(uniqueFormKey('captcha')));
48
+ });
49
+
50
+ afterAll(async () => {
51
+ await app.close();
52
+ });
53
+
54
+ it('rejects missing or invalid tokens and accepts a valid captcha token', async () => {
55
+ const payload = {
56
+ data: {
57
+ fullName: 'Captcha User',
58
+ email: 'captcha@example.com',
59
+ ticketType: 'standard',
60
+ },
61
+ };
62
+
63
+ await request(app.getHttpServer())
64
+ .post(`/public/organisations/${owner.orgId}/forms/${formKey}/submissions`)
65
+ .send(payload)
66
+ .expect(422);
67
+
68
+ await request(app.getHttpServer())
69
+ .post(`/public/organisations/${owner.orgId}/forms/${formKey}/submissions`)
70
+ .send({ ...payload, captchaToken: 'invalid-token' })
71
+ .expect(422);
72
+
73
+ const submitted = await request(app.getHttpServer())
74
+ .post(`/public/organisations/${owner.orgId}/forms/${formKey}/submissions`)
75
+ .send({ ...payload, captchaToken: 'valid-token' })
76
+ .expect(200);
77
+
78
+ expect(submitted.body.submissionId).toBeTruthy();
79
+ expect(seenTokens).toEqual(['invalid-token', 'valid-token']);
80
+ });
81
+
82
+ function publicCaptchaDefinition(key: string): Record<string, unknown> {
83
+ return {
84
+ key,
85
+ version: 1,
86
+ title: 'Captcha public form',
87
+ settings: { access: 'public', captcha: true },
88
+ fields: [
89
+ {
90
+ type: 'text',
91
+ name: 'fullName',
92
+ validators: { required: true, maxLength: 120 },
93
+ },
94
+ {
95
+ type: 'email',
96
+ name: 'email',
97
+ validators: { required: true },
98
+ },
99
+ {
100
+ type: 'select',
101
+ name: 'ticketType',
102
+ validators: { required: true, options: ['standard', 'student', 'vip'] },
103
+ },
104
+ ],
105
+ actions: {
106
+ submit: ['validateAll', 'persist'],
107
+ saveDraft: ['persistDraft'],
108
+ },
109
+ };
110
+ }
111
+
112
+ async function publishDefinition(definition: Record<string, unknown>): Promise<string> {
113
+ const created = await request(app.getHttpServer())
114
+ .post(`/organisations/${owner.orgId}/forms`)
115
+ .set('Authorization', `Bearer ${owner.accessToken}`)
116
+ .send({ definition })
117
+ .expect(201);
118
+
119
+ await request(app.getHttpServer())
120
+ .post(
121
+ `/organisations/${owner.orgId}/forms/${definition.key as string}/versions/${created.body.version as number}/publish`,
122
+ )
123
+ .set('Authorization', `Bearer ${owner.accessToken}`)
124
+ .expect(200);
125
+
126
+ return definition.key as string;
127
+ }
128
+
129
+ async function createUserAndOrg(label: string) {
130
+ const suffix = uniqueSuffix();
131
+ const signup = await request(app.getHttpServer())
132
+ .post('/auth/signup')
133
+ .send({
134
+ email: `${label}-${suffix}@example.com`,
135
+ password: 'test-password-123',
136
+ displayName: label,
137
+ })
138
+ .expect(201);
139
+
140
+ const accessToken = signup.body.accessToken as string;
141
+ const org = await request(app.getHttpServer())
142
+ .post('/organisations')
143
+ .set('Authorization', `Bearer ${accessToken}`)
144
+ .send({
145
+ name: `${label} ${suffix}`,
146
+ slug: `${label}-${suffix}`,
147
+ })
148
+ .expect(201);
149
+
150
+ return {
151
+ accessToken,
152
+ orgId: org.body.organisation.id as string,
153
+ };
154
+ }
155
+
156
+ function uniqueSuffix() {
157
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
158
+ }
159
+
160
+ function uniqueFormKey(label: string) {
161
+ return `${label}-${uniqueSuffix()}`.toLowerCase();
162
+ }
163
+ });
@@ -1,6 +1,5 @@
1
1
  import { INestApplication, ValidationPipe } from '@nestjs/common';
2
2
  import { Test } from '@nestjs/testing';
3
- import { DataSourceRegistry } from '@ftisindia/form-builder';
4
3
  import request from 'supertest';
5
4
  import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
6
5
  import { PrismaService } from '../src/database/prisma/prisma.service';
@@ -43,16 +42,6 @@ describe('Forms file uploads (e2e)', () => {
43
42
  await app.init();
44
43
  prisma = app.get(PrismaService);
45
44
 
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
45
  owner = await createUserAndOrg('files-owner');
57
46
  formKey = await publishDefinition(abstractDefinition(uniqueFormKey('abstract')));
58
47
  });
@@ -87,7 +76,7 @@ describe('Forms file uploads (e2e)', () => {
87
76
  const draft = await request(app.getHttpServer())
88
77
  .post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
89
78
  .set('Authorization', `Bearer ${owner.accessToken}`)
90
- .send({ mode: 'draft', data: { title: 'Versioned upload', track: 'ai' } })
79
+ .send({ mode: 'draft', data: { title: 'Versioned upload', track: 'clinical-research' } })
91
80
  .expect(200);
92
81
 
93
82
  const createdV2 = await request(app.getHttpServer())
@@ -136,7 +125,7 @@ describe('Forms file uploads (e2e)', () => {
136
125
  .set('Authorization', `Bearer ${owner.accessToken}`)
137
126
  .send({
138
127
  mode: 'submit',
139
- data: { title: 'On Linking Files', track: 'ai', manuscript: { fileId } },
128
+ data: { title: 'On Linking Files', track: 'clinical-research', manuscript: { fileId } },
140
129
  })
141
130
  .expect(200);
142
131
 
@@ -159,7 +148,7 @@ describe('Forms file uploads (e2e)', () => {
159
148
  .set('Authorization', `Bearer ${member.accessToken}`)
160
149
  .send({
161
150
  mode: 'submit',
162
- data: { title: 'Stolen Reference', track: 'ml', manuscript: { fileId } },
151
+ data: { title: 'Stolen Reference', track: 'public-health', manuscript: { fileId } },
163
152
  })
164
153
  .expect(422);
165
154
 
@@ -176,7 +165,7 @@ describe('Forms file uploads (e2e)', () => {
176
165
 
177
166
  await prisma.uploadedFile.update({
178
167
  where: { id: fileId },
179
- data: { createdAt: new Date(Date.now() - 48 * 60 * 60 * 1000) },
168
+ data: { createdAt: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000) },
180
169
  });
181
170
 
182
171
  const removed = await app.get(FileGcService).runOnce();
@@ -186,15 +175,48 @@ describe('Forms file uploads (e2e)', () => {
186
175
  expect(row).toBeNull();
187
176
  });
188
177
 
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`)
178
+ it('accepts public uploads only with the returned claim token', async () => {
179
+ const key = await publishDefinition(abstractDefinition(uniqueFormKey('public-file')));
180
+ await request(app.getHttpServer())
181
+ .post(`/organisations/${owner.orgId}/forms/${key}/versions/1/public-access`)
192
182
  .set('Authorization', `Bearer ${owner.accessToken}`)
193
183
  .send({ access: 'public' })
184
+ .expect(200);
185
+
186
+ const upload = await request(app.getHttpServer())
187
+ .post(`/public/organisations/${owner.orgId}/forms/${key}/files`)
188
+ .query({ field: 'manuscript' })
189
+ .attach('file', PDF_BYTES, 'public-manuscript.pdf')
190
+ .expect(200);
191
+
192
+ const fileId = upload.body.fileId as string;
193
+ const uploadToken = upload.body.uploadToken as string;
194
+ expect(fileId).toBeTruthy();
195
+ expect(uploadToken).toBeTruthy();
196
+ expect(upload.body.status).toBe('TEMPORARY');
197
+
198
+ const rejected = await request(app.getHttpServer())
199
+ .post(`/public/organisations/${owner.orgId}/forms/${key}/submissions`)
200
+ .send({
201
+ data: { title: 'Missing token', track: 'clinical-research', manuscript: { fileId } },
202
+ })
194
203
  .expect(422);
204
+ const errors = rejected.body.error.details.errors as Array<{ code: string; path: string }>;
205
+ expect(errors.some((error) => error.code === 'FILE_REFERENCE')).toBe(true);
206
+
207
+ const submitted = await request(app.getHttpServer())
208
+ .post(`/public/organisations/${owner.orgId}/forms/${key}/submissions`)
209
+ .send({
210
+ data: { title: 'Public file', track: 'clinical-research', manuscript: { fileId } },
211
+ uploadTokens: { [fileId]: uploadToken },
212
+ })
213
+ .expect(200);
195
214
 
196
- const issues = response.body.error.details.issues as Array<{ code: string }>;
197
- expect(issues.map((issue) => issue.code)).toContain('PUBLIC_FILE_FIELD');
215
+ const submissionId = submitted.body.submissionId as string;
216
+ const row = await prisma.uploadedFile.findUnique({ where: { id: fileId } });
217
+ expect(row?.ownerId).toBeNull();
218
+ expect(row?.status).toBe('LINKED');
219
+ expect(row?.submissionId).toBe(submissionId);
198
220
  });
199
221
 
200
222
  // ── Helpers ────────────────────────────────────────────────────────────────