import { getAdminPocketBase } from "./pocketbase-admin"; import { createCompany, createCustomer, } from "./management-service"; import { createOrder } from "./orders-service"; /* ============================================================================ * Counter service — backing the standalone PWA order form. * * Counter phones run /new-order without PocketBase user login (the office * staff are logged in already at the main desktop terminal). These * endpoints are intentionally public — they sit behind nothing more than * network-level isolation. Do not expose the /api/counter/* routes * publicly without revisiting this assumption. * * Search endpoints return a small JSON shape (NOT the full PB row) so * the browser can render the combined customer/company combobox * without hauling the entire PB record down the wire. * ========================================================================= */ export interface CustomerLite { id: string; customer: string; email: string; number: string; company_id: string; company_name: string; } export interface CompanyLite { id: string; company: string; address: string; vendor: string; } export interface CreateCounterOrderInput { // Pick OR inline-create. Resolved to ids server-side. customer_id?: string; company_id?: string; inline_customer?: { customer: string; email?: string; number?: string; buyer_email?: string; company_id?: string; }; inline_company?: { company: string; address?: string; vendor?: string; }; contact_name?: string; notes?: string; description?: string; rep?: string; items: Array<{ description: string; quantity?: number; selling_price?: number; notes?: string; }>; } export interface CreateCounterOrderResult { order_id: string; order_number: string; customer_id: string; company_id: string; items_created: number; items_failed: Array<{ description: string; error: string }>; } /* ────────────────────────────────────────────────────────────────────────── * Search helpers * ──────────────────────────────────────────────────────────────────────── */ const SEARCH_PER_PAGE = 200; export async function searchCustomersLite( query: string, ): Promise { const pb = await getAdminPocketBase(); const trimmed = query.trim(); const filter = trimmed ? `customer ~ "${escapePbString(trimmed)}" || email ~ "${escapePbString( trimmed, )}" || number ~ "${escapePbString(trimmed)}"` : ""; const rows = await pb.collection("customers").getList(1, SEARCH_PER_PAGE, { filter, sort: "customer", }); const ids = Array.from( new Set(rows.items.map((r) => (r as { company?: string }).company).filter(Boolean)), ) as string[]; const companiesById = new Map(); if (ids.length > 0) { const companies = await pb.collection("companies").getFullList({ filter: ids.map((id) => `id = "${id}"`).join(" || "), }); for (const co of companies) { companiesById.set(co.id, (co as { company?: string }).company || ""); } } return rows.items.map((row) => { const r = row as { id: string; customer?: string; email?: string; number?: string; company?: string; }; return { id: r.id, customer: r.customer || "", email: r.email || "", number: r.number || "", company_id: r.company || "", company_name: companiesById.get(r.company || "") || "", }; }); } export async function searchCompaniesLite( query: string, ): Promise { const pb = await getAdminPocketBase(); const trimmed = query.trim(); const filter = trimmed ? `company ~ "${escapePbString(trimmed)}" || vendor ~ "${escapePbString( trimmed, )}"` : ""; const rows = await pb.collection("companies").getList(1, SEARCH_PER_PAGE, { filter, sort: "company", }); return rows.items.map((row) => { const r = row as { id: string; company?: string; address?: string; vendor?: string; }; return { id: r.id, company: r.company || "", address: r.address || "", vendor: r.vendor || "", }; }); } /* ────────────────────────────────────────────────────────────────────────── * Mutations * ──────────────────────────────────────────────────────────────────────── */ export async function createCounterCompany(input: { company: string; address?: string; vendor?: string; }): Promise { const pb = await getAdminPocketBase(); const row = (await createCompany(pb, input)) as { id: string; company?: string; address?: string; vendor?: string; }; return { id: row.id, company: row.company || "", address: row.address || "", vendor: row.vendor || "", }; } export async function createCounterCustomer(input: { customer: string; email?: string; number?: string; buyer_email?: string; company?: string; }): Promise { const pb = await getAdminPocketBase(); const row = (await createCustomer(pb, input)) as { id: string; customer?: string; email?: string; number?: string; company?: string; }; let companyName = ""; if (row.company) { try { const co = (await pb.collection("companies").getOne(row.company)) as { company?: string; }; companyName = co.company || ""; } catch { companyName = ""; } } return { id: row.id, customer: row.customer || "", email: row.email || "", number: row.number || "", company_id: row.company || "", company_name: companyName, }; } export async function createCounterOrder( input: CreateCounterOrderInput, ): Promise { // Resolve company_id — pick OR inline-create. let companyId = (input.company_id || "").trim(); if (!companyId && input.inline_company) { const created = await createCounterCompany(input.inline_company); companyId = created.id; } if (!companyId) { throw new Error("company_id or inline_company is required"); } // Resolve customer_id — pick OR inline-create. let customerId = (input.customer_id || "").trim(); if (!customerId && input.inline_customer) { const payload = { ...input.inline_customer, // If the inline customer references a company that doesn't exist yet, // createCompany above gave us a fresh id — but inline_customer carries // its own company_id (possibly empty). Stitch them together here. company: input.inline_customer.company_id || companyId, }; const created = await createCounterCustomer(payload); customerId = created.id; } if (!customerId) { throw new Error("customer_id or inline_customer is required"); } if (!Array.isArray(input.items) || input.items.length === 0) { throw new Error("items must be a non-empty array"); } for (const [i, it] of input.items.entries()) { if (!it.description || !it.description.trim()) { throw new Error(`items[${i}].description is required`); } } const result = await createOrder({ customer_id: customerId, company_id: companyId, notes: input.notes, contact_name: input.contact_name, description: input.description, rep: input.rep, items: input.items.map((it) => ({ description: it.description.trim(), quantity: typeof it.quantity === "number" ? it.quantity : null, selling_price: typeof it.selling_price === "number" ? it.selling_price : undefined, notes: it.notes, })), }); return { order_id: result.order.id, order_number: result.order.order_number, customer_id: customerId, company_id: companyId, items_created: result.items_created.length, items_failed: result.items_failed.map((f) => ({ description: f.draft.description, error: f.error, })), }; } /** * Upload photos to an existing order's `order_attachements` field. * * Photos are uploaded AFTER the order is created so a flaky network * doesn't lose the order. The route handler maps the incoming * multipart/form-data `files` field into a `File[]` and passes them * to the PB admin SDK. */ export async function uploadCounterOrderAttachments( orderId: string, files: File[], ): Promise<{ order_id: string; count: number }> { if (!orderId) throw new Error("orderId is required"); if (!Array.isArray(files) || files.length === 0) { return { order_id: orderId, count: 0 }; } const pb = await getAdminPocketBase(); // Verify the order exists before we try to patch it. Without this // check a stale id would silently succeed and PB would just noop. await pb.collection("orders").getOne(orderId); const form = new FormData(); for (const file of files) { form.append("order_attachements", file); } await pb.collection("orders").update(orderId, form); return { order_id: orderId, count: files.length }; } /* ────────────────────────────────────────────────────────────────────────── * Helpers * ──────────────────────────────────────────────────────────────────────── */ /** * Escape a string for use inside a PB filter expression. PB uses * double-quoted strings and the only character that needs escaping * inside them is the double-quote itself. */ function escapePbString(input: string): string { return input.replace(/"/g, '\\"'); }