/// /** * 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 };