WFDASH-WEBAPP/src/lib/server/pocketbase-admin.ts
Dave 7555196166 feat(audit): implement audit trail for delivery notes and orders
- 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.
2026-09-30 01:48:44 +02:00

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