@ftisindia/create-app 0.1.6 → 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 (31) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +3 -0
  3. package/template/_package.json +4 -1
  4. package/template/docs/FORMS.md +34 -15
  5. package/template/docs/FORMS_CHECKLIST.md +34 -26
  6. package/template/docs/REPORTS.md +12 -3
  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/schema.prisma +5 -1
  10. package/template/src/app.module.ts +4 -3
  11. package/template/src/config/env.validation.ts +3 -0
  12. package/template/src/config/forms.config.ts +1 -0
  13. package/template/src/config/reports.config.ts +2 -0
  14. package/template/src/modules/forms/application/services/handlers/webhook-delivery.transport.ts +319 -0
  15. package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +64 -16
  16. package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +40 -18
  17. package/template/src/modules/forms/forms.module.ts +2 -0
  18. package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +82 -59
  19. package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +81 -0
  20. package/template/src/modules/reports/application/services/reports-exports.service.ts +6 -2
  21. package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +43 -30
  22. package/template/test/forms-captcha.e2e-spec.ts +163 -0
  23. package/template/test/forms-files.e2e-spec.ts +1 -1
  24. package/template/test/forms-outbox.e2e-spec.ts +271 -10
  25. package/template/test/forms-public.e2e-spec.ts +24 -0
  26. package/template/test/forms-throttling.e2e-spec.ts +146 -0
  27. package/template/test/forms-webhooks.e2e-spec.ts +150 -8
  28. package/template/test/jest-e2e.json +1 -0
  29. package/template/test/reports-advanced.e2e-spec.ts +13 -0
  30. package/template/test/reports-query.e2e-spec.ts +52 -0
  31. package/template/test/reports-tiers.e2e-spec.ts +106 -20
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { Injectable } from '@nestjs/common';
2
3
  import { FormOutboxJob as FormOutboxJobRow, Prisma } from '@prisma/client';
