feat(data): card detail loader scoped by read rules

This commit is contained in:
Dave 2026-09-16 10:58:55 +02:00
parent 083fdd0ce5
commit e191b87c80

114
src/lib/data/card.ts Normal file
View file

@ -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<string, string>;
}
/**
* 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<CardDetail | 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 card = await pb.collection("cards").getOne<CardRecord>(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<ColumnRecord>({
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<SubtaskRecord>({
filter: `card = "${cardId}"`,
sort: "position",
});
const comments = await pb.collection("comments").getFullList<CommentRecord>({
filter: `card = "${cardId}"`,
sort: "created",
});
const activity = await pb.collection("activity").getFullList<ActivityRecord>({
filter: `card = "${cardId}"`,
sort: "-created",
});
const userIds = new Set<string>();
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<string, string> = {};
for (const id of userIds) {
const user = await pb
.collection("users")
.getOne<UserRecord>(id)
.catch(() => null);
names[id] = user?.name || user?.email || "Someone";
}
return {
card,
column,
project,
client,
columns,
subtasks,
comments,
activity,
names,
};
} catch {
return null;
}
}