@ftisindia/create-app 0.2.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ftisindia/create-app",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "One-command scaffolder for the Phase 1 NestJS foundation starter.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  "format": "prettier --write .",
11
11
  "format:check": "prettier --check .",
12
12
  "forms:push": "ts-node scripts/push-form.ts",
13
+ "reports:push": "ts-node scripts/push-report.ts",
13
14
  "gen:form": "node scripts/gen-form.mjs",
14
15
  "gen:module": "node scripts/gen-module.mjs",
15
16
  "lint": "eslint .",
@@ -33,8 +34,8 @@
33
34
  },
34
35
  "dependencies": {
35
36
  "@casl/ability": "6.8.1",
36
- "@ftisindia/form-builder": "^0.1.0",
37
- "@ftisindia/report-builder": "^0.1.0",
37
+ "@ftisindia/form-builder": "^1.0.0",
38
+ "@ftisindia/report-builder": "^0.3.0",
38
39
  "@nestjs/common": "11.1.24",
39
40
  "@nestjs/config": "4.0.4",
40
41
  "@nestjs/core": "11.1.24",
@@ -49,6 +49,14 @@ rolls back everything.
49
49
  Implement the engine interface, decorate the provider, list it in a module —
50
50
  the registry bootstrap finds it at startup:
51
51
 
52
+ Definitions can reference a `dataSource.key` only after a matching provider is
53
+ registered. This template ships `ConferenceTracksDataSource` in
54
+ `src/modules/forms/application/services/data-sources/` for the bundled
55
+ `abstract-submission` example (`conference-tracks`). It is intentionally static
56
+ so fresh apps can publish and render the example immediately; replace it with an
57
+ org-scoped Prisma/API lookup before using real production master data. An
58
+ unregistered source fails save/publish lint with `UNKNOWN_DATA_SOURCE`.
59
+
52
60
  ```ts
53
61
  import { FormDataSource } from './modules/forms/infrastructure/registry/form-extension.decorators';
54
62
  import type { DataSourceDef, DataSourceContext, DataSourceOption } from '@ftisindia/form-builder';
@@ -95,9 +103,51 @@ Flip a published definition with `POST .../public-access { "access": "public" }`
95
103
  (requires `forms.managePublicAccess`, writes an audit row). Public routes are
96
104
  throttled tighter than the app default; the engine enforces the per-IP/day
97
105
  cap (`forms.maxSubmissionsPerIpPerDay` setting, per-form override in the
98
- definition's `settings`) and the captcha seam (`settings.captcha: true`
106
+ definition's `settings`) and the captcha seam (`settings.captcha: true` -
99
107
  publish fails closed unless a `CaptchaVerifier` is bound to the
100
- `FORMS_CAPTCHA_VERIFIER` token). Public forms cannot contain file fields.
108
+ `FORMS_CAPTCHA_VERIFIER` token). Public file fields are allowed through the
109
+ public upload endpoint: upload first, then submit `{ fileId }` in `data` and the
110
+ returned token in `uploadTokens[fileId]`. Anonymous upload volume is capped by
111
+ `forms.maxPublicUploadsPerIpPerDay`. Like the public submission cap, it depends on a resolvable request IP; configure proxy/IP handling correctly in deployments behind a reverse proxy.
112
+
113
+ A captcha-enabled public form keeps failing closed until the app binds a real
114
+ verifier. Example Turnstile-style adapter:
115
+
116
+ ```ts
117
+ import { Injectable, Module } from '@nestjs/common';
118
+ import { ConfigService } from '@nestjs/config';
119
+ import type { CaptchaVerifier, FormsContext } from '@ftisindia/form-builder';
120
+ import { FORMS_CAPTCHA_VERIFIER } from './modules/forms/forms.tokens';
121
+
122
+ @Injectable()
123
+ export class TurnstileCaptchaVerifier implements CaptchaVerifier {
124
+ constructor(private readonly config: ConfigService) {}
125
+
126
+ async verify(token: string, _ctx: FormsContext): Promise<boolean> {
127
+ const body = new URLSearchParams({
128
+ secret: this.config.getOrThrow<string>('TURNSTILE_SECRET_KEY'),
129
+ response: token,
130
+ });
131
+ const response = await fetch(
132
+ 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
133
+ { method: 'POST', body },
134
+ );
135
+ const result = (await response.json()) as { success?: boolean };
136
+ return result.success === true;
137
+ }
138
+ }
139
+
140
+ @Module({
141
+ providers: [
142
+ { provide: FORMS_CAPTCHA_VERIFIER, useClass: TurnstileCaptchaVerifier },
143
+ ],
144
+ })
145
+ export class AppCaptchaModule {}
146
+ ```
147
+
148
+ After that binding is present, a definition can opt in with
149
+ `settings.captcha: true`; without the binding, publish fails before the form can
150
+ go public.
101
151
 
102
152
  ## Files
103
153
 
@@ -161,7 +211,7 @@ npm run forms:push -- --file src/modules/forms/definitions/customer-feedback.for
161
211
  --org demo --user owner@example.com --publish
162
212
  ```
163
213
 
164
- `gen:form` writes `src/modules/forms/definitions/<key>.form.json` and
214
+ Example files under `src/modules/forms/examples/` are reference fixtures; copy or scaffold into `src/modules/forms/definitions/` before pushing. `gen:form` writes `src/modules/forms/definitions/<key>.form.json` and
165
215
  `test/<key>.form.spec.ts` (meta-schema + publish-stage lint — fails `npm test`
166
216
  the moment the definition would be rejected by the API). `forms:push` loads a
167
217
  definition file into an org through the real engine path: it impersonates an
@@ -23,6 +23,7 @@ local PostgreSQL `postgres/root`: `npm run dogfood`, generated app
23
23
 
24
24
  - [x] All 11 form permission keys exist in `permissionKeys`, are seeded, and `test/forms-permission-sync.spec.ts` passes (engine <-> template set equality).
25
25
  - [x] Every protected form route is in `routePermissionRegistry` (the boot-time registry validator passes).
26
+ - [x] The bundled `ConferenceTracksDataSource` backs the abstract-submission example; production data sources remain app-provided `@FormDataSource()` providers.
26
27
 
27
28
  ## Gating
28
29
 
@@ -45,10 +46,11 @@ local PostgreSQL `postgres/root`: `npm run dogfood`, generated app
45
46
 
46
47
  - [x] Public path: `@Public()` render/submit works without membership; a non-public form 404s on the public routes; org-scoped storage still enforced; permissioned paths unaffected.
47
48
  - [x] Tier-1 abuse baseline active: public routes throttled tighter than the app default; per-IP/day caps read from typed settings (with per-form override) and enforced; IP/UA stamped on anonymous submissions; captcha seam invoked when enabled; captcha-enabled-without-adapter fails at publish, not at submit.
49
+ - [x] Captcha remains fail-closed by default: apps must bind a real `CaptchaVerifier` to `FORMS_CAPTCHA_VERIFIER` before publishing public forms with `settings.captcha: true`.
48
50
 
49
51
  ## Files
50
52
 
51
- - [x] Upload -> submit -> `LINKED` flow proven; a foreign user's `fileId` is rejected (IDOR); GC sweeps orphaned `TEMPORARY` files past TTL; public forms with file fields are rejected at publish.
53
+ - [x] Upload -> submit -> `LINKED` flow proven; a foreign user's `fileId` is rejected (IDOR); public uploads require the per-file token returned by the public upload endpoint; GC sweeps orphaned `TEMPORARY` files past TTL.
52
54
 
53
55
  ## Webhooks
54
56
 
@@ -50,8 +50,17 @@ definition AND again at execute time.
50
50
 
51
51
  Example definitions live in `src/modules/reports/examples/` — `org-members`
52
52
  (custom source, no form builder anywhere) and `abstract-review-board` (the
53
- form-backed quick path, indexed tier).
53
+ form-backed quick path, indexed tier).
54
+
55
+ ## Definitions as code (`reports:push`)
54
56
 
