From 19705cc8d92b4c1b77400a0ebe51a25e20cba4c7 Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 26 Sep 2026 09:47:49 +0200 Subject: [PATCH] feat(agent-api): PATCH /api/agent/cards/:cardId/move Co-Authored-By: Claude Haiku 4.5 --- .../api/agent/cards/[cardId]/move/route.ts | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 src/app/api/agent/cards/[cardId]/move/route.ts diff --git a/src/app/api/agent/cards/[cardId]/move/route.ts b/src/app/api/agent/cards/[cardId]/move/route.ts new file mode 100644 index 0000000..d315d43 --- /dev/null +++ b/src/app/api/agent/cards/[cardId]/move/route.ts @@ -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 { + 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(cardId).catch(() => null); + if (!card || card.archived) return jsonError("Card not found", 404); + + const columns = await pb.collection("columns").getFullList({ + 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(`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(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, + }); +}