a6f1059ddf
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from a113353 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
118 lines
5.8 KiB
TypeScript
118 lines
5.8 KiB
TypeScript
import { prisma } from "@/lib/db";
|
|
import type { SessionUser } from "@/lib/session";
|
|
import type { StaffSession } from "@/lib/staffsession";
|
|
|
|
/* The audit trail.
|
|
*
|
|
* One rule shapes this file: **record what was touched, never what it said.** A trail that quoted
|
|
* payloads would become a second copy of the staff register — names, phone numbers, payroll
|
|
* numbers — growing forever, outside every retention rule that governs the first copy, and dumped
|
|
* into every backup. So only record identifiers get through, and only ones from a fixed list.
|
|
*
|
|
* That still answers the question people actually ask, which is "who changed this record, and
|
|
* when", because the id points at the record whose current state you can go and look at.
|
|
*
|
|
* Three kinds of thing are recorded, and the op name says which:
|
|
* `issue.create` a coordinator's change, through /api/mutate
|
|
* `staff:damage.report` something a wearer, ward manager or ward desk did, through the staff app
|
|
* `auth:signin` getting in or out, and changes to a second factor
|
|
* The prefixes matter because the ids in the two columns come from different tables: `userId` on a
|
|
* `staff:` row is a Staff id, not a User id, and reading one as the other would name the wrong
|
|
* person in the one place that exists to name the right one.
|
|
*/
|
|
|
|
/** Payload keys allowed into `target`. Everything else — names, notes, reasons, barcodes, emails,
|
|
* quantities that could reconstruct a person's holdings — is dropped. */
|
|
const ID_KEYS = new Set([
|
|
"id", "itemId", "staffId", "orderId", "lineId", "locationId", "supplierId",
|
|
"deptId", "userId", "issueId", "pickupId", "stocktakeId", "si",
|
|
// Staff-app payloads name their own records too.
|
|
"subjectId", "photoId",
|
|
]);
|
|
|
|
/** Ops not worth a row. Everything else is recorded, because an audit trail with a curated view of
|
|
* what counts as important is one that has already lost the argument. */
|
|
const SKIP = new Set(["photo.put"]);
|
|
|
|
function safeTarget(payload: unknown): string {
|
|
if (!payload || typeof payload !== "object") return "";
|
|
const out: Record<string, string | number> = {};
|
|
for (const [k, v] of Object.entries(payload as Record<string, unknown>)) {
|
|
if (!ID_KEYS.has(k)) continue;
|
|
if (typeof v === "number" && Number.isFinite(v)) out[k] = v;
|
|
// Ids are cuids; anything longer is not an id and has no business here.
|
|
else if (typeof v === "string" && v.length <= 40) out[k] = v;
|
|
}
|
|
const s = JSON.stringify(out);
|
|
return s === "{}" ? "" : s.slice(0, 500);
|
|
}
|
|
|
|
export type Actor = { facilityId: string; userId: string; userName: string };
|
|
|
|
/**
|
|
* The one writer.
|
|
*
|
|
* Never throws and never blocks the caller's response: a failure to write history must not undo
|
|
* work that already succeeded, and a person shouldn't see an error because the log was busy.
|
|
*/
|
|
function write(actor: Actor, op: string, target: string, ip: string): void {
|
|
void prisma.auditEvent
|
|
.create({
|
|
data: {
|
|
facilityId: actor.facilityId,
|
|
userId: actor.userId,
|
|
// Denormalised on purpose: the trail has to still name the person after their account is
|
|
// deleted, and deleting an account is precisely the kind of event you look back at.
|
|
userName: actor.userName.slice(0, 120),
|
|
op: op.slice(0, 60),
|
|
target,
|
|
ip: ip.slice(0, 60),
|
|
},
|
|
})
|
|
.catch((e) => console.error("[audit] could not record", op, (e as Error).message));
|
|
}
|
|
|
|
/** Record one successful coordinator change. */
|
|
export function recordAudit(user: SessionUser, op: string, payload: unknown, ip: string): void {
|
|
if (SKIP.has(op)) return;
|
|
const name = [user.first, user.last].filter(Boolean).join(" ").trim() || user.email;
|
|
write({ facilityId: user.facilityId, userId: user.id, userName: name }, op, safeTarget(payload), ip);
|
|
}
|
|
|
|
/**
|
|
* Record one successful change made from the staff app — a wearer, a ward manager or the desk.
|
|
*
|
|
* `result` is folded in because most of these ops create something: the request, the damage report
|
|
* or the dispute exists only once the op has run, so its id is in the answer rather than in what
|
|
* was sent, and an id is the whole reason to have the row. Anything the payload already named wins,
|
|
* and only an id is taken from the result.
|
|
*/
|
|
export function recordStaffAudit(sess: StaffSession, op: string, payload: unknown, ip: string, result?: unknown): void {
|
|
const name = [sess.first, sess.last].filter(Boolean).join(" ").trim() || sess.email;
|
|
const made = result && typeof result === "object" ? (result as { id?: unknown }).id : undefined;
|
|
const sent = (payload && typeof payload === "object" ? payload : {}) as Record<string, unknown>;
|
|
const target = typeof sent.id === "string" || typeof made !== "string" ? sent : { ...sent, id: made };
|
|
write({ facilityId: sess.facilityId, userId: sess.staffId, userName: name }, `staff:${op}`, safeTarget(target), ip);
|
|
}
|
|
|
|
/**
|
|
* Record a change by someone the session machinery cannot name — the ward manager deciding from an
|
|
* emailed link has no session at all, only a signed token, and the person is looked up by hand.
|
|
*/
|
|
export function recordFor(actor: Actor, op: string, payload: unknown, ip: string): void {
|
|
write(actor, op, safeTarget(payload), ip);
|
|
}
|
|
|
|
/**
|
|
* Record something that happened to an account rather than to a record: a sign-in, a sign-out, a
|
|
* second factor turned on or off, a password set from a reset link.
|
|
*
|
|
* `detail` is a fixed word written in our own source — "recovery", "email-link" — never anything a
|
|
* caller typed, and it is filtered to letters, digits and dashes so that stays true even if someone
|
|
* later wires it to something they shouldn't.
|
|
*/
|
|
export function recordAuthEvent(actor: Actor, op: string, ip: string, detail = ""): void {
|
|
const d = detail.replace(/[^a-z0-9.-]/gi, "").slice(0, 24);
|
|
write(actor, op, d ? JSON.stringify({ how: d }) : "", ip);
|
|
}
|