From c3d4d3e21cfb323046aaaa64600d35b7da0ad65e Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 30 Sep 2026 01:48:44 +0200 Subject: [PATCH] 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. --- audit_log.pb.js | 42 +++++++ audit_log_utils.js | 270 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 312 insertions(+) create mode 100644 audit_log.pb.js create mode 100644 audit_log_utils.js diff --git a/audit_log.pb.js b/audit_log.pb.js new file mode 100644 index 0000000..82edd18 --- /dev/null +++ b/audit_log.pb.js @@ -0,0 +1,42 @@ +/// +/** + * Audit trail for quotes, orders and delivery notes. + * + * Every create / update / delete made through the PB API on the + * collections below writes one `audit_log` row: what changed (per-field + * from → to), who did it, and which quote / order / delivery note it + * belongs to. Covers every writer — the dashboard's direct PB calls, + * the WFDASH server, n8n and the MCP — because they all go through the + * API. + * + * Deploy: copy BOTH this file and audit_log_utils.js into the PB + * server's pb_hooks/ directory, after running + * scripts/migrations/2026-09-25-create-audit-log.ts. PB picks up new + * hook files on restart. + */ + +onRecordCreateRequest((e) => { + const audit = require(`${__hooks}/audit_log_utils.js`); + e.next(); + audit.log(e, "create", null, audit.toPlain(e.record)); +}, "quotes", "quote_items", "quote_attachments", "orders", "order_items", "delivery_notes"); + +onRecordUpdateRequest((e) => { + const audit = require(`${__hooks}/audit_log_utils.js`); + // Snapshot the stored state before the save replaces it. + let before = null; + try { + before = audit.toPlain(e.record.original()); + } catch (err) { + console.log("[audit_log] could not snapshot original: " + err); + } + e.next(); + if (before) audit.log(e, "update", before, audit.toPlain(e.record)); +}, "quotes", "quote_items", "orders", "order_items", "delivery_notes"); + +onRecordDeleteRequest((e) => { + const audit = require(`${__hooks}/audit_log_utils.js`); + const before = audit.toPlain(e.record); + e.next(); + audit.log(e, "delete", before, null); +}, "quotes", "quote_items", "quote_attachments", "orders", "order_items", "delivery_notes"); diff --git a/audit_log_utils.js b/audit_log_utils.js new file mode 100644 index 0000000..caec6d4 --- /dev/null +++ b/audit_log_utils.js @@ -0,0 +1,270 @@ +/// +/** + * Shared helpers for audit_log.pb.js. Loaded with + * `require(`${__hooks}/audit_log_utils.js`)` from INSIDE each handler — + * PB runs handlers in isolated VMs, so top-level code in the .pb.js file + * is not visible to them. + * + * Not named *.pb.js on purpose: PB must not auto-load it as a hook file. + */ + +// Fields that change on their own (search indexing, AI enrichment, +// timers) or are covered by a dedicated event. Logging them would bury +// the human edits. +const IGNORED_FIELDS = { + id: 1, created: 1, updated: 1, collectionId: 1, collectionName: 1, expand: 1, + meiliesearch: 1, ai_insights: 1, ai_item_insights: 1, ai_status: 1, ai_error: 1, + ai_processed_at: 1, migration_meta: 1, migration_source: 1, migrated_at: 1, + wfdash_quote_id: 1, wfdash_order_id: 1, claim_expires_at: 1, sending_started_at: 1, + email_tracking_id: 1, has_new_reply: 1, last_reply_at: 1, pdf_generated_at: 1, + quote_matching_attempted: 1, delivery_note_matching_attempted: 1, + duplicate_order_matching_attempted: 1, order_number_duplicate_checked: 1, + email_opened: 1, email_opened_at: 1, email_delivered: 1, email_delivered_at: 1, +}; + +// Key fields recorded when a line is added or removed, so the trail can +// say "added M16 bolt × 10 @ R3.11" without a separate lookup. +const SNAPSHOT_FIELDS = { + quote_items: ["description", "quantity", "cost_price", "selling_price", "markup", "supplier"], + order_items: ["description", "quantity", "cost_price", "selling_price", "markup", "supplier", "item_status"], + quote_attachments: ["filename", "kind"], +}; + +const MAX_VALUE_CHARS = 300; + +function toPlain(record) { + if (!record) return null; + // Record implements json.Marshaler, so this yields the same public + // shape the REST API returns (json fields parsed, files as names). + return JSON.parse(JSON.stringify(record)); +} + +function isEmpty(v) { + return v === null || v === undefined || v === "" || + (Array.isArray(v) && v.length === 0); +} + +function clip(v) { + if (v === null || v === undefined) return null; + if (typeof v === "string") { + return v.length > MAX_VALUE_CHARS ? v.slice(0, MAX_VALUE_CHARS) + "…" : v; + } + if (typeof v === "number" || typeof v === "boolean") return v; + const s = JSON.stringify(v); + return s.length > MAX_VALUE_CHARS ? s.slice(0, MAX_VALUE_CHARS) + "…" : v; +} + +function same(a, b) { + if (isEmpty(a) && isEmpty(b)) return true; + return JSON.stringify(a) === JSON.stringify(b); +} + +function isPlainObject(v) { + return v !== null && typeof v === "object" && !Array.isArray(v); +} + +/** [{ field, from, to }] for every changed, non-ignored field. */ +function diff(before, after) { + const changes = []; + const keys = {}; + Object.keys(before || {}).forEach((k) => { keys[k] = 1; }); + Object.keys(after || {}).forEach((k) => { keys[k] = 1; }); + Object.keys(keys).sort().forEach((field) => { + if (IGNORED_FIELDS[field]) return; + const from = before ? before[field] : undefined; + const to = after ? after[field] : undefined; + if (same(from, to)) return; + // JSON blobs (e.g. order_item_details): diff one level down so an + // invoice stamp reads as `order_item_details.invoice`, not a wall + // of JSON. + if (isPlainObject(from) || isPlainObject(to)) { + const f = isPlainObject(from) ? from : {}; + const t = isPlainObject(to) ? to : {}; + const sub = {}; + Object.keys(f).forEach((k) => { sub[k] = 1; }); + Object.keys(t).forEach((k) => { sub[k] = 1; }); + Object.keys(sub).sort().forEach((k) => { + if (!same(f[k], t[k])) { + changes.push({ field: field + "." + k, from: clip(f[k]), to: clip(t[k]) }); + } + }); + return; + } + // Append-only text (notes feeds): keep just the appended tail. + if (typeof from === "string" && typeof to === "string" && from && to.indexOf(from) === 0) { + changes.push({ field: field, from: null, to: clip(to.slice(from.length).trim()), appended: true }); + return; + } + changes.push({ field: field, from: clip(from), to: clip(to) }); + }); + return changes; +} + +function snapshotChanges(collection, data, side) { + const fields = SNAPSHOT_FIELDS[collection] || []; + const out = []; + fields.forEach((field) => { + const v = data ? data[field] : undefined; + if (isEmpty(v)) return; + out.push(side === "from" + ? { field: field, from: clip(v), to: null } + : { field: field, from: null, to: clip(v) }); + }); + return out; +} + +function header(info, name) { + const h = (info && info.headers) || {}; + // PB exposes request headers lowercased with "-" turned into "_". + const raw = String(h[name.replace(/-/g, "_")] || h[name] || "").trim(); + // The WFDASH server URI-encodes the name hints (header values must be + // ASCII). Plain values pass through unchanged. + try { + return decodeURIComponent(raw); + } catch (_) { + return raw; + } +} + +function displayName(user) { + return user.getString("name") || user.getString("email") || user.id; +} + +/** + * Who made the change. + * - A dashboard user writing straight to PB: their verified auth record. + * - The WFDASH server / n8n (superuser key): the X-Audit-* headers the + * caller attached. Only trusted on superuser requests, so a normal + * user can't impersonate someone else. X-Audit-Auth carries the + * browser user's PB token, which we verify here rather than trusting + * a name. + */ +function resolveActor(e) { + let info = null; + try { info = e.requestInfo(); } catch (_) { info = null; } + const auth = e.auth; + const isSuper = !!(auth && auth.isSuperuser && auth.isSuperuser()); + + if (auth && !isSuper) { + return { + id: auth.id, + name: displayName(auth), + email: auth.getString("email"), + source: header(info, "x-audit-source") || "web", + }; + } + + const source = header(info, "x-audit-source") || (isSuper ? "api" : "unknown"); + const token = header(info, "x-audit-auth"); + if (isSuper && token) { + try { + const user = e.app.findAuthRecordByToken(token, "auth"); + if (user && !(user.isSuperuser && user.isSuperuser())) { + return { id: user.id, name: displayName(user), email: user.getString("email"), source: source }; + } + } catch (_) { + // Expired / invalid token — fall through to the name hints. + } + } + if (isSuper) { + const name = header(info, "x-audit-actor-name"); + const email = header(info, "x-audit-actor-email"); + return { + id: header(info, "x-audit-actor-id"), + name: name || email || (source === "api" ? "Automation (admin API)" : "System"), + email: email, + source: source, + }; + } + return { id: "", name: "Unknown", email: "", source: source }; +} + +function findDeliveryNoteId(app, number) { + if (!number) return ""; + try { + return app.findFirstRecordByData("delivery_notes", "delivery_note_number", number).id; + } catch (_) { + return ""; + } +} + +/** Where the change shows up: { parentType, parentId, label, refs }. */ +function resolveParent(app, collection, data) { + const d = data || {}; + const refs = []; + if (d.delivery_note_number) refs.push("dn:" + d.delivery_note_number); + switch (collection) { + case "quotes": + return { parentType: "quote", parentId: d.id, label: d.quote_number || "", refs: refs }; + case "quote_items": + return { parentType: "quote", parentId: d.quote, label: d.description || "", refs: refs }; + case "quote_attachments": + return { parentType: "quote", parentId: d.quote, label: d.filename || "", refs: refs }; + case "orders": + if (d.quote) refs.push("quote:" + d.quote); + return { parentType: "order", parentId: d.id, label: d.order_number || "", refs: refs }; + case "order_items": + if (d.order) { + return { parentType: "order", parentId: d.order, label: d.description || "", refs: refs }; + } + // Ad-hoc delivery-note lines have no parent order. + return { + parentType: "delivery_note", + parentId: findDeliveryNoteId(app, d.delivery_note_number), + label: d.description || "", + refs: refs, + }; + case "delivery_notes": + if (d.order) refs.push("order:" + d.order); + return { parentType: "delivery_note", parentId: d.id, label: d.delivery_note_number || "", refs: refs }; + } + return null; +} + +/** + * Write one audit row. Never throws — an audit failure must not undo + * or fail the user's actual save. + */ +function log(e, action, before, after) { + try { + const collection = (after || before).collectionName; + const data = after || before; + const parent = resolveParent(e.app, collection, data); + if (!parent || !parent.parentId) return; + + let changes; + let finalAction = action; + if (action === "update") { + changes = diff(before, after); + if (changes.length === 0) return; + } else if (action === "create") { + changes = snapshotChanges(collection, after, "to"); + if (collection === "quote_attachments") { + finalAction = after.kind === "pdf" ? "pdf_generated" : "attachment_added"; + } + } else { + changes = snapshotChanges(collection, before, "from"); + } + + const actor = resolveActor(e); + const col = e.app.findCollectionByNameOrId("audit_log"); + const row = new Record(col); + row.set("collection_name", collection); + row.set("record_id", data.id); + row.set("parent_type", parent.parentType); + row.set("parent_id", parent.parentId); + row.set("action", finalAction); + row.set("label", String(parent.label || "").slice(0, 500)); + row.set("changes", changes); + row.set("actor_id", String(actor.id || "").slice(0, 32)); + row.set("actor_name", String(actor.name || "").slice(0, 200)); + row.set("actor_email", String(actor.email || "").slice(0, 200)); + row.set("source", String(actor.source || "").slice(0, 32)); + row.set("refs", parent.refs.join(" ").slice(0, 500)); + e.app.save(row); + } catch (err) { + console.log("[audit_log] failed to record " + action + ": " + err); + } +} + +module.exports = { toPlain: toPlain, diff: diff, log: log };