// lib/pb.ts // SERVER-ONLY PocketBase client. // // Token handling is self-healing: PB superuser tokens expire every ~24h, // so on every request we may transparently re-authenticate using the // admin's email + password (PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD in // `.env.local`) and refresh the in-memory cached token. Concurrent // requests share a single in-flight refresh (no thundering herd), and // each fetch retries exactly once on a 401/403 from PB before // surfacing the error. import "server-only"; import { deriveCompanyStatus } from "./format"; export const PB_URL = process.env.PB_URL; export const BASE_URL = PB_URL; if (!BASE_URL) throw new Error("PB_URL is not set."); // Re-auth fallback creds. Required for self-healing; optional otherwise. const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL ?? ""; const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD ?? ""; // In-memory token cache. Starts as the env-var value at module load // (so the very first request uses the static PB_TOKEN). Once a // request fails with 401/403, this is cleared and the next call to // getAuthToken() will trigger a re-auth. let cachedToken: string | null = process.env.PB_TOKEN ?? null; // Promise-dedupe so N concurrent failed requests trigger exactly // one re-auth, not N. let refreshInFlight: Promise | null = null; interface AdminAuthResponse { token: string; record?: { id: string; email: string; exp?: number }; } /** Re-authenticate as the PB superuser and cache the new JWT. * Throws if credentials are missing or the auth endpoint rejects. */ async function refreshToken(): Promise { if (!ADMIN_EMAIL || !ADMIN_PASSWORD) { throw new Error( "PB token expired and PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD are not set in .env.local — cannot self-heal." ); } const url = `${BASE_URL}/api/collections/_superusers/auth-with-password`; const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ identity: ADMIN_EMAIL, password: ADMIN_PASSWORD }), cache: "no-store", signal: AbortSignal.timeout(15000), }); if (!res.ok) { const body = await res.text(); throw new Error( `PB superuser re-auth failed (${res.status}): ${body.slice(0, 200)}. Check PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD in .env.local.` ); } const data = (await res.json()) as AdminAuthResponse; if (!data?.token) { throw new Error("PB superuser re-auth: response missing token field"); } cachedToken = data.token; const exp = data.record?.exp; const expStr = typeof exp === "number" ? ` (new expiry ${new Date(exp * 1000).toISOString()})` : ""; console.warn(`[pb] Superuser token refreshed automatically${expStr}.`); return cachedToken; } /** Returns a valid (fresh) superuser token, refreshing if needed. * Concurrent calls share a single in-flight refresh. */ async function getAuthToken(): Promise { if (cachedToken) return cachedToken; if (!refreshInFlight) { refreshInFlight = refreshToken().finally(() => { refreshInFlight = null; }); } return refreshInFlight; } /** Authorization header for a hand-rolled PocketBase fetch. * * Prefer the helpers in this module (pbFetch / createRecord / * updateRecord / …) — they route through pbFetchRaw and get the * 401-refresh-retry behaviour for free. Reach for this ONLY when you * need to hand-build a URL or use a verb pbFetchRaw doesn't expose. * * Never read `process.env.PB_TOKEN` directly in server code: that * value is a static ~24h JWT that is frequently stale, and it bypasses * the self-healing refresh. It is deliberately NOT exported as * `PB_TOKEN` so that mistake is a compile error rather than a silent * `Authorization: undefined` at runtime. */ export async function pbAuthHeaders(): Promise> { return { Authorization: await getAuthToken() }; } /** Low-level wrapper around fetch that: * - attaches the current superuser token * - on 401/403, clears the cache, refreshes, and retries ONCE * - if the retry also fails, surfaces the second response's error * All exported functions in this module route through this wrapper. */ async function pbFetchRaw(url: string, init: RequestInit = {}): Promise { const token = await getAuthToken(); const headers = new Headers(init.headers); headers.set("Authorization", token); const res = await fetch(url, { ...init, headers, cache: "no-store" }); if (res.status === 401 || res.status === 403) { // Token may be stale (expired/invalidated). Force a re-auth and // retry exactly once — the second attempt uses a guaranteed-fresh // JWT, so a retry failure is a real permission error to surface. cachedToken = null; const newToken = await getAuthToken(); const retryHeaders = new Headers(init.headers); retryHeaders.set("Authorization", newToken); return fetch(url, { ...init, headers: retryHeaders, cache: "no-store" }); } return res; } export interface PBList { page: number; perPage: number; totalItems: number; totalPages: number; items: T[]; } export interface PBRecord { id: string; collectionId: string; collectionName: string; [key: string]: unknown; } function buildUrl(path: string, query: Record = {}): string { const params = new URLSearchParams(); for (const [k, v] of Object.entries(query)) { if (v !== undefined && v !== null && v !== "") params.set(k, String(v)); } const qs = params.toString(); return `${BASE_URL}${path}${qs ? `?${qs}` : ""}`; } export async function pbFetch(path: string, query: Record = {}): Promise { const url = buildUrl(path, query); const res = await pbFetchRaw(url); if (!res.ok) { const body = await res.text(); throw new Error(`PocketBase ${res.status} on ${path}: ${body.slice(0, 200)}`); } return (await res.json()) as T; } export async function createRecordMultipart(collection: string, fields: Record, files: { field: string; file: File }[] = []): Promise { const form = new FormData(); for (const [k, v] of Object.entries(fields)) form.set(k, v); for (const { field, file } of files) form.append(field, file, file.name); const url = `${BASE_URL}/api/collections/${collection}/records`; const res = await pbFetchRaw(url, { method: "POST", body: form }); if (!res.ok) { const body = await res.text(); throw new Error(`PocketBase ${res.status} on POST ${collection}: ${body.slice(0, 300)}`); } return (await res.json()) as T; } export async function updateRecord(collection: string, id: string, fields: Record): Promise { const url = `${BASE_URL}/api/collections/${collection}/records/${encodeURIComponent(id)}`; const res = await pbFetchRaw(url, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(fields), }); if (!res.ok) { const body = await res.text(); throw new Error(`PocketBase ${res.status} on PATCH ${collection}/${id}: ${body.slice(0, 300)}`); } return (await res.json()) as T; } export async function fetchCollection(collection: string, params: { page?: number; perPage?: number; sort?: string; filter?: string; expand?: string; fields?: string } = {}): Promise> { return pbFetch>(`/api/collections/${collection}/records`, { page: params.page ?? 1, perPage: params.perPage ?? 25, sort: params.sort, filter: params.filter, expand: params.expand, fields: params.fields, }); } /** * JSON create — for collections without file fields. * Explicit IDs are preserved (some collections have required system-id fields * with no auto-generate pattern). */ export async function createRecord( collection: string, fields: Record, ): Promise { const url = `${BASE_URL}/api/collections/${collection}/records`; const res = await pbFetchRaw(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(fields), }); if (!res.ok) { const body = await res.text(); throw new Error(`PocketBase ${res.status} on POST ${collection}: ${body.slice(0, 300)}`); } return (await res.json()) as T; } export async function fetchRecord(collection: string, id: string, expand?: string): Promise { return pbFetch(`/api/collections/${collection}/records/${encodeURIComponent(id)}`, { expand }); } /** * Junction table helpers — ysterbaard_company_directors (pbc_2497518006) */ export async function fetchJunctionRecords(params: { filter?: string; expand?: string; perPage?: number; sort?: string; }): Promise> { return pbFetch>("/api/collections/pbc_2497518006/records", { page: 1, perPage: params.perPage ?? 500, sort: params.sort, filter: params.filter, expand: params.expand, }); } /** All directors linked to a company, via the junction table */ export async function fetchDirectorsForCompany(companyId: string): Promise { const safe = companyId.replace(/'/g, "''"); const list = await fetchJunctionRecords({ filter: `company = '${safe}'`, expand: "director", perPage: 500, }); return list.items.filter((j) => (j.expand as Record)?.director); } /** All companies linked to a director, via the junction table — returns junction records */ export async function fetchJunctionEntriesForDirector(directorId: string): Promise { const safe = directorId.replace(/'/g, "''"); const list = await fetchJunctionRecords({ filter: `director = '${safe}'`, expand: "company", perPage: 500, }); return list.items.filter((j) => (j.expand as Record)?.company); } /** All directors linked to a company, via the junction table — returns junction records */ export async function fetchJunctionEntriesForCompany(companyId: string): Promise { const safe = companyId.replace(/'/g, "''"); const list = await fetchJunctionRecords({ filter: `company = '${safe}'`, expand: "director", perPage: 500, }); return list.items.filter((j) => (j.expand as Record)?.director); } /** * Recompute a director's denormalised company counters from the junction * table. * * `ysterbaard_company_directors` is the single source of truth for * director<->company links. `total_companies` / `active_companies` / * `inactive_companies` are only a cache of it — they used to be hard-zeroed * on every director create and never recomputed, which is why the Directors * tab disagreed with the company pages. * * Only "In business" counts as active, matching the badge on the company * page. "In process" is not yet trading, so it lands in inactive and * total = active + inactive always holds. * * Call this after any link/unlink so the counters cannot drift again. */ export async function recomputeDirectorCompanyCounts(directorId: string): Promise { const safe = directorId.replace(/'/g, "''"); const junctions = await fetchJunctionRecords({ filter: `director = '${safe}'`, perPage: 500, }); const companyIds = [ ...new Set(junctions.items.map((j) => String(j.company ?? "")).filter(Boolean)), ]; let active = 0; if (companyIds.length > 0) { const companies = await fetchCollection("ysterbaard_companies", { perPage: 500, filter: companyIds.map((id) => `id = "${id}"`).join(" || "), }); active = companies.items.filter( (c) => deriveCompanyStatus( (c.enterprise_status as string | null) ?? null, Boolean(c.is_active) ) === "active" ).length; } const total = companyIds.length; await updateRecord("ysterbaard_directors", directorId, { total_companies: total, active_companies: active, inactive_companies: total - active, }); } export async function deleteRecord(collection: string, id: string): Promise { const url = `${BASE_URL}/api/collections/${collection}/records/${encodeURIComponent(id)}`; const res = await pbFetchRaw(url, { method: "DELETE" }); if (!res.ok && res.status !== 204) { const body = await res.text(); throw new Error(`PocketBase ${res.status} on DELETE ${collection}/${id}: ${body.slice(0, 300)}`); } } export async function updateRecordMultipart( collection: string, id: string, fields: Record, files: { field: string; file: File }[] = [] ): Promise { const form = new FormData(); for (const [k, v] of Object.entries(fields)) form.set(k, v); for (const { field, file } of files) form.append(field, file, file.name); const url = `${BASE_URL}/api/collections/${collection}/records/${encodeURIComponent(id)}`; const res = await pbFetchRaw(url, { method: "PATCH", body: form }); if (!res.ok) { const body = await res.text(); throw new Error(`PocketBase ${res.status} on PATCH ${collection}/${id}: ${body.slice(0, 300)}`); } return (await res.json()) as T; }