64 lines
2.5 KiB
TypeScript
64 lines
2.5 KiB
TypeScript
|
|
import type { db as defaultDb } from "@tasks/database";
|
||
|
|
import { auditLog } from "@tasks/database/schema";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Append a single audit-log row. Every mutation that wants accountability
|
||
|
|
* (workspace state changes, member changes, invite lifecycle, object
|
||
|
|
* mutations once we instrument them) calls this once on success.
|
||
|
|
*
|
||
|
|
* Invariants:
|
||
|
|
* - Never fail silently — if the insert throws, let it propagate. An
|
||
|
|
* audit miss is data-loss; the caller's transaction should roll back
|
||
|
|
* too if it can.
|
||
|
|
* - Keep `metadata` SMALL. Caller is responsible for picking the right
|
||
|
|
* handful of fields, not dumping the whole row. The convention is
|
||
|
|
* "what would a support engineer want to see at a glance?"
|
||
|
|
* - `actorUserId === null` is reserved for genuine system actors
|
||
|
|
* (markdown importer, scheduled jobs). Caller must also stamp
|
||
|
|
* `metadata.system_actor` with a short identifier so the audit UI
|
||
|
|
* can render "system: markdown-importer" instead of an empty cell.
|
||
|
|
*/
|
||
|
|
export type RecordAuditInput = {
|
||
|
|
workspaceId: string;
|
||
|
|
/** null = system actor. Pair with `metadata.system_actor` when null. */
|
||
|
|
actorUserId: string | null;
|
||
|
|
/** `<target>.<verb>` — e.g. `workspace.archive`, `invite.create`. */
|
||
|
|
action: string;
|
||
|
|
/** e.g. `workspace`, `invite`, `workspace_member`, `object`. */
|
||
|
|
targetType: string;
|
||
|
|
/** Natural id of the affected row, or null for batch / cascade actions. */
|
||
|
|
targetId?: string | null;
|
||
|
|
metadata?: Record<string, unknown> | null;
|
||
|
|
};
|
||
|
|
|
||
|
|
export async function recordAudit(
|
||
|
|
db: typeof defaultDb,
|
||
|
|
input: RecordAuditInput,
|
||
|
|
): Promise<void> {
|
||
|
|
// Light validation. Procedure-layer zod schemas should already enforce
|
||
|
|
// these, but the audit table is the last stop and we want garbage rows
|
||
|
|
// to fail loudly rather than silently distort the trail.
|
||
|
|
if (!input.workspaceId) {
|
||
|
|
throw new Error("recordAudit: workspaceId is required");
|
||
|
|
}
|
||
|
|
if (!input.action || !input.action.includes(".")) {
|
||
|
|
throw new Error(
|
||
|
|
`recordAudit: action must follow "<target>.<verb>", got: ${input.action}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (input.actorUserId === null && !input.metadata?.system_actor) {
|
||
|
|
throw new Error(
|
||
|
|
"recordAudit: actorUserId is null but metadata.system_actor is missing — name the system actor explicitly",
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
await db.insert(auditLog).values({
|
||
|
|
workspaceId: input.workspaceId,
|
||
|
|
actorUserId: input.actorUserId,
|
||
|
|
action: input.action,
|
||
|
|
targetType: input.targetType,
|
||
|
|
targetId: input.targetId ?? null,
|
||
|
|
metadata: input.metadata ?? null,
|
||
|
|
});
|
||
|
|
}
|