/** * TASK-0109 — form responses routes (workspace-scoped, database-bound): * * GET /forms/:formId/submissions cursor list (joined view) * DELETE /forms/:formId/submissions/:submissionId cascade delete (confirm=true) * GET /forms/:formId/export 501 Phase-13 stub * * Query params (list): `cursor`, `limit` (≤ 100, default 50), `q` (answer * text + submitter name/email + IP), `from`/`to` (ISO dates), * `anonymousOnly=true`. Permissions live in the service (VIEW to read, * EDIT to delete — Owner/Admin pass via the role baseline). */ import { Body, Controller, Delete, Get, Inject, Param, Post, Put, Query, Req } from "@nestjs/common"; // (Body/Post/Put serve the public form file sessions below.) import type { Request } from "express"; import { z } from "zod"; import { SubmissionsService } from "./submissions.service.js"; import { PublicFormsService } from "./public-forms.service.js"; import { ZodValidate } from "../../common/pipes/zod-validation.pipe.js"; import { requireUser } from "../../common/types.js"; import { ApiError } from "../../common/errors.js"; /** The list endpoint's query string (all optional; defaults in the service). */ const SubmissionsQueryDto = z .object({ cursor: z.string().min(1).max(120).optional(), limit: z.coerce.number().int().min(1).max(100).optional(), q: z.string().min(1).max(200).optional(), from: z.string().datetime().optional(), to: z.string().datetime().optional(), anonymousOnly: z .enum(["true", "false"]) .optional() .transform((v) => v === "true") }) .strict(); /** A public form file session (TASK-0108 — anonymous, form-scoped). */ const PublicUploadDto = z .object({ name: z.string().min(1).max(255), size: z.number().int().min(1).max(1024 ** 3 * 25), mimeType: z.string().min(1).max(150) }) .strict(); type PublicUploadInput = z.infer; @Controller() export class SubmissionsController { constructor( @Inject(SubmissionsService) private readonly submissions: SubmissionsService, @Inject(PublicFormsService) private readonly publicForms: PublicFormsService ) {} // ---------- responses (TASK-0109, authenticated) ---------- @Get("forms/:formId/submissions") list( @Param("formId") formId: string, @Query() query: unknown, @Req() req: Request ) { const parsed = SubmissionsQueryDto.safeParse(query ?? {}); if (!parsed.success) { throw ApiError.validation("Invalid list query.", [ { field: "query", issue: parsed.error.issues[0]?.message ?? "invalid" } ]); } return this.submissions.list(formId, requireUser(req), parsed.data); } @Delete("forms/:formId/submissions/:submissionId") delete( @Param("formId") formId: string, @Param("submissionId") submissionId: string, @Query("confirm") confirm: string | undefined, @Req() req: Request ) { return this.submissions.delete(formId, submissionId, requireUser(req), confirm === "true", req); } @Get("forms/:formId/export") export( @Param("formId") formId: string, @Query("format") format: string | undefined, @Req() req: Request ) { return this.submissions.export(formId, requireUser(req), format ?? "csv"); } // ---------- public file sessions (TASK-0108, anonymous — guard whitelist) ---------- @Post("public/forms/:slug/files") openPublicUpload( @Param("slug") slug: string, @Body(ZodValidate(PublicUploadDto)) body: PublicUploadInput, @Req() req: Request ) { return this.publicForms.openPublicUpload(slug, body, req); } @Put("public/forms/:slug/files/:fileId/complete") completePublicUpload( @Param("slug") slug: string, @Param("fileId") fileId: string, @Req() req: Request ) { return this.publicForms.completePublicUpload(slug, fileId, req); } }