feat(roadmap): loader scoped by the caller's own read rules

This commit is contained in:
Dave 2026-09-17 07:17:18 +02:00
parent b164f3273c
commit 6e231374e2

137
src/lib/roadmap/load.ts Normal file
View file

@ -0,0 +1,137 @@
import "server-only";
import { getServerPb } from "@/lib/pb/server";
import { deriveBand, type Band } from "@/lib/roadmap/band";
import { checklistPercent } from "@/lib/roadmap/progress";
import type {
CardRecord,
ClientRecord,
ColumnRecord,
ProjectRecord,
SubtaskRecord,
} from "@/lib/pb/types";
export interface RoadmapCard {
id: string;
number: number;
title: string;
priority: CardRecord["priority"];
startDate: string;
targetDate: string;
band: Band | null;
percent: number | null;
projectName: string;
projectSlug: string;
clientName: string;
clientSlug: string;
clientColour: string;
href: string;
}
export interface RoadmapData {
cards: RoadmapCard[];
inboxCount: number;
doneCount: number;
}
/**
* Every card the caller may plan against.
*
* Read with the caller's OWN PocketBase token, so an owner sees every client
* and a client manager sees only theirs. The Phase 1 read rules do the scoping —
* this is not a UI filter, and there is no branch here that could be forgotten.
*/
export async function loadRoadmap(): Promise<RoadmapData> {
const pb = await getServerPb();
if (!pb.authStore.isValid) return { cards: [], inboxCount: 0, doneCount: 0 };
const projects = await pb.collection("projects").getFullList<ProjectRecord>({
filter: 'status = "active"',
sort: "name",
});
if (projects.length === 0) return { cards: [], inboxCount: 0, doneCount: 0 };
const clients = await pb.collection("clients").getFullList<ClientRecord>();
const clientById = new Map(clients.map((c) => [c.id, c]));
const columns = await pb.collection("columns").getFullList<ColumnRecord>({
sort: "position",
});
const cards = await pb.collection("cards").getFullList<CardRecord>({
filter: "archived = false",
});
const subtasks = await pb.collection("subtasks").getFullList<SubtaskRecord>({
fields: "card,done",
});
const checklist = new Map<string, { done: number; total: number }>();
for (const subtask of subtasks) {
const entry = checklist.get(subtask.card) ?? { done: 0, total: 0 };
entry.total += 1;
if (subtask.done) entry.done += 1;
checklist.set(subtask.card, entry);
}
// Working columns per project, in board order — the index into this is what
// deriveBand reads, so a renamed or added column needs no code change.
const workingByProject = new Map<string, ColumnRecord[]>();
for (const column of columns) {
if (column.is_inbox || column.is_done) continue;
const list = workingByProject.get(column.project) ?? [];
list.push(column);
workingByProject.set(column.project, list);
}
const columnById = new Map(columns.map((c) => [c.id, c]));
const projectById = new Map(projects.map((p) => [p.id, p]));
const out: RoadmapCard[] = [];
let inboxCount = 0;
let doneCount = 0;
for (const card of cards) {
const project = projectById.get(card.project);
const column = columnById.get(card.column);
if (!project || !column) continue;
const client = clientById.get(project.client);
if (!client) continue;
if (column.is_inbox) inboxCount += 1;
if (column.is_done) doneCount += 1;
const working = workingByProject.get(card.project) ?? [];
const counts = checklist.get(card.id);
const band = deriveBand({
archived: Boolean(card.archived),
isInbox: Boolean(column.is_inbox),
isDone: Boolean(column.is_done),
columnIndex: working.findIndex((c) => c.id === column.id),
workingColumnCount: working.length,
priority: card.priority,
targetDate: card.target_date ?? "",
});
out.push({
id: card.id,
number: card.number ?? 0,
title: card.title,
priority: card.priority,
startDate: card.start_date ?? "",
targetDate: card.target_date ?? "",
band,
percent: counts ? checklistPercent(counts.done, counts.total) : null,
projectName: project.name,
projectSlug: project.slug,
clientName: client.name,
clientSlug: client.slug,
clientColour: client.brand_color || "var(--brand)",
href: `/p/${client.slug}/${project.slug}/c/${card.id}`,
});
}
return { cards: out, inboxCount, doneCount };
}