- 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.
220 lines
6.8 KiB
TypeScript
220 lines
6.8 KiB
TypeScript
import { getAdminPocketBase } from "./pocketbase-admin";
|
|
import {
|
|
MARKUP_FIELDS,
|
|
PRICE_FIELDS,
|
|
type AuditChange,
|
|
type AuditEntry,
|
|
type AuditParentType,
|
|
type AuditSummary,
|
|
type AuditTrail,
|
|
} from "../audit-format";
|
|
|
|
/**
|
|
* Read side of the audit trail. Rows are written by the PB hook in
|
|
* pocketbase/pb_hooks/audit_log.pb.js — this service only reads them,
|
|
* resolves supplier ids to names, and derives the summary counts shown
|
|
* at the top of the History panels.
|
|
*/
|
|
|
|
const AUDIT_COLLECTION = "audit_log";
|
|
const MAX_ENTRIES = 1000;
|
|
|
|
type AuditRow = {
|
|
id: string;
|
|
created: string;
|
|
collection_name: string;
|
|
record_id: string;
|
|
parent_type: string;
|
|
parent_id: string;
|
|
action: string;
|
|
label: string;
|
|
changes: AuditChange[] | null;
|
|
actor_name: string;
|
|
actor_email: string;
|
|
source: string;
|
|
refs: string;
|
|
};
|
|
|
|
const PARENT_TYPES = new Set<AuditParentType>(["quote", "order", "delivery_note"]);
|
|
|
|
export function isAuditParentType(value: string): value is AuditParentType {
|
|
return PARENT_TYPES.has(value as AuditParentType);
|
|
}
|
|
|
|
function esc(value: string): string {
|
|
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
}
|
|
|
|
function isMissingCollection(err: unknown): boolean {
|
|
const status = (err as { status?: number })?.status;
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return status === 404 || /missing collection|not found/i.test(message);
|
|
}
|
|
|
|
function touches(entry: Pick<AuditEntry, "changes">, fields: Set<string>): boolean {
|
|
return entry.changes.some((c) => fields.has(c.field));
|
|
}
|
|
|
|
/**
|
|
* Oldest-first pass that numbers the quote PDFs and marks price / markup
|
|
* edits made after one. Returns entries in the same order.
|
|
*/
|
|
export function annotateAfterPdf(entries: AuditEntry[]): AuditEntry[] {
|
|
let pdfs = 0;
|
|
return entries.map((entry) => {
|
|
if (entry.action === "pdf_generated") {
|
|
pdfs += 1;
|
|
return { ...entry, afterPdf: null };
|
|
}
|
|
const pricing =
|
|
entry.action === "update" &&
|
|
(touches(entry, PRICE_FIELDS) || touches(entry, MARKUP_FIELDS));
|
|
return { ...entry, afterPdf: pricing && pdfs > 0 ? pdfs : null };
|
|
});
|
|
}
|
|
|
|
export function summarizeAuditEntries(entries: AuditEntry[]): AuditSummary {
|
|
const editors = new Map<string, number>();
|
|
let pdfCount = 0;
|
|
let priceChanges = 0;
|
|
let markupChanges = 0;
|
|
let changesAfterPdf = 0;
|
|
for (const entry of entries) {
|
|
if (entry.actorName) editors.set(entry.actorName, (editors.get(entry.actorName) ?? 0) + 1);
|
|
if (entry.action === "pdf_generated") pdfCount += 1;
|
|
if (entry.action !== "update") continue;
|
|
if (touches(entry, PRICE_FIELDS)) priceChanges += 1;
|
|
if (touches(entry, MARKUP_FIELDS)) markupChanges += 1;
|
|
if (entry.afterPdf !== null) changesAfterPdf += 1;
|
|
}
|
|
return {
|
|
pdfCount,
|
|
priceChanges,
|
|
markupChanges,
|
|
changesAfterPdf,
|
|
editors: [...editors.entries()]
|
|
.map(([name, count]) => ({ name, count }))
|
|
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)),
|
|
};
|
|
}
|
|
|
|
const APPROVAL_OUTCOMES = new Set(["processed", "approval_rejected"]);
|
|
|
|
/**
|
|
* The order-markup approval is decided by tapping the ✅ / ❌ link in the
|
|
* WhatsApp message sent by the n8n "ORDER-MARKUP-APPROVAL-LINKS"
|
|
* workflow. That workflow writes with the admin key and doesn't know who
|
|
* tapped, so the raw row says "Automation". Name it for what it is.
|
|
*/
|
|
export function approverLabel(
|
|
entry: Pick<AuditEntry, "collection" | "source" | "changes" | "actorName">,
|
|
): string {
|
|
if (entry.collection !== "order_items") return entry.actorName;
|
|
if (entry.source !== "api" && entry.source !== "n8n") return entry.actorName;
|
|
const decided = entry.changes.some(
|
|
(c) => c.field === "item_status" && c.from === "approval_needed" && APPROVAL_OUTCOMES.has(String(c.to)),
|
|
);
|
|
return decided ? "Markup approver (WhatsApp link)" : entry.actorName;
|
|
}
|
|
|
|
const EMPTY_TRAIL: AuditTrail = {
|
|
enabled: false,
|
|
entries: [],
|
|
summary: { pdfCount: 0, priceChanges: 0, markupChanges: 0, changesAfterPdf: 0, editors: [] },
|
|
};
|
|
|
|
/**
|
|
* Full history for one quote / order / delivery note, newest first.
|
|
* For a delivery note, also includes edits to order lines carrying its
|
|
* number (qty changes, invoice stamps) even though those lines belong
|
|
* to an order.
|
|
*/
|
|
export async function getAuditTrail(
|
|
parentType: AuditParentType,
|
|
parentId: string,
|
|
): Promise<AuditTrail> {
|
|
const pb = await getAdminPocketBase();
|
|
|
|
let filter = `parent_type = "${parentType}" && parent_id = "${esc(parentId)}"`;
|
|
if (parentType === "delivery_note") {
|
|
const dn = await pb
|
|
.collection("delivery_notes")
|
|
.getOne<{ delivery_note_number?: string }>(parentId, { fields: "id,delivery_note_number" })
|
|
.catch(() => null);
|
|
if (dn?.delivery_note_number) {
|
|
filter = `(${filter}) || refs ~ "dn:${esc(dn.delivery_note_number)}"`;
|
|
}
|
|
}
|
|
|
|
let rows: AuditRow[];
|
|
try {
|
|
const page = await pb.collection(AUDIT_COLLECTION).getList<AuditRow>(1, MAX_ENTRIES, {
|
|
filter,
|
|
sort: "created",
|
|
});
|
|
rows = page.items;
|
|
} catch (err) {
|
|
if (isMissingCollection(err)) return EMPTY_TRAIL;
|
|
throw err;
|
|
}
|
|
|
|
const supplierNames = await resolveSupplierNames(pb, rows);
|
|
const oldestFirst = annotateAfterPdf(
|
|
rows.map((row) => withApproverLabel({
|
|
id: row.id,
|
|
created: row.created,
|
|
collection: row.collection_name,
|
|
recordId: row.record_id,
|
|
action: row.action,
|
|
label: row.label || "",
|
|
changes: (Array.isArray(row.changes) ? row.changes : []).map((c) =>
|
|
c.field === "supplier"
|
|
? {
|
|
...c,
|
|
from: supplierNames.get(String(c.from)) ?? c.from,
|
|
to: supplierNames.get(String(c.to)) ?? c.to,
|
|
}
|
|
: c,
|
|
),
|
|
actorName: row.actor_name || "Unknown",
|
|
actorEmail: row.actor_email || "",
|
|
source: row.source || "",
|
|
afterPdf: null,
|
|
})),
|
|
);
|
|
|
|
return {
|
|
enabled: true,
|
|
entries: oldestFirst.slice().reverse(),
|
|
summary: summarizeAuditEntries(oldestFirst),
|
|
};
|
|
}
|
|
|
|
function withApproverLabel(entry: AuditEntry): AuditEntry {
|
|
const actorName = approverLabel(entry);
|
|
return actorName === entry.actorName ? entry : { ...entry, actorName };
|
|
}
|
|
|
|
async function resolveSupplierNames(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
rows: AuditRow[],
|
|
): Promise<Map<string, string>> {
|
|
const ids = new Set<string>();
|
|
for (const row of rows) {
|
|
for (const c of row.changes ?? []) {
|
|
if (c.field !== "supplier") continue;
|
|
for (const v of [c.from, c.to]) {
|
|
if (typeof v === "string" && /^[a-z0-9]{15}$/.test(v)) ids.add(v);
|
|
}
|
|
}
|
|
}
|
|
if (ids.size === 0) return new Map();
|
|
const suppliers = await pb
|
|
.collection("suppliers")
|
|
.getFullList<{ id: string; suppliername: string }>({
|
|
filter: [...ids].map((id) => `id = "${id}"`).join(" || "),
|
|
fields: "id,suppliername",
|
|
})
|
|
.catch(() => []);
|
|
return new Map(suppliers.map((s) => [s.id, s.suppliername]));
|
|
}
|