WORKFLOW-APP/lib/pb.ts
Dave da504c45a0 wip: registration wizard, address splits, document panel, appointments
Uncommitted work in progress at the point of starting the Kanban
project. Captures:
- Registration wizard refactor + multi-step routing
- structured physical/postal address fields on companies and directors
- appointments flow (sign + verify)
- document type manager + upload/panel for companies and directors
- status actions, approve button, action/dropdown buttons
- step2 review page

tsconfig.tsbuildinfo is now ignored (build artefact).
2026-08-17 18:33:56 +02:00

197 lines
7.1 KiB
TypeScript

// lib/pb.ts
// SERVER-ONLY PocketBase client.
import "server-only";
export const PB_URL = process.env.PB_URL;
export const BASE_URL = PB_URL;
export const PB_TOKEN = process.env.PB_TOKEN;
const TOKEN = PB_TOKEN;
if (!BASE_URL) throw new Error("PB_URL is not set.");
if (!TOKEN || TOKEN === "PASTE_SUPERUSER_JWT_HERE") throw new Error("PB_TOKEN missing");
export interface PBList<T> { 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, string | number | undefined> = {}): 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<T>(path: string, query: Record<string, string | number | undefined> = {}): Promise<T> {
const url = buildUrl(path, query);
const res = await fetch(url, {
headers: { Authorization: TOKEN as string },
cache: "no-store",
signal: AbortSignal.timeout(15000),
});
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<T extends PBRecord>(collection: string, fields: Record<string, string>, files: { field: string; file: File }[] = []): Promise<T> {
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 fetch(url, {
method: "POST",
headers: { Authorization: TOKEN as string },
body: form,
cache: "no-store",
});
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<T extends PBRecord>(collection: string, id: string, fields: Record<string, unknown>): Promise<T> {
const url = `${BASE_URL}/api/collections/${collection}/records/${encodeURIComponent(id)}`;
const res = await fetch(url, {
method: "PATCH",
headers: { Authorization: TOKEN as string, "Content-Type": "application/json" },
body: JSON.stringify(fields),
cache: "no-store",
});
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<T extends PBRecord>(collection: string, params: { page?: number; perPage?: number; sort?: string; filter?: string; expand?: string; fields?: string } = {}): Promise<PBList<T>> {
return pbFetch<PBList<T>>(`/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<T extends PBRecord>(
collection: string,
fields: Record<string, unknown>,
): Promise<T> {
const url = `${BASE_URL}/api/collections/${collection}/records`;
const res = await fetch(url, {
method: "POST",
headers: { Authorization: TOKEN as string, "Content-Type": "application/json" },
body: JSON.stringify(fields),
cache: "no-store",
});
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<T = unknown>(collection: string, id: string, expand?: string): Promise<T> {
return pbFetch<T>(`/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<PBList<PBRecord>> {
return pbFetch<PBList<PBRecord>>("/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<PBRecord[]> {
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<string, unknown>)?.director);
}
/** All companies linked to a director, via the junction table — returns junction records */
export async function fetchJunctionEntriesForDirector(directorId: string): Promise<PBRecord[]> {
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<string, unknown>)?.company);
}
/** All directors linked to a company, via the junction table — returns junction records */
export async function fetchJunctionEntriesForCompany(companyId: string): Promise<PBRecord[]> {
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<string, unknown>)?.director);
}
export async function deleteRecord(collection: string, id: string): Promise<void> {
const url = `${BASE_URL}/api/collections/${collection}/records/${encodeURIComponent(id)}`;
const res = await fetch(url, {
method: "DELETE",
headers: { Authorization: TOKEN as string },
cache: "no-store",
});
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<T extends PBRecord>(
collection: string,
id: string,
fields: Record<string, string>,
files: { field: string; file: File }[] = []
): Promise<T> {
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 fetch(url, {
method: "PATCH",
headers: { Authorization: TOKEN as string },
body: form,
cache: "no-store",
});
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;
}