feat(data): board and project summary loaders scoped by read rules

This commit is contained in:
Dave 2026-09-16 10:01:21 +02:00
parent 5d3323e765
commit 153484390f

133
src/lib/data/board.ts Normal file
View file

@ -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<BoardData | null> {
const pb = await getServerPb();
if (!pb.authStore.isValid) return null;
try {
const client = await pb
.collection("clients")
.getFirstListItem<ClientRecord>(`slug = "${clientSlug}"`);
const project = await pb
.collection("projects")
.getFirstListItem<ProjectRecord>(
`slug = "${projectSlug}" && client = "${client.id}"`
);
const columns = await pb
.collection("columns")
.getFullList<ColumnRecord>({
filter: `project = "${project.id}"`,
sort: "position",
});
const cards = await pb.collection("cards").getFullList<CardRecord>({
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<ProjectSummary[]> {
const pb = await getServerPb();
if (!pb.authStore.isValid) return [];
const projects = await pb.collection("projects").getFullList<ProjectRecord>({
filter: 'status != "archived"',
sort: "name",
});
if (projects.length === 0) return [];
const clients = await pb.collection("clients").getFullList<ClientRecord>();
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<ColumnRecord>(
`project = "${project.id}" && is_inbox = true`
)
.catch(() => null);
const done = await pb
.collection("columns")
.getFirstListItem<ColumnRecord>(
`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;
}