feat(data): card detail loader scoped by read rules
This commit is contained in:
parent
083fdd0ce5
commit
e191b87c80
1 changed files with 114 additions and 0 deletions
114
src/lib/data/card.ts
Normal file
114
src/lib/data/card.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue