2026-09-29 23:48:44 +00:00
|
|
|
|
/// <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,
|
2026-10-16 23:18:10 +00:00
|
|
|
|
email_opened: 1, email_opened_at: 1, email_delivered: 1, email_delivered_at: 1, ai_check: 1,
|
feat(orders): fields and settings for the AI order assistant
Task 3 of the AI order assistant plan.
- types: OrderAiQuoteMatch, OrderAiLinePair, OrderItemAiFill,
OrderAssistantRunSummary; Order.ai_quote_match, OrderItem.ai_fill,
OrderItem.quote_item
- ai_settings: order_assistant_enabled (off), _interval_minutes (10,
5-1440), _auto_link (on), _fill_lines (on), _prompt, _last_run_at,
_last_run_summary; recordOrderAssistantRun()
- migration script + npm run pb:migrate:order-assistant (not run)
- audit_log_utils IGNORED_FIELDS: ai_quote_match, ai_fill
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 10:56:35 +00:00
|
|
|
|
ai_quote_match: 1, ai_fill: 1,
|
2026-09-29 23:48:44 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 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 };
|