fix(agent-api): wrap all six agent routes in a catch-all error handler

Every /api/owner/* and /api/client/* route wraps its body in try/catch;
none of the six /api/agent/* routes did, so an unexpected failure (e.g. a
PocketBase validation error partway through the subtasks loop) surfaced as
Next's default non-JSON 500 instead of the jsonError shape this surface
otherwise guarantees everywhere else. A caller retrying after an opaque
500 mid-loop could also create duplicate subtasks for ones that already
succeeded.

Follows the exact pattern already used by
src/app/api/owner/cards/[id]/move/route.ts: requireAgentToken and the
initial body-parse/validation stay outside the try (they touch no
PocketBase and already return correctly-shaped errors); everything after
is wrapped, with a route-specific console.error prefix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-09-26 10:23:20 +02:00
parent e486bd818c
commit 76a012bb43
5 changed files with 223 additions and 196 deletions

View file

@ -37,67 +37,72 @@ export async function PATCH(
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);
try {
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 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 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);
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;
// 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);
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) }
: {}),
});
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,
});
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,
});
} catch (error) {
console.error("[agent-api] move card failed", error);
return jsonError("Something went wrong", 500);
}
}

View file

@ -25,64 +25,70 @@ export async function GET(
if (denied) return denied;
const { cardId } = await context.params;
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);
try {
const pb = await getAdminPb();
const column = await pb
.collection("columns")
.getOne<ColumnRecord>(card.column)
.catch(() => null);
const card = await pb.collection("cards").getOne<CardRecord>(cardId).catch(() => null);
if (!card || card.archived) return jsonError("Card not found", 404);
const subtasks = await pb.collection("subtasks").getFullList<SubtaskRecord>({
filter: `card = "${cardId}"`,
sort: "position",
});
const column = await pb
.collection("columns")
.getOne<ColumnRecord>(card.column)
.catch(() => null);
const comments = await pb.collection("comments").getFullList<CommentRecord>({
filter: `card = "${cardId}"`,
sort: "created",
});
const subtasks = await pb.collection("subtasks").getFullList<SubtaskRecord>({
filter: `card = "${cardId}"`,
sort: "position",
});
const userIds = new Set(comments.map((c) => c.author).filter(Boolean));
const names: Record<string, string> = {};
for (const id of userIds) {
const user = await pb.collection("users").getOne<UserRecord>(id).catch(() => null);
names[id] = user?.name || user?.email || "Someone";
const comments = await pb.collection("comments").getFullList<CommentRecord>({
filter: `card = "${cardId}"`,
sort: "created",
});
const userIds = new Set(comments.map((c) => c.author).filter(Boolean));
const names: Record<string, string> = {};
for (const id of userIds) {
const user = await pb.collection("users").getOne<UserRecord>(id).catch(() => null);
names[id] = user?.name || user?.email || "Someone";
}
const recentCommits = await pb.collection("commits").getList<CommitRecord>(1, 10, {
filter: `card = "${cardId}" && is_merge = false`,
sort: "-authored_at",
});
return jsonOk({
id: card.id,
number: card.number,
title: card.title,
description: card.description,
priority: card.priority,
target_date: card.target_date,
size_band: card.size_band,
column: column
? {
id: column.id,
name: column.name,
is_inbox: column.is_inbox,
is_done: column.is_done,
}
: null,
subtasks: subtasks.map((s) => ({ id: s.id, title: s.title, done: s.done })),
comments: comments.map((c) => ({
id: c.id,
author: names[c.author] ?? "Someone",
body: c.body,
created: c.created,
})),
recentCommits: recentCommits.items.map((commit) => ({
summary: commit.summary || commit.subject,
authored_at: commit.authored_at,
})),
});
} catch (error) {
console.error("[agent-api] get card failed", error);
return jsonError("Something went wrong", 500);
}
const recentCommits = await pb.collection("commits").getList<CommitRecord>(1, 10, {
filter: `card = "${cardId}" && is_merge = false`,
sort: "-authored_at",
});
return jsonOk({
id: card.id,
number: card.number,
title: card.title,
description: card.description,
priority: card.priority,
target_date: card.target_date,
size_band: card.size_band,
column: column
? {
id: column.id,
name: column.name,
is_inbox: column.is_inbox,
is_done: column.is_done,
}
: null,
subtasks: subtasks.map((s) => ({ id: s.id, title: s.title, done: s.done })),
comments: comments.map((c) => ({
id: c.id,
author: names[c.author] ?? "Someone",
body: c.body,
created: c.created,
})),
recentCommits: recentCommits.items.map((commit) => ({
summary: commit.summary || commit.subject,
authored_at: commit.authored_at,
})),
});
}

View file

@ -41,28 +41,33 @@ export async function POST(
return jsonError("At least one non-empty title 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);
try {
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 last = await pb
.collection("subtasks")
.getFirstListItem<SubtaskRecord>(`card = "${cardId}"`, { sort: "-position" })
.catch(() => null);
const last = await pb
.collection("subtasks")
.getFirstListItem<SubtaskRecord>(`card = "${cardId}"`, { sort: "-position" })
.catch(() => null);
let position: number | null = last ? last.position : null;
const created: string[] = [];
let position: number | null = last ? last.position : null;
const created: string[] = [];
for (const title of cleaned) {
position = positionBetween(position, null);
const subtask = await pb.collection("subtasks").create({
card: cardId,
title,
done: false,
position,
});
created.push(subtask.id);
for (const title of cleaned) {
position = positionBetween(position, null);
const subtask = await pb.collection("subtasks").create({
card: cardId,
title,
done: false,
position,
});
created.push(subtask.id);
}
return jsonOk({ created }, 201);
} catch (error) {
console.error("[agent-api] create subtask failed", error);
return jsonError("Something went wrong", 500);
}
return jsonOk({ created }, 201);
}

View file

@ -18,55 +18,61 @@ export async function GET(
if (denied) return denied;
const { projectId } = await context.params;
const pb = await getAdminPb();
const project = await pb.collection("projects").getOne(projectId).catch(() => null);
if (!project) return jsonError("Project not found", 404);
try {
const pb = await getAdminPb();
const columns = await pb.collection("columns").getFullList<ColumnRecord>({
filter: `project = "${projectId}"`,
sort: "position",
});
const columnsById = new Map(columns.map((c) => [c.id, c]));
const project = await pb.collection("projects").getOne(projectId).catch(() => null);
if (!project) return jsonError("Project not found", 404);
const url = new URL(request.url);
const onlyInbox = url.searchParams.get("column") === "inbox";
const columns = await pb.collection("columns").getFullList<ColumnRecord>({
filter: `project = "${projectId}"`,
sort: "position",
});
const columnsById = new Map(columns.map((c) => [c.id, c]));
let cards = await pb.collection("cards").getFullList<CardRecord>({
filter: `project = "${projectId}" && archived = false`,
});
const url = new URL(request.url);
const onlyInbox = url.searchParams.get("column") === "inbox";
if (onlyInbox) {
cards = cards.filter((card) => columnsById.get(card.column)?.is_inbox === true);
let cards = await pb.collection("cards").getFullList<CardRecord>({
filter: `project = "${projectId}" && archived = false`,
});
if (onlyInbox) {
cards = cards.filter((card) => columnsById.get(card.column)?.is_inbox === true);
}
cards.sort((a, b) => {
const colA = columnsById.get(a.column)?.position ?? 0;
const colB = columnsById.get(b.column)?.position ?? 0;
return colA - colB || a.position - b.position;
});
const out = cards
.map((card) => {
const column = columnsById.get(card.column);
if (!column) return null;
return {
id: card.id,
number: card.number,
title: card.title,
description: card.description,
priority: card.priority,
target_date: card.target_date,
size_band: card.size_band,
column: {
id: column.id,
name: column.name,
is_inbox: column.is_inbox,
is_done: column.is_done,
},
};
})
.filter((c): c is NonNullable<typeof c> => c !== null);
return jsonOk(out);
} catch (error) {
console.error("[agent-api] list project cards failed", error);
return jsonError("Something went wrong", 500);
}
cards.sort((a, b) => {
const colA = columnsById.get(a.column)?.position ?? 0;
const colB = columnsById.get(b.column)?.position ?? 0;
return colA - colB || a.position - b.position;
});
const out = cards
.map((card) => {
const column = columnsById.get(card.column);
if (!column) return null;
return {
id: card.id,
number: card.number,
title: card.title,
description: card.description,
priority: card.priority,
target_date: card.target_date,
size_band: card.size_band,
column: {
id: column.id,
name: column.name,
is_inbox: column.is_inbox,
is_done: column.is_done,
},
};
})
.filter((c): c is NonNullable<typeof c> => c !== null);
return jsonOk(out);
}

View file

@ -1,6 +1,6 @@
import { getAdminPb } from "@/lib/pb/admin";
import { requireAgentToken } from "@/lib/agent/auth";
import { jsonOk } from "@/lib/http/json";
import { jsonError, jsonOk } from "@/lib/http/json";
import type { ClientRecord, ProjectRecord } from "@/lib/pb/types";
/**
@ -14,29 +14,34 @@ export async function GET(request: Request): Promise<Response> {
const denied = requireAgentToken(request);
if (denied) return denied;
const pb = await getAdminPb();
try {
const pb = await getAdminPb();
const [projects, clients] = await Promise.all([
pb.collection("projects").getFullList<ProjectRecord>({ sort: "name" }),
pb.collection("clients").getFullList<ClientRecord>(),
]);
const [projects, clients] = await Promise.all([
pb.collection("projects").getFullList<ProjectRecord>({ sort: "name" }),
pb.collection("clients").getFullList<ClientRecord>(),
]);
const clientsById = new Map(clients.map((c) => [c.id, c]));
const clientsById = new Map(clients.map((c) => [c.id, c]));
const out = projects
.map((project) => {
const client = clientsById.get(project.client);
if (!client) return null;
return {
id: project.id,
name: project.name,
slug: project.slug,
client: { id: client.id, name: client.name, slug: client.slug },
kind: project.kind,
status: project.status,
};
})
.filter((p): p is NonNullable<typeof p> => p !== null);
const out = projects
.map((project) => {
const client = clientsById.get(project.client);
if (!client) return null;
return {
id: project.id,
name: project.name,
slug: project.slug,
client: { id: client.id, name: client.name, slug: client.slug },
kind: project.kind,
status: project.status,
};
})
.filter((p): p is NonNullable<typeof p> => p !== null);
return jsonOk(out);
return jsonOk(out);
} catch (error) {
console.error("[agent-api] list projects failed", error);
return jsonError("Something went wrong", 500);
}
}