- Added `NEXT_PUBLIC_APP_URL` to `.env.example` for app URL configuration. - Updated `README.md` to document the new `NEXT_PUBLIC_APP_URL` variable. - Improved CSS to support light and dark color schemes. - Refactored intake link actions to utilize the new app URL and handle token generation more efficiently. - Enhanced public intake route to validate work area selection. - Updated `IntakeLinksPanel` to allow copying of generated links. - Improved error handling and user experience in `BriefPreviewPublic` and `IntakePublic` components. - Refined theme synchronization and toggle components for better user preference management.
198 lines
6.4 KiB
TypeScript
198 lines
6.4 KiB
TypeScript
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 {
|
|
bumpDailyCapForProject,
|
|
bumpSubmissionCount,
|
|
isLinkProjectUsable,
|
|
resolveIntakeLink,
|
|
} from "@/lib/intake-links";
|
|
import { resolveProjectForClient } from "@/lib/intake-links/client-projects";
|
|
import { jsonError, jsonOk } from "@/lib/http/json";
|
|
import { resolveWorkArea } from "@/lib/work-areas/server";
|
|
import { cleanWorkAreaName } from "@/lib/work-areas/core";
|
|
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.
|
|
*
|
|
* Phase 9: when the link is client-bound (link.client set), the body must
|
|
* carry a projectId, and that projectId must belong to the link's client
|
|
* AND be opted in / active. The daily cap is per-(link, project). When
|
|
* the link is per-project (Phase 8 shape), behaviour is unchanged.
|
|
*/
|
|
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 areaId = typeof record.areaId === "string" ? record.areaId.trim() : "";
|
|
const newAreaName = cleanWorkAreaName(record.newAreaName);
|
|
if (!areaId && !newAreaName) {
|
|
return jsonError("Choose an area", 400);
|
|
}
|
|
|
|
const pb = await getAdminPb();
|
|
|
|
// Phase 9: resolve the projectId from the body, verify it against
|
|
// the link's client, and check the per-(link, project) cap.
|
|
let resolvedProjectId = link.project;
|
|
if (link.client) {
|
|
const projectId = record.projectId;
|
|
if (typeof projectId !== "string" || !projectId) {
|
|
return jsonError("Not found", 404);
|
|
}
|
|
const project = await resolveProjectForClient(link.client, projectId);
|
|
if (!project) {
|
|
return jsonError("Not found", 404);
|
|
}
|
|
resolvedProjectId = project.id;
|
|
|
|
const usable = await isLinkProjectUsable(link, project.id);
|
|
if (!usable) {
|
|
return jsonError("You've reached the limit for today.", 429);
|
|
}
|
|
}
|
|
|
|
// 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 = "${resolvedProjectId}" && is_inbox = true`
|
|
)
|
|
.catch(() => null);
|
|
|
|
if (!inbox) {
|
|
console.error(`[intake-public] project ${resolvedProjectId} has no inbox column`);
|
|
return jsonError("This project cannot accept new cards", 409);
|
|
}
|
|
|
|
const workArea = await resolveWorkArea(pb, resolvedProjectId, {
|
|
areaId,
|
|
newAreaName,
|
|
}).catch(() => null);
|
|
if (workArea === null) return jsonError("Area not found in this project", 400);
|
|
|
|
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 = "${resolvedProjectId}"`,
|
|
fields: "number",
|
|
});
|
|
|
|
const card = await pb.collection("cards").create<CardRecord>({
|
|
project: resolvedProjectId,
|
|
// Every field below is chosen here, not taken from the request.
|
|
column: inbox.id,
|
|
work_area: workArea,
|
|
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: resolvedProjectId,
|
|
card: card.id,
|
|
// No actor — the submitter is anonymous by construction. The card detail
|
|
// page distinguishes anonymous submissions via the
|
|
// card_submitted_anonymously action rather than the actor field.
|
|
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 {
|
|
if (link.client) {
|
|
// Phase 9: per-(link, project) counter.
|
|
await bumpDailyCapForProject(link, resolvedProjectId);
|
|
} else {
|
|
// Phase 8: per-link counter (legacy).
|
|
await bumpSubmissionCount(link);
|
|
}
|
|
} catch (error) {
|
|
console.error("[intake-public] bumpCounter failed:", error);
|
|
}
|
|
|
|
return jsonOk(
|
|
{ card: { id: card.id, number: card.number, title: card.title } },
|
|
201
|
|
);
|
|
}
|