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);
|
return jsonError("A destination column name is required", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pb = await getAdminPb();
|
try {
|
||||||
const card = await pb.collection("cards").getOne<CardRecord>(cardId).catch(() => null);
|
const pb = await getAdminPb();
|
||||||
if (!card || card.archived) return jsonError("Card not found", 404);
|
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>({
|
const columns = await pb.collection("columns").getFullList<ColumnRecord>({
|
||||||
filter: `project = "${card.project}"`,
|
filter: `project = "${card.project}"`,
|
||||||
sort: "position",
|
sort: "position",
|
||||||
});
|
});
|
||||||
|
|
||||||
const destination = columns.find(
|
const destination = columns.find(
|
||||||
(c) => c.name.toLowerCase() === columnName.trim().toLowerCase()
|
(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
|
|
||||||
);
|
);
|
||||||
}
|
if (!destination) {
|
||||||
|
return jsonError(
|
||||||
|
`Unknown column "${columnName}". This project's columns are: ${columns
|
||||||
|
.map((c) => c.name)
|
||||||
|
.join(", ")}`,
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const lastInDestination = await pb
|
const lastInDestination = await pb
|
||||||
.collection("cards")
|
.collection("cards")
|
||||||
.getFirstListItem<CardRecord>(`column = "${destination.id}" && archived = false`, {
|
.getFirstListItem<CardRecord>(`column = "${destination.id}" && archived = false`, {
|
||||||
sort: "-position",
|
sort: "-position",
|
||||||
})
|
})
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
const position = positionBetween(lastInDestination ? lastInDestination.position : null, null);
|
const position = positionBetween(lastInDestination ? lastInDestination.position : null, null);
|
||||||
|
|
||||||
// Same accept-transition rule as the owner's own drag-and-drop move: a
|
// 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,
|
// card leaving the Inbox for a real working column, for the first time,
|
||||||
// is being accepted — not just relocated.
|
// is being accepted — not just relocated.
|
||||||
const accepting = !card.accepted && card.column !== destination.id && !destination.is_inbox;
|
const accepting = !card.accepted && card.column !== destination.id && !destination.is_inbox;
|
||||||
|
|
||||||
const updated = await pb.collection("cards").update<CardRecord>(cardId, {
|
const updated = await pb.collection("cards").update<CardRecord>(cardId, {
|
||||||
column: destination.id,
|
column: destination.id,
|
||||||
position,
|
position,
|
||||||
...(accepting
|
...(accepting
|
||||||
? { accepted: true, start_date: card.start_date || new Date().toISOString().slice(0, 10) }
|
? { 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({
|
if (accepting) {
|
||||||
id: updated.id,
|
const owner = await getOwnerUser();
|
||||||
column: destination.name,
|
await logActivity({
|
||||||
accepted: updated.accepted,
|
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;
|
if (denied) return denied;
|
||||||
|
|
||||||
const { cardId } = await context.params;
|
const { cardId } = await context.params;
|
||||||
const pb = await getAdminPb();
|
|
||||||
|
|
||||||
const card = await pb.collection("cards").getOne<CardRecord>(cardId).catch(() => null);
|
try {
|
||||||
if (!card || card.archived) return jsonError("Card not found", 404);
|
const pb = await getAdminPb();
|
||||||
|
|
||||||
const column = await pb
|
const card = await pb.collection("cards").getOne<CardRecord>(cardId).catch(() => null);
|
||||||
.collection("columns")
|
if (!card || card.archived) return jsonError("Card not found", 404);
|
||||||
.getOne<ColumnRecord>(card.column)
|
|
||||||
.catch(() => null);
|
|
||||||
|
|
||||||
const subtasks = await pb.collection("subtasks").getFullList<SubtaskRecord>({
|
const column = await pb
|
||||||
filter: `card = "${cardId}"`,
|
.collection("columns")
|
||||||
sort: "position",
|
.getOne<ColumnRecord>(card.column)
|
||||||
});
|
.catch(() => null);
|
||||||
|
|
||||||
const comments = await pb.collection("comments").getFullList<CommentRecord>({
|
const subtasks = await pb.collection("subtasks").getFullList<SubtaskRecord>({
|
||||||
filter: `card = "${cardId}"`,
|
filter: `card = "${cardId}"`,
|
||||||
sort: "created",
|
sort: "position",
|
||||||
});
|
});
|
||||||
|
|
||||||
const userIds = new Set(comments.map((c) => c.author).filter(Boolean));
|
const comments = await pb.collection("comments").getFullList<CommentRecord>({
|
||||||
const names: Record<string, string> = {};
|
filter: `card = "${cardId}"`,
|
||||||
for (const id of userIds) {
|
sort: "created",
|
||||||
const user = await pb.collection("users").getOne<UserRecord>(id).catch(() => null);
|
});
|
||||||
names[id] = user?.name || user?.email || "Someone";
|
|
||||||
|
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);
|
return jsonError("At least one non-empty title is required", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pb = await getAdminPb();
|
try {
|
||||||
const card = await pb.collection("cards").getOne<CardRecord>(cardId).catch(() => null);
|
const pb = await getAdminPb();
|
||||||
if (!card || card.archived) return jsonError("Card not found", 404);
|
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
|
const last = await pb
|
||||||
.collection("subtasks")
|
.collection("subtasks")
|
||||||
.getFirstListItem<SubtaskRecord>(`card = "${cardId}"`, { sort: "-position" })
|
.getFirstListItem<SubtaskRecord>(`card = "${cardId}"`, { sort: "-position" })
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
|
|
||||||
let position: number | null = last ? last.position : null;
|
let position: number | null = last ? last.position : null;
|
||||||
const created: string[] = [];
|
const created: string[] = [];
|
||||||
|
|
||||||
for (const title of cleaned) {
|
for (const title of cleaned) {
|
||||||
position = positionBetween(position, null);
|
position = positionBetween(position, null);
|
||||||
const subtask = await pb.collection("subtasks").create({
|
const subtask = await pb.collection("subtasks").create({
|
||||||
card: cardId,
|
card: cardId,
|
||||||
title,
|
title,
|
||||||
done: false,
|
done: false,
|
||||||
position,
|
position,
|
||||||
});
|
});
|
||||||
created.push(subtask.id);
|
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;
|
if (denied) return denied;
|
||||||
|
|
||||||
const { projectId } = await context.params;
|
const { projectId } = await context.params;
|
||||||
const pb = await getAdminPb();
|
|
||||||
|
|
||||||
const project = await pb.collection("projects").getOne(projectId).catch(() => null);
|
try {
|
||||||
if (!project) return jsonError("Project not found", 404);
|
const pb = await getAdminPb();
|
||||||
|
|
||||||
const columns = await pb.collection("columns").getFullList<ColumnRecord>({
|
const project = await pb.collection("projects").getOne(projectId).catch(() => null);
|
||||||
filter: `project = "${projectId}"`,
|
if (!project) return jsonError("Project not found", 404);
|
||||||
sort: "position",
|
|
||||||
});
|
|
||||||
const columnsById = new Map(columns.map((c) => [c.id, c]));
|
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const columns = await pb.collection("columns").getFullList<ColumnRecord>({
|
||||||
const onlyInbox = url.searchParams.get("column") === "inbox";
|
filter: `project = "${projectId}"`,
|
||||||
|
sort: "position",
|
||||||
|
});
|
||||||
|
const columnsById = new Map(columns.map((c) => [c.id, c]));
|
||||||
|
|
||||||
let cards = await pb.collection("cards").getFullList<CardRecord>({
|
const url = new URL(request.url);
|
||||||
filter: `project = "${projectId}" && archived = false`,
|
const onlyInbox = url.searchParams.get("column") === "inbox";
|
||||||
});
|
|
||||||
|
|
||||||
if (onlyInbox) {
|
let cards = await pb.collection("cards").getFullList<CardRecord>({
|
||||||
cards = cards.filter((card) => columnsById.get(card.column)?.is_inbox === true);
|
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 { getAdminPb } from "@/lib/pb/admin";
|
||||||
import { requireAgentToken } from "@/lib/agent/auth";
|
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";
|
import type { ClientRecord, ProjectRecord } from "@/lib/pb/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -14,29 +14,34 @@ export async function GET(request: Request): Promise<Response> {
|
||||||
const denied = requireAgentToken(request);
|
const denied = requireAgentToken(request);
|
||||||
if (denied) return denied;
|
if (denied) return denied;
|
||||||
|
|
||||||
const pb = await getAdminPb();
|
try {
|
||||||
|
const pb = await getAdminPb();
|
||||||
|
|
||||||
const [projects, clients] = await Promise.all([
|
const [projects, clients] = await Promise.all([
|
||||||
pb.collection("projects").getFullList<ProjectRecord>({ sort: "name" }),
|
pb.collection("projects").getFullList<ProjectRecord>({ sort: "name" }),
|
||||||
pb.collection("clients").getFullList<ClientRecord>(),
|
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
|
const out = projects
|
||||||
.map((project) => {
|
.map((project) => {
|
||||||
const client = clientsById.get(project.client);
|
const client = clientsById.get(project.client);
|
||||||
if (!client) return null;
|
if (!client) return null;
|
||||||
return {
|
return {
|
||||||
id: project.id,
|
id: project.id,
|
||||||
name: project.name,
|
name: project.name,
|
||||||
slug: project.slug,
|
slug: project.slug,
|
||||||
client: { id: client.id, name: client.name, slug: client.slug },
|
client: { id: client.id, name: client.name, slug: client.slug },
|
||||||
kind: project.kind,
|
kind: project.kind,
|
||||||
status: project.status,
|
status: project.status,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((p): p is NonNullable<typeof p> => p !== null);
|
.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