/** * TASK-0107/0108 — the PUBLIC form surface (no auth). * * `GET /public/forms/:slug` (TASK-0107 §1) * - PUBLISHED → the spec's EXACT whitelist: form { slug, name, description, * status: "published"|"closed", config { iconUrl, intro (sanitized * markdown), accent, successMessage, closedMessage } } + questions * [{ id, label, description, type, required, placeholder, validation: * public-safe subset }]. NEVER: workspace/database/property ids or * names, question propertyId, other forms, user data, submissions. * - DRAFT / unknown slug → 404 (no existence leak). * - CLOSED → 200 + status "closed" + closedMessage (intentionally visible). * * `POST /public/forms/:slug/submit` (TASK-0108 pipeline): * 1. load form + questions (DRAFT/unknown → 404; CLOSED → 403 FORM_CLOSED); * 2. content-type + 64 KB payload guard (415 / 413); * 3. honeypot → 400 generic (NOT revealed; logged `form.honeypot_triggered`); * 4. reCAPTCHA stub (skips while unconfigured — WARN-logged); * 5. rate limit 5/min per IP per form → 429 + Retry-After; * 6. optional session (valid JWT for this workspace → attributed; invalid * or absent → anonymous — never a 401 on the public path); * 7. dynamic validation from the STORED questions (server is authoritative; * ALL violations in one 422, unknown questionIds ignored); * 8. FILE answers verified: same workspace + status READY + linked to THIS * form (metadata.formId) → 422 FILE_NOT_OWNED / FILE_NOT_READY (fail * fast, BEFORE the transaction — a failed submit must not half-write); * 9. ONE Prisma transaction: the DatabaseRecord (+ its backing Page with * the title) + RecordProperty rows (Phase-5 coercion shapes) + the * FormSubmission (RAW answers — the audit trail) + File.pageId links; * 10. after commit: `form.submitted` event (the Phase-11 trigger; IP * redacted to /24 in the payload — full IP only in the DB) + owner * Notification (best-effort) + `form.submitted` audit (mandatory). * * `POST /public/forms/:slug/files` + `PUT .../files/:fileId/complete` * (TASK-0108 file decision, recorded): public upload sessions attributed to * the FORM (workspace = the form's, `metadata.formId` set at creation — the * submit then accepts only files that both belong to the workspace AND were * opened for this form). Reuses the Phase-9 primitives (MIME allow-list, * quota, magic-byte sniff, worker pipeline) — no duplication of the state * machine; the difference from the authenticated session is only the auth * context (anonymous, form-scoped) and the formId attribution. * * Zero workspace data leaves this surface: every payload is hand-picked, * the config is re-parsed through the shared strict schema, the intro is * sanitized, and signed URLs point at object storage only. */ import { Inject, Injectable } from "@nestjs/common"; import type { Request } from "express"; import { randomBytes } from "node:crypto"; import { FORM_CONFIG_SCHEMA, FORM_ACCENTS, FORM_ERROR_CODES, toPublicSafeValidation, type FormQuestionType, type FormStatus } from "@factory/shared"; import { Prisma } from "@prisma/client"; import { buildFileKey, isAllowedMime, presignGet, presignPut, sanitizeFilename, sniffVerdict } from "@factory/storage"; import { jobTraceMetadata } from "../../common/trace-metadata.js"; import { PrismaService } from "../../common/prisma.service.js"; import { AuditService } from "../../common/audit.service.js"; import { EventBus } from "../../common/event-bus.js"; import { RedisService } from "../../common/redis.service.js"; import { logger } from "../../common/logger.js"; import { ApiError, type ErrorDetail } from "../../common/errors.js"; import { EVENTS } from "../../constants.js"; import { between } from "../../lib/positions.js"; import { loadEnv } from "../../config/env.js"; import { effectiveStorageQuota } from "../files/quota.js"; import { effectiveMaxFileSize } from "../files/uploads.service.js"; import { filesBucketConfig, makeFilesClient, signExpirySeconds } from "../files/s3.js"; import { renderIntroMarkdown } from "./markdown.js"; import { FormRateLimiter } from "./rate-limit.js"; import { verifyRecaptcha } from "./recaptcha.js"; import { buildAnswerResult, configIntegrityIssue, type StoredQuestion } from "./submit-schema.js"; import { NotificationsService } from "../notifications/notifications.service.js"; /** The honeypot field names the public page renders (both checked). */ const HONEYPOT_FIELDS = ["website", "hp"] as const; /** The answers payload cap (spec §7: ≤ 64 KB → 413 PAYLOAD_TOO_LARGE). */ const MAX_ANSWERS_BYTES = 64 * 1024; /** A public file session's declared shape (same as Phase 9's, minus anchors). */ interface PublicUploadInput { name: string; size: number; mimeType: string; } @Injectable() export class PublicFormsService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AuditService) private readonly audit: AuditService, @Inject(EventBus) private readonly events: EventBus, @Inject(NotificationsService) private readonly notifications: NotificationsService, @Inject(FormRateLimiter) private readonly rateLimiter: FormRateLimiter, @Inject(RedisService) private readonly redis: RedisService ) {} // ------------------------------------------------------------------ GET /** `GET /public/forms/:slug` — the spec's exact whitelist. */ async getPublic(slug: string) { const form = await this.prisma.form.findUnique({ where: { slug }, include: { questions: { orderBy: { position: "asc" } } } }); // DRAFT + unknown slug → 404 (the spec's explicit choice: drafts are not // discoverable; a soft-deleted form's released slug 404s the same way). if (!form || form.status === "DRAFT") throw ApiError.notFound("Form not found."); const config = this.safeConfig(form.config); const status = form.status as FormStatus; const published = status === "PUBLISHED"; // Deviation from the §1 whitelist, recorded (same spec section requires // the keys to function — §4 footer, §6c recaptcha slot): `poweredBy` + // `recaptcha.enabled` are added; NOTHING else beyond the whitelist. return { form: { slug: form.slug, name: form.name, description: form.description, status: published ? "published" : "closed", config: { iconUrl: published ? await this.signedIconUrl(form, config.icon) : null, intro: renderIntroMarkdown(typeof config.intro === "string" ? config.intro : ""), accent: config.accent ?? FORM_ACCENTS[0], successMessage: config.successMessage ?? null, closedMessage: config.closedMessage ?? null, poweredBy: config.poweredBy ?? true, recaptcha: { enabled: Boolean((config.recaptcha as { enabled?: boolean } | undefined)?.enabled) } } }, questions: form.questions.map((q) => this.publicQuestion(q, published)) }; } /** A question's PUBLIC shape: no propertyId, safe validation only. A * closed form exposes no validation at all (no inputs are rendered). */ private publicQuestion( q: { id: string; label: string; description: string | null; type: string; required: boolean; validation: unknown; placeholder: string | null; }, published: boolean ) { return { id: q.id, type: q.type as FormQuestionType, label: q.label, description: q.description, required: q.required, placeholder: q.placeholder, validation: published ? toPublicSafeValidation(q.validation) : {} }; } /** Re-parse the stored config through the shared strict schema (fail * closed to the empty config — corrupt Json is never echoed raw). */ private safeConfig(config: unknown): Record { const parsed = FORM_CONFIG_SCHEMA.safeParse(config ?? {}); return parsed.success ? (parsed.data as Record) : {}; } /** Sign a 15-min GET for the icon file (workspace + READY + image only). */ private async signedIconUrl( form: { workspaceId: string }, icon: unknown ): Promise { if (typeof icon !== "string" || icon.length === 0) return null; const file = await this.prisma.file.findFirst({ where: { id: icon, workspaceId: form.workspaceId, deletedAt: null }, select: { id: true, status: true, key: true, mimeType: true } }); if (!file || file.status !== "READY" || !file.mimeType.startsWith("image/")) return null; try { const env = loadEnv(); const { url } = presignGet({ config: filesBucketConfig(env), key: file.key, expiresSec: 900, now: new Date() }); return url; } catch { return null; } } // ---------------------------------------------------------------- SUBMIT /** * `POST /public/forms/:slug/submit`. 201 `{ success, submissionId, * message }` + `X-Submission-Id` header (the controller sets it). */ async submitPublic( slug: string, body: { answers?: unknown; website?: string; hp?: string; recaptchaToken?: string }, req: Request ): Promise<{ success: true; submissionId: string; message: string | null }> { // Content-type guard (CSRF mitigation per TASK-0107: a cross-origin form // post can't send application/json without a preflight). const contentType = String(req.headers["content-type"] ?? ""); if (!contentType.includes("application/json")) { throw new ApiError("UNSUPPORTED_MEDIA_TYPE", 415, "Content-Type must be application/json.", [ { issue: "json_required" } ]); } const form = await this.prisma.form.findUnique({ where: { slug }, include: { questions: { orderBy: { position: "asc" } } } }); // DRAFT + unknown slug → 404 (same shape as the GET path). if (!form || form.status === "DRAFT") throw ApiError.notFound("Form not found."); if (form.status === "CLOSED") { throw new ApiError("FORBIDDEN", 403, "This form is closed.", [ { reason: FORM_ERROR_CODES.FORM_CLOSED } ]); } // 64 KB answers guard (spec §7). const answersJson = JSON.stringify(body.answers ?? {}); if (answersJson.length > MAX_ANSWERS_BYTES) { throw new ApiError("PAYLOAD_TOO_LARGE", 413, "The answers payload is too large.", [ { issue: FORM_ERROR_CODES.PAYLOAD_TOO_LARGE, maxBytes: MAX_ANSWERS_BYTES } ]); } // 1. honeypot → 400 generic (the spec's TASK-0107 §6a: NOT a fake // success; the reason is never revealed — only the internal log). if (HONEYPOT_FIELDS.some((f) => body[f] && String(body[f]).trim() !== "")) { logger.warn(`form.honeypot_triggered slug=${slug} ip=${req.ip ?? "unknown"}`); throw new ApiError("VALIDATION_ERROR", 400, "Submission failed. Please try again."); } const ip = req.ip ?? "unknown"; // 2. reCAPTCHA stub (skips while unconfigured — WARN-logged). await verifyRecaptcha(body.recaptchaToken, ip); // 3. rate limit (after the honeypot — bots do not eat real slots). const rl = await this.rateLimiter.check(slug, ip); if (!rl.allowed) { // The Retry-After header must ride the 429 (spec §6): set it on the // response BEFORE throwing — the exception filter writes the body and // the header flushes with it. req.res?.setHeader("Retry-After", String(rl.retryAfterSeconds)); throw new ApiError("RATE_LIMITED", 429, "Too many submissions. Try again shortly.", [ { retryAfterSeconds: rl.retryAfterSeconds } ]); } // 4. optional session (invalid/absent → anonymous; never 401 here — the // OptionalAuthGuard already decoded a valid user JWT into req.auth). const submittedBy = await this.optionalSession(req, form.workspaceId); const anonymous = submittedBy === null; // 5. dynamic validation (the stored questions ARE the schema source). const stored: StoredQuestion[] = form.questions.map((q) => ({ id: q.id, label: q.label, type: q.type, required: q.required, validation: q.validation })); const configIssue = configIntegrityIssue(stored); if (configIssue) { throw new ApiError("INTERNAL_ERROR", 500, "Form configuration is corrupt.", [ { reason: FORM_ERROR_CODES.FORM_CONFIG_INVALID, detail: configIssue } ]); } const { answers, violations } = buildAnswerResult( stored, (body.answers ?? {}) as Record ); if (violations.length > 0) { const details: ErrorDetail[] = violations.map((v) => ({ questionId: v.questionId, code: v.code, message: v.message })); throw new ApiError("UNPROCESSABLE_ENTITY", 422, "Submission failed validation.", details); } // 6. FILE answers: same workspace + READY + linked to THIS form // (metadata.formId — the public session sets it; a forged id from // another workspace/form is rejected + audited below via the 422 // details, and the honeypot-independent log line). const fileIds = collectFileIds(answers); const fileRows = new Map(); if (fileIds.length > 0) { const files = await this.prisma.file.findMany({ where: { id: { in: fileIds }, workspaceId: form.workspaceId, deletedAt: null }, select: { id: true, status: true, metadata: true } }); for (const f of files) fileRows.set(f.id, { status: f.status, metadata: f.metadata }); for (const id of fileIds) { const f = fileRows.get(id); const meta = (f?.metadata ?? {}) as Record; if (!f || meta.formId !== form.id) { logger.warn(`form.file_not_owned slug=${slug} fileId=${id} workspaceId=${form.workspaceId}`); throw new ApiError("UNPROCESSABLE_ENTITY", 422, "A submitted file is not available in this form.", [ { fileId: id, reason: FORM_ERROR_CODES.FILE_NOT_OWNED } ]); } if (f.status !== "READY") { throw new ApiError("UNPROCESSABLE_ENTITY", 422, "A submitted file is still processing — try again in a moment.", [ { fileId: id, reason: FORM_ERROR_CODES.FILE_NOT_READY } ]); } } } // 7. map answers → record property values. Mappings were validated at // question-write time; re-verify here because a property can be // deleted AFTER the form was saved — a dead mapping is SKIPPED (the // answer is retained in FormSubmission.answers; the record still // gets everything that still maps). Coercion mirrors Phase 5's // RecordService.coerceValue shapes (documented reuse decision). const properties = await this.prisma.databaseProperty.findMany({ where: { databaseId: form.databaseId, deletedAt: null }, select: { id: true, type: true, isTitle: true } }); const propById = new Map(properties.map((p) => [p.id, p])); const recordValues = new Map(); let titlePropertyId: string | null = null; let mappedCount = 0; for (const q of form.questions) { const prop = q.propertyId ? propById.get(q.propertyId) : undefined; if (!prop) continue; // dead mapping (property deleted after save) — skipped mappedCount += 1; if (prop.isTitle || prop.type === "TITLE") titlePropertyId = prop.id; if (!(q.id in answers)) continue; recordValues.set(prop.id, coerceForProperty(answers[q.id], prop.type)); } const mapped = { length: mappedCount }; // Record title (spec §3a): the TITLE-mapped question's value, else the // deterministic fallback `"
— "`. const titleAnswer = titlePropertyId !== null ? (answers[form.questions.find((q) => q.propertyId === titlePropertyId)?.id ?? ""] ?? undefined) : undefined; const title = typeof titleAnswer === "string" && titleAnswer.trim() !== "" ? titleAnswer.trim().slice(0, 300) : fallbackTitle(form.name, answers); // 8. ONE transaction: record + page + properties + submission + file // links (atomicity — a failure leaves NOTHING, spec §3). const createdBy = submittedBy ?? form.createdById; const { submission, recordId, pageId } = await this.prisma.$transaction(async (tx) => { let recordIdOut: string | null = null; let pageIdOut: string | null = null; if (mapped.length > 0) { const last = await tx.databaseRecord.findFirst({ where: { databaseId: form.databaseId }, orderBy: { position: "desc" }, select: { position: true } }); const position = between(last?.position, undefined); const page = await tx.page.create({ data: { workspaceId: form.workspaceId, title, position, createdBy, updatedBy: createdBy } }); const record = await tx.databaseRecord.create({ data: { workspaceId: form.workspaceId, databaseId: form.databaseId, pageId: page.id, position, createdBy, lastEditedBy: createdBy } }); recordIdOut = record.id; pageIdOut = page.id; for (const [propertyId, value] of recordValues) { const json = value === null ? Prisma.JsonNull : (value as Prisma.InputJsonValue); await tx.recordProperty.upsert({ where: { recordId_propertyId: { recordId: record.id, propertyId } }, update: { value: json }, create: { recordId: record.id, propertyId, value: json } }); } } const sub = await tx.formSubmission.create({ data: { workspaceId: form.workspaceId, formId: form.id, recordId: recordIdOut, answers: answers as object, submittedBy: submittedBy ?? null, ipAddress: ip } }); // Link the submitted files (spec §3c): File.pageId → the record's // page (+ blockId null), metadata.formId/submissionId stamped (the // TASK-0109 cascade finds them by these). Runs even when NO record // was created (all questions unmapped) — the submissionId stamp is // what makes the files deletable-with-their-submission. if (fileIds.length > 0) { for (const id of fileIds) { const meta = (fileRows.get(id)?.metadata ?? {}) as Record; await tx.file.update({ where: { id }, data: { pageId: pageIdOut, blockId: null, metadata: { ...meta, formId: form.id, submissionId: sub.id } } }); } } return { submission: sub, recordId: recordIdOut, pageId: pageIdOut }; }); // 9. Side effects (after commit — best-effort, their own error // handling per spec §4; a failure never undoes the submission). const redactedIp = redactIp24(ip); await this.events.publish(EVENTS.FORM_SUBMITTED, { workspaceId: form.workspaceId, formId: form.id, slug: form.slug, submissionId: submission.id, recordId, databaseId: form.databaseId, questionCount: form.questions.length, anonymous, ip: redactedIp }); try { const owner = await this.prisma.user.findUnique({ where: { id: form.createdById }, select: { displayName: true, name: true } }); await this.notifications.notify({ workspaceId: form.workspaceId, userId: form.createdById, type: "FORM_SUBMITTED", title: `${form.name} received a new response`, body: submittedBy ? `From ${submittedBy === form.createdById ? "you" : (owner?.displayName ?? owner?.name ?? "a member")}.` : "Anonymous public submission.", link: { type: "form", id: form.id }, actorId: submittedBy ?? null }); } catch (err) { // A notification failure never fails the submit (spec §4). logger.warn(`form owner notification failed (${form.id}): ${(err as Error).message}`); } await this.audit.audit(req, undefined, { action: "form.submitted", resourceType: "form_submission", resourceId: submission.id, workspaceId: form.workspaceId, actorId: submittedBy ?? "anonymous", metadata: { formId: form.id, slug: form.slug, recordId, ip, anonymous, questionCount: form.questions.length } }); // X-Submission-Id: client dedupe logging (spec §6) — set on the response // before the JSON body is written. req.res?.setHeader("X-Submission-Id", submission.id); return { success: true, submissionId: submission.id, message: (this.safeConfig(form.config).successMessage as string | undefined) ?? null }; } /** The guard-decoded user session, when it belongs to THIS workspace. */ /** * Resolve the attributing user for a public submit. A valid user JWT * (decoded by the controller's OptionalAuthGuard into req.auth) is the * candidate; the WORKSPACE MEMBERSHIP is verified against the DB (the * `workspaceMember` table — the source of truth for roles, exactly as * `effectiveLevel` resolves them), NOT against the JWT's `workspaces` * snapshot. That claim is frozen at mint time, so a membership granted * after the token was issued would otherwise go unattributed (the user * IS a member, but the stale claim says otherwise). A non-member or a * forged/invalid token → anonymous (never an error — the public surface * must stay open; attribution is a best-effort, server-side decision). */ private async optionalSession(req: Request, workspaceId: string): Promise { const auth = (req as Request & { auth?: import("../../common/types.js").AuthContext }).auth; if (!auth || auth.kind !== "user" || !auth.userId) return null; const membership = await this.prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId, userId: auth.userId } } }); return membership ? auth.userId : null; } // ---------------------------------------------- public FILE sessions /** * `POST /public/forms/:slug/files` — open a form-attributed upload session * (anonymous). Same Phase-9 gates as the authenticated session: workspace * plan size limit, storage quota, closed MIME allow-list. The File row * carries `metadata.formId` — the submit endpoint later accepts ONLY * files with this exact attribution (forged ids → FILE_NOT_OWNED). */ async openPublicUpload(slug: string, input: PublicUploadInput, req: Request) { const form = await this.loadPublicForm(slug); const ws = await this.prisma.workspace.findUnique({ where: { id: form.workspaceId } }); if (!ws || ws.deletedAt) throw ApiError.notFound("Form not found."); if (!isAllowedMime(input.mimeType)) { throw ApiError.validation("File type is not allowed.", [ { field: "mimeType", issue: "mime_not_allowed", received: input.mimeType } ]); } const limits = (ws.usageLimits ?? {}) as { maxFileSize?: number; storageQuota?: number }; const maxFileSize = effectiveMaxFileSize(ws.billingPlan, limits); if (input.size > maxFileSize) { throw new ApiError("FILE_TOO_LARGE", 413, "File size exceeds the workspace plan limit.", [ { maxBytes: maxFileSize, plan: ws.billingPlan } ]); } const quota = effectiveStorageQuota(ws.billingPlan, limits); const used = Number(ws.usageStorageBytes ?? 0n); if (input.size > quota - used) { throw new ApiError("QUOTA_EXCEEDED", 413, "The workspace storage quota is full.", [ { maxBytes: quota, usedBytes: used } ]); } const env = loadEnv(); const fileId = `file_${randomBytes(12).toString("base64url")}`; const key = buildFileKey({ workspaceId: form.workspaceId, fileId, version: 1, name: sanitizeFilename(input.name) }); const now = new Date(); const expiresSec = signExpirySeconds(env); const file = await this.prisma.file.create({ data: { id: fileId, workspaceId: form.workspaceId, pageId: null, blockId: null, name: sanitizeFilename(input.name), key, size: input.size, mimeType: input.mimeType, status: "UPLOADING", metadata: { formId: form.id, public: true } as object, uploadedBy: "anonymous" } }); const put = presignPut({ config: filesBucketConfig(env), key, contentType: input.mimeType, expiresSec, now }); await this.audit.audit(req, undefined, { action: "form.public_upload_created", resourceType: "file", resourceId: file.id, workspaceId: form.workspaceId, actorId: "anonymous", metadata: { formId: form.id, name: input.name, size: input.size, mimeType: input.mimeType } }); return { upload: { fileId: file.id, uploadUrl: put.url, method: "PUT" as const, headers: { "Content-Type": input.mimeType }, expiresAt: new Date(now.getTime() + expiresSec * 1000).toISOString() } }; } /** * `PUT /public/forms/:slug/files/:fileId/complete` — the public half of * Phase 9's two-phase completion (HEAD size check + magic-byte sniff + * SCANNING + worker enqueue). The file must belong to this form's * session (metadata.formId + workspace) or the request 404s. */ async completePublicUpload(slug: string, fileId: string, req: Request) { const form = await this.loadPublicForm(slug); const file = await this.prisma.file.findFirst({ where: { id: fileId, workspaceId: form.workspaceId, deletedAt: null } }); const meta = (file?.metadata ?? {}) as Record; if (!file || meta.formId !== form.id) throw ApiError.notFound("Upload session not found."); if (file.status !== "UPLOADING") { throw new ApiError("CONFLICT", 409, "This upload session has already been completed.", [ { reason: "upload_already_completed" } ]); } const s3 = makeFilesClient(); let head: { size: number }; try { head = await s3.headObject(file.key); } catch (err) { const status = (err as { status?: number })?.status; if (status === 404) { throw new ApiError("UPLOAD_EXPIRED", 410, "The upload object is not present.", [ { reason: "upload_expired" } ]); } throw err; } if (head.size !== file.size) { throw new ApiError("UPLOAD_INCOMPLETE", 422, "Uploaded object size does not match the declared size.", [ { received: head.size, expected: file.size } ]); } const { prefix } = await s3.readPrefix(file.key, 8 * 1024); const verdict = sniffVerdict(prefix, file.mimeType); if (verdict === "mismatch") { await s3.deleteObject(file.key).catch(() => undefined); await this.prisma.file.update({ where: { id: file.id }, data: { status: "FAILED", metadata: { ...meta, pipeline: { error: "mime_mismatch" } }, scanReport: { engine: "magic-bytes", result: "mismatch", claimed: file.mimeType } as object } }); throw new ApiError("MIME_MISMATCH", 415, "The uploaded bytes do not match the declared MIME type.", [ { claimed: file.mimeType, issue: "mime_mismatch" } ]); } await this.prisma.file.update({ where: { id: file.id }, data: { status: "SCANNING", size: head.size } }); await this.enqueueProcessFile(file.id, file.workspaceId); await this.audit.audit(req, undefined, { action: "form.public_upload_completed", resourceType: "file", resourceId: file.id, workspaceId: file.workspaceId, actorId: "anonymous", metadata: { formId: form.id, name: file.name, size: head.size } }); return { id: file.id, status: "SCANNING", verifiedBy: "worker" }; } /** The PUBLISHED form row for public surface ops (DRAFT/unknown → 404). */ private async loadPublicForm(slug: string) { const form = await this.prisma.form.findUnique({ where: { slug } }); if (!form || form.status === "DRAFT") throw ApiError.notFound("Form not found."); return form; } /** Enqueue the Phase-9 pipeline job (worker does scan/metadata/thumbs). */ private async enqueueProcessFile(fileId: string, workspaceId: string): Promise { try { const { Queue } = await import("bullmq"); const q = new Queue("files", { connection: this.redis.getRedis().options as never, defaultJobOptions: { attempts: 3, backoff: { type: "exponential", delay: 2_000 }, removeOnComplete: 500, removeOnFail: 200 } }); // TASK-0135: continue the enqueueing request's trace into the job. await q.add("process-file", { fileId, version: 1, workspaceId }, { ...jobTraceMetadata() }); await q.close(); } catch (err) { logger.warn(`files queue enqueue failed for ${fileId}: ${(err as Error).message}`); } } } /** Redact an IPv4 to /24 (`1.2.3.***`) — the event payload's IP form. */ function redactIp24(ip: string): string { if (!ip) return "unknown"; const v4 = ip.replace(/^::ffff:/, ""); const parts = v4.split("."); if (parts.length === 4) return `${parts[0]}.${parts[1]}.${parts[2]}.***`; return "***"; } /** Deterministic fallback title (spec §3a): name + first 8 chars of the * first text-ish answer in question order. */ function fallbackTitle(formName: string, answers: Record): string { for (const v of Object.values(answers)) { if (typeof v === "string" && v.trim() !== "") { const head = v.trim().slice(0, 8); return `${formName} — ${head}`.slice(0, 300); } } return formName.slice(0, 300); } /** Every FILE answer's file ids (flat, deduped). */ function collectFileIds(answers: Record): string[] { const out: string[] = []; for (const v of Object.values(answers)) { if (Array.isArray(v)) { for (const item of v) if (typeof item === "string") out.push(item); } } return [...new Set(out)]; } /** * Coerce a validated answer to the database property's storage shape — * the SAME shapes Phase 5's RecordService.coerceValue writes (documented * reuse: the anonymous submit cannot call that service — it asserts an * authenticated EDIT — so the shapes are mirrored here and kept in lockstep * by the TASK-0108 integration assertions on stored values). */ function coerceForProperty(value: unknown, propType: string): unknown { if (value === undefined || value === null) return null; switch (propType) { case "TITLE": case "TEXT": case "RICH_TEXT": return { text: String(value), marks: [] }; case "NUMBER": return { value: Number(value) }; case "SELECT": case "STATUS": return { optionId: String(value) }; case "MULTI_SELECT": return { optionIds: Array.isArray(value) ? value.map(String) : [String(value)] }; case "DATE": case "DATE_RANGE": return { start: String(value), end: null }; case "EMAIL": case "PHONE": return { value: String(value) }; case "URL": return { value: String(value), title: null }; case "CHECKBOX": return { value: Boolean(value) }; case "FILES": return { fileIds: Array.isArray(value) ? value : [value] }; default: // A matrix-allowed mapping with no direct storage shape — store the // raw answer (recorded: the matrix is the contract; raw is the // lossless default). return value; } }