3
4
  import type {
@@ -20,96 +21,117 @@ export class PrismaOutboxStore implements OutboxStore {
20
21
  }
21
22
 
22
23
  async enqueue(job: OutboxJobInput, tx: EngineTx): Promise<void> {
23
- await this.client(tx).formOutboxJob.create({
24
- data: {
25
- type: job.type,
26
- payload: job.payload as unknown as Prisma.InputJsonValue,
27
- status: 'PENDING',
28
- attempts: 0,
29
- maxAttempts: job.maxAttempts ?? 8,
30
- idempotencyKey: job.idempotencyKey ?? null,
31
- runAfter: job.runAfter ?? null,
32
- orgId: job.orgId ?? null,
33
- actorUserId: job.actorUserId ?? null,
34
- originRequestId: job.originRequestId ?? null,
35
- },
36
- });
37
- }
38
-
39
- /**
40
- * Claims due PENDING jobs and stale PROCESSING jobs by selecting candidates
41
- * and flipping them to PROCESSING with a guarded updateMany. This is safe
42
- * for the template's single in-process dispatcher; multi-instance
43
- * deployments should move the claim to `SELECT ... FOR UPDATE SKIP LOCKED`.
44
- */
45
- async claimDue(now: Date, batchSize: number): Promise<OutboxJobRecord[]> {
46
- const staleProcessingBefore = new Date(now.getTime() - PROCESSING_LEASE_MS);
47
- const claimableWhere: Prisma.FormOutboxJobWhereInput = {
48
- OR: [
24
+ await this.client(tx).formOutboxJob.createMany({
25
+ data: [
49
26
  {
27
+ type: job.type,
28
+ payload: job.payload as unknown as Prisma.InputJsonValue,
50
29
  status: 'PENDING',
51
- OR: [{ runAfter: null }, { runAfter: { lte: now } }],
52
- },
53
- {
54
- status: 'PROCESSING',
55
- updatedAt: { lte: staleProcessingBefore },
30
+ attempts: 0,
31
+ maxAttempts: job.maxAttempts ?? 8,
32
+ idempotencyKey: job.idempotencyKey ?? null,
33
+ runAfter: job.runAfter ?? null,
34
+ orgId: job.orgId ?? null,
35
+ actorUserId: job.actorUserId ?? null,
36
+ originRequestId: job.originRequestId ?? null,
56
37
  },
57
38
  ],
58
- };
59
-
60
- const due = await this.client().formOutboxJob.findMany({
61
- where: claimableWhere,
62
- orderBy: { createdAt: 'asc' },
63
- take: batchSize,
64
- select: { id: true },
39
+ skipDuplicates: true,
65
40
  });
41
+ }
66
42
 
67
- if (due.length === 0) {
43
+ /**
44
+ * Claims due PENDING jobs and stale PROCESSING jobs with row-level locks so
45
+ * multiple app instances can poll without double-delivering the same job.
46
+ */
47
+ async claimDue(now: Date, batchSize: number): Promise<OutboxJobRecord[]> {
48
+ if (batchSize <= 0) {
68
49
  return [];
69
50
  }
70
51
 
71
- const ids = due.map((row) => row.id);
72
-
73
- await this.client().formOutboxJob.updateMany({
74
- where: { id: { in: ids }, ...claimableWhere },
75
- data: { status: 'PROCESSING' },
76
- });
77
-
78
- const claimed = await this.client().formOutboxJob.findMany({
79
- where: { id: { in: ids }, status: 'PROCESSING' },
80
- });
52
+ const staleProcessingBefore = new Date(now.getTime() - PROCESSING_LEASE_MS);
53
+ const claimedBy = randomUUID();
54
+ const claimed = await this.prisma.$transaction(
55
+ (tx) =>
56
+ tx.$queryRaw<FormOutboxJobRow[]>`
57
+ WITH due AS (
58
+ SELECT "id"
59
+ FROM "FormOutboxJob"
60
+ WHERE (
61
+ "status" = 'PENDING'
62
+ AND ("runAfter" IS NULL OR "runAfter" <= (${now}::timestamptz AT TIME ZONE 'UTC'))
63
+ )
64
+ OR (
65
+ "status" = 'PROCESSING'
66
+ AND "updatedAt" <= (${staleProcessingBefore}::timestamptz AT TIME ZONE 'UTC')
67
+ )
68
+ ORDER BY "createdAt" ASC
69
+ LIMIT ${batchSize}
70
+ FOR UPDATE SKIP LOCKED
71
+ )
72
+ UPDATE "FormOutboxJob" AS job
73
+ SET "status" = 'PROCESSING',
74
+ "claimedBy" = ${claimedBy},
75
+ "updatedAt" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
76
+ FROM due
77
+ WHERE job."id" = due."id"
78
+ RETURNING job.*
79
+ `,
80
+ );
81
81
 
82
82
  return claimed.map((row) => this.toRecord(row));
83
83
  }
84
84
 
85
- async markDone(id: string): Promise<void> {
86
- await this.client().formOutboxJob.update({
87
- where: { id },
88
- data: { status: 'DONE' },
85
+ async markDone(id: string, claimedBy: string): Promise<boolean> {
86
+ const result = await this.client().formOutboxJob.updateMany({
87
+ where: { id, status: 'PROCESSING', claimedBy },
88
+ data: { status: 'DONE', claimedBy: null },
89
89
  });
90
+ return result.count === 1;
90
91
  }
91
92
 
92
- async markRetry(id: string, attempts: number, runAfter: Date, lastError: string): Promise<void> {
93
- await this.client().formOutboxJob.update({
94
- where: { id },
93
+ async markRetry(
94
+ id: string,
95
+ claimedBy: string,
96
+ attempts: number,
97
+ runAfter: Date,
98
+ lastError: string,
99
+ ): Promise<boolean> {
100
+ const result = await this.client().formOutboxJob.updateMany({
101
+ where: { id, status: 'PROCESSING', claimedBy },
95
102
  data: {
96
103
  status: 'PENDING',
104
+ claimedBy: null,
97
105
  attempts,
98
106
  runAfter,
99
107
  lastError,
100
108
  },
101
109
  });
110
+ return result.count === 1;
102
111
  }
103
112
 
104
- async markFailed(id: string, attempts: number, lastError: string): Promise<void> {
105
- await this.client().formOutboxJob.update({
106
- where: { id },
113
+ async markFailed(id: string, claimedBy: string, attempts: number, lastError: string): Promise<boolean> {
114
+ const result = await this.client().formOutboxJob.updateMany({
115
+ where: { id, status: 'PROCESSING', claimedBy },
107
116
  data: {
108
117
  status: 'FAILED',
118
+ claimedBy: null,
109
119
  attempts,
110
120
  lastError,
111
121
  },
112
122
  });
123
+ return result.count === 1;
124
+ }
125
+
126
+ async touchProcessing(id: string, claimedBy: string): Promise<boolean> {
127
+ const touched = await this.prisma.$executeRaw`
128
+ UPDATE "FormOutboxJob"
129
+ SET "updatedAt" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
130
+ WHERE "id" = ${id}
131
+ AND "status" = 'PROCESSING'
132
+ AND "claimedBy" = ${claimedBy}
133
+ `;
134
+ return touched === 1;
113
135
  }
114
136
 
115
137
  private toRecord(row: FormOutboxJobRow): OutboxJobRecord {
@@ -118,6 +140,7 @@ export class PrismaOutboxStore implements OutboxStore {
118
140
  type: row.type,
119
141
  payload: row.payload as unknown as Record<string, unknown>,
120
142
  status: row.status as OutboxStatus,
143
+ claimedBy: row.claimedBy,
121
144
  attempts: row.attempts,
122
145
  maxAttempts: row.maxAttempts,
123
146
  idempotencyKey: row.idempotencyKey,
@@ -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
+ }
@@ -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
+ });
@@ -176,7 +176,7 @@ describe('Forms file uploads (e2e)', () => {
176
176
 
177
177
  await prisma.uploadedFile.update({
178
178
  where: { id: fileId },
179
- data: { createdAt: new Date(Date.now() - 48 * 60 * 60 * 1000) },
179
+ data: { createdAt: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000) },
180
180
  });
181
181
 
182
182
  const removed = await app.get(FileGcService).runOnce();