From c67afb2fff45f12c67fce4699e27270add5589d1 Mon Sep 17 00:00:00 2001 From: Hardik Date: Tue, 5 May 2026 23:24:24 +0530 Subject: [PATCH] feat(state-machine): PO lifecycle state machine with role-gated transitions 10 statuses, 11 transitions. Each transition declares allowedRoles, requiresNote flag and sideEffects (which email groups to notify). Helpers: getTransition, canPerformAction, getAvailableActions, requiresNote. --- App/pelagia-portal/lib/po-state-machine.ts | 135 +++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 App/pelagia-portal/lib/po-state-machine.ts diff --git a/App/pelagia-portal/lib/po-state-machine.ts b/App/pelagia-portal/lib/po-state-machine.ts new file mode 100644 index 0000000..b3270d6 --- /dev/null +++ b/App/pelagia-portal/lib/po-state-machine.ts @@ -0,0 +1,135 @@ +import type { POStatus, Role } from "@prisma/client"; + +export type POAction = + | "submit" + | "approve" + | "approve_with_note" + | "reject" + | "request_edits" + | "request_vendor_id" + | "provide_vendor_id" + | "process_payment" + | "mark_paid" + | "confirm_receipt"; + +export type SideEffect = + | "EMAIL_MANAGER" + | "EMAIL_SUBMITTER" + | "EMAIL_ACCOUNTS" + | "EMAIL_SUBMITTER_AND_MANAGER"; + +interface Transition { + to: POStatus; + allowedRoles: Role[]; + requiresNote: boolean; + sideEffects: SideEffect[]; +} + +type TransitionMap = Partial>; + +const TRANSITIONS: Partial> = { + DRAFT: { + submit: { + to: "SUBMITTED", + allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"], + requiresNote: false, + sideEffects: ["EMAIL_MANAGER"], + }, + }, + SUBMITTED: { + // Auto-advances to MGR_REVIEW in the server action immediately after SUBMITTED + }, + MGR_REVIEW: { + approve: { + to: "MGR_APPROVED", + allowedRoles: ["MANAGER", "SUPERUSER"], + requiresNote: false, + sideEffects: ["EMAIL_SUBMITTER", "EMAIL_ACCOUNTS"], + }, + approve_with_note: { + to: "MGR_APPROVED", + allowedRoles: ["MANAGER", "SUPERUSER"], + requiresNote: true, + sideEffects: ["EMAIL_SUBMITTER", "EMAIL_ACCOUNTS"], + }, + reject: { + to: "REJECTED", + allowedRoles: ["MANAGER", "SUPERUSER"], + requiresNote: true, + sideEffects: ["EMAIL_SUBMITTER"], + }, + request_edits: { + to: "EDITS_REQUESTED", + allowedRoles: ["MANAGER", "SUPERUSER"], + requiresNote: true, + sideEffects: ["EMAIL_SUBMITTER"], + }, + request_vendor_id: { + to: "VENDOR_ID_PENDING", + allowedRoles: ["MANAGER", "SUPERUSER"], + requiresNote: false, + sideEffects: ["EMAIL_SUBMITTER"], + }, + }, + VENDOR_ID_PENDING: { + provide_vendor_id: { + to: "MGR_REVIEW", + allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"], + requiresNote: false, + sideEffects: ["EMAIL_MANAGER"], + }, + }, + EDITS_REQUESTED: { + submit: { + to: "SUBMITTED", + allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"], + requiresNote: false, + sideEffects: ["EMAIL_MANAGER"], + }, + }, + MGR_APPROVED: { + process_payment: { + to: "SENT_FOR_PAYMENT", + allowedRoles: ["ACCOUNTS", "SUPERUSER"], + requiresNote: false, + sideEffects: ["EMAIL_SUBMITTER_AND_MANAGER"], + }, + }, + SENT_FOR_PAYMENT: { + mark_paid: { + to: "PAID_DELIVERED", + allowedRoles: ["ACCOUNTS", "SUPERUSER"], + requiresNote: false, + sideEffects: ["EMAIL_SUBMITTER", "EMAIL_MANAGER"], + }, + }, + PAID_DELIVERED: { + confirm_receipt: { + to: "CLOSED", + allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"], + requiresNote: false, + sideEffects: ["EMAIL_MANAGER", "EMAIL_ACCOUNTS"], + }, + }, +}; + +export function getTransition(from: POStatus, action: POAction): Transition | null { + return TRANSITIONS[from]?.[action] ?? null; +} + +export function canPerformAction(from: POStatus, action: POAction, role: Role): boolean { + const transition = getTransition(from, action); + return transition?.allowedRoles.includes(role) ?? false; +} + +export function getAvailableActions(status: POStatus, role: Role): POAction[] { + const map = TRANSITIONS[status]; + if (!map) return []; + return (Object.keys(map) as POAction[]).filter((action) => + canPerformAction(status, action, role) + ); +} + +export function requiresNote(from: POStatus, action: POAction): boolean { + return getTransition(from, action)?.requiresNote ?? false; +}