@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
@@ -1,41 +1,89 @@
1
1
  import { createHmac } from 'node:crypto';
2
2
  import { Injectable } from '@nestjs/common';
3
+ import type { Prisma } from '@prisma/client';
3
4
  import type { OutboxJobHandler, OutboxJobRecord } from '@ftisindia/form-builder';
5
+ import { PrismaService } from '../../../../../database/prisma/prisma.service';
6
+ import { deliverWebhookRequest } from './webhook-delivery.transport';
4
7
 
5
8
  /**
6
- * Generic webhook delivery. Payload contract: { url: string, body?: object,
7
- * secret?: string }. When a secret is set, the request carries
8
- * `x-forms-signature: sha256=<hmac-sha256(rawBody)>` so receivers can verify
9
- * authenticity. Non-2xx responses and timeouts throw, which the dispatcher
10
- * turns into a retry with backoff (then parks as FAILED after max attempts).
9
+ * Generic webhook delivery. Payload contract: { url: string, rawBody: string,
10
+ * signature?: string }. The engine computes the optional signature before
11
+ * enqueueing so the shared secret is not copied into the outbox payload.
12
+ * Non-2xx responses and timeouts throw, which the dispatcher turns into a
13
+ * retry with backoff (then parks as FAILED after max attempts).
11
14
  */
12
15
  @Injectable()
