From 04e78aef6d9783b7c12c142ecdca6c900a70fa05 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 18:04:44 +0200 Subject: [PATCH] feat(api): intake route creating an Inbox card from a brief --- src/app/api/client/intake/route.ts | 148 +++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 src/app/api/client/intake/route.ts diff --git a/src/app/api/client/intake/route.ts b/src/app/api/client/intake/route.ts new file mode 100644 index 0000000..b54593b --- /dev/null +++ b/src/app/api/client/intake/route.ts @@ -0,0 +1,148 @@ +import { getAdminPb } from "@/lib/pb/admin"; +import { requireSession } from "@/lib/auth/session"; +import { + AccessError, + assertClientOwnsProject, + canWriteClientFields, +} from "@/lib/auth/access"; +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 { jsonError, jsonOk } from "@/lib/http/json"; +import type { CardRecord, ColumnRecord, ProjectRecord } from "@/lib/pb/types"; + +/** + * POST /api/client/intake — turn a confirmed brief into an Inbox card. + * + * Deliberately separate from POST /api/client/cards rather than an extension of + * it: that route's tests were written against a two-field body, and widening + * the shape it accepts would leave them passing while proving less. See the + * header of the Phase 4b plan. + * + * The brief arrives from the browser, so it is re-parsed here through the same + * whitelist the interview used. Trusting the client to send back the brief the + * server generated would let them send a different one — and the whole point of + * parseBrief is that fields like priority and accepted never survive it. + * + * Column, priority, accepted and created_by_role are set here regardless of + * what the body contains. That is the security property: the worst a hostile + * brief can achieve is a strangely-worded card in the Inbox. + */ +export async function POST(request: Request): Promise { + try { + const session = await requireSession(); + + let body: unknown; + try { + body = await request.json(); + } catch { + return jsonError("Invalid request body", 400); + } + + const record = (body ?? {}) as Record; + const projectId = record.project; + + if (typeof projectId !== "string" || !projectId) { + return jsonError("A project id is required", 400); + } + + const pb = await getAdminPb(); + + let project: ProjectRecord; + try { + project = await pb.collection("projects").getOne(projectId); + } catch { + return jsonError("Project not found", 404); + } + + assertClientOwnsProject(session, project); + + if (!canWriteClientFields(session, project.client)) { + return jsonError("Project not found", 404); + } + + // 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 = "${projectId}" && is_inbox = true`) + .catch(() => null); + + if (!inbox) { + console.error(`[intake] project ${projectId} 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 = "${projectId}"`, + fields: "number", + }); + + const card = await pb.collection("cards").create({ + project: projectId, + // 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: session.userId, + created_by_role: "client", + accepted: false, + archived: false, + number: nextCardNumber(numbered.map((c) => Number(c.number) || 0)), + brief, + transcript: turns, + size_band: sizeBand, + }); + + // Sub-tasks come from the brief's decomposition — the thing the developer + // asked for in the first place: "a written out plan with sub-categories". + 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: projectId, + card: card.id, + actor: session.userId, + action: "card_created", + field: "", + old_value: "", + new_value: card.title, + }); + + return jsonOk( + { card: { id: card.id, number: card.number, title: card.title } }, + 201 + ); + } catch (error) { + if (error instanceof AccessError) { + return jsonError(error.message, error.status); + } + console.error("[intake] unexpected error", error); + return jsonError("Something went wrong", 500); + } +}