Workflow Approval Engine — generic multi-stage document approval
The whole engine reduces to one idea: any document (
refId) is submitted with an ordered list of stages; each stage is oneWorkflowTaskholding one-or-moreWorkflowApprovalrows (one per approver); the engine walks the stages one task at a time, and when the last stage is approved it calls back into the owning module'sworkflowCompleted(refId)so that module flips its own document status. The engine is kind-agnostic — it knows nothing about leave/loan/order semantics. It only knows: stages, tasks, approvals, anapproveByrule (ANY/ALL), and aWorkflowTaskFactorythat maps akind→ the service that owns the document. Approver resolution (role → user) is not the engine's job — it is done by each domain's orchestrator (HR) or threshold policy (inventory) beforesubmit()is called, and handed in asdynamicStages.
Source: BE src/modules/workflow (engine/, workflow.*, stage/, approver/, approval/, task/, event/, notification/) + the HR glue src/modules/hr/approval-orchestrator, src/modules/hr/approval-policy, src/modules/hr/approval · Admin src/modules/workflow (+ src/modules/hr/approval, src/modules/hr/approval-policy)
1. Purpose & scope
The engine owns the generic approval state machine and its supporting collections. It is responsible for:
- Workflow definitions (
workflows+workflow_stages+workflow_approvers): a reusable, admin-built template — a named workflow with ordered stages, each stage holding anapproveByrule and a fixed set of approver users. - Submission (
engine.submit): turning either a stored workflow or an inlinedynamicStages[]array into a chain ofworkflow_tasks(one per stage) andworkflow_approvals(one per approver per stage), then activating the first stage. - Stage progression (
engine.approve/engine.reject): advancing through the task chain, evaluatingANY/ALLper stage, returning to a prior stage on rejection (or terminating). - Completion callbacks: on final approval / terminal rejection it invokes
WorkflowTaskFactory.getTaskKindService(kind).workflowCompleted(refId)/workflowRejected?(refId)so the source module owns the status flip and any side effects (GL posting,POSTEDstatus, etc.). - Audit trail of the run (
workflow_events) and per-user inbox notifications (workflow_notifications, with a live GraphQL subscription). - The approver inbox (
workflowTaskPage/findMySubmissionsand the HRapprovalPage/myPendingApprovals).
It explicitly does not:
- Resolve who the approvers are. The engine receives concrete
approverIds (User_ids). For HR documents the HR approval orchestrator resolvesMANAGER/HEAD_OF_DEPARTMENT/HR→ user ids from the approval policy and the employee's reporting line. For inventory it reads a fixed approver set off a stored workflow's stages. - Own the document's business status.
Leave.status,Loan.status,Order.statusetc. live on their own schemas; the engine only calls the registered callback. (See leave, claims/loans/advances,inventory stock flow.) - Enforce RBAC on who may approve. Authority is dynamic (you're an approver if a
WorkflowApprovalrow names you), not a static permission. See permissions-access. - Run inside one transaction.
submit/approve/rejectare sequences of individual writes — there is nowithRetryTransactionwrapper inside the engine (a known gotcha, §9).
2. Data model
Seven collections. All extend BaseSchema (_id, companyId, branchId, ref, documentCode/Date, createdAt/By, updatedAt/By, canUpdate/View/Delete/Post) via @ApSchema, and all register mongoose-delete (deletedAt) for soft-delete. Every *Id @Prop has a set that coerces a 24-char hex string → ObjectId (BaseSchema.toObjectId).
workflows ─1:N─▶ workflow_stages ─1:N─▶ workflow_approvers (the reusable DEFINITION)
│ (submit copies into ↓)
workflow_tasks ─1:N─▶ workflow_approvals (a live RUN, keyed by refId)
workflow_events (immutable history of the run)
workflow_notifications (per-recipient inbox rows derived from events)
Definition vs. run.
workflows/stages/approversare templates you build once.tasks/approvalsare created fresh per submission and carry the live state. A dynamic submission (HR) writes tasks/approvals with noworkflowId/stageId— the stage name/order/approveBy are denormalised straight onto the task.
2.1 workflows — the definition header
workflow.schema.ts (Workflow extends BaseSchema, @ApSchema({ collection: "workflows" })).
| field | type | required | description |
|---|---|---|---|
name |
string | — | Workflow label (e.g. "PO Approval"). |
description |
string | — | Free text. |
stageId |
string | — | Legacy/unused pointer (the real link is stage.workflowId). |
status |
enum WorkflowStatus |
yes | Default DRAFT. Lifecycle of the definition, not a run. |
export enum WorkflowStatus { DRAFT, IN_PROGRESS, COMPLETED, CANCELLED }2.2 workflow_stages — an ordered step of the definition
stage/stage.schema.ts (WorkflowStage extends BaseSchema, @ApSchema({ collection: "workflow_stages" })).
| field | type | required | description |
|---|---|---|---|
name |
string | — | Stage label ("Manager", "Finance"). |
workflowId |
ObjectId → workflows |
— | Parent workflow. |
order |
number | — | Sort key — stages execute ascending by order. |
approveBy |
enum WorflowStageApprovalTypes |
yes | ANY (one approver suffices) or ALL (every approver must approve). Default ANY. |
status |
enum WorkflowStageStageStatus |
yes | Default DRAFT. |
approverIds |
string[] | — | Not persisted (no @Prop) — a transient field the admin form posts; approvers are actually stored as separate workflow_approvers rows. |
export enum WorflowStageApprovalTypes { ANY = "ANY", ALL = "ALL" }
export enum WorkflowStageStageTypes { SEQUENTIAL, PARALLEL } // declared but commented-out on the schema → unused; stages are always sequential by order
export enum WorkflowStageStageStatus { DRAFT, IN_PROGRESS, COMPLETED, CANCELLED }The
type: SEQUENTIAL|PARALLELprop is commented out in the schema — there is no parallel-stage mode. "Parallel" only exists within a stage viaapproveBy: ALL(all approvers of one stage act together). Stages themselves always run one-at-a-time byorder.
2.3 workflow_approvers — a definition's fixed approver
approver/approver.schema.ts (WorkflowApprover extends BaseSchema, @ApSchema({ collection: "workflow_approvers" })).
| field | type | required | description |
|---|---|---|---|
stageId |
ObjectId → workflow_stages |
— | The stage this approver belongs to. |
workflowId |
ObjectId → workflows |
— | Denormalised parent. |
approverId |
ObjectId → users |
— | The approving User _id. |
Used only for stored-workflow submissions (inventory). HR submissions never read this collection — they pass
approverIdsinline indynamicStages.
2.4 workflow_tasks — one live stage of a run (the inbox row)
task/task.schema.ts (WorkflowTask extends BaseSchema, @ApSchema({ collection: "workflow_tasks" })). This is the central "thing to approve". One task = one stage of one document's approval.
| field | type | required | description |
|---|---|---|---|
refId |
ObjectId | — | The source document _id (a leave/loan/order). The join key for the whole run. |
parentId |
ObjectId → workflow_tasks |
— | Set when a return-rejection spawns a fresh task at a prior stage (links the new task to the rejected one). |
ref |
string | — | The source document's human ref (e.g. "PO-0007"), used in messages. |
title |
string | — | "<stageName> - Approval". |
orderNo |
number | — | Stage order copied onto the task (so dynamic tasks know their position without a stageId). |
archived |
boolean | — | Default false. Rejected tasks and cancelled-document tasks are archived → excluded from inboxes. |
status |
enum WorkflowTaskStatus |
— | Default PENDING. See §4.1. |
stageId |
ObjectId → workflow_stages |
— | Only set for stored-workflow runs. Null/absent for dynamic (HR) runs → isDynamic. |
workflowId |
ObjectId → workflows |
— | Null for dynamic runs. |
stageName |
string | — | Denormalised stage name (the source of truth for dynamic tasks). |
approveBy |
enum WorflowStageApprovalTypes |
— | Denormalised stage rule (source of truth for dynamic tasks). |
kind |
enum WorkflowTaskKind |
yes | Document type → routes the completion callback via the factory. |
completionDate |
number (unix) | — | Set via BaseSchema.toUnixTimestamp. |
stage / approvals |
virtual | — | Hydrated by the repository's $lookup aggregations. |
export enum WorkflowTaskKind {
PurchaseRequisition, PurchaseOrder, SalesQuotation, SalesOrder, // inventory (stored-workflow)
Leave, Claim, Loan, Advance, // HR (dynamic / orchestrated)
}
export enum WorkflowTaskStatus {
PENDING = "PENDING", // created, not yet the active stage
AWAITING_APPROVAL = "AWAITING_APPROVAL", // the active stage — visible in inboxes
APPROVED = "APPROVED", // stage satisfied
REJECTED = "REJECTED", // this task was rejected (also archived=true)
ARCHIVED = "ARCHIEVED", // ⚠ string value is misspelled "ARCHIEVED"
}⚠
WorkflowTaskStatus.ARCHIVEDhas the string value"ARCHIEVED"(typo in code). Any external query/filter must use the misspelled literal. (task.schema.ts:24.)
The repository (task/task.repository.ts) always hydrates via two $lookups — $lookupApprovals (workflow_approvals by taskId) and $lookupStage (workflow_stages by stageId) — so a fetched task carries its approvals[] and stage.
2.5 workflow_approvals — one approver's verdict on one task
approval/approval.schema.ts (WorkflowApproval extends BaseSchema, @ApSchema({ collection: "workflow_approvals" })). One row per approver per task — this is where an individual approve/reject is recorded.
| field | type | required | description |
|---|---|---|---|
taskId |
ObjectId → workflow_tasks |
— | Parent task. |
stageId |
ObjectId | — | Optional (set on return-spawned tasks). |
workflowId |
ObjectId | — | Optional. |
approverId |
ObjectId → users |
— | The approving User _id. Matched against the acting user on approve/reject. |
remark |
string | — | Approver's comment / rejection reason. |
archived |
boolean | — | Set when the task is rejected (so old verdicts don't count on re-run). |
statusUpdateDate |
number (unix) | — | When the verdict was recorded. |
status |
enum WorkflowApprovalStatus |
— | Default PENDING. |
export enum WorkflowApprovalStatus {
PENDING = "PENDING", // created on an inactive stage
AWAITING_APPROVAL = "AWAITING_APPROVAL", // active stage — this approver may act
APPROVED = "APPROVED",
REJECTED = "REJECTED",
COMPLETED = "COMPLETED", // declared + GraphQL-registered, never set by the engine (dead)
}2.6 workflow_events — immutable run history
event/workflow-event.schema.ts. One row per engine transition; drives both the timeline UI and notification fan-out. Indexed { refId: 1, createdAt: -1 }.
| field | type | description |
|---|---|---|
workflowId / taskId / stageId / refId |
ObjectId | Context pointers (some null for dynamic). |
kind |
WorkflowTaskKind |
Document type. |
event |
enum WorkflowEventType |
What happened. |
actorId |
ObjectId → users |
Who did it (submitter or approver). |
targetIds |
ObjectId[] | Who should be notified. |
metadata |
object | { ref, stageName, remark, actorName, fromStageId, toStageId, returnToStageId }. |
export enum WorkflowEventType {
TASK_CREATED, STAGE_ADVANCED, APPROVED, REJECTED, COMPLETED, RESUBMITTED,
}
// (RESUBMITTED is declared but never emitted — dead.)2.7 workflow_notifications — per-user inbox row
notification/workflow-notification.schema.ts. One row per recipient per event (fanned out from event.targetIds). Indexed { userId, read } and { userId, createdAt: -1 }.
| field | type | description |
|---|---|---|
userId |
ObjectId → users |
Recipient. |
eventId / workflowId / taskId / refId |
ObjectId | Source pointers. |
kind |
WorkflowTaskKind |
For filtering/icon. |
message |
string | Pre-rendered human string (see buildMessage, §4.5). |
read |
boolean | Default false. |
3. API surface
3.1 Workflow definition CRUD (workflow.resolver.ts, stage/stage.resolver.ts, approver/approver.resolver.ts)
All guarded by @ApGqlAuthorize(); mutations carry @AuditMeta. Resolvers extend ApBaseResolver.
| Operation | Type | Input | Returns | Notes |
|---|---|---|---|---|
createWorkflow / updateWorkflow / deleteWorkflow |
Mutation | Create/UpdateWorkflowInput |
Workflow / Boolean |
Standard CRUD over the definition. @ResolveField stages joins workflow_stages; creator joins users. |
workflowPage / findWorkflow |
Query | WorkflowPageInput / QueryWorkflowInput |
WorkflowPageResult / Workflow |
List/find definitions. |
stage CRUD (createWorkflowStage, …) |
Mutation/Query | stage DTOs | WorkflowStage |
Stage builder; approverIds carried for the admin form. |
| approver CRUD | Mutation/Query | approver DTOs | WorkflowApprover |
Per-stage approver rows. |
3.2 Run / inbox (task/task.resolver.ts)
| Operation | Type | Input | Returns | Notes |
|---|---|---|---|---|
newWorkflowTask |
Mutation | CreateWorkflowTaskInput { workflowId, refId, kind, ref? } |
Boolean |
Submits a stored workflow → engine.submit(...). (Confusing name — it starts a whole run, not one task.) |
updateWorkflowTask / deleteWorkflowTask |
Mutation | id + input | WorkflowTask / Boolean |
Direct task edits (rare). |
workflowTaskPage |
Query | WorkflowTaskPageInput { skip, take, keyword?, status?, kinds?, involvedUserId? } |
WorkflowTaskPageResult |
The inbox list; forces archived:false. Repository self-scopes non-privileged users to tasks where they're an approver/creator (§5). |
findMySubmissions |
Query | WorkflowTaskSubmissionsPageInput |
WorkflowTaskSubmissionsPageResult |
Tasks createdBy = me — "what I submitted". |
findOneWorkflowTask |
Query | QueryWorkflowTaskInput |
WorkflowTask |
Single task w/ approvals + stage. |
@ResolveFields on WorkflowTask: order (joins the inventory Order by refId), workflow, stage, approvals, canUpdateStatus (whether the current user may still act).
3.3 Approve / reject (approval/approval.resolver.ts)
| Operation | Type | Input | Returns | Auth |
|---|---|---|---|---|
updateWorkflowApprovalStatus |
Mutation | WorkflowApprovalStatusUpdateInput { taskId, status, remark?, returnToStageId? } |
Boolean |
@ApGqlAuthorize, @AuditMeta STATUS_CHANGE |
createWorkflowApproval / updateWorkflowApproval / deleteWorkflowApproval |
Mutation | approval DTOs | WorkflowApproval / Boolean |
Low-level CRUD. |
findOneWorkflowApproval / findWorkflowApproval |
Query | QueryWorkflowApprovalInput |
WorkflowApproval(s) |
@ResolveField approver joins users. |
// the one mutation approvers actually call:
updateWorkflowApprovalStatus({ taskId, status, remark, returnToStageId }) {
approverId = currentUser._id;
if (status === APPROVED) engine.approve({ taskId, approverId, remark });
else if (status === REJECTED) engine.reject({ taskId, approverId, remark, returnToStageId });
else throw "Cannot set approval status to <status>";
}3.4 Notifications (notification/workflow-notification.resolver.ts)
| Operation | Type | Input | Returns |
|---|---|---|---|
findMyNotifications |
Query | read? |
[WorkflowNotificationDto] |
markNotificationRead |
Mutation | id |
Boolean (ownership-checked) |
markAllNotificationsRead |
Mutation | — | Boolean |
workflowNotificationAdded |
Subscription | — | WorkflowNotificationDto — live, filtered server-side to payload.userId === currentUser._id (Redis pub/sub). |
3.5 HR approval glue — the friendlier facade (hr/approval/hr-approval.resolver.ts)
A thin orchestration layer (no schema of its own) that maps WorkflowTasks ↔︎ a flat Approval shape for the HR/ESS approvals inbox, normalising engine statuses to HrApprovalStatus { PENDING, APPROVED, REJECTED, CANCELLED }.
| Operation | Type | Input | Returns |
|---|---|---|---|
approvalPage |
Query | HrApprovalPageInput { skip, take, status?, kind?, approverId? } |
ApprovalPageResult (HR-kind tasks only) |
myPendingApprovals |
Query | — | [Approval] (current user's AWAITING_APPROVAL HR tasks) |
actionApproval |
Mutation | id (taskId) + HrApprovalActionInput { status, remark? } |
Approval → calls engine.approve/reject |
actionApprovalhas a notable behaviour: if a pending approval exists on the task but is assigned to a different user, it reassignsapproverIdto the acting user before approving (hr-approval.service.ts:113) — so any authorised HR/manager viewing the inbox can act on the row that's waiting.
3.6 HR approval policy (hr/approval-policy/approval-policy.resolver.ts)
| Operation | Type | Input | Returns |
|---|---|---|---|
hrApprovalPolicyPage |
Query | HrApprovalPolicyPageInput |
HrApprovalPolicyPageResult |
createHrApprovalPolicy / updateHrApprovalPolicy / deleteHrApprovalPolicy |
Mutation | Create/UpdateHrApprovalPolicyInput { kind, levels[] } |
HrApprovalPolicyDto |
3.7 REST (workflow.controller.ts)
GET /api/workflow/download and GET /api/workflow/task/download — XLSX exports of workflows and tasks (status/kind labels humanised). No write endpoints.
4. Business rules & the state machine
4.1 Task & approval status lifecycle
submit()
┌──────────────────────────────────────────────┐
▼ │
stage 1 task: AWAITING_APPROVAL stages 2..N: PENDING (queued)
approvals: AWAITING_APPROVAL approvals: PENDING
│
┌──────────┴───────────────┐
│ approve (ANY: 1 / ALL: all)│ reject
▼ ▼
task APPROVED task REJECTED + archived=true
│ │ approvals archived=true
│ find next PENDING task │
▼ (by orderNo asc) ▼ return to PREVIOUS task (orderNo desc)
next task AWAITING_APPROVAL prior task re-opened AWAITING_APPROVAL
│ │ (its approvals reset: AWAITING_APPROVAL, archived=false, remark/date cleared)
│ ... repeat ... │ OR no prior task → terminal rejection
▼ ▼
no next task → workflowRejected(refId)
workflowCompleted(refId)
The per-row statuses are driven by the active stage:
- PENDING — task/approval created but its stage isn't active yet (queued behind earlier stages).
- AWAITING_APPROVAL — the stage is currently active; only
AWAITING_APPROVAL(orPENDING) approvals may be acted on (engine.approve/rejectreject anything else with"Approval not found or cannot be updated"). - APPROVED / REJECTED — recorded verdict.
4.2 Submit — WorkflowEngine.submit(input) (engine/workflow.engine.ts:80)
submit({ workflowId?, refId, kind, ref?, submitterId, dynamicStages? })- Resolve stages, either:
- Dynamic (
dynamicStagesprovided, HR path): sort byorder. Each entry already carries{ name, order, approveBy, approverIds }. - Stored (
workflowIdprovided, inventory path): load the workflow (404 →"Workflow not found"), load itsworkflow_stages(empty →"Workflow has no stages"), sort byorder, and for each stage loadworkflow_approvers(empty →"Stage \"<name>\" has no approvers"), mapping to{ name, order, approveBy, approverIds }.
- Dynamic (
- Create one task per stage (
status: PENDING,archived: false, denormalisingstageName/approveBy/orderNo;workflowIdnull for dynamic). For each task, create oneworkflow_approvalperapproverId(status: PENDING). - Activate the first stage: set task[0] →
AWAITING_APPROVAL, and all its approvals →AWAITING_APPROVAL. - Emit
TASK_CREATEDevent (actor = submitter, targets = first-stage approvers) and fan out notifications to those approvers.
Stages 2..N stay
PENDINGuntil earlier stages complete — the chain is created up-front but walked lazily.
4.3 Approve — WorkflowEngine.approve({ taskId, approverId, remark }) (engine.ts:175)
- Find this approver's
workflow_approvalfor the task; must beAWAITING_APPROVAL/PENDINGelse throw. - Set it
APPROVED(+remark,statusUpdateDate). - Recompute stage completeness over the task's non-archived approvals:
approvedCount = approvals.filter(APPROVED).length isStageComplete = (approveBy === ANY) ? approvedCount >= 1 : approvedCount === approvals.length // ALL - If the stage is not yet complete (an
ALLstage still waiting on others): emitAPPROVEDevent to the submitter, notify them, and return (task staysAWAITING_APPROVAL). - If complete: set the task
APPROVED, emitAPPROVEDevent + notify submitter, then find the next task:nextTask = tasks(refId, archived:false) .filter(t !== current && t.status === PENDING) .sort(by orderNo asc)[0]- Next task exists → activate it (task + its approvals →
AWAITING_APPROVAL), emitSTAGE_ADVANCED, notify the next approvers. - No next task (last stage done) → call
taskFactory.getTaskKindService(kind).workflowCompleted(refId), emitCOMPLETED, notify the submitter.
- Next task exists → activate it (task + its approvals →
4.4 Reject — WorkflowEngine.reject({ taskId, approverId, remark, returnToStageId? }) (engine.ts:335)
A rejection never terminates the whole document by default — it returns to the previous stage ("return for rework") unless there is no prior stage.
- Find approver's approval; must be
AWAITING_APPROVAL/PENDING. Set itREJECTED(+ remark + date). - Mark the current task
REJECTEDandarchived: true; archive all its approvals (archived: true). - Determine the return target:
- Stored workflow + explicit
returnToStageId: find an existing non-archived task at thatstageId; if none, create a fresh task at that stage from its DB approvers (parentId= rejected task), all approvalsAWAITING_APPROVAL. If the stage has no approvers → terminal rejection. - Dynamic / default (no
returnToStageId): pick the highest-orderNonon-archived task below the current order. If none exists → terminal rejection.
- Stored workflow + explicit
- If a return target was found: re-open it → task
AWAITING_APPROVAL; reset its approvals toAWAITING_APPROVAL,archived:false, clearremark/statusUpdateDate(so the prior approver gets a clean re-decision). - If terminal: call
taskFactory.getTaskKindService(kind).workflowRejected?(refId). - Emit
REJECTEDevent (targets = submitter + the return-stage approvers) and notify them. The message says "returned to".
Key consequence: a stage-2 rejection bounces back to stage 1 for re-approval; only a stage-1 rejection (nothing prior) is terminal and flips the source document to its rejected status. There is no "hard reject" that skips straight to terminal from a middle stage (short of
returnToStageIdpointing at an approver-less stage).
4.5 Notification message rendering (workflow-notification.service.ts:23)
createForEvent(event, recipientIds) de-dupes recipients, renders one message per event.event, writes a workflow_notifications row per recipient, and publishes each to Redis topic WORKFLOW_NOTIFICATION_ADDED (drives the live subscription):
| event | message template |
|---|---|
TASK_CREATED |
<ref> submitted for your approval — <stageName> |
STAGE_ADVANCED |
<ref> reached <stageName> — awaiting your approval |
APPROVED |
<ref> was approved by <actorName> |
REJECTED |
<ref> was rejected: <remark> — returned to <stageName> |
COMPLETED |
<ref> has been fully approved |
4.6 The completion callback contract (task/task.factory.ts)
The engine never imports leave/loan/order. It calls back through a registry:
export interface IWorkflowTaskKindService {
workflowCompleted(refId: string): Promise<void>;
workflowRejected?(refId: string): Promise<void>; // optional
}
class WorkflowTaskFactory {
register(kind, service) // domain services call this in onModuleInit()
getTaskKindService(kind): service | null // engine resolves the owner per task.kind
}- HR kinds (
Leave/Claim/Loan/Advance): each service registers itself inonModuleInit()(e.g.leave.service.ts:34→taskFactory.register(WorkflowTaskKind.Leave, this)).workflowCompletedflips status toAPPROVED(loan/advance additionally post the disbursement GL — see claims/loans/advances §4.3–4.4);workflowRejectedflips toREJECTED. - Inventory kinds (
PurchaseRequisition/PurchaseOrder/SalesQuotation/SalesOrder): not registered dynamically — the factory has a hard-codedswitchreturningOrderServicefor those four kinds.OrderService.workflowCompleted(orderId)setsOrder.status = POSTED(order.service.ts:864). There is noworkflowRejectedfor orders (the optional method is absent → a terminal order rejection does nothing to the order's status; it just stays unposted).
5. Permissions
- Auth: every resolver is
@ApGqlAuthorize()(JWT). See auth. - Inbox self-scoping (the real access control):
WorkflowTaskRepository.buildQuery— for a non-privileged user (!isPrivileged) it forces$or: [{ "approvals.approverId": me }, { createdBy: me }, { submitterId: me }]. So an ordinary user only ever sees tasks they must approve or that they submitted. Privileged users see all. (task/task.repository.ts:68.) - Approval authority is dynamic, not RBAC: you can act on a task iff a
WorkflowApprovalrow names youruserIdon the active stage.engine.approve/rejectenforce this implicitly (they look up the approval by{ taskId, approverId }).WorkflowTaskService.canUpdateStatus(task)exposes "can the current user still act" to the UI (false if no approval for me, or mine is alreadyAPPROVED/REJECTED). - HR facade reassignment:
hr-approval.service.actionApprovallets a manager/HR act on a pending row even if it was created for a different user, by reassigningapproverIdfirst (§3.5). - No CASL module gate on the engine itself. Action-level RBAC for the consuming documents (leave/loan/order) is enforced by those modules + UI. See permissions-access.
6. How consumers plug in
6.1 HR approval policy — the level template (hr/approval-policy)
HR doesn't store workflows. Instead each company has one hr_approval_policies row per kind defining the roles that must approve, in order:
@HrSchema({ collection: "hr_approval_policies" }) // physical: hr_approval_policies
class HrApprovalPolicy extends BaseSchema {
kind: HrApprovalKind; // LEAVE | CLAIM | LOAN | ADVANCE
levels: HrApprovalLevel[]; // [{ role, order, approveBy }]
}
enum HrApprovalRole { MANAGER, HEAD_OF_DEPARTMENT, HR }- Unique index
{ companyId, kind }(one policy per kind per tenant). normalizeLevelsenforces: ≥1 level, positive-integer uniqueorder, uniqueroleper policy.- Seeded on company creation —
CompanyService.createcallsapprovalPolicySvc.seedDefaults(companyId)(company.service.ts:268), writing the defaults fromapproval-policy.defaults.ts:
| kind | default levels (order: role, approveBy) |
|---|---|
| LEAVE / CLAIM / LOAN | 1: MANAGER (ANY), 2: HEAD_OF_DEPARTMENT (ANY), 3: HR (ANY) |
| ADVANCE | 1: MANAGER (ANY), 2: HR (ANY) |
6.2 HR approver resolution — role → user (hr/approval-orchestrator)
HrApprovalOrchestratorService.validateAndResolveStages(employeeId, kind) turns a policy into concrete DynamicStage[] before anything is persisted (so a bad config fails the create cleanly):
- Map
WorkflowTaskKind → HrApprovalKind; load the policy for{ kind, companyId }(throws if absent / no levels). - Load the employee + (optionally) their department.
- Resolve each role to a User
_id:MANAGER→employee.reportingTo(an Employee_id) → looked up to that employee'suserId.HEAD_OF_DEPARTMENT→department.hodId(already a User_id— no lookup).HR→employee.hrId(an Employee_id) → looked up to itsuserId.
- Sort levels by
order, drop levels with no resolvable approver, buildDynamicStage { name: ROLE_LABEL, order, approveBy, approverIds: [userId] }. - If zero stages resolve → throw
"No approvers could be resolved for <kind>…".
resolveAndSubmit(input) = validateAndResolveStages(...) then workflowEngine.submit({ refId, kind, submitterId, ref, dynamicStages }). cancelWorkflow(refId) = workflowTaskSvc.archiveByRefId(refId) (archives stale AWAITING_APPROVAL tasks when the source doc is cancelled).
Why the lookup matters:
WorkflowApproval.approverIdis matched against the acting user'sUser._id.reportingTo/hrIdare Employee ids, so they must be re-resolved;department.hodIdis stored as a User id. If none resolve, the document can't be created (see leave §4.5, claims/loans/advances §6.2).
HR submission end-to-end (leave example; claim/loan/advance identical skeleton):
createLeave → validate → tx { leaveRepo.create(status=PENDING_APPROVAL)
orchestrator.resolveAndSubmit({ refId: leave._id, kind: Leave, ... }) }
→ validateAndResolveStages: policy(LEAVE) → [Manager→u1, HOD→u2, HR→u3] as DynamicStage[]
→ engine.submit(dynamicStages) → 3 tasks (stage1 AWAITING, 2&3 PENDING) + 3 approvals
... u1 approves → stage1 APPROVED → stage2 AWAITING (STAGE_ADVANCED) ...
... u3 approves final stage → engine calls LeaveService.workflowCompleted(leaveId) → status=APPROVED
reject anywhere except stage1 → returns to previous stage; reject at stage1 → workflowRejected → REJECTED
6.3 Inventory approval thresholds — auto-submit a stored workflow (inventory/approval-threshold)
Inventory uses the stored-workflow path. A per-company, per-kind approval_thresholds policy decides whether an order needs approval; if so it submits the company's default workflow for that kind.
@ApSchema({ collection: "approval_thresholds" })
class ApprovalThreshold extends BaseSchema {
kind: ApprovalThresholdKind; // SalesQuotation|SalesOrder|SalesInvoice|PurchaseRequisition|PurchaseOrder|PurchaseInvoice
workflowId: ObjectId; // workflow to run when a threshold is exceeded
amountThreshold: number; // require approval when totalAmount > this (null = ignore)
discountPercentageThreshold: number; // require approval when discount% > this (null = ignore)
enabled: boolean;
}ApprovalThresholdService.evaluate(order) (approval-threshold.service.ts:40):
- Feature-flag gated — returns
{ required: false }unless the company hasapprovalThresholdsEnabled(OFF by default → a no-op for every company today). - Loads the enabled policy for
order.kind; flagsrequirediftotalAmount > amountThresholdor (discountValueType === "PERCENTAGE"anddiscountValue > discountPercentageThreshold), returning the policy'sworkflowId.
Two integration points in OrderService:
- On create —
maybeRequireApproval(order): ifevaluate().requiredand no task yet forrefId,engine.submit({ workflowId, refId: order._id, kind: WorkflowTaskKind[order.kind], submitterId }). Wrapped in try/catch — approval automation must never block document creation (order.service.ts:196). - On post —
postInvoice: ifevaluate().required, load the order's tasks, computegetWorkflowStatus, and block posting with403 "This order requires approval before it can be posted"unless the status includesAPPROVED(order.service.ts:300).
Separately, maybeAutoSubmitDefaultWorkflow / runWorkflow submit a company default workflow (from company.defaultWorkflows[] matched by transactionType) for the order kind even without a threshold — same engine.submit(workflowId, …) call. On completion OrderService.workflowCompleted sets the order POSTED.
Inventory submits with a
workflowId(stored stages +workflow_approvers); HR submits withdynamicStages(resolved per-employee). The engine handles both via the onesubmitsignature.
7. Admin UI
Routes (zerp-admin/src/pages):
| Area | Route | Module |
|---|---|---|
| Workflow dashboard | /workflow |
modules/workflow (WorkflowDashboard) |
| Workflow builder (definition) | /workflow/setup, /workflow/[_id] (+ /stage, /approval, /task) |
modules/workflow + stage/, approver/, approval/ |
| My tasks (approver inbox) | /workflow/my-tasks, task detail /workflow/task/[_id] |
modules/workflow/task |
| My submissions | /workflow/submitted |
modules/workflow/submitted |
| HR approvals inbox | /hr/approval (+ ESS /ess/approvals) |
modules/hr/approval |
| HR approval policy editor | /hr/approval-policy |
modules/hr/approval-policy |
Each module follows the zync-nextjs standard: context.tsx is the sole use<Feature>Query() consumer; components use use<Feature>State() only.
- Workflow builder —
WorkflowDashboard/WorkflowCard/WorkflowDrawerlist definitions;StageProgressvisualises a run's stage chain. Stages are reordered by drag-and-drop (stage/SortableStageList.tsx,@dnd-kit) — theorderfield is rewritten on drop. Each stage row shows anapproveBytag ("Any one" / "All must approve").stage/components/create.tsxpicks the approver users;WorkflowHealthZone/WorkflowToastsurface run health. - Task inbox (
task/context.tsx,task/page.tsx):fetchWorkflowTaskPage,findWorkflowTask,updateWorkflowApprovalStatus.taskAction.tsxdrives approve/reject: approve = Formik form requiring aremark→updateWorkflowApprovalStatus({ taskId, status: APPROVED, remark }); reject =rejection-modal.tsxwhich lets the approver pick a prior stage to return to (priorStages= stages withorder < current, default = nearest prior) →{ status: REJECTED, returnToStageId }.viewKind.tsxrenders the source document inline bykind. After acting it re-fetches the task and toasts. - Submitted view (
submitted/):history-timeline.tsxrenders theworkflow_eventsfor arefIdas a vertical timeline. - Notifications (
notification/):bell.tsx(unread badge + dropdown) andalert-bar.tsx, fed byfindMyNotificationsand the liveworkflowNotificationAddedsubscription;markNotificationRead/markAllNotificationsReadflipread. - HR approvals (
hr/approval): a flat inbox overapprovalPage/myPendingApprovals;actionApprovalapproves/rejects (reassigning the row to the acting user). The detail shows requester, kind, reason, remark. - HR policy editor (
hr/approval-policy): per-kind level editor (role + order +approveBy), enforcing thenormalizeLevelsrules; this is the only HR "workflow design" surface (no stage/approver tables).
8. Dependencies & integrations
Engine calls into:
UserService—getActorNamefor event/notification copy, and approver name resolution.WorkflowTaskFactory→ the registeredIWorkflowTaskKindService(leave/claim/loan/advance) or the hard-wiredOrderServicefor inventory kinds — the only outward coupling to business modules, via the callback contract.RedisService— pub/sub for the live notification subscription (WORKFLOW_NOTIFICATION_ADDED).
Called by:
- HR — leave, claims/loans/advances via
HrApprovalOrchestratorService→engine.submit(dynamicStages); the HR approval policy supplies the levels; employee/department supplyreportingTo/hrId/hodId. - Inventory —
OrderServiceviaApprovalThresholdService.evaluate→engine.submit(workflowId); completion sets the orderPOSTED(seeinventory stock flow). - HR dashboard —
WorkflowTaskService.countPendingByKind(companyId)(countsAWAITING_APPROVALtasks per kind) feeds the hr-dashboard "pending approvals" metrics.
Events / jobs: the engine emits workflow_events and Redis notifications; no cron, no external service beyond Redis. All transitions are synchronous within the triggering request.
Audit: every mutation carries @AuditMeta (module workflow/workflow-task/workflow-approval/workflow-notification/hr-approval/hr-approval-policy). See audit-trail.
9. Gotchas & project-specific rules
WorkflowTaskStatus.ARCHIVEDserialises to"ARCHIEVED"(typo). Filters/queries must use the misspelled literal (task.schema.ts:24).- Two submission modes, one engine.
submit({ workflowId })= stored stages +workflow_approvers(inventory).submit({ dynamicStages })= inline per-employee approvers, noworkflowId/stageId(HR). The reject path branches onisDynamic = !task.stageId. - Rejection is "return for rework", not "kill". A reject bounces to the previous stage and resets that stage's approvals for a fresh decision. Only a stage-1 (no prior) rejection — or
returnToStageIdpointing at an approver-less stage — is terminal and triggersworkflowRejected(refId). ALLis the only "parallel".WorkflowStageStageTypes(SEQUENTIAL/PARALLEL) is dead (commented out). Stages always run sequentially byorder; concurrency only exists within a stage viaapproveBy: ALL.- No engine-level transaction.
submit/approve/rejectare sequences of separate writes (manyPromise.alls), not wrapped inwithRetryTransaction. A mid-sequence failure can leave a partially-advanced run. The callbacks (e.g.LeaveService.workflowCompleted) run their own transactions. - Inventory has no
workflowRejected.OrderServiceimplements onlyworkflowCompleted(→POSTED). A terminal rejection of an order does nothing to the order's status — it simply never gets posted. - Approver reassignment in the HR facade.
actionApprovalrewrites a pendingWorkflowApproval.approverIdto the acting user before approving — convenient for "any HR can clear the queue", but it means the recorded approver may differ from the policy-resolved one. - Dead enum members.
WorkflowApprovalStatus.COMPLETEDandWorkflowEventType.RESUBMITTEDare declared/registered but never set/emitted. approverIdson the stage schema is transient. It has no@Prop; real approvers areworkflow_approversrows. Readingstage.approverIdsoff a fetched stage will be empty.- HR policies are seeded at company creation, thresholds are flag-gated off. New tenants get default LEAVE/CLAIM/LOAN/ADVANCE policies automatically; inventory approval thresholds do nothing until
company.approvalThresholdsEnabledis turned on. Two very different defaults for the same engine. - Self-scoping is the access control. There's no CASL gate on tasks — non-privileged users are silently restricted by
buildQueryto tasks where they're approver/creator/submitter. Don't assumeworkflowTaskPagereturns everything. newWorkflowTaskstarts a whole run, not one task. The misleading mutation name maps toengine.submit(stored-workflow). It is the admin/inventory manual-submit entry point.