feat(api): intake route creating an Inbox card from a brief
This commit is contained in:
parent
05ce52a6bf
commit
04e78aef6d
1 changed files with 148 additions and 0 deletions
148
src/app/api/client/intake/route.ts
Normal file
148
src/app/api/client/intake/route.ts
Normal file
|
|
@ -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<Response> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<ProjectRecord>(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<ColumnRecord>(`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<CardRecord>(`column = "${inbox.id}"`, { sort: "-position" })
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
const numbered = await pb.collection("cards").getFullList<CardRecord>({
|
||||||
|
filter: `project = "${projectId}"`,
|
||||||
|
fields: "number",
|
||||||
|
});
|
||||||
|
|
||||||
|
const card = await pb.collection("cards").create<CardRecord>({
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue