import { getAdminPocketBase } from "./pocketbase-admin"; import { MARKUP_FIELDS, PRICE_FIELDS, type AuditChange, type AuditEntry, type AuditParentType, type AuditSummary, type AuditTrail, } from "../audit-format"; /** * Read side of the audit trail. Rows are written by the PB hook in * pocketbase/pb_hooks/audit_log.pb.js — this service only reads them, * resolves supplier ids to names, and derives the summary counts shown * at the top of the History panels. */ const AUDIT_COLLECTION = "audit_log"; const MAX_ENTRIES = 1000; type AuditRow = { id: string; created: string; collection_name: string; record_id: string; parent_type: string; parent_id: string; action: string; label: string; changes: AuditChange[] | null; actor_name: string; actor_email: string; source: string; refs: string; }; const PARENT_TYPES = new Set(["quote", "order", "delivery_note"]); export function isAuditParentType(value: string): value is AuditParentType { return PARENT_TYPES.has(value as AuditParentType); } function esc(value: string): string { return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } function isMissingCollection(err: unknown): boolean { const status = (err as { status?: number })?.status; const message = err instanceof Error ? err.message : String(err); return status === 404 || /missing collection|not found/i.test(message); } function touches(entry: Pick, fields: Set): boolean { return entry.changes.some((c) => fields.has(c.field)); } /** * Oldest-first pass that numbers the quote PDFs and marks price / markup * edits made after one. Returns entries in the same order. */ export function annotateAfterPdf(entries: AuditEntry[]): AuditEntry[] { let pdfs = 0; return entries.map((entry) => { if (entry.action === "pdf_generated") { pdfs += 1; return { ...entry, afterPdf: null }; } const pricing = entry.action === "update" && (touches(entry, PRICE_FIELDS) || touches(entry, MARKUP_FIELDS)); return { ...entry, afterPdf: pricing && pdfs > 0 ? pdfs : null }; }); } export function summarizeAuditEntries(entries: AuditEntry[]): AuditSummary { const editors = new Map(); let pdfCount = 0; let priceChanges = 0; let markupChanges = 0; let changesAfterPdf = 0; for (const entry of entries) { if (entry.actorName) editors.set(entry.actorName, (editors.get(entry.actorName) ?? 0) + 1); if (entry.action === "pdf_generated") pdfCount += 1; if (entry.action !== "update") continue; if (touches(entry, PRICE_FIELDS)) priceChanges += 1; if (touches(entry, MARKUP_FIELDS)) markupChanges += 1; if (entry.afterPdf !== null) changesAfterPdf += 1; } return { pdfCount, priceChanges, markupChanges, changesAfterPdf, editors: [...editors.entries()] .map(([name, count]) => ({ name, count })) .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)), }; } const APPROVAL_OUTCOMES = new Set(["processed", "approval_rejected"]); /** * The order-markup approval is decided by tapping the ✅ / ❌ link in the * WhatsApp message sent by the n8n "ORDER-MARKUP-APPROVAL-LINKS" * workflow. That workflow writes with the admin key and doesn't know who * tapped, so the raw row says "Automation". Name it for what it is. */ export function approverLabel( entry: Pick, ): string { if (entry.collection !== "order_items") return entry.actorName; if (entry.source !== "api" && entry.source !== "n8n") return entry.actorName; const decided = entry.changes.some( (c) => c.field === "item_status" && c.from === "approval_needed" && APPROVAL_OUTCOMES.has(String(c.to)), ); return decided ? "Markup approver (WhatsApp link)" : entry.actorName; } const EMPTY_TRAIL: AuditTrail = { enabled: false, entries: [], summary: { pdfCount: 0, priceChanges: 0, markupChanges: 0, changesAfterPdf: 0, editors: [] }, }; /** * Full history for one quote / order / delivery note, newest first. * For a delivery note, also includes edits to order lines carrying its * number (qty changes, invoice stamps) even though those lines belong * to an order. */ export async function getAuditTrail( parentType: AuditParentType, parentId: string, ): Promise { const pb = await getAdminPocketBase(); let filter = `parent_type = "${parentType}" && parent_id = "${esc(parentId)}"`; if (parentType === "delivery_note") { const dn = await pb .collection("delivery_notes") .getOne<{ delivery_note_number?: string }>(parentId, { fields: "id,delivery_note_number" }) .catch(() => null); if (dn?.delivery_note_number) { filter = `(${filter}) || refs ~ "dn:${esc(dn.delivery_note_number)}"`; } } let rows: AuditRow[]; try { const page = await pb.collection(AUDIT_COLLECTION).getList(1, MAX_ENTRIES, { filter, sort: "created", }); rows = page.items; } catch (err) { if (isMissingCollection(err)) return EMPTY_TRAIL; throw err; } const supplierNames = await resolveSupplierNames(pb, rows); const oldestFirst = annotateAfterPdf( rows.map((row) => withApproverLabel({ id: row.id, created: row.created, collection: row.collection_name, recordId: row.record_id, action: row.action, label: row.label || "", changes: (Array.isArray(row.changes) ? row.changes : []).map((c) => c.field === "supplier" ? { ...c, from: supplierNames.get(String(c.from)) ?? c.from, to: supplierNames.get(String(c.to)) ?? c.to, } : c, ), actorName: row.actor_name || "Unknown", actorEmail: row.actor_email || "", source: row.source || "", afterPdf: null, })), ); return { enabled: true, entries: oldestFirst.slice().reverse(), summary: summarizeAuditEntries(oldestFirst), }; } function withApproverLabel(entry: AuditEntry): AuditEntry { const actorName = approverLabel(entry); return actorName === entry.actorName ? entry : { ...entry, actorName }; } async function resolveSupplierNames( pb: Awaited>, rows: AuditRow[], ): Promise> { const ids = new Set(); for (const row of rows) { for (const c of row.changes ?? []) { if (c.field !== "supplier") continue; for (const v of [c.from, c.to]) { if (typeof v === "string" && /^[a-z0-9]{15}$/.test(v)) ids.add(v); } } } if (ids.size === 0) return new Map(); const suppliers = await pb .collection("suppliers") .getFullList<{ id: string; suppliername: string }>({ filter: [...ids].map((id) => `id = "${id}"`).join(" || "), fields: "id,suppliername", }) .catch(() => []); return new Map(suppliers.map((s) => [s.id, s.suppliername])); }