- 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.
103 lines
3.2 KiB
TypeScript
103 lines
3.2 KiB
TypeScript
import PocketBase from "pocketbase";
|
|
|
|
import { resolveAuditHeaders } from "./audit-context";
|
|
|
|
const url = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090";
|
|
const ADMIN_TOKEN_REFRESH_BUFFER_MS = 60_000;
|
|
|
|
type CachedAdminAuth = {
|
|
token: string;
|
|
expiresAt: number;
|
|
};
|
|
|
|
let cachedAdminAuth: CachedAdminAuth | null = null;
|
|
|
|
function readJwtExpiryMs(token: string): number {
|
|
const parts = token.split(".");
|
|
if (parts.length < 2) return 0;
|
|
try {
|
|
const payload = JSON.parse(
|
|
Buffer.from(parts[1], "base64url").toString("utf8"),
|
|
) as { exp?: unknown };
|
|
if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) {
|
|
return 0;
|
|
}
|
|
return payload.exp * 1000;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
function buildPocketBaseWithCachedAuth(): PocketBase | null {
|
|
if (
|
|
!cachedAdminAuth ||
|
|
cachedAdminAuth.expiresAt <= Date.now() + ADMIN_TOKEN_REFRESH_BUFFER_MS
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const pb = new PocketBase(url);
|
|
// Disable auto-cancellation. The PB SDK's default behaviour is to
|
|
// cancel every in-flight request whenever a new one starts — that
|
|
// works fine for sequential UI calls but breaks any service that
|
|
// fans out reads via Promise.all (e.g. `populateQuoteItemInsights`
|
|
// fires 8 PB queries in parallel and the SDK aborts the first 7 the
|
|
// moment the 8th starts). Safe for admin client: we don't have user
|
|
// cancellation semantics, and the requests are idempotent.
|
|
pb.autoCancellation(false);
|
|
if (pb.authStore && typeof pb.authStore.save === "function") {
|
|
pb.authStore.save(cachedAdminAuth.token);
|
|
return pb;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function clearAdminPocketBaseAuthCache(): void {
|
|
cachedAdminAuth = null;
|
|
}
|
|
|
|
export async function getAdminPocketBase() {
|
|
const email = process.env.PB_ADMIN_EMAIL;
|
|
const password = process.env.PB_ADMIN_PASSWORD;
|
|
|
|
if (!email || !password) {
|
|
throw new Error("PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD are required for server API operations.");
|
|
}
|
|
|
|
const cachedPb = buildPocketBaseWithCachedAuth();
|
|
if (cachedPb) return withAuditHeaders(cachedPb);
|
|
|
|
const pb = new PocketBase(url);
|
|
// See buildPocketBaseWithCachedAuth — autoCancellation(false) so
|
|
// Promise.all fan-outs (e.g. populateQuoteItemInsights) don't get
|
|
// aborted mid-flight.
|
|
pb.autoCancellation(false);
|
|
const authData = await pb.collection("_superusers").authWithPassword(email, password);
|
|
cachedAdminAuth = {
|
|
token: authData.token,
|
|
expiresAt: readJwtExpiryMs(authData.token),
|
|
};
|
|
return withAuditHeaders(pb);
|
|
}
|
|
|
|
/**
|
|
* Stamp every PB request from this client with the X-Audit-* headers
|
|
* for the person the server is acting for, so the `audit_log` hook
|
|
* attributes admin-key writes to them. A fresh client is built per
|
|
* `getAdminPocketBase()` call, so the headers never leak across
|
|
* requests.
|
|
*/
|
|
async function withAuditHeaders(pb: PocketBase): Promise<PocketBase> {
|
|
let auditHeaders: Record<string, string> = {};
|
|
try {
|
|
auditHeaders = await resolveAuditHeaders();
|
|
} catch {
|
|
auditHeaders = {};
|
|
}
|
|
if (Object.keys(auditHeaders).length === 0) return pb;
|
|
pb.beforeSend = (url, options) => {
|
|
options.headers = { ...auditHeaders, ...(options.headers as Record<string, string> | undefined) };
|
|
return { url, options };
|
|
};
|
|
return pb;
|
|
}
|