From e191b87c807a0589738528acd262d53a5cc67c54 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 10:58:55 +0200 Subject: [PATCH] feat(data): card detail loader scoped by read rules --- src/lib/data/card.ts | 114 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src/lib/data/card.ts diff --git a/src/lib/data/card.ts b/src/lib/data/card.ts new file mode 100644 index 0000000..2db8dc4 --- /dev/null +++ b/src/lib/data/card.ts @@ -0,0 +1,114 @@ +import "server-only"; + +import { getServerPb } from "@/lib/pb/server"; +import type { + ActivityRecord, + CardRecord, + ClientRecord, + ColumnRecord, + CommentRecord, + ProjectRecord, + SubtaskRecord, + UserRecord, +} from "@/lib/pb/types"; + +export interface CardDetail { + card: CardRecord; + column: ColumnRecord; + project: ProjectRecord; + client: ClientRecord; + columns: ColumnRecord[]; + subtasks: SubtaskRecord[]; + comments: CommentRecord[]; + activity: ActivityRecord[]; + /** Display names by user id, for comments and the timeline. */ + names: Record; +} + +/** + * Everything the card detail page needs, read with the caller's own token so + * the Plan 1a read rules do the scoping. + * + * Returns null when the card does not exist OR the caller may not see it. The + * page renders the same 404 for both, so a client cannot discover that another + * client's card is real by probing ids. + */ +export async function loadCardDetail( + clientSlug: string, + projectSlug: string, + cardId: string +): Promise { + const pb = await getServerPb(); + if (!pb.authStore.isValid) return null; + + try { + const client = await pb + .collection("clients") + .getFirstListItem(`slug = "${clientSlug}"`); + + const project = await pb + .collection("projects") + .getFirstListItem( + `slug = "${projectSlug}" && client = "${client.id}"` + ); + + const card = await pb.collection("cards").getOne(cardId); + + // The card must belong to the project in the URL. Without this check a + // valid card id from another project would render under this project's + // breadcrumb — and for a client manager, the read rules would have already + // allowed it if both projects are theirs. + if (card.project !== project.id) return null; + + const columns = await pb.collection("columns").getFullList({ + filter: `project = "${project.id}"`, + sort: "position", + }); + + const column = columns.find((c) => c.id === card.column); + if (!column) return null; + + const subtasks = await pb.collection("subtasks").getFullList({ + filter: `card = "${cardId}"`, + sort: "position", + }); + + const comments = await pb.collection("comments").getFullList({ + filter: `card = "${cardId}"`, + sort: "created", + }); + + const activity = await pb.collection("activity").getFullList({ + filter: `card = "${cardId}"`, + sort: "-created", + }); + + const userIds = new Set(); + for (const c of comments) if (c.author) userIds.add(c.author); + for (const a of activity) if (a.actor) userIds.add(a.actor); + if (card.created_by) userIds.add(card.created_by); + + const names: Record = {}; + for (const id of userIds) { + const user = await pb + .collection("users") + .getOne(id) + .catch(() => null); + names[id] = user?.name || user?.email || "Someone"; + } + + return { + card, + column, + project, + client, + columns, + subtasks, + comments, + activity, + names, + }; + } catch { + return null; + } +} \ No newline at end of file