feat(intake-links): picker project list and resolver

Phase 9b Task 3. listPickerProjects reads the public-eligible
projects on a client; resolveProjectForClient checks that a given
projectId belongs to the client and is opted in. Both fail closed
on any DB read error.
This commit is contained in:
Dave 2026-09-21 13:31:48 +02:00
parent 34c36eee15
commit 78c3b9e401

View file

@ -0,0 +1,55 @@
import "server-only";
import { getAdminPb } from "@/lib/pb/admin";
import type { ProjectRecord } from "@/lib/pb/types";
/**
* List every project on the given client that is eligible to appear
* in the public intake picker.
*
* Eligible = (status = "active") AND (show_in_intake = true).
*
* Reads via the superuser connection. The route that uses this is a
* public server component — no session, no user, so the only authority
* is the superuser.
*
* Sorted by project name so the picker is alphabetical and predictable.
*/
export async function listPickerProjects(
clientId: string
): Promise<ProjectRecord[]> {
const pb = await getAdminPb();
return pb
.collection("projects")
.getFullList<ProjectRecord>({
filter: `client = "${clientId}" && status = "active" && show_in_intake = true`,
sort: "name",
});
}
/**
* Read a single project, scoped to the client. Used by the
* `/intake/[token]/p/[projectId]` page to verify the URL's projectId
* is one the picker could have shown.
*
* Returns null if the project is missing, archived, opted out, or
* belongs to a different client. The caller 404s on null — same
* wording as the wizard, so a hostile body cannot probe.
*/
export async function resolveProjectForClient(
clientId: string,
projectId: string
): Promise<ProjectRecord | null> {
const pb = await getAdminPb();
try {
const project = await pb
.collection("projects")
.getOne<ProjectRecord>(projectId);
if (project.client !== clientId) return null;
if (project.status !== "active") return null;
if (!project.show_in_intake) return null;
return project;
} catch {
return null;
}
}