kanban-dev-webapp/src/app/api/client/cards/route.ts
Dave d06d1e99ec feat(cards): per-project card numbers for commit references
- number field on cards, unique per project
- backfilled existing cards in created order
- both create paths (owner action, client API) assign on creation
- seed script is idempotent across re-runs
2026-09-16 13:20:24 +02:00

129 lines
No EOL
3.9 KiB
TypeScript

import { getAdminPb } from "@/lib/pb/admin";
import { requireSession } from "@/lib/auth/session";
import {
AccessError,
assertClientOwnsProject,
canWriteClientFields,
} from "@/lib/auth/access";
import { pickClientCardCreate } from "@/lib/cards/client-patch";
import { positionBetween } from "@/lib/ordering/fractional";
import { jsonError, jsonOk } from "@/lib/http/json";
import type { CardRecord, ColumnRecord, ProjectRecord } from "@/lib/pb/types";
/**
* POST /api/client/cards — a client manager proposes a new card.
*
* The destination column, priority and accepted flag are chosen here, never
* taken from the request. A card always lands in the project's Inbox at
* medium priority with accepted=false, whatever the body asks for. The owner
* accepts it by dragging it out, which is what sets accepted=true.
*/
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 projectId =
typeof body === "object" && body !== null
? (body as Record<string, unknown>).project
: undefined;
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);
}
const { input, dropped } = pickClientCardCreate(body);
if (dropped.length > 0) {
console.warn(
`[client-create] user=${session.userId} project=${projectId} dropped=${dropped.join(",")}`
);
}
if (!input) {
return jsonError("A title is required", 400);
}
const inbox = await pb
.collection("columns")
.getFirstListItem<ColumnRecord>(
`project = "${projectId}" && is_inbox = true`
)
.catch(() => null);
if (!inbox) {
// The column invariants in src/lib/columns/seed.ts should make this
// impossible. If it happens, the project's columns are corrupt and
// silently inventing a destination would hide that.
console.error(`[client-create] 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,
column: inbox.id,
title: input.title,
description: input.description,
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) => c.number)),
});
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, title: card.title } }, 201);
} catch (error) {
if (error instanceof AccessError) {
return jsonError(error.message, error.status);
}
console.error("[client-create] unexpected error", error);
return jsonError("Something went wrong", 500);
}
}