/** * TASK-0109 — form responses: list (joined view model), delete (cascade), * export stub. * * The responses are WORKSPACE data (answers can carry PII; IPs are PII): * - list requires `VIEW` on the form's database; * - the IP is MASKED by default (`1.2.***.***` — first two octets); the * full IP is returned ONLY to workspace Owner/Admin (the ROLE, not * merely a permission level — privacy-by-default, spec §1); * - delete requires `EDIT` on the database (Owner/Admin pass via the role * baseline) and a `confirm=true` query flag (422 CONFIRM_REQUIRED * without it — accidental programmatic deletes are made explicit). * * Delete cascade (spec §3, one transaction): the created record (+ its * backing page + blocks — Phase-5 `deleteRecord` semantics), the submission * row, and every FILE linked via `metadata.formId` = this form + * `metadata.submissionId` = this submission (their objects via the Phase-9 * purge path: object + version objects + thumbs + the workspace byte * counter). Then the audit rows: `record.deleted` per deleted record + * `form.submission_deleted` (metadata: submissionId, recordId, submitter). * * Export (spec §5): `GET /forms/:formId/export` → 501 NOT_IMPLEMENTED with * `implementedIn: "phase-13"`; the future CSV contract is documented in * `docs/api/forms.md` so Phase 13 implements against it (the UI button is * disabled with the same roadmap note). */ import { Inject, Injectable } from "@nestjs/common"; import type { Request } from "express"; import { PrismaService } from "../../common/prisma.service.js"; import { AuditService } from "../../common/audit.service.js"; import { logger } from "../../common/logger.js"; import { ApiError, type ErrorDetail } from "../../common/errors.js"; import { FORM_ERROR_CODES } from "@factory/shared"; import { actorIsAdminKey, assertCan } from "../../lib/authorize.js"; import { deletePrefix, makeFilesClient, makeThumbsClient } from "../files/s3.js"; import { adjustWorkspaceStorageBytes } from "../files/quota.js"; export interface SubmissionsQuery { cursor?: string; limit?: number; q?: string; from?: string; to?: string; anonymousOnly?: boolean; } export interface SubmitterView { id: string; name: string; avatarUrl: string | null; email: string | null; anonymous: false; } export interface SubmissionView { id: string; formId: string; recordId: string | null; /** The raw answers (questionId → value) — the audit trail, unmasked. */ answers: Record; /** Question labels joined server-side (questionId → { label, type }). */ questionLabels: Record; submittedBy: SubmitterView | null; /** Masked by default (`1.2.***.***`); full only for workspace Owner/Admin. */ ip: string; ipMasked: boolean; createdAt: Date; } /** Mask an IPv4 to its first two octets (`1.2.***.***`). Non-IPv4 → "***". */ export function maskIp(ip: string | null | undefined): string { if (!ip) return "***"; const v4 = ip.replace(/^::ffff:/, ""); const parts = v4.split("."); if (parts.length === 4 && parts.every((p) => /^\d{1,3}$/.test(p))) { return `${parts[0]}.${parts[1]}.***.***`; } return "***"; } /** The workspace Owner/Admin role check (IP unmask + delete fast-path). */ export async function isWorkspaceAdmin( prisma: PrismaService, workspaceId: string, userId: string ): Promise { const membership = await prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId, userId } }, select: { role: true } }); return membership?.role === "OWNER" || membership?.role === "ADMIN"; } @Injectable() export class SubmissionsService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AuditService) private readonly audit: AuditService ) {} /** `GET /forms/:formId/submissions` — cursor list of the joined view. */ async list( formId: string, userId: string, query: SubmissionsQuery ): Promise<{ items: SubmissionView[]; nextCursor: string | null }> { const form = await this.prisma.form.findUnique({ where: { id: formId } }); if (!form) throw ApiError.notFound("Form not found."); await assertCan(this.prisma, { workspaceId: form.workspaceId, userId, resource: { databaseId: form.databaseId } }, "VIEW"); const limit = Math.min(Math.max(Number(query.limit ?? 50) || 50, 1), 100); // 1. submissions (filtered + cursor + a one-row over-fetch for nextCursor). const where: Record = { formId }; if (query.anonymousOnly) where.submittedBy = null; if (query.from || query.to) { const createdAt: Record = {}; if (query.from) createdAt.gte = new Date(query.from); if (query.to) createdAt.lte = new Date(query.to); where.createdAt = createdAt; } const rows = await this.prisma.formSubmission.findMany({ where, orderBy: { createdAt: "desc" }, ...(query.cursor ? { cursor: { id: query.cursor }, skip: 1 } : {}), take: limit + 1 }); const hasMore = rows.length > limit; const page = hasMore ? rows.slice(0, limit) : rows; const nextCursor = hasMore ? (page[page.length - 1]?.id ?? null) : null; if (page.length === 0) return { items: [], nextCursor: null }; // 2. join data (batched — no N+1: questions + submitters = 2 queries // regardless of page size). A submitter whose USER ROW is gone is // still "Former member" (the id survives in the submission row). const submitterIds = [...new Set(page.map((s) => s.submittedBy).filter((v): v is string => Boolean(v)))]; const [questions, userRows] = await Promise.all([ this.prisma.formQuestion.findMany({ where: { formId }, select: { id: true, label: true, type: true } }), this.prisma.user.findMany({ where: { id: { in: submitterIds } }, select: { id: true, name: true, displayName: true, avatarUrl: true, email: true } }) ]); const labelByQ = new Map(questions.map((q) => [q.id, { label: q.label, type: q.type }])); const userById = new Map(userRows.map((u) => [u.id, u])); // TASK-0127: an ADMIN-scoped API key of the workspace sees the admin // view (full IPs); a READ/WRITE key does not. Never the creator's role. const admin = actorIsAdminKey(form.workspaceId) || (await isWorkspaceAdmin(this.prisma, form.workspaceId, userId)); // 3. build views, then apply the `q=` search (matches answer text, // submitter name/email, or the IP — masked or full as the role sees). let items = page.map((s) => this.buildView(s, labelByQ, userById, admin)); const needle = query.q?.trim().toLowerCase(); if (needle) { items = items.filter((v) => { const haystacks = [ JSON.stringify(v.answers), v.submittedBy?.name ?? "", v.submittedBy?.email ?? "", v.ip ].join("\n"); return haystacks.toLowerCase().includes(needle); }); } return { items, nextCursor }; } /** Build one SubmissionView row (the spec §6 view model). */ private buildView( s: { id: string; formId: string; recordId: string | null; answers: unknown; submittedBy: string | null; ipAddress: string | null; createdAt: Date; }, labelByQ: Map, userById: Map, admin: boolean ): SubmissionView { const answers = (s.answers ?? {}) as Record; const questionLabels: Record = {}; for (const qid of Object.keys(answers)) { questionLabels[qid] = labelByQ.get(qid) ?? { label: "Deleted question", type: "UNKNOWN" }; } let submittedBy: SubmitterView | null = null; if (s.submittedBy) { const u = userById.get(s.submittedBy); submittedBy = u ? { id: u.id, name: u.displayName ?? u.name ?? "Member", avatarUrl: u.avatarUrl, email: u.email, anonymous: false } : { id: s.submittedBy, name: "Former member", avatarUrl: null, email: null, anonymous: false }; } const ip = s.ipAddress ?? ""; return { id: s.id, formId: s.formId, recordId: s.recordId, answers, questionLabels, submittedBy, ip: admin ? ip : maskIp(ip), ipMasked: !admin, createdAt: s.createdAt }; } /** * `DELETE /forms/:formId/submissions/:submissionId?confirm=true` — the * cascade (spec §3). One transaction for the DB side; the object-storage * purge runs against the collected keys (best-effort per key — a storage * blip must not wedge the cascade; the orphan sweeper reaps leftovers). */ async delete(formId: string, submissionId: string, userId: string, confirm: boolean, req: Request) { if (confirm !== true) { throw new ApiError("UNPROCESSABLE_ENTITY", 422, "Deletion requires the confirm=true flag.", [ { reason: FORM_ERROR_CODES.CONFIRM_REQUIRED } ]); } const form = await this.prisma.form.findUnique({ where: { id: formId } }); if (!form) throw ApiError.notFound("Form not found."); await assertCan(this.prisma, { workspaceId: form.workspaceId, userId, resource: { databaseId: form.databaseId } }, "EDIT"); const submission = await this.prisma.formSubmission.findFirst({ where: { id: submissionId, formId } }); if (!submission) throw ApiError.notFound("Submission not found."); // Plan: which resources does this submission own? const record = submission.recordId ? await this.prisma.databaseRecord.findUnique({ where: { id: submission.recordId }, select: { id: true, pageId: true, workspaceId: true, databaseId: true } }) : null; // Linked files: metadata.formId = this form AND metadata.submissionId = // this submission (stamped by the submit pipeline; a forged metadata is // impossible — the submit is the only writer of submissionId). The // STORAGE PLAN (object keys + sizes) is captured BEFORE the transaction // deletes the rows — the post-commit purge needs them. const files = await this.prisma.file.findMany({ where: { workspaceId: form.workspaceId, deletedAt: null, metadata: { path: ["formId"], equals: form.id }, AND: [{ metadata: { path: ["submissionId"], equals: submission.id } }] }, select: { id: true, key: true, size: true } }); const purgedFileIds = files.map((f) => f.id); const purgedVersionKeys: string[] = []; if (purgedFileIds.length > 0) { const versionRows = await this.prisma.fileVersion.findMany({ where: { fileId: { in: purgedFileIds } }, select: { key: true } }); purgedVersionKeys.push(...versionRows.map((v) => v.key)); } const purgedBytes = BigInt(files.reduce((sum, f) => sum + f.size, 0)); await this.prisma.$transaction(async (tx) => { // 1. the record + its backing page + blocks (Phase-5 deleteRecord // semantics — explicit 1:1 page removal; properties/links cascade). if (record) { if (record.pageId) { await tx.block.deleteMany({ where: { pageId: record.pageId } }); await tx.page.delete({ where: { id: record.pageId } }); } await tx.databaseRecord.delete({ where: { id: record.id } }); } // 2. the linked files' rows (Phase-9 purge; the OBJECTS are deleted // after commit — storage calls must not sit in the write window). if (purgedFileIds.length > 0) { await tx.fileVersion.deleteMany({ where: { fileId: { in: purgedFileIds } } }); await tx.file.deleteMany({ where: { id: { in: purgedFileIds } } }); } // 3. the submission row itself (the last one — the cascade's root). await tx.formSubmission.delete({ where: { id: submission.id } }); // 4. the workspace byte counter (every purged file's bytes leave the // stored total — the Phase-9 purge invariant). if (purgedBytes > 0n) { await adjustWorkspaceStorageBytes(tx, form.workspaceId, -purgedBytes); } }); // Object-storage purge (post-commit, best-effort per key — a storage // blip must not wedge the cascade; the orphan sweeper reaps leftovers). if (purgedFileIds.length > 0) { await this.purgeStorage(form.workspaceId, { fileIds: purgedFileIds, fileKeys: files.map((f) => f.key), versionKeys: purgedVersionKeys }); } // 4. audit (spec §3: record.deleted + form.submission_deleted). if (record) { await this.audit.audit(req, req.auth, { action: "record.deleted", resourceType: "record", resourceId: record.id, workspaceId: record.workspaceId, metadata: { databaseId: record.databaseId, via: "form_submission_deleted", submissionId: submission.id } }); } await this.audit.audit(req, req.auth, { action: "form.submission_deleted", resourceType: "form_submission", resourceId: submission.id, workspaceId: form.workspaceId, metadata: { formId: form.id, recordId: submission.recordId, submitter: submission.submittedBy ?? "anonymous", filesPurged: purgedFileIds.length } }); return { deleted: true, recordDeleted: record !== null, filesPurged: purgedFileIds.length }; } /** Delete the file objects + version objects + thumbnails for purged rows. */ private async purgeStorage( workspaceId: string, plan: { fileIds: string[]; fileKeys: string[]; versionKeys: string[] } ): Promise { const s3 = makeFilesClient(); const s3Thumbs = makeThumbsClient(); for (const key of plan.fileKeys) { await s3.deleteObject(key).catch(() => undefined); } for (const key of plan.versionKeys) { await s3.deleteObject(key).catch(() => undefined); } // The WHOLE thumbnail set per file (160/480/1280 artifacts live under // `thumbs/{ws}/{fileId}/{size}/…` — deleting only one would orphan rest). for (const id of plan.fileIds) { await deletePrefix(s3Thumbs, `thumbs/${workspaceId}/${id}/`).catch(() => 0); } logger.info( `form submission cascade purged storage (ws ${workspaceId}): ${plan.fileIds.length} file(s), ` + `${plan.fileKeys.length + plan.versionKeys.length} object(s)` ); } /** `GET /forms/:formId/export` — the Phase-13 stub (spec §5). */ async export(formId: string, userId: string, format: string) { const form = await this.prisma.form.findUnique({ where: { id: formId } }); if (!form) throw ApiError.notFound("Form not found."); await assertCan(this.prisma, { workspaceId: form.workspaceId, userId, resource: { databaseId: form.databaseId } }, "VIEW"); void format; const details: ErrorDetail[] = [ { reason: FORM_ERROR_CODES.NOT_IMPLEMENTED_YET, implementedIn: "phase-13" }, { contract: "CSV columns: submission number, received (ISO 8601), submitter (email-or-id-or-anonymous), one column per question (\"label (type)\"), record link. JSON: { items: [SubmissionView] }. See docs/api/forms.md." } ]; throw new ApiError("NOT_IMPLEMENTED", 501, "CSV export is not implemented yet.", details); } }