From 78c3b9e4019c7f8bdf4d70b48bff08f71b542bb3 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 21 Sep 2026 13:31:48 +0200 Subject: [PATCH] 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. --- src/lib/intake-links/client-projects.ts | 55 +++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/lib/intake-links/client-projects.ts diff --git a/src/lib/intake-links/client-projects.ts b/src/lib/intake-links/client-projects.ts new file mode 100644 index 0000000..28346fd --- /dev/null +++ b/src/lib/intake-links/client-projects.ts @@ -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 { + const pb = await getAdminPb(); + return pb + .collection("projects") + .getFullList({ + 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 { + const pb = await getAdminPb(); + try { + const project = await pb + .collection("projects") + .getOne(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; + } +}