221 lines
6.8 KiB
TypeScript
221 lines
6.8 KiB
TypeScript
|
|
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<AuditParentType>(["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<AuditEntry, "changes">, fields: Set<string>): 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<string, number>();
|
||
|
|
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<AuditEntry, "collection" | "source" | "changes" | "actorName">,
|
||
|
|
): 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<AuditTrail> {
|
||
|
|
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<AuditRow>(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<ReturnType<typeof getAdminPocketBase>>,
|
||
|
|
rows: AuditRow[],
|
||
|
|
): Promise<Map<string, string>> {
|
||
|
|
const ids = new Set<string>();
|
||
|
|
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]));
|
||
|
|
}
|