WFDASH-PBHOOKS/audit_log_utils.js
Dave fd158d8fda feat(collection-ai): data model, settings, migration
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 01:18:10 +02:00

270 lines
9.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// <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, ai_check: 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 };