feat(agent-api): PATCH /api/agent/cards/:cardId/move

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-09-26 09:47:49 +02:00
parent ded7ffbb1f
commit 19705cc8d9

View file

@ -0,0 +1,103 @@
import { getAdminPb } from "@/lib/pb/admin";
import { requireAgentToken } from "@/lib/agent/auth";
import { getOwnerUser } from "@/lib/agent/owner";
import { logActivity } from "@/app/actions/activity";
import { relinkProject } from "@/lib/linking/engine";
import { positionBetween } from "@/lib/ordering/fractional";
import { jsonError, jsonOk } from "@/lib/http/json";
import type { CardRecord, ColumnRecord } from "@/lib/pb/types";
/**
* PATCH /api/agent/cards/:cardId/move — move a card by destination column
* NAME (not id — the agent has no reason to know internal PocketBase ids),
* resolved case-insensitively against the card's own project.
*
* Always appends to the end of the destination column — there is no concept
* of "drop position" for a machine caller, so it lands last, exactly like a
* freshly created card does.
*/
export async function PATCH(
request: Request,
context: { params: Promise<{ cardId: string }> }
): Promise<Response> {
const denied = requireAgentToken(request);
if (denied) return denied;
const { cardId } = await context.params;
let json: unknown;
try {
json = await request.json();
} catch {
return jsonError("Invalid request body", 400);
}
const columnName = (json as { column?: unknown } | null)?.column;
if (typeof columnName !== "string" || !columnName.trim()) {
return jsonError("A destination column name is required", 400);
}
const pb = await getAdminPb();
const card = await pb.collection("cards").getOne<CardRecord>(cardId).catch(() => null);
if (!card || card.archived) return jsonError("Card not found", 404);
const columns = await pb.collection("columns").getFullList<ColumnRecord>({
filter: `project = "${card.project}"`,
sort: "position",
});
const destination = columns.find(
(c) => c.name.toLowerCase() === columnName.trim().toLowerCase()
);
if (!destination) {
return jsonError(
`Unknown column "${columnName}". This project's columns are: ${columns
.map((c) => c.name)
.join(", ")}`,
400
);
}
const lastInDestination = await pb
.collection("cards")
.getFirstListItem<CardRecord>(`column = "${destination.id}" && archived = false`, {
sort: "-position",
})
.catch(() => null);
const position = positionBetween(lastInDestination ? lastInDestination.position : null, null);
// Same accept-transition rule as the owner's own drag-and-drop move: a
// card leaving the Inbox for a real working column, for the first time,
// is being accepted — not just relocated.
const accepting = !card.accepted && card.column !== destination.id && !destination.is_inbox;
const updated = await pb.collection("cards").update<CardRecord>(cardId, {
column: destination.id,
position,
...(accepting
? { accepted: true, start_date: card.start_date || new Date().toISOString().slice(0, 10) }
: {}),
});
if (accepting) {
const owner = await getOwnerUser();
await logActivity({
project: card.project,
card: card.id,
actor: owner.id,
action: "card_accepted",
newValue: destination.name,
}).catch((error) => {
console.error("[agent-api] accepted card but activity failed", error);
});
await relinkProject(card.project).catch((error) => {
console.error("[agent-api] accepted card but relinking failed", error);
});
}
return jsonOk({
id: updated.id,
column: destination.name,
accepted: updated.accepted,
});
}