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:
commit
c3d4d3e21c
2 changed files with 312 additions and 0 deletions
42
audit_log.pb.js
Normal file
42
audit_log.pb.js
Normal 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
270
audit_log_utils.js
Normal 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 };
|
||||
Loading…
Reference in a new issue