Membangun Workflow Engine untuk Persetujuan Multi-Langkah: NestJS, PostgreSQL, dan React

Foto oleh Unsplash
Menulis logika persetujuan kustom per proses bisnis memang praktis di awal, tetapi menjadi masalah pemeliharaan ketika kebutuhan berubah — misalnya, menambahkan level persetujuan kedua untuk nominal tertentu berarti harus menyentuh setiap alur persetujuan secara terpisah. Engine generik berarti Anda memperbarui tabel transisi sekali, dan semua proses yang berbagi konfigurasi itu langsung mendapat manfaat. Pendekatan inilah yang diterapkan saat membangun ERP Commsult Indonesia, di mana WorkflowService yang sama menggerakkan permintaan cuti, voucher AP, maupun invoice AR.
Setiap transisi state ditambahkan ke kolom JSONB bernama 'history' di tabel workflow_instances, sehingga tersedia catatan lengkap dan terurut tentang siapa melakukan apa dan kapan. Operator JSONB PostgreSQL memudahkan kueri terhadap riwayat ini, dan pendekatan ini cukup cepat untuk rantai persetujuan tipikal sebanyak 5–15 langkah. Instance workflow juga menyimpan entity_type dan entity_id secara terpisah agar engine tetap benar-benar generik lintas jenis dokumen.
WorkflowService hanya memiliki satu metode publik — transition(instanceId, action, actorRole, comment) — yang memuat instance, menemukan transisi yang cocok, memvalidasi peran aktor, menerapkan perubahan state, dan menambahkan ke riwayat JSONB. Alih-alih memanggil logika bisnis secara langsung, ia memancarkan domain event, dan handler downstream mendengarkan event state workflow tertentu menggunakan EventEmitter2 dari NestJS. Pemisahan berbasis event ini membuat engine tidak perlu mengetahui apa pun tentang kebijakan cuti, aturan invoice, atau domain bisnis lainnya.
Karena workflow engine memengaruhi setiap proses persetujuan dalam sistem, artikel ini merekomendasikan penulisan unit test yang komprehensif untuk setiap transisi yang valid maupun tidak valid. Untuk setiap transisi, Anda perlu memverifikasi bahwa peran yang benar dapat melakukannya, bahwa peran yang salah ditolak dengan ForbiddenException, dan bahwa riwayat tercatat dengan benar. Gunakan test.each dari Jest untuk mencakup semua transisi tanpa kode test yang berulang.
Tiga peningkatan yang paling menonjol: pertama, tambahkan optimistic locking melalui kolom versi pada workflow_instances untuk mencegah konflik transisi bersamaan; kedua, simpan identitas aktor secara lengkap di riwayat JSONB daripada hanya string peran; ketiga, tambahkan fitur delegasi lebih awal agar manajer yang sedang cuti dapat melimpahkan wewenang persetujuan kepada wakilnya. Artikel ini juga memperingatkan agar tidak langsung membuat engine berbasis konfigurasi dari UI database di versi pertama — mulailah dengan tabel transisi TypeScript yang dikodekan keras hingga pengguna benar-benar memintanya.