57
+ ```bash
58
+ npm run reports:push -- --file src/modules/reports/definitions/member-directory.report.json \
59
+ --org demo --user owner@example.com --publish
60
+ ```
61
+
62
+ `src/modules/reports/definitions/*.report.json` files are source files; they are not loaded automatically at boot. `reports:push` loads a definition into an org through the real service path, so member RBAC, save/publish linting, versioning, and audit rows match the HTTP API. Re-pushing unchanged content is a no-op; changed content creates the next draft version, and `--publish` publishes it.
63
+
55
64
  ## One contract: the QuerySpec
56
65
 
57
66
  The frontend never builds queries. Every grid interaction posts a declarative
@@ -0,0 +1,7 @@
1
+ -- AlterTable
2
+ ALTER TABLE "UploadedFile" ALTER COLUMN "ownerId" DROP NOT NULL,
3
+ ADD COLUMN "claimTokenHash" TEXT,
4
+ ADD COLUMN "uploadedIp" TEXT;
5
+
6
+ -- CreateIndex
7
+ CREATE INDEX "UploadedFile_orgId_uploadedIp_createdAt_idx" ON "UploadedFile"("orgId", "uploadedIp", "createdAt");
@@ -298,7 +298,7 @@ model AuditLog {
298
298
  @@index([createdAt])
299
299
  }
300
300
  // ─────────────────────────────────────────────────────────────────────────────
301
- // @ftisindia/form-builder canonical Prisma model snippet (ENGINE_SCHEMA_VERSION 1)
301
+ // @ftisindia/form-builder - canonical Prisma model snippet (ENGINE_SCHEMA_VERSION 3)
302
302
  //
303
303
  // The APP owns schema.prisma and the migration history (ecosystem guide §10.1).
304
304
  // Copy these models into your app's prisma/schema.prisma verbatim and run
