@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
package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts
CHANGED
|
@@ -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'
|
|
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(
|
|
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
|
};
|
package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts
CHANGED
|
@@ -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
|
|
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-
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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() -
|
|
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('
|
|
190
|
-
const
|
|
191
|
-
|
|
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
|
|
197
|
-
|
|
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 ────────────────────────────────────────────────────────────────
|