From 153484390f232430756e57a709707680da3da1bc Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 10:01:21 +0200 Subject: [PATCH] feat(data): board and project summary loaders scoped by read rules --- src/lib/data/board.ts | 133 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/lib/data/board.ts diff --git a/src/lib/data/board.ts b/src/lib/data/board.ts new file mode 100644 index 0000000..7eb6525 --- /dev/null +++ b/src/lib/data/board.ts @@ -0,0 +1,133 @@ +import "server-only"; + +import { getServerPb } from "@/lib/pb/server"; +import type { + CardRecord, + ClientRecord, + ColumnRecord, + ProjectRecord, +} from "@/lib/pb/types"; + +export interface BoardData { + client: ClientRecord; + project: ProjectRecord; + columns: ColumnRecord[]; + cards: CardRecord[]; +} + +export interface ProjectSummary { + project: ProjectRecord; + client: ClientRecord; + openCards: number; + inboxCards: number; +} + +/** + * Everything one board needs, loaded with the signed-in user's own PocketBase + * token. + * + * Using the user's token rather than the superuser's is the point: the read + * rules from Plan 1a do the scoping, so a client manager requesting another + * client's board gets nothing back from the database itself. The UI is not + * what keeps them out. + * + * Returns null when the project does not exist OR the user may not see it. + * Callers render a 404 for both — distinguishing them would confirm that + * another client's project is real. + */ +export async function loadBoard( + clientSlug: string, + projectSlug: 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 columns = await pb + .collection("columns") + .getFullList({ + filter: `project = "${project.id}"`, + sort: "position", + }); + + const cards = await pb.collection("cards").getFullList({ + filter: `project = "${project.id}" && archived = false`, + sort: "position", + }); + + return { client, project, columns, cards }; + } catch { + return null; + } +} + +/** Every project the signed-in user may see, with counts for the home page. */ +export async function loadProjectSummaries(): Promise { + const pb = await getServerPb(); + if (!pb.authStore.isValid) return []; + + const projects = await pb.collection("projects").getFullList({ + filter: 'status != "archived"', + sort: "name", + }); + + if (projects.length === 0) return []; + + const clients = await pb.collection("clients").getFullList(); + const clientsById = new Map(clients.map((c) => [c.id, c])); + + const summaries: ProjectSummary[] = []; + + for (const project of projects) { + const client = clientsById.get(project.client); + if (!client) continue; + + const inbox = await pb + .collection("columns") + .getFirstListItem( + `project = "${project.id}" && is_inbox = true` + ) + .catch(() => null); + + const done = await pb + .collection("columns") + .getFirstListItem( + `project = "${project.id}" && is_done = true` + ) + .catch(() => null); + + const openFilter = done + ? `project = "${project.id}" && archived = false && column != "${done.id}"` + : `project = "${project.id}" && archived = false`; + + const open = await pb + .collection("cards") + .getList(1, 1, { filter: openFilter, fields: "id" }); + + const inboxCount = inbox + ? await pb.collection("cards").getList(1, 1, { + filter: `column = "${inbox.id}" && archived = false`, + fields: "id", + }) + : null; + + summaries.push({ + project, + client, + openCards: open.totalItems, + inboxCards: inboxCount?.totalItems ?? 0, + }); + } + + return summaries; +} \ No newline at end of file