13
16
  export class WebhookOutboxHandler implements OutboxJobHandler {
14
17
  readonly type = 'webhook';
15
18
 
19
+ constructor(private readonly prisma: PrismaService) {}
20
+
16
21
  async handle(job: OutboxJobRecord): Promise<void> {
17
- const { url, body, secret } = job.payload as {
22
+ const { url, rawBody, body, signature, secret } = job.payload as {
18
23
  url?: unknown;
24
+ rawBody?: unknown;
19
25
  body?: unknown;
26
+ signature?: unknown;
27
+ /** Backward-compatible support for jobs enqueued before secrets moved out of payloads. */
20
28
  secret?: unknown;
21
29
  };
22
30
  if (typeof url !== 'string' || url.length === 0) {
23
31
  throw new Error('Webhook job payload requires a "url" string.');
24
32
  }
25
- const rawBody = JSON.stringify(body ?? {});
26
- const headers: Record<string, string> = { 'content-type': 'application/json' };
27
- if (typeof secret === 'string' && secret.length > 0) {
28
- headers['x-forms-signature'] =
29
- 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
33
+ const requestBody = typeof rawBody === 'string' ? rawBody : JSON.stringify(body ?? {});
34
+ const headers: Record<string, string> = {
35
+ 'content-type': 'application/json',
36
+ };
37
+ if (job.idempotencyKey) {
38
+ headers['x-forms-idempotency-key'] = job.idempotencyKey;
39
+ }
40
+ let resolvedSignature: string | undefined;
41
+ if (typeof signature === 'string' && signature.length > 0) {
42
+ resolvedSignature = signature;
43
+ } else if (typeof secret === 'string' && secret.length > 0) {
44
+ resolvedSignature =
45
+ 'sha256=' + createHmac('sha256', secret).update(requestBody).digest('hex');
46
+ await this.sanitizeLegacyPayload(job, url, requestBody, resolvedSignature);
30
47
  }
31
- const response = await fetch(url, {
32
- method: 'POST',
48
+ if (resolvedSignature) {
49
+ headers['x-forms-signature'] = resolvedSignature;
50
+ }
51
+ const response = await deliverWebhookRequest(url, {
33
52
  headers,
34
- body: rawBody,
35
- signal: AbortSignal.timeout(10_000),
53
+ body: requestBody,
54
+ timeoutMs: 10_000,
36
55
  });
37
- if (!response.ok) {
56
+ if (response.status >= 300 && response.status < 400) {
57
+ throw new Error(`Webhook redirects are not allowed (status ${response.status}).`);
58
+ }
59
+ if (response.status < 200 || response.status >= 300) {
38
60
  throw new Error(`Webhook endpoint responded with status ${response.status}.`);
39
61
  }
40
62
  }
63
+
64
+ private async sanitizeLegacyPayload(
65
+ job: OutboxJobRecord,
66
+ url: string,
67
+ rawBody: string,
68
+ signature: string,
69
+ ): Promise<void> {
70
+ const claimedBy = typeof job.claimedBy === 'string' ? job.claimedBy : '';
71
+ if (!claimedBy) {
72
+ throw new Error('Webhook job is missing a claim token.');
73
+ }
74
+
75
+ const result = await this.prisma.formOutboxJob.updateMany({
76
+ where: { id: job.id, status: 'PROCESSING', claimedBy },
77
+ data: {
78
+ payload: {
79
+ url,
80
+ rawBody,
81
+ signature,
82
+ } satisfies Prisma.InputJsonValue,
83
+ },
84
+ });
85
+ if (result.count !== 1) {
86
+ throw new Error('Webhook job lease was lost before legacy payload cleanup.');
87
+ }
88
+ }
41
89
  }
@@ -7,6 +7,8 @@ import { AuditService } from '../../../audit/application/services/audit.service'
7
7
  import { RequestContextService } from '../../../request-context/application/services/request-context.service';
8
8
  import { PrismaOutboxStore } from '../../infrastructure/stores/prisma-outbox.store';
9
9
 
10
+ const DEFAULT_PROCESSING_HEARTBEAT_MS = 60_000;
11
+
10
12
  /**
11
13
  * In-process outbox poller (ecosystem guide §3/§8.3). Every claimed job runs
12
14
  * inside requestContext.run({ source: 'worker', jobId, orgId, userId,
@@ -15,9 +17,9 @@ import { PrismaOutboxStore } from '../../infrastructure/stores/prisma-outbox.sto
15
17
  * originating request. Successful delivery writes an audit row.
16
18
  *
17
19
  * Deterministic in tests: FORMS_OUTBOX_ENABLED=false disables the timer and
18
- * runOnce() drives a single cycle by hand. Single-instance by design a
19
- * multi-node deployment should swap the store's claim for SKIP LOCKED or a
20
- * real queue (BullMQ) without touching the engine.
20
+ * runOnce() drives a single cycle by hand. The default Prisma store claims
21
+ * jobs with row-level locks; apps that outgrow the starter poller can swap
22
+ * this boundary for a queue-backed implementation without touching handlers.
21
23
  */
22
24
  @Injectable()
23
25
  export class OutboxDispatcherService implements OnApplicationBootstrap, OnModuleDestroy {
@@ -53,6 +55,8 @@ export class OutboxDispatcherService implements OnApplicationBootstrap, OnModule
53
55
  }
54
56
 
55
57
  async runOnce(): Promise<DispatchCycleResult> {
58
+ const heartbeatMs =
59
+ this.config.get<number>('forms.outboxHeartbeatMs') ?? DEFAULT_PROCESSING_HEARTBEAT_MS;
56
60
  return runDispatchCycle(this.store, this.handlers, {
57
61
  wrapJob: (job, run) =>
58
62
  this.requestContext.run(
@@ -64,25 +68,43 @@ export class OutboxDispatcherService implements OnApplicationBootstrap, OnModule
64
68
  requestId: job.originRequestId ?? undefined,
65
69
  },
66
70
  async () => {
67
- const outcome = await run();
68
- if (outcome === 'done') {
69
- try {
70
- await this.audit.write(this.prisma, {
71
- orgId: job.orgId ?? null,
72
- actorUserId: job.actorUserId ?? null,
73
- action: 'forms.outbox.delivered',
74
- targetType: 'FormOutboxJob',
75
- targetId: job.id,
76
- metadata: { type: job.type, attempts: job.attempts + 1 },
77
- });
78
- } catch (error) {
71
+ const claimedBy = typeof job.claimedBy === 'string' ? job.claimedBy : '';
72
+ const heartbeat = setInterval(() => {
73
+ if (!claimedBy) {
74
+ return;
75
+ }
76
+ void this.store.touchProcessing(job.id, claimedBy).catch((error: unknown) => {
79
77
  this.logger.warn(
80
- `Outbox delivery audit failed for job ${job.id}.`,
78
+ `Outbox heartbeat failed for job ${job.id}.`,
81
79
  error instanceof Error ? error.stack : String(error),
82
80
  );
81
+ });
82
+ }, heartbeatMs);
83
+ heartbeat.unref?.();
84
+
85
+ try {
86
+ const outcome = await run();
87
+ if (outcome === 'done') {
88
+ try {
89
+ await this.audit.write(this.prisma, {
90
+ orgId: job.orgId ?? null,
91
+ actorUserId: job.actorUserId ?? null,
92
+ action: 'forms.outbox.delivered',
93
+ targetType: 'FormOutboxJob',
94
+ targetId: job.id,
95
+ metadata: { type: job.type, attempts: job.attempts + 1 },
96
+ });
97
+ } catch (error) {
98
+ this.logger.warn(
99
+ `Outbox delivery audit failed for job ${job.id}.`,
100
+ error instanceof Error ? error.stack : String(error),
101
+ );
102
+ }
83
103
  }
104
+ return outcome;
105
+ } finally {
106
+ clearInterval(heartbeat);
84
107
  }
85
- return outcome;
86
108
  },
87
109
  ),
88
110
  });
@@ -97,7 +119,7 @@ export class OutboxDispatcherService implements OnApplicationBootstrap, OnModule
97
119
  const result = await this.runOnce();
98
120
  if (result.claimed > 0) {
99
121
  this.logger.log(
100
- `Outbox cycle: claimed ${result.claimed}, done ${result.done}, retried ${result.retried}, failed ${result.failed}.`,
122
+ `Outbox cycle: claimed ${result.claimed}, done ${result.done}, retried ${result.retried}, failed ${result.failed}, stale ${result.stale}.`,
101
123
  );
102
124
  }
103
125
  } catch (error) {
@@ -0,0 +1,10 @@
1
+ import { ApiProperty } from '@nestjs/swagger';
2
+ import { FileUploadResponseDto } from './file-upload-response.dto';
3
+
4
+ export class PublicFileUploadResponseDto extends FileUploadResponseDto {
5
+ @ApiProperty({
6
+ example: 'qg7N4gJvG7lQzZ5aH8gHovC6hRr0QZq1LurNkrXJq6w',
7
+ description: 'One-time claim token required when submitting this public file reference.',
8
+ })
9
+ uploadToken!: string;
10
+ }
@@ -16,6 +16,15 @@ export class PublicSubmitFormDto {
16
16
  @Min(1)
17
17
  version?: number;
18
18
 
19
+ @ApiPropertyOptional({
20
+ type: 'object',
21
+ additionalProperties: { type: 'string' },
22
+ description: 'Public upload claim tokens keyed by fileId.',
23
+ })
24
+ @IsOptional()
25
+ @IsObject()
26
+ uploadTokens?: Record<string, string>;
27
+
19
28
  @ApiPropertyOptional({ description: 'Captcha token, required when the form enables captcha.' })
20
29
  @IsOptional()
21
30
  @IsString()
@@ -20,6 +20,7 @@ import { FormsFilesService } from './application/services/forms-files.service';
20
20
  import { FormsPublicService } from './application/services/forms-public.service';
21
21
  import { FormsSettingsReader } from './application/services/forms-settings-reader.service';
22
22
  import { FormsSubmissionsService } from './application/services/forms-submissions.service';
23
+ import { ConferenceTracksDataSource } from './application/services/data-sources/conference-tracks.data-source';
23
24
  import { AuthenticateActionHandler } from './application/services/handlers/authenticate.action';
24
25
  import { LoggingEmailHandler } from './application/services/handlers/logging-email.handler';
25
26
  import { SendConfirmationEmailAction } from './application/services/handlers/send-confirmation-email.action';
@@ -42,7 +43,9 @@ import { FormsDefinitionsController } from './presentation/forms-definitions.con
42
43
  import { FormsFilesController } from './presentation/forms-files.controller';
43
44
  import { FormsSubmissionsController } from './presentation/forms-submissions.controller';
44
45
  import { FormsUploadInterceptor } from './presentation/forms-upload.interceptor';
45
- import { PublicFormsController } from './presentation/public-forms.controller';
46
+ import { PublicFormsController } from './presentation/public-forms.controller';
47
+ import { PublicFormsFilesController } from './presentation/public-forms-files.controller';
48
+ import { FORMS_CAPTCHA_VERIFIER } from './forms.tokens';
46
49
 
47
50
  /**
48
51
  * The forms glue module — the ONLY place that binds the framework-free
@@ -60,7 +63,8 @@ import { PublicFormsController } from './presentation/public-forms.controller';
60
63
  FormsDefinitionsController,
61
64
  FormsFilesController,
62
65
  FormsSubmissionsController,
63
- PublicFormsController,
66
+ PublicFormsController,
67
+ PublicFormsFilesController,
64
68
  ],
65
69
  providers: [
66
70
  // Adapters over the ecosystem seams.
@@ -75,6 +79,9 @@ import { PublicFormsController } from './presentation/public-forms.controller';
75
79
  PrismaFileStore,
76
80
  PrismaActionLogStore,
77
81
  FormsSettingsReader,
82
+ // Secure default: public forms with settings.captcha=true fail publish
83
+ // until the app replaces this provider with a real CaptchaVerifier.
84
+ { provide: FORMS_CAPTCHA_VERIFIER, useValue: undefined },
78
85
  // Engine registries — singletons; core field types registered up front.
79
86
  {
80
87
  provide: FieldTypeRegistry,
@@ -194,6 +201,9 @@ import { PublicFormsController } from './presentation/public-forms.controller';
194
201
  LocalDiskStorageAdapter,
195
202
  FileGcService,
196
203
  FormsUploadInterceptor,
204
+ // Example data source backing examples/abstract-submission.form.json.
205
+ // Delete or replace it with an org-scoped lookup in production apps.
206
+ ConferenceTracksDataSource,
197
207
  // Default outbox handlers + shipped form actions.
198
208
  LoggingEmailHandler,
199
209
  WebhookOutboxHandler,
@@ -1,3 +1,4 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto';
1
2
  import { Injectable } from '@nestjs/common';
2
3
  import { Prisma, UploadedFile as UploadedFileRow } from '@prisma/client';
3
4
  import type {
@@ -26,6 +27,8 @@ export class PrismaFileStore implements FileStore {
26
27
  size: record.size,
27
28
  checksum: record.checksum,
28
29
  ownerId: record.ownerId,
30
+ claimTokenHash: record.claimTokenHash ?? null,
31
+ uploadedIp: record.uploadedIp ?? null,
29
32
  orgId: record.orgId,
30
33
  status: record.status,
31
34
  },
@@ -34,12 +37,19 @@ export class PrismaFileStore implements FileStore {
34
37
  return this.toRecord(row);
35
38
  }
36
39
 
37
- async findById(orgId: string, id: string, tx?: EngineTx): Promise<UploadedFileRecord | null> {
40
+ async findById(
41
+ orgId: string,
42
+ id: string,
43
+ tx?: EngineTx,
44
+ presentedClaimToken?: string,
45
+ ): Promise<UploadedFileRecord | null> {
38
46
  const row = await this.client(tx).uploadedFile.findFirst({
39
47
  where: { id, orgId },
40
48
  });
41
49
 
42
- return row ? this.toRecord(row) : null;
50
+ return row
51
+ ? this.toRecord(row, this.claimTokenMatches(row.claimTokenHash, presentedClaimToken))
52
+ : null;
43
53
  }
44
54
 
45
55
  async updateStatus(
@@ -71,6 +81,21 @@ export class PrismaFileStore implements FileStore {
71
81
  return this.toRecord(row);
72
82
  }
73
83
 
84
+ async countPublicUploadsByIpSince(
85
+ orgId: string,
86
+ ipAddress: string,
87
+ since: Date,
88
+ ): Promise<number> {
89
+ return this.client().uploadedFile.count({
90
+ where: {
91
+ orgId,
92
+ ownerId: null,
93
+ uploadedIp: ipAddress,
94
+ createdAt: { gte: since },
95
+ },
96
+ });
97
+ }
98
+
74
99
  async listGcEligible(cutoff: Date, limit: number): Promise<UploadedFileRecord[]> {
75
100
  const rows = await this.client().uploadedFile.findMany({
76
101
  where: {
@@ -90,7 +115,21 @@ export class PrismaFileStore implements FileStore {
90
115
  });
91
116
  }
92
117
 
93
- private toRecord(row: UploadedFileRow): UploadedFileRecord {
118
+ private claimTokenMatches(
119
+ expectedHash: string | null,
120
+ presentedClaimToken: string | undefined,
121
+ ): boolean {
122
+ if (!expectedHash || !presentedClaimToken) {
123
+ return false;
124
+ }
125
+
126
+ const presentedHash = createHash('sha256').update(presentedClaimToken).digest('hex');
127
+ const expected = Buffer.from(expectedHash, 'hex');
128
+ const presented = Buffer.from(presentedHash, 'hex');
129
+ return expected.length === presented.length && timingSafeEqual(expected, presented);
130
+ }
131
+
132
+ private toRecord(row: UploadedFileRow, claimTokenValid?: boolean): UploadedFileRecord {
94
133
  return {
95
134
  id: row.id,
96
135
  storageKey: row.storageKey,
@@ -99,6 +138,7 @@ export class PrismaFileStore implements FileStore {
99
138
  size: row.size,
100
139
  checksum: row.checksum,
101
140
  ownerId: row.ownerId,
141
+ claimTokenValid,
102
142
  orgId: row.orgId,
103
143
  status: row.status as FileStatus,
104
144
  submissionId: row.submissionId,
@@ -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,
@@ -0,0 +1,66 @@
1
+ import {
2
+ Controller,
3
+ HttpCode,
4
+ Param,
5
+ Post,
6
+ Query,
7
+ UploadedFile,
8
+ UseGuards,
9
+ UseInterceptors,
10
+ } from '@nestjs/common';
11
+ import {
12
+ ApiBody,
13
+ ApiConsumes,
14
+ ApiOkResponse,
15
+ ApiOperation,
16
+ ApiParam,
17
+ ApiTags,
18
+ } from '@nestjs/swagger';
19
+ import { Throttle } from '@nestjs/throttler';
20
+ import { ApiProtectedErrorResponses } from '../../../common/swagger/api-error-responses';
21
+ import { Public } from '../../access-control/presentation/public.decorator';
22
+ import { OrgScopeGuard } from '../../request-context/presentation/org-scope.guard';
23
+ import { FormsFilesService } from '../application/services/forms-files.service';
24
+ import { PublicFileUploadResponseDto } from '../dto/public-file-upload-response.dto';
25
+ import { UploadFileQueryDto } from '../dto/upload-file-query.dto';
26
+ import { FormsUploadInterceptor } from './forms-upload.interceptor';
27
+
28
+ @ApiTags('Forms (public)')
29
+ @ApiParam({ name: 'orgId', description: 'Organisation ID.', format: 'uuid' })
30
+ @ApiParam({ name: 'formKey', description: 'Form definition key.' })
31
+ @ApiProtectedErrorResponses(400, 404, 413, 415, 429)
32
+ @Controller('public/organisations/:orgId/forms/:formKey/files')
33
+ @UseGuards(OrgScopeGuard)
34
+ export class PublicFormsFilesController {
35
+ constructor(private readonly filesService: FormsFilesService) {}
36
+
37
+ @Post()
38
+ @Public()
39
+ @HttpCode(200)
40
+ @Throttle({ default: { limit: 5, ttl: 60_000 } })
41
+ @UseInterceptors(FormsUploadInterceptor)
42
+ @ApiConsumes('multipart/form-data')
43
+ @ApiBody({
44
+ schema: {
45
+ type: 'object',
46
+ properties: { file: { type: 'string', format: 'binary' } },
47
+ required: ['file'],
48
+ },
49
+ })
50
+ @ApiOperation({
51
+ summary:
52
+ 'Upload a file for a public form field. Submit must include the returned fileId and uploadToken.',
53
+ })
54
+ @ApiOkResponse({
55
+ description: 'Uploaded public file reference.',
56
+ type: PublicFileUploadResponseDto,
57
+ })
58
+ upload(
59
+ @Param('orgId') orgId: string,
60
+ @Param('formKey') formKey: string,
61
+ @Query() query: UploadFileQueryDto,
62
+ @UploadedFile() file: { originalname: string; size: number; buffer: Buffer } | undefined,
63
+ ) {
64
+ return this.filesService.uploadPublic(orgId, formKey, query, file);
65
+ }
66
+ }