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:
parent
e486bd818c
commit
76a012bb43
5 changed files with 223 additions and 196 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue