Ringkasan Utama
Audit trail ERP menjawab siapa yang mengubah sebuah record, kapan, dan seperti apa isinya sebelum itu. Tanpa audit trail, angka keuangan yang salah tidak bisa dibedakan antara salah ketik, bug, atau manipulasi yang disengaja. Menangkapnya di NestJS interceptor menjaga logging tetap keluar dari setiap service satu per satu.
Audit trail menjawab pertanyaan terpenting dalam sistem bisnis mana pun: 'siapa yang mengubah ini, kapan, dan bagaimana tampilannya sebelumnya?' Ketika saya membangun ERP kustom sebagai konsultan ERP lepas, audit trail dirancang dari awal sebagai persyaratan kepatuhan dan kepercayaan.
Apa yang Termasuk dalam Audit Log
Tidak semuanya perlu diaudit, tetapi apa pun yang mempengaruhi catatan keuangan, data pribadi, atau konfigurasi sistem mutlak perlu. Pada ERP kustom yang saya bangun, saya mengaudit semua operasi CREATE/UPDATE/DELETE, semua transisi state workflow, semua perubahan peran, dan semua operasi massal.
Skema Tabel audit_logs
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.
Memilih Antara Logging Tingkat Aplikasi dan Tingkat Database
Saya menggunakan logging tingkat aplikasi sebagai mekanisme utama karena menangkap konteks permintaan (identitas pengguna, alamat IP) yang tidak dapat diakses trigger database. Saya juga menambahkan 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.
Implementasi NestJS Audit Interceptor
Saya 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.
React Activity Feed untuk Pengguna Akhir
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>
);
}Kepatuhan dan Retensi Data
Pasal 28 ayat (11) UU KUP, sebagaimana diubah dengan UU 28/2007, mewajibkan buku, catatan, dan dokumen yang menjadi dasar pembukuan, termasuk hasil pengolahan data yang dikelola secara elektronik atau secara program aplikasi online, disimpan selama 10 tahun di Indonesia. Perlakukan catatan keuangan beserta riwayat perubahannya sebagai bagian dari kewajiban itu. Rancang strategi retensi sejak hari pertama: gunakan partisi tabel PostgreSQL per tahun, arsipkan partisi lama ke penyimpanan yang lebih murah setelah 2 tahun dengan tetap menyimpannya di Indonesia, dan hapus sebuah partisi hanya setelah umurnya lebih dari 10 tahun. Jangan simpan audit log di database yang sama dengan data aplikasi utama jika instance database Anda kecil.
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.
Pertimbangan Performa
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.
Audit Log sebagai Alat Debugging
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.

