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.
This commit is contained in:
Dave 2026-09-30 01:48:44 +02:00
commit c3d4d3e21c
2 changed files with 312 additions and 0 deletions

42
audit_log.pb.js Normal file
View file

@ -0,0 +1,42 @@
/// <reference path="../pb_data/types.d.ts" />
/**
* 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");

270
audit_log_utils.js Normal file
View file

@ -0,0 +1,270 @@
/// <reference path="../pb_data/types.d.ts" />
/**
* 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 };