diff --git a/src/app/api/public/intake/route.ts b/src/app/api/public/intake/route.ts new file mode 100644 index 0000000..f066ce1 --- /dev/null +++ b/src/app/api/public/intake/route.ts @@ -0,0 +1,151 @@ +import { getAdminPb } from "@/lib/pb/admin"; +import { positionBetween } from "@/lib/ordering/fractional"; +import { nextCardNumber } from "@/lib/cards/number"; +import { parseBrief } from "@/lib/intake/brief"; +import { sanitiseTurns } from "@/lib/intake/turn"; +import { isIntakeToken } from "@/lib/intake/token"; +import { + bumpSubmissionCount, + resolveIntakeLink, +} from "@/lib/intake-links"; +import { jsonError, jsonOk } from "@/lib/http/json"; +import type { CardRecord, ColumnRecord } from "@/lib/pb/types"; + +/** + * POST /api/public/intake — turn a confirmed brief into an Inbox card via a + * public token. + * + * Deliberately a sibling of /api/client/intake rather than a branch inside + * it. The signed-in route's tests prove the server decides column, priority, + * accepted and created_by_role against a hostile body; this route has the + * same shape with one extra field (`created_by_role = "anonymous"`) set in + * code. The hostile-brief test in Task 17 is what proves the property holds + * here. + * + * The brief is re-parsed through the same `parseBrief` whitelist — the model + * (and the browser it ran in) is not trusted to send back what it generated. + * + * Token resolution happens here too, so a malformed body without a `token` + * fails before the database is touched. + */ +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return jsonError("Invalid request body", 400); + } + + const record = (body ?? {}) as Record; + const token = record.token; + + if (!isIntakeToken(token)) { + return jsonError("Not found", 404); + } + + const link = await resolveIntakeLink(token); + // 404 for unknown, revoked, or expired — same wording for all three so the + // response cannot be used to probe token existence. + if ( + !link || + !link.active || + (link.expires_at && link.expires_at < new Date().toISOString().slice(0, 10)) + ) { + return jsonError("Not found", 404); + } + + const pb = await getAdminPb(); + + // Re-parsed, not trusted. The browser could send anything here. + const turns = sanitiseTurns(record.transcript as never); + const brief = parseBrief( + typeof record.brief === "string" + ? record.brief + : JSON.stringify(record.brief ?? {}), + turns + ); + + const band = record.sizeBand; + const sizeBand = + band === "small" || band === "medium" || band === "large" ? band : ""; + + const inbox = await pb + .collection("columns") + .getFirstListItem( + `project = "${link.project}" && is_inbox = true` + ) + .catch(() => null); + + if (!inbox) { + console.error(`[intake-public] project ${link.project} has no inbox column`); + return jsonError("This project cannot accept new cards", 409); + } + + const last = await pb + .collection("cards") + .getFirstListItem(`column = "${inbox.id}"`, { + sort: "-position", + }) + .catch(() => null); + + const numbered = await pb.collection("cards").getFullList({ + filter: `project = "${link.project}"`, + fields: "number", + }); + + const card = await pb.collection("cards").create({ + project: link.project, + // Every field below is chosen here, not taken from the request. + column: inbox.id, + title: brief.title, + description: brief.summary, + position: positionBetween(last ? last.position : null, null), + priority: "medium", + created_by: "", + created_by_role: "anonymous", + accepted: false, + archived: false, + number: nextCardNumber(numbered.map((c) => Number(c.number) || 0)), + brief, + transcript: turns, + size_band: sizeBand, + }); + + let position = 1000; + for (const title of brief.subtasks) { + await pb.collection("subtasks").create({ + card: card.id, + title, + done: false, + position, + }); + position += 1000; + } + + await pb.collection("activity").create({ + project: link.project, + card: card.id, + // Empty actor on purpose — the schema requires the field, but the submitter + // is anonymous by construction. The card detail page distinguishes + // anonymous submissions in the UI rather than via the actor field. + actor: "", + action: "card_submitted_anonymously", + field: "", + old_value: "", + new_value: card.title, + }); + + // Counter bumps are best-effort — failing one must not lose the card. + // The card is already created; an inconsistent counter is recoverable from + // the admin UI. + try { + await bumpSubmissionCount(link); + } catch (error) { + console.error("[intake-public] bumpSubmissionCount failed:", error); + } + + return jsonOk( + { card: { id: card.id, number: card.number, title: card.title } }, + 201 + ); +}