Skip to content

Commit 339c512

Browse files
committed
feat: add audit log system for tracking entity changes
- Add per-company audit log tables (auditLog_{companyId}) with RPC functions - Capture INSERT, UPDATE, DELETE operations on key entities (item, itemCost, purchaseInvoice, salesInvoice, purchaseOrder, salesOrder, customer, supplier, job, quote, employee) - Store actor ID (from auth.uid()), operation type, entity info, and diff of changes - Add audit.config.ts for configuration and audit.types.ts for type definitions - Add audit task handler in Trigger.dev for processing audit events - Update event system to capture actorId and support dynamic primary keys - Add global audit log view in Settings with expandable rows showing colored diffs - Add Table component renderExpandedRow prop for row expansion support - Add AuditLogDrawer component for entity-level audit history - Integrate audit log drawer into entity headers (Customer, Supplier, Item, etc.)
1 parent bd0e681 commit 339c512

39 files changed

+5257
-346
lines changed
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import type { AuditLogEntry } from "@carbon/database/audit.types";
2+
import {
3+
Badge,
4+
Drawer,
5+
DrawerBody,
6+
DrawerContent,
7+
DrawerHeader,
8+
DrawerTitle,
9+
HStack,
10+
Spinner,
11+
VStack
12+
} from "@carbon/react";
13+
import { formatDateTime } from "@carbon/utils";
14+
import { memo, useEffect } from "react";
15+
import { LuFilePen, LuFilePlus, LuFileX, LuHistory } from "react-icons/lu";
16+
import { useFetcher } from "react-router";
17+
import { EmployeeAvatar } from "~/components";
18+
19+
type AuditLogDrawerProps = {
20+
isOpen: boolean;
21+
onClose: () => void;
22+
entityType: string;
23+
entityId: string;
24+
companyId: string;
25+
};
26+
27+
type AuditLogFetcherData = {
28+
entries: AuditLogEntry[];
29+
};
30+
31+
const operationLabels: Record<
32+
string,
33+
{ label: string; variant: "green" | "blue" | "red"; icon: React.ReactNode }
34+
> = {
35+
INSERT: {
36+
label: "Created",
37+
variant: "green",
38+
icon: <LuFilePlus className="size-3" />
39+
},
40+
UPDATE: {
41+
label: "Updated",
42+
variant: "blue",
43+
icon: <LuFilePen className="size-3" />
44+
},
45+
DELETE: {
46+
label: "Deleted",
47+
variant: "red",
48+
icon: <LuFileX className="size-3" />
49+
}
50+
};
51+
52+
const AuditLogDrawer = memo(
53+
({
54+
isOpen,
55+
onClose,
56+
entityType,
57+
entityId,
58+
companyId
59+
}: AuditLogDrawerProps) => {
60+
const fetcher = useFetcher<AuditLogFetcherData>();
61+
62+
// Load audit log data when drawer opens
63+
useEffect(() => {
64+
if (
65+
isOpen &&
66+
entityType &&
67+
entityId &&
68+
fetcher.state === "idle" &&
69+
!fetcher.data
70+
) {
71+
const params = new URLSearchParams({
72+
entityType,
73+
entityId,
74+
companyId
75+
});
76+
fetcher.load(`/api/audit-log?${params.toString()}`);
77+
}
78+
}, [isOpen, entityType, entityId, companyId, fetcher]);
79+
80+
// Reset when drawer closes
81+
useEffect(() => {
82+
if (!isOpen) {
83+
// The fetcher will be reset on next open due to the data check above
84+
}
85+
}, [isOpen]);
86+
87+
const entries = fetcher.data?.entries ?? [];
88+
const isLoading = fetcher.state === "loading";
89+
90+
return (
91+
<Drawer
92+
open={isOpen}
93+
onOpenChange={(open) => {
94+
if (!open) onClose();
95+
}}
96+
>
97+
<DrawerContent size="md">
98+
<DrawerHeader>
99+
<DrawerTitle className="flex items-center gap-2">
100+
<LuHistory className="size-5" />
101+
Audit History
102+
</DrawerTitle>
103+
</DrawerHeader>
104+
<DrawerBody>
105+
{isLoading ? (
106+
<div className="flex items-center justify-center py-12">
107+
<Spinner />
108+
</div>
109+
) : entries.length === 0 ? (
110+
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
111+
<LuHistory className="size-12 mb-4 opacity-50" />
112+
<p>No audit history found</p>
113+
<p className="text-sm">
114+
Changes to this record will appear here.
115+
</p>
116+
</div>
117+
) : (
118+
<VStack className="gap-4">
119+
{entries.map((entry) => (
120+
<AuditLogEntryCard key={entry.id} entry={entry} />
121+
))}
122+
</VStack>
123+
)}
124+
</DrawerBody>
125+
</DrawerContent>
126+
</Drawer>
127+
);
128+
}
129+
);
130+
131+
AuditLogDrawer.displayName = "AuditLogDrawer";
132+
export default AuditLogDrawer;
133+
134+
type AuditLogEntryCardProps = {
135+
entry: AuditLogEntry;
136+
};
137+
138+
const AuditLogEntryCard = memo(({ entry }: AuditLogEntryCardProps) => {
139+
const opInfo = operationLabels[entry.operation] ?? {
140+
label: entry.operation,
141+
variant: "secondary" as const,
142+
icon: null
143+
};
144+
145+
const diffKeys = entry.diff ? Object.keys(entry.diff) : [];
146+
147+
return (
148+
<div className="border rounded-lg p-4 w-full">
149+
<HStack className="justify-between items-start mb-3">
150+
<HStack className="gap-2">
151+
{entry.actorId ? (
152+
<EmployeeAvatar employeeId={entry.actorId} />
153+
) : (
154+
<VStack className="items-start gap-0">
155+
<span className="font-medium">System</span>
156+
<span className="text-xs text-muted-foreground">
157+
{formatDateTime(entry.createdAt)}
158+
</span>
159+
</VStack>
160+
)}
161+
{entry.actorId && (
162+
<VStack className="items-start gap-0">
163+
<span className="text-xs text-muted-foreground">
164+
{formatDateTime(entry.createdAt)}
165+
</span>
166+
</VStack>
167+
)}
168+
</HStack>
169+
<Badge variant={opInfo.variant}>
170+
<HStack className="gap-1">
171+
{opInfo.icon}
172+
<span>{opInfo.label}</span>
173+
</HStack>
174+
</Badge>
175+
</HStack>
176+
177+
{/* Show diff for UPDATE operations */}
178+
{entry.operation === "UPDATE" && diffKeys.length > 0 && (
179+
<div className="mt-3 pt-3 border-t">
180+
<p className="text-sm font-medium mb-2">Changes</p>
181+
<VStack className="gap-2">
182+
{diffKeys.map((key) => {
183+
const change = entry.diff![key];
184+
return (
185+
<div
186+
key={key}
187+
className="text-sm bg-muted/50 rounded px-2 py-1"
188+
>
189+
<span className="font-medium text-muted-foreground">
190+
{formatFieldName(key)}:
191+
</span>{" "}
192+
<span className="text-red-600 line-through">
193+
{formatValue(change.old)}
194+
</span>{" "}
195+
<span className="text-muted-foreground"></span>{" "}
196+
<span className="text-green-600">
197+
{formatValue(change.new)}
198+
</span>
199+
</div>
200+
);
201+
})}
202+
</VStack>
203+
</div>
204+
)}
205+
</div>
206+
);
207+
});
208+
209+
AuditLogEntryCard.displayName = "AuditLogEntryCard";
210+
211+
function formatFieldName(key: string): string {
212+
// Convert camelCase to Title Case with spaces
213+
// Also handles nested paths like "customFields.myField"
214+
return key
215+
.replace(/([A-Z])/g, " $1")
216+
.replace(/\./g, " → ")
217+
.replace(/^./, (str) => str.toUpperCase())
218+
.trim();
219+
}
220+
221+
function formatValue(value: unknown): string {
222+
if (value === null || value === undefined) return "(empty)";
223+
if (typeof value === "boolean") return value ? "Yes" : "No";
224+
if (typeof value === "object") return JSON.stringify(value);
225+
return String(value);
226+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as AuditLogDrawer } from "./AuditLogDrawer";

0 commit comments

Comments
 (0)