WFDASH-WEBAPP/src/lib/server/counter-service.ts

327 lines
10 KiB
TypeScript
Raw Normal View History

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<CustomerLite[]> {
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<string, string>();
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<CompanyLite[]> {
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<CompanyLite> {
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<CustomerLite> {
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<CreateCounterOrderResult> {
// 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, '\\"');
}