@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.
- package/package.json +1 -1
- package/template/.env.example +3 -0
- package/template/_package.json +7 -3
- package/template/docs/FORMS.md +87 -18
- package/template/docs/FORMS_CHECKLIST.md +36 -26
- package/template/docs/REPORTS.md +22 -4
- package/template/docs/REPORTS_CHECKLIST.md +150 -95
- package/template/prisma/migrations/20260616000000_add_form_outbox_claimed_by/migration.sql +5 -0
- package/template/prisma/migrations/20260625000000_form_builder_public_uploads/migration.sql +7 -0
- package/template/prisma/schema.prisma +13 -6
- package/template/scripts/push-report.ts +123 -0
- package/template/src/app.module.ts +4 -3
- package/template/src/common/swagger/api-error-responses.ts +8 -2
- package/template/src/config/env.validation.ts +3 -0
- package/template/src/config/forms.config.ts +1 -0
- package/template/src/config/reports.config.ts +2 -0
- package/template/src/modules/forms/application/services/data-sources/conference-tracks.data-source.ts +32 -0
- package/template/src/modules/forms/application/services/forms-files.service.ts +143 -39
- package/template/src/modules/forms/application/services/forms-public.service.ts +2 -1
- package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +5 -3
- package/template/src/modules/forms/application/services/handlers/webhook-delivery.transport.ts +319 -0
- package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +64 -16
- package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +40 -18
- package/template/src/modules/forms/dto/public-file-upload-response.dto.ts +10 -0
- package/template/src/modules/forms/dto/public-submit-form.dto.ts +9 -0
- package/template/src/modules/forms/forms.module.ts +12 -2
- package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +43 -3
- package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +82 -59
- package/template/src/modules/forms/presentation/public-forms-files.controller.ts +66 -0
- package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +81 -0
- package/template/src/modules/reports/application/services/reports-exports.service.ts +6 -2
- package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +43 -30
- package/template/src/modules/settings/types/setting-definitions.ts +4 -0
- package/template/test/forms-captcha.e2e-spec.ts +163 -0
- package/template/test/forms-files.e2e-spec.ts +42 -20
- package/template/test/forms-outbox.e2e-spec.ts +271 -10
- package/template/test/forms-public.e2e-spec.ts +24 -0
- package/template/test/forms-submissions.e2e-spec.ts +2 -11
- package/template/test/forms-throttling.e2e-spec.ts +146 -0
- package/template/test/forms-webhooks.e2e-spec.ts +150 -8
- package/template/test/jest-e2e.json +1 -0
- package/template/test/reports-advanced.e2e-spec.ts +13 -0
- package/template/test/reports-query.e2e-spec.ts +52 -0
- package/template/test/reports-tiers.e2e-spec.ts +106 -20
- 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,
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
26
|
-
const headers: Record<string, string> = {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
48
|
+
if (resolvedSignature) {
|
|
49
|
+
headers['x-forms-signature'] = resolvedSignature;
|
|
50
|
+
}
|
|
51
|
+
const response = await deliverWebhookRequest(url, {
|
|
33
52
|
headers,
|
|
34
|
-
body:
|
|
35
|
-
|
|
53
|
+
body: requestBody,
|
|
54
|
+
timeoutMs: 10_000,
|
|
36
55
|
});
|
|
37
|
-
if (
|
|
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.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
await this.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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<
|
|
86
|
-
await this.client().formOutboxJob.
|
|
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(
|
|
93
|
-
|
|
94
|
-
|
|
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<
|
|
105
|
-
await this.client().formOutboxJob.
|
|
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
|
+
}
|