@@ -441,9 +441,11 @@ model UploadedFile {
441
441
  size Int
442
442
  // sha256 hex.
443
443
  checksum String
444
- // Two-factor ownership: uploader identity AND tenant scope (§10).
445
- ownerId String
446
- orgId String
444
+ // Authenticated uploads have ownerId; anonymous public uploads have a claim token.
445
+ ownerId String?
446
+ claimTokenHash String?
447
+ uploadedIp String?
448
+ orgId String
447
449
 
448
450
  status FileStatus @default(TEMPORARY)
449
451
  submissionId String?
@@ -451,7 +453,8 @@ model UploadedFile {
451
453
  createdAt DateTime @default(now())
452
454
 
453
455
  @@index([orgId, status])
454
- @@index([ownerId, status])
456
+ @@index([ownerId, status])
457
+ @@index([orgId, uploadedIp, createdAt])
455
458
  @@index([status, createdAt])
456
459
  }
457
460
 
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Push a report-definition JSON file into an organisation through the real
3
+ * report engine path: member RBAC, save/publish linting, versioning, and audit
4
+ * rows all stay intact.
5
+ *
6
+ * npm run reports:push -- --file src/modules/reports/definitions/my-report.report.json \
7
+ * --org <org-id-or-slug> --user <member-email> [--publish]
8
+ *
9
+ * Idempotent: if the file matches the latest stored version's authored content,
10
+ * nothing new is created. With --publish, a matching DRAFT is published in place.
11
+ * Runs under ts-node because it bootstraps Nest.
12
+ */
13
+ import { readFileSync } from 'node:fs';
14
+ import { NestFactory } from '@nestjs/core';
15
+ import type { ReportDefinition } from '@ftisindia/report-builder';
16
+ import { AppModule } from '../src/app.module';
17
+ import { PrismaService } from '../src/database/prisma/prisma.service';
18
+ import { RbacCacheService } from '../src/modules/access-control/application/services/rbac-cache.service';
19
+ import { RequestContextService } from '../src/modules/request-context/application/services/request-context.service';
20
+ import { ReportsDefinitionsService } from '../src/modules/reports/application/services/reports-definitions.service';
21
+ import { CreateReportDefinitionDto } from '../src/modules/reports/dto/create-report-definition.dto';
22
+
23
+ function argValue(flag: string): string | undefined {
24
+ const index = process.argv.indexOf(flag);
25
+ return index !== -1 ? process.argv[index + 1] : undefined;
26
+ }
27
+
28
+ function stableStringify(value: unknown): string {
29
+ if (Array.isArray(value)) {
30
+ return `[${value.map(stableStringify).join(',')}]`;
31
+ }
32
+ if (value && typeof value === 'object') {
33
+ const entries = Object.entries(value as Record<string, unknown>)
34
+ .filter(([, v]) => v !== undefined)
35
+ .sort(([a], [b]) => a.localeCompare(b))
36
+ .map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`);
37
+ return `{${entries.join(',')}}`;
38
+ }
39
+ return JSON.stringify(value);
40
+ }
41
+
42
+ /** Version/status are managed by the engine - compare authored content only. */
43
+ function normalized(schema: ReportDefinition): string {
44
+ const { version: _version, status: _status, ...rest } = schema;
45
+ return stableStringify(rest);
46
+ }
47
+
48
+ async function main() {
49
+ const file = argValue('--file');
50
+ const orgRef = argValue('--org');
51
+ const userEmail = argValue('--user');
52
+ const publish = process.argv.includes('--publish');
53
+ if (!file || !orgRef || !userEmail) {
54
+ throw new Error(
55
+ 'Usage: npm run reports:push -- --file <path> --org <org-id-or-slug> --user <member-email> [--publish]',
56
+ );
57
+ }
58
+
59
+ const definition = JSON.parse(readFileSync(file, 'utf8')) as ReportDefinition;
60
+ if (!definition.key) {
61
+ throw new Error(`${file} does not look like a report definition (missing "key").`);
62
+ }
63
+
64
+ const app = await NestFactory.createApplicationContext(AppModule, {
65
+ logger: ['error', 'warn'],
66
+ });
67
+ try {
68
+ const prisma = app.get(PrismaService);
69
+ const user = await prisma.user.findFirst({ where: { email: userEmail } });
70
+ if (!user) {
71
+ throw new Error(`No user found with email "${userEmail}".`);
72
+ }
73
+ const org = await prisma.organisation.findFirst({
74
+ where: { OR: [{ id: orgRef }, { slug: orgRef }] },
75
+ });
76
+ if (!org) {
77
+ throw new Error(`No organisation found with id or slug "${orgRef}".`);
78
+ }
79
+
80
+ const rbac = await app.get(RbacCacheService).getContext(user.id, org.id);
81
+ const requestContext = app.get(RequestContextService);
82
+ const definitions = app.get(ReportsDefinitionsService);
83
+
84
+ await requestContext.run(
85
+ { source: 'worker', orgId: org.id, userId: user.id, rbac },
86
+ async () => {
87
+ const latest = await prisma.reportDefinition.findFirst({
88
+ where: { orgId: org.id, key: definition.key },
89
+ orderBy: { version: 'desc' },
90
+ });
91
+
92
+ if (latest && normalized(latest.schema as unknown as ReportDefinition) === normalized(definition)) {
93
+ if (publish && latest.status === 'DRAFT') {
94
+ await definitions.publish(org.id, definition.key);
95
+ console.log(`Unchanged content - published existing draft v${latest.version}.`);
96
+ } else {
97
+ console.log(
98
+ `No changes - "${definition.key}" v${latest.version} (${latest.status}) already matches ${file}.`,
99
+ );
100
+ }
101
+ return;
102
+ }
103
+
104
+ const created = await definitions.create(
105
+ org.id,
106
+ definition as unknown as CreateReportDefinitionDto,
107
+ );
108
+ console.log(`Created "${created.key}" v${created.version} (DRAFT).`);
109
+ if (publish) {
110
+ await definitions.publish(org.id, created.key);
111
+ console.log(`Published "${created.key}" v${created.version}.`);
112
+ }
113
+ },
114
+ );
115
+ } finally {
116
+ await app.close();
117
+ }
118
+ }
119
+
120
+ main().catch((error) => {
121
+ console.error(error instanceof Error ? error.message : error);
122
+ process.exit(1);
123
+ });
@@ -6,13 +6,15 @@ import {
6
6
  ApiGoneResponse,
7
7
  ApiInternalServerErrorResponse,
8
8
  ApiNotFoundResponse,
9
+ ApiPayloadTooLargeResponse,
9
10
  ApiServiceUnavailableResponse,
10
11
  ApiTooManyRequestsResponse,
11
12
  ApiUnauthorizedResponse,
13
+ ApiUnsupportedMediaTypeResponse,
12
14
  } from '@nestjs/swagger';
13
15
  import { ErrorResponseDto } from '../dto/error-response.dto';
14
16
 
15
- type ApiErrorStatus = 400 | 401 | 403 | 404 | 409 | 410 | 429 | 500 | 503;
17
+ type ApiErrorStatus = 400 | 401 | 403 | 404 | 409 | 410 | 413 | 415 | 429 | 500 | 503;
16
18
 
17
19
  const errorResponseFactories = {
18
20
  400: ApiBadRequestResponse,
@@ -21,6 +23,8 @@ const errorResponseFactories = {
21
23
  404: ApiNotFoundResponse,
22
24
  409: ApiConflictResponse,
23
25
  410: ApiGoneResponse,
26
+ 413: ApiPayloadTooLargeResponse,
27
+ 415: ApiUnsupportedMediaTypeResponse,
24
28
  429: ApiTooManyRequestsResponse,
25
29
  500: ApiInternalServerErrorResponse,
26
30
  503: ApiServiceUnavailableResponse,
@@ -33,6 +37,8 @@ const descriptions: Record<ApiErrorStatus, string> = {
33
37
  404: 'The requested resource was not found.',
34
38
  409: 'The request conflicts with current resource state.',
35
39
  410: 'The resource is no longer available.',
40
+ 413: 'The request payload is too large.',
41
+ 415: 'The uploaded media type is not supported.',
36
42
  429: 'Too many requests.',
37
43
  500: 'Unexpected server error.',
38
44
  503: 'The service is temporarily unavailable.',
@@ -51,4 +57,4 @@ export function ApiErrorResponses(...statuses: ApiErrorStatus[]) {
51
57
 
52
58
  export function ApiProtectedErrorResponses(...extraStatuses: ApiErrorStatus[]) {
53
59
  return ApiErrorResponses(400, 401, 403, ...extraStatuses);
54
- }
60
+ }
@@ -0,0 +1,32 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import type {
3
+ DataSourceContext,
4
+ DataSourceDef,
5
+ DataSourceOption,
6
+ } from '@ftisindia/form-builder';
7
+ import { FormDataSource } from '../../../infrastructure/registry/form-extension.decorators';
8
+
9
+ /**
10
+ * Example data source for src/modules/forms/examples/abstract-submission.form.json.
11
+ *
12
+ * Real apps should replace this static list with an org-scoped Prisma/API
13
+ * lookup using ctx.orgId. Keeping the shipped example self-contained makes
14
+ * the data-source registry usable immediately after scaffolding.
15
+ */
16
+ @Injectable()
17
+ @FormDataSource()
18
+ export class ConferenceTracksDataSource implements DataSourceDef {
19
+ readonly key = 'conference-tracks';
20
+
21
+ fetch(
22
+ _params: Record<string, unknown>,
23
+ _ctx: DataSourceContext,
24
+ ): Promise<DataSourceOption[]> {
25
+ return Promise.resolve([
26
+ { value: 'clinical-research', label: 'Clinical Research' },
27
+ { value: 'public-health', label: 'Public Health' },
28
+ { value: 'biomedical-engineering', label: 'Biomedical Engineering' },
29
+ { value: 'data-science', label: 'Data Science' },
30
+ ]);
31
+ }
32
+ }
@@ -1,7 +1,9 @@
1
- import { createHash, randomUUID } from 'node:crypto';
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
2
  import {
3
3
  BadRequestException,
4
- ConflictException,
4
+ ConflictException,
5
+ HttpException,
6
+ HttpStatus,
5
7
  Injectable,
6
8
  NotFoundException,
7
9
  PayloadTooLargeException,
@@ -12,7 +14,12 @@ import {
12
14
  acceptSatisfied,
13
15
  walkFields,
14
16
  } from '@ftisindia/form-builder';
15
- import type { FieldDef, FormDefinitionRecord, UploadedFileRecord } from '@ftisindia/form-builder';
17
+ import type {
18
+ FieldDef,
19
+ FormDefinitionRecord,
20
+ OrgFormsPolicy,
21
+ UploadedFileRecord,
22
+ } from '@ftisindia/form-builder';
16
23
  import { AuthenticatedUser } from '../../../auth/types/authenticated-user';
17
24
  import { RequestContextService } from '../../../request-context/application/services/request-context.service';
18
25
  import { UploadFileQueryDto } from '../../dto/upload-file-query.dto';
@@ -23,15 +30,20 @@ import { LocalDiskStorageAdapter } from '../../infrastructure/storage/local-disk
23
30
  import { FormsSettingsReader } from './forms-settings-reader.service';
24
31
 
25
32
  const sniffer = new DefaultMagicByteSniffer();
33
+ const DAY_MS = 24 * 60 * 60 * 1000;
34
+
35
+ type UploadedMultipartFile = {
36
+ originalname: string;
37
+ size: number;
38
+ buffer: Buffer;
39
+ };
26
40
 
27
41
  /**
28
- * Upload-before-submit (design doc §10): bytes are stored here, the form
29
- * submission later carries only the file id. Every trust decision is
30
- * server-side MIME type is sniffed from content (never the client header),
31
- * size is enforced against both the field's and the org's caps, sha256 is
32
- * computed, and the record is bound to uploader AND org (two-factor
33
- * ownership). Files start TEMPORARY; submit-time linking promotes to LINKED;
34
- * the GC sweeps the rest after the TTL.
42
+ * Upload-before-submit (design doc section 10): bytes are stored here, the form
43
+ * submission later carries only the file id. Authenticated uploads bind to the
44
+ * user id; public uploads bind to an anonymous claim token returned once by the
45
+ * public upload endpoint. Submit-time linking re-checks the same ownership or
46
+ * token claim before promoting the file to LINKED.
35
47
  */
36
48
  @Injectable()
37
49
  export class FormsFilesService {
@@ -48,24 +60,90 @@ export class FormsFilesService {
48
60
  orgId: string,
49
61
  formKey: string,
50
62
  query: UploadFileQueryDto,
51
- file: { originalname: string; size: number; buffer: Buffer } | undefined,
63
+ file: UploadedMultipartFile | undefined,
52
64
  user: AuthenticatedUser,
53
65
  ) {
54
66
  this.requestContext.assertOrgScope(orgId);
67
+ this.assertUploadRequest(query, file);
68
+
69
+ const definition = await this.resolveTargetDefinition(orgId, formKey, query);
70
+ const field = this.requireFileField(definition, query.field);
71
+ const policy = await this.settings.policyFor(orgId);
72
+ const record = await this.storeUpload(orgId, field, file, policy, {
73
+ ownerId: user.id,
74
+ });
75
+
76
+ return this.toResponse(record);
77
+ }
78
+
79
+ async uploadPublic(
80
+ orgId: string,
81
+ formKey: string,
82
+ query: UploadFileQueryDto,
83
+ file: UploadedMultipartFile | undefined,
84
+ ) {
85
+ this.requestContext.merge({ orgId });
86
+ this.assertUploadRequest(query, file);
87
+ if (query.submissionId) {
88
+ throw new BadRequestException('Public uploads cannot target draft submissions.');
89
+ }
90
+
91
+ const definition = await this.resolvePublicDefinition(orgId, formKey, query);
92
+ const field = this.requireFileField(definition, query.field);
93
+ const policy = await this.settings.policyFor(orgId);
94
+ await this.enforcePublicUploadCap(orgId, policy);
95
+
96
+ const uploadToken = randomBytes(32).toString('base64url');
97
+ const record = await this.storeUpload(orgId, field, file, policy, {
98
+ ownerId: null,
99
+ claimTokenHash: createHash('sha256').update(uploadToken).digest('hex'),
100
+ uploadedIp: this.requestContext.getIpAddress() ?? null,
101
+ });
102
+
103
+ return { ...this.toResponse(record), uploadToken };
104
+ }
105
+
106
+ async getMeta(orgId: string, fileId: string) {
107
+ this.requestContext.assertOrgScope(orgId);
108
+ const record = await this.files.findById(orgId, fileId);
109
+ if (!record) {
110
+ throw new NotFoundException('File was not found.');
111
+ }
112
+ return this.toResponse(record);
113
+ }
114
+
115
+ /** Storage keys never leave the server. */
116
+ private toResponse(record: UploadedFileRecord) {
117
+ return {
118
+ fileId: record.id,
119
+ originalName: record.originalName,
120
+ mimeType: record.mimeType,
121
+ size: record.size,
122
+ checksum: record.checksum,
123
+ status: record.status,
124
+ createdAt: record.createdAt,
125
+ };
126
+ }
127
+
128
+ private assertUploadRequest(
129
+ query: UploadFileQueryDto,
130
+ file: UploadedMultipartFile | undefined,
131
+ ): asserts file is UploadedMultipartFile {
55
132
  if (!file || !file.buffer || file.size === 0) {
56
133
  throw new BadRequestException('A non-empty file is required (multipart field "file").');
57
134
  }
58
135
  if (!query.field) {
59
136
  throw new BadRequestException('The "field" query parameter is required.');
60
137
  }
138
+ }
61
139
 
62
- const definition = await this.resolveTargetDefinition(orgId, formKey, query);
63
- const field = this.findFileField(definition.schema.fields, query.field);
64
- if (!field) {
65
- throw new NotFoundException(`Form has no file field named "${query.field}".`);
66
- }
67
-
68
- const policy = await this.settings.policyFor(orgId);
140
+ private async storeUpload(
141
+ orgId: string,
142
+ field: FieldDef,
143
+ file: UploadedMultipartFile,
144
+ policy: OrgFormsPolicy,
145
+ ownership: { ownerId: string | null; claimTokenHash?: string; uploadedIp?: string | null },
146
+ ) {
69
147
  const capMb = Math.min(field.maxSizeMb ?? Infinity, policy.maxFileSizeMb ?? Infinity);
70
148
  if (Number.isFinite(capMb) && file.size > capMb * 1024 * 1024) {
71
149
  throw new PayloadTooLargeException(`File exceeds the ${capMb} MB limit for this field.`);
@@ -84,39 +162,38 @@ export class FormsFilesService {
84
162
  const checksum = createHash('sha256').update(file.buffer).digest('hex');
85
163
  const storageKey = `${orgId}/${randomUUID()}`;
86
164
  await this.storage.put(storageKey, file.buffer);
87
- const record = await this.files.create({
165
+ return this.files.create({
88
166
  storageKey,
89
167
  originalName: file.originalname,
90
168
  mimeType: sniffed.mimeType,
91
169
  size: file.size,
92
170
  checksum,
93
- ownerId: user.id,
171
+ ownerId: ownership.ownerId,
172
+ claimTokenHash: ownership.claimTokenHash ?? null,
173
+ uploadedIp: ownership.uploadedIp ?? null,
94
174
  orgId,
95
175
  status: 'TEMPORARY',
96
176
  });
97
- return this.toResponse(record);
98
177
  }
99
178
 
100
- async getMeta(orgId: string, fileId: string) {
101
- this.requestContext.assertOrgScope(orgId);
102
- const record = await this.files.findById(orgId, fileId);
103
- if (!record) {
104
- throw new NotFoundException('File was not found.');
179
+ private async enforcePublicUploadCap(orgId: string, policy: OrgFormsPolicy): Promise<void> {
180
+ const cap = policy.maxPublicUploadsPerIpPerDay;
181
+ const ipAddress = this.requestContext.getIpAddress();
182
+ if (!cap || cap <= 0 || !ipAddress) {
183
+ return;
105
184
  }
106
- return this.toResponse(record);
107
- }
108
185
 
109
- /** Storage keys never leave the server. */
110
- private toResponse(record: UploadedFileRecord) {
111
- return {
112
- fileId: record.id,
113
- originalName: record.originalName,
114
- mimeType: record.mimeType,
115
- size: record.size,
116
- checksum: record.checksum,
117
- status: record.status,
118
- createdAt: record.createdAt,
119
- };
186
+ const count = await this.files.countPublicUploadsByIpSince(
187
+ orgId,
188
+ ipAddress,
189
+ new Date(Date.now() - DAY_MS),
190
+ );
191
+ if (count >= cap) {
192
+ throw new HttpException(
193
+ 'Public upload limit reached. Please try again later.',
194
+ HttpStatus.TOO_MANY_REQUESTS,
195
+ );
196
+ }
120
197
  }
121
198
 
122
199
  private async resolveTargetDefinition(
@@ -152,6 +229,33 @@ export class FormsFilesService {
152
229
  return record;
153
230
  }
154
231
 
232
+ private async resolvePublicDefinition(
233
+ orgId: string,
234
+ formKey: string,
235
+ query: UploadFileQueryDto,
236
+ ): Promise<FormDefinitionRecord> {
237
+ const record =
238
+ query.version !== undefined
239
+ ? await this.definitions.findByKeyVersion(orgId, formKey, query.version)
240
+ : await this.definitions.findLatest(orgId, formKey, 'PUBLISHED');
241
+ if (
242
+ !record ||
243
+ record.status !== 'PUBLISHED' ||
244
+ (record.schema.settings?.access ?? 'private') !== 'public'
245
+ ) {
246
+ throw new NotFoundException('Form was not found.');
247
+ }
248
+ return record;
249
+ }
250
+
251
+ private requireFileField(definition: FormDefinitionRecord, name: string): FieldDef {
252
+ const field = this.findFileField(definition.schema.fields, name);
253
+ if (!field) {
254
+ throw new NotFoundException(`Form has no file field named "${name}".`);
255
+ }
256
+ return field;
257
+ }
258
+
155
259
  private findFileField(fields: FieldDef[], name: string): FieldDef | undefined {
156
260
  let found: FieldDef | undefined;
157
261
  walkFields(fields, (field) => {
@@ -38,7 +38,8 @@ export class FormsPublicService {
38
38
  formKey,
39
39
  version: dto.version,
40
40
  data: dto.data,
41
- public: true,
41
+ public: true,
42
+ uploadTokens: dto.uploadTokens,
42
43
  captchaToken: dto.captchaToken,
43
44
  },
44
45
  this.formsContext,
@@ -9,7 +9,8 @@ const FORMS_SETTING_KEYS = [
9
9
  'forms.maxFileSizeMb',
10
10
  'forms.enableRuleIteration',
11
11
  'forms.virusScanRequired',
12
- 'forms.maxSubmissionsPerIpPerDay',
12
+ 'forms.maxSubmissionsPerIpPerDay',
13
+ 'forms.maxPublicUploadsPerIpPerDay',
13
14
  'forms.webhookAllowedHosts',
14
15
  ] as const;
15
16
 
@@ -18,7 +19,7 @@ const FORMS_SETTING_KEYS = [
18
19
  * setting definitions' defaults for unset keys (the SettingsService 404s on
19
20
  * unset keys, which is wrong for policy reads). The result feeds every
20
21
  * engine call that takes an OrgFormsPolicy — dangerous-action gating, rule
21
- * iteration, file caps, public-submission caps.
22
+ * iteration, file caps, public submission/upload caps.
22
23
  */
23
24
  @Injectable()
24
25
  export class FormsSettingsReader {
@@ -45,7 +46,8 @@ export class FormsSettingsReader {
45
46
  enableRuleIteration: read<boolean>('forms.enableRuleIteration'),
46
47
  maxFileSizeMb: read<number>('forms.maxFileSizeMb'),
47
48
  virusScanRequired: read<boolean>('forms.virusScanRequired'),
48
- maxSubmissionsPerIpPerDay: read<number>('forms.maxSubmissionsPerIpPerDay'),
49
+ maxSubmissionsPerIpPerDay: read<number>('forms.maxSubmissionsPerIpPerDay'),
50
+ maxPublicUploadsPerIpPerDay: read<number>('forms.maxPublicUploadsPerIpPerDay'),
49
51
  webhookAllowedHosts: read<string[]>('forms.webhookAllowedHosts'),
50
52
  captchaConfigured: this.captcha != null,
51
53
  };
@@ -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,8 @@ 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';
46
48
  import { FORMS_CAPTCHA_VERIFIER } from './forms.tokens';
47
49
 
48
50
  /**
@@ -61,7 +63,8 @@ import { FORMS_CAPTCHA_VERIFIER } from './forms.tokens';
61
63
  FormsDefinitionsController,
62
64
  FormsFilesController,
63
65
  FormsSubmissionsController,
64
- PublicFormsController,
66
+ PublicFormsController,
67
+ PublicFormsFilesController,
65
68
  ],
66
69
  providers: [
67
70
  // Adapters over the ecosystem seams.
@@ -76,6 +79,8 @@ import { FORMS_CAPTCHA_VERIFIER } from './forms.tokens';
76
79
  PrismaFileStore,
77
80
  PrismaActionLogStore,
78
81
  FormsSettingsReader,
82
+ // Secure default: public forms with settings.captcha=true fail publish
83
+ // until the app replaces this provider with a real CaptchaVerifier.
79
84
  { provide: FORMS_CAPTCHA_VERIFIER, useValue: undefined },
80
85
  // Engine registries — singletons; core field types registered up front.
81
86
  {
@@ -196,6 +201,9 @@ import { FORMS_CAPTCHA_VERIFIER } from './forms.tokens';
196
201
  LocalDiskStorageAdapter,
197
202
  FileGcService,
198
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,
199
207
  // Default outbox handlers + shipped form actions.
200
208
  LoggingEmailHandler,
201
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,
@@ -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
+ }
@@ -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': {
@@ -1,6 +1,5 @@
1
1
  import { INestApplication, ValidationPipe } from '@nestjs/common';
2
2
  import { Test } from '@nestjs/testing';
3
- import { DataSourceRegistry } from '@ftisindia/form-builder';
4
3
  import request from 'supertest';
5
4
  import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
6
5
  import { PrismaService } from '../src/database/prisma/prisma.service';
@@ -43,16 +42,6 @@ describe('Forms file uploads (e2e)', () => {
43
42
  await app.init();
44
43
  prisma = app.get(PrismaService);
45
44
 
46
- // The abstract-like definition binds a select to this source; register the
47
- // stub BEFORE creating the definition so save-time lint passes.
48
- app.get(DataSourceRegistry).register({
49
- key: 'conference-tracks',
50
- fetch: () => Promise.resolve([
51
- { value: 'ai', label: 'Artificial Intelligence' },
52
- { value: 'ml', label: 'Machine Learning' },
53
- ]),
54
- });
55
-
56
45
  owner = await createUserAndOrg('files-owner');
57
46
  formKey = await publishDefinition(abstractDefinition(uniqueFormKey('abstract')));
58
47
  });
@@ -87,7 +76,7 @@ describe('Forms file uploads (e2e)', () => {
87
76
  const draft = await request(app.getHttpServer())
88
77
  .post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
89
78
  .set('Authorization', `Bearer ${owner.accessToken}`)
90
- .send({ mode: 'draft', data: { title: 'Versioned upload', track: 'ai' } })
79
+ .send({ mode: 'draft', data: { title: 'Versioned upload', track: 'clinical-research' } })
91
80
  .expect(200);
92
81
 
93
82
  const createdV2 = await request(app.getHttpServer())
@@ -136,7 +125,7 @@ describe('Forms file uploads (e2e)', () => {
136
125
  .set('Authorization', `Bearer ${owner.accessToken}`)
137
126
  .send({
138
127
  mode: 'submit',
139
- data: { title: 'On Linking Files', track: 'ai', manuscript: { fileId } },
128
+ data: { title: 'On Linking Files', track: 'clinical-research', manuscript: { fileId } },
140
129
  })
141
130
  .expect(200);
142
131
 
@@ -159,7 +148,7 @@ describe('Forms file uploads (e2e)', () => {
159
148
  .set('Authorization', `Bearer ${member.accessToken}`)
160
149
  .send({
161
150
  mode: 'submit',
162
- data: { title: 'Stolen Reference', track: 'ml', manuscript: { fileId } },
151
+ data: { title: 'Stolen Reference', track: 'public-health', manuscript: { fileId } },
163
152
  })
164
153
  .expect(422);
165
154
 
@@ -186,15 +175,48 @@ describe('Forms file uploads (e2e)', () => {
186
175
  expect(row).toBeNull();
187
176
  });
188
177
 
189
- it('refuses to flip a definition with file fields to public access', async () => {
190
- const response = await request(app.getHttpServer())
191
- .post(`/organisations/${owner.orgId}/forms/${formKey}/versions/1/public-access`)
178
+ it('accepts public uploads only with the returned claim token', async () => {
179
+ const key = await publishDefinition(abstractDefinition(uniqueFormKey('public-file')));
180
+ await request(app.getHttpServer())
181
+ .post(`/organisations/${owner.orgId}/forms/${key}/versions/1/public-access`)
192
182
  .set('Authorization', `Bearer ${owner.accessToken}`)
193
183
  .send({ access: 'public' })
184
+ .expect(200);
185
+
186
+ const upload = await request(app.getHttpServer())
187
+ .post(`/public/organisations/${owner.orgId}/forms/${key}/files`)
188
+ .query({ field: 'manuscript' })
189
+ .attach('file', PDF_BYTES, 'public-manuscript.pdf')
190
+ .expect(200);
191
+
192
+ const fileId = upload.body.fileId as string;
193
+ const uploadToken = upload.body.uploadToken as string;
194
+ expect(fileId).toBeTruthy();
195
+ expect(uploadToken).toBeTruthy();
196
+ expect(upload.body.status).toBe('TEMPORARY');
197
+
198
+ const rejected = await request(app.getHttpServer())
199
+ .post(`/public/organisations/${owner.orgId}/forms/${key}/submissions`)
200
+ .send({
201
+ data: { title: 'Missing token', track: 'clinical-research', manuscript: { fileId } },
202
+ })
194
203
  .expect(422);
204
+ const errors = rejected.body.error.details.errors as Array<{ code: string; path: string }>;
205
+ expect(errors.some((error) => error.code === 'FILE_REFERENCE')).toBe(true);
206
+
207
+ const submitted = await request(app.getHttpServer())
208
+ .post(`/public/organisations/${owner.orgId}/forms/${key}/submissions`)
209
+ .send({
210
+ data: { title: 'Public file', track: 'clinical-research', manuscript: { fileId } },
211
+ uploadTokens: { [fileId]: uploadToken },
212
+ })
213
+ .expect(200);
195
214
 
196
- const issues = response.body.error.details.issues as Array<{ code: string }>;
197
- expect(issues.map((issue) => issue.code)).toContain('PUBLIC_FILE_FIELD');
215
+ const submissionId = submitted.body.submissionId as string;
216
+ const row = await prisma.uploadedFile.findUnique({ where: { id: fileId } });
217
+ expect(row?.ownerId).toBeNull();
218
+ expect(row?.status).toBe('LINKED');
219
+ expect(row?.submissionId).toBe(submissionId);
198
220
  });
199
221
 
200
222
  // ── Helpers ────────────────────────────────────────────────────────────────
@@ -1,6 +1,6 @@
1
1
  import { INestApplication, ValidationPipe } from '@nestjs/common';
2
2
  import { Test } from '@nestjs/testing';
3
- import { ActionRegistry, DataSourceRegistry } from '@ftisindia/form-builder';
3
+ import { ActionRegistry } from '@ftisindia/form-builder';
4
4
  import request from 'supertest';
5
5
  import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
6
6
  import { PrismaService } from '../src/database/prisma/prisma.service';
@@ -51,15 +51,6 @@ describe('Forms submissions engine flow (e2e)', () => {
51
51
  execute: (ctx) => Promise.resolve({ ...ctx.data }),
52
52
  });
53
53
 
54
- const dataSources = app.get(DataSourceRegistry);
55
- dataSources.register({
56
- key: 'conference-tracks',
57
- fetch: () => Promise.resolve([
58
- { value: 'ai', label: 'Artificial Intelligence' },
59
- { value: 'ml', label: 'Machine Learning' },
60
- ]),
61
- });
62
-
63
54
  owner = await createUserAndOrg('sub-owner');
64
55
  });
65
56
 
@@ -263,7 +254,7 @@ describe('Forms submissions engine flow (e2e)', () => {
263
254
  await request(app.getHttpServer())
264
255
  .post(`/organisations/${owner.orgId}/forms/${key}/submissions`)
265
256
  .set('Authorization', `Bearer ${owner.accessToken}`)
266
- .send({ mode: 'submit', data: { track: 'ai' } })
257
+ .send({ mode: 'submit', data: { track: 'clinical-research' } })
267
258
  .expect(200);
268
259
  });
269
260
 
@@ -12,7 +12,8 @@ import { FormsDataSourcesController } from '../src/modules/forms/presentation/fo
12
12
  import { FormsDefinitionsController } from '../src/modules/forms/presentation/forms-definitions.controller';
13
13
  import { FormsFilesController } from '../src/modules/forms/presentation/forms-files.controller';
14
14
  import { FormsSubmissionsController } from '../src/modules/forms/presentation/forms-submissions.controller';
15
- import { PublicFormsController } from '../src/modules/forms/presentation/public-forms.controller';
15
+ import { PublicFormsController } from '../src/modules/forms/presentation/public-forms.controller';
16
+ import { PublicFormsFilesController } from '../src/modules/forms/presentation/public-forms-files.controller';
16
17
  import { InvitationsController } from '../src/modules/invitations/presentation/invitations.controller';
17
18
  import { MembershipsController } from '../src/modules/memberships/presentation/memberships.controller';
18
19
  import { OrganisationsController } from '../src/modules/organisations/presentation/organisations.controller';
@@ -34,7 +35,8 @@ const controllers = [
34
35
  FormsDefinitionsController,
35
36
  FormsFilesController,
36
37
  FormsSubmissionsController,
37
- PublicFormsController,
38
+ PublicFormsController,
39
+ PublicFormsFilesController,
38
40
  InvitationsController,
39
41
  MembershipsController,
40
42
  OrganisationsController,