Soft-delete cascade was the missing half of archive: stamping workspaces.archived_at alone left objects visible to anyone with a direct id. The cascade runs in one transaction so the partial state isn't reachable, and restore inverts it for any archived row in the workspace — provenance-blind on purpose until we have a use case that needs to distinguish per-workspace from per-object archives. audit_log keeps the keyset index on (workspace_id, created_at) and the actor_user_id FK with onDelete set null. recordAudit() refuses to write a null actor without a metadata.system_actor label so the audit view always has something to render. workspaces and invites mutations call recordAudit on success; objects-router instrumentation and the markdown importer's system-actor flow are filed as P2 follow-ups because each needs a thoughtful "what's audit-worthy?" pass, not mechanical wiring. Settings → Audit log lives at /<slug>/settings/audit, owner-gated, keyset-paginated. ACTION_LABELS is small on purpose; new actions fall back to their raw key so missing a label degrades gracefully. Co-authored-by: Cursor <cursoragent@cursor.com>
63 lines
2.5 KiB
TypeScript
63 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,
|
|
});
|
|
}
|