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; + } +}