feat(api): public intake submission route
Phase 8a Task 8. POST /api/public/intake is the sibling of /api/client/intake, not a branch: same parseBrief whitelist, same card-create code path, with created_by_role='anonymous' set in code (not from the body). 404 for unknown/revoked/expired tokens — same wording for all three, so the response cannot probe token existence. Counter bumps are best-effort so a card is never lost to a counter-update failure. Deviation from plan: dropped the dead 'tokenHeader' line that the plan's own comment admits is vestigial.
This commit is contained in:
parent
ff68abec2a
commit
4004aecefd
1 changed files with 151 additions and 0 deletions
151
src/app/api/public/intake/route.ts
Normal file
151
src/app/api/public/intake/route.ts
Normal file
|
|
@ -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<Response> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonError("Invalid request body", 400);
|
||||
}
|
||||
|
||||
const record = (body ?? {}) as Record<string, unknown>;
|
||||
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<ColumnRecord>(
|
||||
`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<CardRecord>(`column = "${inbox.id}"`, {
|
||||
sort: "-position",
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
const numbered = await pb.collection("cards").getFullList<CardRecord>({
|
||||
filter: `project = "${link.project}"`,
|
||||
fields: "number",
|
||||
});
|
||||
|
||||
const card = await pb.collection("cards").create<CardRecord>({
|
||||
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
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue