ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 49da3a4 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
+117
@@ -0,0 +1,117 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user