Skip to main content
BlogERP

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

Cara mengimplementasikan sistem logging audit yang komprehensif untuk ERP kustom — mencakup desain skema PostgreSQL, logging berbasis NestJS interceptor, snapshot JSONB sebelum/sesudah, dan komponen activity feed React.

Matthews Wong

Software Engineer

Juni 20258 menit baca
Membangun Audit Trail dan Activity Log ERP: PostgreSQL, NestJS Interceptors, dan React Activity Feed

Foto oleh Unsplash

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.

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Mengapa menggunakan logging di tingkat aplikasi dibanding trigger PostgreSQL untuk audit trail?

Logging di tingkat aplikasi dapat menangkap konteks permintaan — seperti identitas pengguna yang terautentikasi dan alamat IP — yang tidak dapat diakses trigger database dengan mudah. Trigger PostgreSQL tetap digunakan sebagai jaring pengaman ringan pada tabel paling sensitif, tetapi NestJS interceptor menjadi mekanisme utama karena memiliki visibilitas penuh atas siapa yang membuat permintaan dan dari mana.

Mengapa menyimpan snapshot JSONB lengkap sebelum dan sesudah perubahan, bukan sekadar daftar field yang berubah?

Snapshot JSONB lengkap memungkinkan rekonstruksi tampilan invoice atau data pada tanggal tertentu, yang persis dibutuhkan auditor dan investigator. Jika hanya diff field yang disimpan, tidak mungkin menelusuri kembali riwayat dan mereproduksi tampilan data pada titik waktu tertentu.

Bagaimana operasi massal seperti impor Excel ditangani agar tidak memperlambat penulisan audit log?

Penulisan ke audit log menambahkan penulisan database sinkron ke setiap operasi API, yang tidak signifikan untuk operasi satu baris tetapi bisa menambah beberapa detik untuk impor 500 baris. Untuk operasi massal, penulisan audit log harus dimasukkan ke antrian via BullMQ agar non-blocking, dan worker di latar belakang yang memproses entri audit secara asinkron.

Berapa lama audit log harus disimpan berdasarkan regulasi Indonesia?

Pasal 28 ayat (11) UU KUP, sebagaimana diubah dengan UU 28/2007, mewajibkan buku, catatan, dan dokumen dasarnya, termasuk data yang diolah secara elektronik, disimpan selama 10 tahun di Indonesia. Perlakukan catatan keuangan beserta riwayat perubahannya sebagai bagian dari kewajiban itu. Post ini merekomendasikan partisi tabel PostgreSQL per tahun, mengarsipkan partisi lama ke penyimpanan yang lebih murah di Indonesia setelah 2 tahun, dan hanya menghapus partisi yang berumur lebih dari 10 tahun.

Mengapa tabel audit_logs harus append-only, dan bagaimana immutabilitas ditegakkan?

Immutabilitas adalah yang memberi audit log nilai hukum dan kepatuhan — jika baris dapat dimodifikasi atau dihapus, log tidak dapat dipercaya. Immutabilitas ditegakkan dengan hanya memberikan hak INSERT dan SELECT kepada pengguna database aplikasi, bukan UPDATE atau DELETE. Jika perlu koreksi, gunakan INSERT kompensasi yang mencatat koreksi tersebut, bukan memodifikasi baris asli.