Foto oleh Unsplash
Setiap proses bisnis yang melibatkan persetujuan — permintaan cuti, purchase order, voucher pembayaran — pada dasarnya adalah state machine. Dalam pekerjaan konsultasi ERP lepas saya, ketika saya membangun ERP kustom untuk klien, saya mengekstrak workflow engine generik yang menggerakkan semua alur persetujuan dalam sistem.
Alternatif dari engine generik adalah menulis logika persetujuan kustom untuk setiap proses bisnis. Engine generik berarti Anda memperbarui tabel transisi sekali, dan semua proses yang berbagi konfigurasi itu segera mendapat manfaat.
State machine untuk alur kerja bisnis membutuhkan empat hal: sekumpulan state yang terbatas, sekumpulan aksi yang terbatas, tabel yang memetakan (from_state, action, required_role) → to_state, dan cara untuk menolak transisi yang tidak valid.
Setiap transisi state ditambahkan ke kolom JSONB bernama 'history' di tabel workflow_instances. Ini memberikan catatan lengkap dan terurut tentang siapa yang melakukan apa dan kapan — tanpa memerlukan tabel riwayat terpisah.
Multi-Step Approval Workflow State Machine
┌───────────┐ submit() ┌───────────────┐
│ DRAFT │──────────────►│ PENDING_L1 │
└───────────┘ │ (Mgr Approval)│
└───────┬───────┘
│
┌─────────────────┼──────────────────┐
│ approve() │ │ reject()
▼ │ ▼
┌────────────────┐ │ ┌───────────────┐
│ PENDING_L2 │ │ │ REJECTED │
│ (Dir Approval) │ │ └───────────────┘
└───────┬────────┘ │
│ │ revise()
┌───────────┼──────────┐ ▼
│ approve() │ │ ┌────────────┐
▼ │ reject() │ │ REVISION │──► back to DRAFT
┌──────────┐ ▼ │ └────────────┘
│PENDING_FIN│ ┌───────────┐│
│(Finance) │ │ REJECTED ││
└────┬─────┘ └───────────┘│
│ │
│ approve() │
▼
┌──────────────┐
│ APPROVED │──► Trigger downstream actions
└──────────────┘ (email, PDF, payment run)
PostgreSQL table: workflow_instances
Columns: id, entity_type, entity_id, current_state,
history JSONB, created_at, updated_atGunakan gen_random_uuid() PostgreSQL untuk ID instance workflow, dan simpan entity_type + entity_id secara terpisah. Ini membuat workflow engine benar-benar generik — WorkflowService yang sama menggerakkan permintaan cuti, voucher AP, dan proses masa depan apa pun.
WorkflowService memiliki satu metode publik: transition(instanceId, action, actorRole, comment). Ia memuat instance workflow, menemukan transisi yang cocok, memvalidasi peran aktor, menerapkan perubahan state, menambahkan ke riwayat JSONB, dan memancarkan event domain untuk handler downstream.
Komponen React untuk state workflow memiliki dua bagian: lencana status dengan kode warna, dan timeline yang dapat dilipat menampilkan riwayat transisi lengkap dengan aktor, aksi, cap waktu, dan komentar opsional.
// NestJS: Generic workflow state machine service
// Works for Leave Requests, AP Vouchers, AR Invoices etc.
export type WorkflowState =
| 'DRAFT' | 'PENDING_L1' | 'PENDING_L2' | 'PENDING_FINANCE'
| 'APPROVED' | 'REJECTED' | 'REVISION';
export type WorkflowAction =
| 'submit' | 'approve' | 'reject' | 'revise' | 'cancel';
interface Transition {
from: WorkflowState;
action: WorkflowAction;
to: WorkflowState;
requiredRole: string;
}
const TRANSITIONS: Transition[] = [
{ from: 'DRAFT', action: 'submit', to: 'PENDING_L1', requiredRole: 'EMPLOYEE' },
{ from: 'PENDING_L1', action: 'approve', to: 'PENDING_L2', requiredRole: 'MANAGER' },
{ from: 'PENDING_L1', action: 'reject', to: 'REJECTED', requiredRole: 'MANAGER' },
{ from: 'PENDING_L1', action: 'revise', to: 'REVISION', requiredRole: 'MANAGER' },
{ from: 'PENDING_L2', action: 'approve', to: 'PENDING_FINANCE', requiredRole: 'DIRECTOR' },
{ from: 'PENDING_L2', action: 'reject', to: 'REJECTED', requiredRole: 'DIRECTOR' },
{ from: 'PENDING_FINANCE', action: 'approve', to: 'APPROVED', requiredRole: 'FINANCE' },
{ from: 'PENDING_FINANCE', action: 'reject', to: 'REJECTED', requiredRole: 'FINANCE' },
{ from: 'REVISION', action: 'submit', to: 'PENDING_L1', requiredRole: 'EMPLOYEE' },
];
@Injectable()
export class WorkflowService {
constructor(
@InjectRepository(WorkflowInstance)
private workflowRepo: Repository<WorkflowInstance>,
private eventEmitter: EventEmitter2,
) {}
async transition(
instanceId: string,
action: WorkflowAction,
actorRole: string,
comment?: string,
): Promise<WorkflowInstance> {
const instance = await this.workflowRepo.findOneOrFail({ where: { id: instanceId } });
const transition = TRANSITIONS.find(
t => t.from === instance.currentState
&& t.action === action
&& t.requiredRole === actorRole,
);
if (!transition) {
throw new ForbiddenException(
`Action "${action}" not allowed from state "${instance.currentState}" for role "${actorRole}"`
);
}
// Append to JSONB history
const historyEntry = {
from: instance.currentState,
to: transition.to,
action,
actorRole,
comment,
timestamp: new Date().toISOString(),
};
await this.workflowRepo.update(instanceId, {
currentState: transition.to,
history: () => `history || '${JSON.stringify(historyEntry)}'::jsonb`,
});
// Emit event for downstream handlers (email, notifications)
this.eventEmitter.emit(`workflow.${transition.to.toLowerCase()}`, {
instanceId,
entityType: instance.entityType,
entityId: instance.entityId,
});
return this.workflowRepo.findOneOrFail({ where: { id: instanceId } });
}
}Alur persetujuan dunia nyata memiliki dua kasus tepi: permintaan revisi dan pembatalan. Keduanya membutuhkan state mereka sendiri — REVISION dan CANCELLED. State REVISION memungkinkan pengirim asli untuk memperbarui dokumen dan mengirim ulang.
Jangan mencoba membuat workflow engine berbasis konfigurasi dari UI database di versi pertama Anda. Mulai dengan tabel transisi TypeScript yang dikodekan keras, buat engine bekerja dengan andal, kemudian tambahkan fitur konfigurasi hanya ketika pengguna benar-benar memintanya.
Workflow engine adalah jantung ERP — bug di sini mempengaruhi setiap proses persetujuan dalam sistem. Tulis unit test yang komprehensif untuk setiap transisi yang valid dan tidak valid. Gunakan test.each Jest untuk mencakup semua transisi tanpa kode test yang berulang.
Setelah 12 bulan di produksi: gunakan optimistic locking pada workflow_instances untuk mencegah konflik transisi bersamaan; simpan identitas aktor lengkap dalam riwayat JSONB; dan tambahkan fitur 'delegasi' lebih awal untuk manajer yang sedang cuti.