Membangun Audit Trail dan Activity Log ERP: PostgreSQL, NestJS Interceptors, dan React Activity Feed

Foto oleh Unsplash

Foto oleh Unsplash
Audit trail menjawab pertanyaan terpenting dalam sistem bisnis mana pun: 'siapa yang mengubah ini, kapan, dan bagaimana tampilannya sebelumnya?' Ketika saya membangun ERP untuk Commsult Indonesia, audit trail dirancang dari awal sebagai persyaratan kepatuhan dan kepercayaan.
Tidak semuanya perlu diaudit, tetapi apa pun yang mempengaruhi catatan keuangan, data pribadi, atau konfigurasi sistem mutlak perlu. Kami mengaudit semua operasi CREATE/UPDATE/DELETE, semua transisi state workflow, semua perubahan peran, dan semua operasi massal.
Tabel audit_logs perlu append-only. Tabel ini menyimpan: user_id, action (CREATE/UPDATE/DELETE), resource dan resource_id, old_data dan new_data sebagai JSONB, ip_address, dan created_at. Indeks parsial memungkinkan kueri riwayat per-entitas yang cepat.
Kami menggunakan logging tingkat aplikasi sebagai mekanisme utama karena menangkap konteks permintaan (identitas pengguna, alamat IP) yang tidak dapat diakses trigger database. Kami juga menggunakan trigger PostgreSQL ringan pada tabel paling sensitif sebagai jaring pengaman.
ERP Audit Trail Architecture (PostgreSQL + NestJS + React)
┌──────────────────────────────────────────────────────┐
│ APPLICATION LAYER │
│ │
│ User Action → NestJS Controller → Service │
│ │ │
│ ▼ │
│ AuditInterceptor (@Injectable) │
│ captures: user, action, before/after │
└────────────────────┬─────────────────────────────────┘
│ writes to
▼
┌──────────────────────────────────────────────────────┐
│ audit_logs table (PostgreSQL) │
│──────────────────────────────────────────────────────│
│ id UUID PRIMARY KEY DEFAULT gen_random_uuid │
│ user_id UUID NOT NULL REFERENCES users(id) │
│ action VARCHAR(50) -- CREATE, UPDATE, DELETE │
│ resource VARCHAR(100) -- 'invoice', 'leave', etc │
│ resource_id UUID │
│ old_data JSONB -- snapshot before change │
│ new_data JSONB -- snapshot after change │
│ ip_address INET │
│ user_agent TEXT │
│ created_at TIMESTAMPTZ DEFAULT NOW() │
└────────────────────┬─────────────────────────────────┘
│ queried by
▼
┌──────────────────────────────────────────────────────┐
│ React: Activity Feed UI │
│ ┌──────────────────────────────────────────────┐ │
│ │ 🟢 Matthews created Invoice INV-2026-001 │ │
│ │ Apr 11, 2026 · 09:14 AM │ │
│ ├──────────────────────────────────────────────┤ │
│ │ 🟡 Siti approved Leave Request LR-0042 │ │
│ │ Apr 11, 2026 · 08:30 AM │ │
│ ├──────────────────────────────────────────────┤ │
│ │ 🔴 Budi voided AP Voucher APV-0019 │ │
│ │ Apr 10, 2026 · 16:55 PM │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘Simpan snapshot JSON lengkap sebelum/sesudah dalam audit log daripada hanya daftar field yang berubah. Dengan snapshot JSONB lengkap, Anda dapat menelusuri kembali dan menampilkan dengan tepat seperti apa invoice pada tanggal tertentu — yang persis dibutuhkan auditor.
Kami mengimplementasikan logging audit sebagai NestJS interceptor yang membungkus setiap panggilan API yang mengubah state. Interceptor dapat mengamati permintaan masuk dan respons keluar, menjadikannya ideal untuk menangkap state sebelum/sesudah.
Data audit log diperlihatkan ke pengguna akhir melalui komponen activity feed React. Setiap halaman detail entitas memiliki tab 'Aktivitas' dengan riwayat perubahan lengkap, termasuk nama pengguna, tindakan, cap waktu relatif, dan diff untuk perubahan UPDATE.
-- PostgreSQL: Trigger-based audit logging
-- Applied to any table that needs auditing
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
action VARCHAR(20) NOT NULL CHECK (action IN ('CREATE','UPDATE','DELETE')),
resource VARCHAR(100) NOT NULL,
resource_id UUID,
old_data JSONB,
new_data JSONB,
ip_address INET,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Partial index for fast per-resource lookups
CREATE INDEX idx_audit_resource ON audit_logs (resource, resource_id, created_at DESC);
CREATE INDEX idx_audit_user ON audit_logs (user_id, created_at DESC);
-- NestJS: Audit Interceptor (application-level logging)
// audit.interceptor.ts
@Injectable()
export class AuditInterceptor implements NestInterceptor {
constructor(private auditService: AuditService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest();
const { method, url, user, ip, headers } = req;
const startState = req.body ? JSON.parse(JSON.stringify(req.body)) : null;
return next.handle().pipe(
tap(async (response) => {
if (['POST', 'PATCH', 'PUT', 'DELETE'].includes(method)) {
const action = method === 'POST' ? 'CREATE'
: method === 'DELETE' ? 'DELETE' : 'UPDATE';
await this.auditService.log({
userId: user?.id,
action,
resource: this.extractResource(url),
resourceId: response?.id ?? req.params?.id,
oldData: action === 'UPDATE' ? startState : null,
newData: action !== 'DELETE' ? response : null,
ipAddress: ip,
});
}
}),
);
}
private extractResource(url: string): string {
// '/api/v1/invoices/123' → 'invoice'
const segment = url.split('/').filter(Boolean)[2] ?? 'unknown';
return segment.replace(/s$/, ''); // plurals → singular
}
}
// React: Activity feed component
export function ActivityFeed({ resourceId, resource }: Props) {
const { data: logs } = useQuery({
queryKey: ['audit', resource, resourceId],
queryFn: () => api.get(`/audit-logs?resource=${resource}&resourceId=${resourceId}`),
});
return (
<ul className="space-y-3">
{logs?.map(log => (
<li key={log.id} className="flex gap-3 text-sm">
<span className={actionColor(log.action)}>●</span>
<span>
<strong>{log.user.name}</strong> {actionLabel(log.action)}{' '}
{log.resource} <code>{log.resourceId?.slice(0, 8)}</code>
</span>
<time className="ml-auto text-slate-400">
{formatDistanceToNow(new Date(log.createdAt), { addSuffix: true })}
</time>
</li>
))}
</ul>
);
}Berdasarkan UU PDP dan regulasi akuntansi Indonesia, catatan keuangan harus disimpan minimal 5 tahun. Gunakan partisi tabel PostgreSQL per tahun, arsipkan partisi lama setelah 2 tahun, dan hapus setelah 7 tahun.
Jangan pernah mengizinkan peran aplikasi mana pun untuk UPDATE atau DELETE baris di tabel audit_logs. Berikan hanya INSERT dan SELECT ke pengguna database aplikasi. Jika perlu 'mengoreksi' entri, gunakan INSERT kompensasi — jangan pernah memodifikasi baris asli.
Menulis ke audit log menambahkan penulisan database sinkron ke setiap operasi API. Untuk operasi massal, gunakan antrian (BullMQ) untuk memproses penulisan audit log secara asinkron — tulis ke antrian segera (non-blocking), dan worker memproses di latar belakang.
Selain kepatuhan, audit log sangat berharga sebagai alat debugging. Ketika pengguna melaporkan 'jumlah invoice salah', buka audit log untuk invoice tersebut dan telusuri kembali setiap perubahan. Ini biasanya menyelesaikan perselisihan dalam hitungan menit.