import PocketBase from "pocketbase"; /** * Management CRUD — server-side helpers used by the MCP layer. * * Why a separate module from `lib/pb-helpers.ts`? * - `pb-helpers.ts` is the *client* surface (browser auth, getX / getAllX * reads for React components). It uses the cookie-auth pocketbase * instance and is bundled into the browser. * - These helpers run *server-side* with the admin PB (full read/write * across all collections, no per-row auth rules) and are called from * `/api/mcp`. The MCP layer is what the Hermes agents in n8n hit. * * Naming convention mirrors pb-helpers.ts but with the verb prefix * (createCompany / updateCompany / deleteCompany) — caller's pb * instance is passed in so we can share auth state with other server * helpers (e.g. findOrCreateCustomer in quotes-service.ts). * * Field shape notes (post 2026-06-28 schema changes): * - Company.coordinates, Customer.coordinates: `{ lon: number, lat: number }` * (PB geoPoint). Null Island `{0,0}` is treated as "unset" by the UI. * - Customer.company: PB relation → companies (pass a company id). * - Customer.rep: PB relation → users (pass a user id). * - Supplier.coordinates: text "lat, lng" (unchanged from pre-migration). */ // ─── Companies ───────────────────────────────────────────────────────────── export async function createCompany( pb: PocketBase, input: { company: string; address?: string; vendor?: string; coordinates?: { lon: number; lat: number }; } ) { if (!input.company?.trim()) { throw new Error("company name is required"); } return pb.collection("companies").create({ company: input.company.trim(), address: input.address || "", vendor: input.vendor || "", coordinates: input.coordinates || { lon: 0, lat: 0 }, }); } export async function updateCompany( pb: PocketBase, id: string, patch: Partial<{ company: string; address: string; vendor: string; coordinates: { lon: number; lat: number }; }> ) { return pb.collection("companies").update(id, patch); } export async function deleteCompany(pb: PocketBase, id: string) { return pb.collection("companies").delete(id); } // ─── Customers ───────────────────────────────────────────────────────────── export async function createCustomer( pb: PocketBase, input: { customer: string; email?: string; alternative_email?: string; buyer_email?: string; number?: string; /** * Either a company record id (preferred) or a company name. If a name * is passed we look it up case-insensitively; if no match, the * relation is left empty so the operator can link it in the UI. */ company?: string; /** * Either a user id (preferred) or a user's name / email. Same * name-resolution behaviour as `company`. */ rep?: string; coordinates?: { lon: number; lat: number }; description?: string; } ) { if (!input.customer?.trim()) { throw new Error("customer name is required"); } const companyId = await resolveCompanyRef(pb, input.company); const repId = await resolveUserRef(pb, input.rep); return pb.collection("customers").create({ customer: input.customer.trim(), email: input.email || "", alternative_email: input.alternative_email || "", buyer_email: input.buyer_email || "", number: input.number || "", company: companyId, rep: repId, coordinates: input.coordinates || { lon: 0, lat: 0 }, description: input.description || "", dateadded: new Date().toISOString().slice(0, 10), lastvisiteddate: new Date().toISOString(), }); } export async function updateCustomer( pb: PocketBase, id: string, patch: Partial<{ customer: string; email: string; alternative_email: string; buyer_email: string; number: string; /** id or name; "" to unset the relation. */ company: string; /** id or name; "" to unset the relation. */ rep: string; coordinates: { lon: number; lat: number }; description: string; }> ) { const next: Record = { ...patch }; if ("company" in patch) { next.company = patch.company ? await resolveCompanyRef(pb, patch.company) : ""; } if ("rep" in patch) { next.rep = patch.rep ? await resolveUserRef(pb, patch.rep) : ""; } return pb.collection("customers").update(id, next); } export async function deleteCustomer(pb: PocketBase, id: string) { return pb.collection("customers").delete(id); } // ─── Suppliers ───────────────────────────────────────────────────────────── export async function createSupplier( pb: PocketBase, input: { suppliername: string; email?: string; suppliernumber?: string; supplieraddress?: string; supply_tags?: string; /** Combined "lat, lng" text — suppliers still use the old format. */ coordinates?: string; closingtime?: string; } ) { if (!input.suppliername?.trim()) { throw new Error("supplier name is required"); } return pb.collection("suppliers").create({ suppliername: input.suppliername.trim(), email: input.email || "", suppliernumber: input.suppliernumber || "", supplieraddress: input.supplieraddress || "", supply_tags: input.supply_tags || "", coordinates: input.coordinates || "", closingtime: input.closingtime || "", }); } export async function updateSupplier( pb: PocketBase, id: string, patch: Partial<{ suppliername: string; email: string; suppliernumber: string; supplieraddress: string; supply_tags: string; coordinates: string; closingtime: string; }> ) { return pb.collection("suppliers").update(id, patch); } export async function deleteSupplier(pb: PocketBase, id: string) { return pb.collection("suppliers").delete(id); } // ─── Reference resolution helpers ──────────────────────────────────────── /** * Accepts either a company record id or a company name. If it looks * like a 15-char PocketBase id, return as-is. Otherwise look up by * name (case-insensitive, trimmed). Returns "" when no match. * * The 15-char check is fuzzy — PocketBase ids are alphanumeric and * default to exactly 15 chars. We err on the side of "if it has a * passable name shape, look it up". */ async function resolveCompanyRef(pb: PocketBase, ref?: string): Promise { if (!ref) return ""; const trimmed = ref.trim(); if (!trimmed) return ""; // Fast path: if it parses as a PB id and the row exists, use it. if (isPocketBaseId(trimmed)) { try { await pb.collection("companies").getOne(trimmed); return trimmed; } catch { // Fall through to name lookup. } } const match = await findCompanyByName(pb, trimmed); return match?.id || ""; } async function resolveUserRef(pb: PocketBase, ref?: string): Promise { if (!ref) return ""; const trimmed = ref.trim(); if (!trimmed) return ""; if (isPocketBaseId(trimmed)) { try { await pb.collection("users").getOne(trimmed); return trimmed; } catch { // Fall through. } } // Try name match, then email match. const byName = await pb.collection("users").getFirstListItem(`name = "${trimmed.replace(/"/g, '\\"')}"`) .catch(() => null); if (byName) return byName.id; const byEmail = await pb.collection("users").getFirstListItem(`email = "${trimmed.toLowerCase().replace(/"/g, '\\"')}"`) .catch(() => null); return byEmail?.id || ""; } async function findCompanyByName(pb: PocketBase, name: string) { const trimmed = name.trim(); if (!trimmed) return null; const escaped = trimmed.replace(/"/g, '\\"'); try { return await pb .collection("companies") .getFirstListItem(`company = "${escaped}"`); } catch { return null; } } function isPocketBaseId(s: string): boolean { // PocketBase ids are 15 alphanumeric chars by default. We don't try // to be stricter than that — false positives just fall through to // the name lookup which is harmless. return /^[a-z0-9]{15}$/i.test(s); }