WFDASH-WEBAPP/src/lib/server/audit-log-service.ts
Dave 7555196166 feat(audit): implement audit trail for delivery notes and orders
- Added an audit trail feature that logs every create, update, and delete action on delivery notes and orders, capturing detailed changes per field.
- Introduced the `AuditTimeline` component to display the history of changes for delivery notes and orders, enhancing traceability.
- Updated the `page.tsx` files for delivery notes and orders to fetch and display audit logs, ensuring users can view the history of modifications.
- Enhanced the `package.json` with a new migration script for setting up the audit log functionality.

This feature improves accountability and transparency in the management of delivery notes and orders.
2026-09-30 01:48:44 +02:00

220 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]));
}