feat(roadmap): loader scoped by the caller's own read rules
This commit is contained in:
parent
b164f3273c
commit
6e231374e2
1 changed files with 137 additions and 0 deletions
137
src/lib/roadmap/load.ts
Normal file
137
src/lib/roadmap/load.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue