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 one WorkflowTask holding one-or-more WorkflowApproval rows (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's workflowCompleted(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, an approveBy rule (ANY/ALL), and a WorkflowTaskFactory that maps a kind → 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) before submit() is called, and handed in as dynamicStages.

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 an approveBy rule and a fixed set of approver users.
  • Submission (engine.submit): turning either a stored workflow or an inline dynamicStages[] array into a chain of workflow_tasks (one per stage) and workflow_approvals (one per approver per stage), then activating the first stage.
  • Stage progression (engine.approve / engine.reject): advancing through the task chain, evaluating ANY/ALL per 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, POSTED status, etc.).
  • Audit trail of the run (workflow_events) and per-user inbox notifications (workflow_notifications, with a live GraphQL subscription).
  • The approver inbox (workflowTaskPage / findMySubmissions and the HR approvalPage / myPendingApprovals).

It explicitly does not:

  • Resolve who the approvers are. The engine receives concrete approverIds (User _ids). For HR documents the HR approval orchestrator resolves MANAGER/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.status etc. 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 WorkflowApproval row names you), not a static permission. See permissions-access.
  • Run inside one transaction. submit/approve/reject are sequences of individual writes — there is no withRetryTransaction wrapper 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/approvers are templates you build once. tasks/approvals are created fresh per submission and carry the live state. A dynamic submission (HR) writes tasks/approvals with no workflowId/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|PARALLEL prop is commented out in the schema — there is no parallel-stage mode. "Parallel" only exists within a stage via approveBy: ALL (all approvers of one stage act together). Stages themselves always run one-at-a-time by order.

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 approverIds inline in dynamicStages.

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.ARCHIVED has 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 workflowengine.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

actionApproval has a notable behaviour: if a pending approval exists on the task but is assigned to a different user, it reassigns approverId to 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 (or PENDING) approvals may be acted on (engine.approve/reject reject 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? })
  1. Resolve stages, either:
    • Dynamic (dynamicStages provided, HR path): sort by order. Each entry already carries { name, order, approveBy, approverIds }.
    • Stored (workflowId provided, inventory path): load the workflow (404 → "Workflow not found"), load its workflow_stages (empty → "Workflow has no stages"), sort by order, and for each stage load workflow_approvers (empty → "Stage \"<name>\" has no approvers"), mapping to { name, order, approveBy, approverIds }.
  2. Create one task per stage (status: PENDING, archived: false, denormalising stageName/approveBy/orderNo; workflowId null for dynamic). For each task, create one workflow_approval per approverId (status: PENDING).
  3. Activate the first stage: set task[0] → AWAITING_APPROVAL, and all its approvals → AWAITING_APPROVAL.
  4. Emit TASK_CREATED event (actor = submitter, targets = first-stage approvers) and fan out notifications to those approvers.

Stages 2..N stay PENDING until earlier stages complete — the chain is created up-front but walked lazily.

4.3 Approve — WorkflowEngine.approve({ taskId, approverId, remark }) (engine.ts:175)

  1. Find this approver's workflow_approval for the task; must be AWAITING_APPROVAL/PENDING else throw.
  2. Set it APPROVED (+ remark, statusUpdateDate).
  3. Recompute stage completeness over the task's non-archived approvals:
    approvedCount = approvals.filter(APPROVED).length
    isStageComplete = (approveBy === ANY)  ? approvedCount >= 1
                                           : approvedCount === approvals.length   // ALL
  4. If the stage is not yet complete (an ALL stage still waiting on others): emit APPROVED event to the submitter, notify them, and return (task stays AWAITING_APPROVAL).
  5. If complete: set the task APPROVED, emit APPROVED event + 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), emit STAGE_ADVANCED, notify the next approvers.
    • No next task (last stage done) → call taskFactory.getTaskKindService(kind).workflowCompleted(refId), emit COMPLETED, notify the submitter.

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.

  1. Find approver's approval; must be AWAITING_APPROVAL/PENDING. Set it REJECTED (+ remark + date).
  2. Mark the current task REJECTED and archived: true; archive all its approvals (archived: true).
  3. Determine the return target:
    • Stored workflow + explicit returnToStageId: find an existing non-archived task at that stageId; if none, create a fresh task at that stage from its DB approvers (parentId = rejected task), all approvals AWAITING_APPROVAL. If the stage has no approvers → terminal rejection.
    • Dynamic / default (no returnToStageId): pick the highest-orderNo non-archived task below the current order. If none exists → terminal rejection.
  4. If a return target was found: re-open it → task AWAITING_APPROVAL; reset its approvals to AWAITING_APPROVAL, archived:false, clear remark/statusUpdateDate (so the prior approver gets a clean re-decision).
  5. If terminal: call taskFactory.getTaskKindService(kind).workflowRejected?(refId).
  6. Emit REJECTED event (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 returnToStageId pointing 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 in onModuleInit() (e.g. leave.service.ts:34taskFactory.register(WorkflowTaskKind.Leave, this)). workflowCompleted flips status to APPROVED (loan/advance additionally post the disbursement GL — see claims/loans/advances §4.3–4.4); workflowRejected flips to REJECTED.
  • Inventory kinds (PurchaseRequisition/PurchaseOrder/SalesQuotation/SalesOrder): not registered dynamically — the factory has a hard-coded switch returning OrderService for those four kinds. OrderService.workflowCompleted(orderId) sets Order.status = POSTED (order.service.ts:864). There is no workflowRejected for 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 WorkflowApproval row names your userId on the active stage. engine.approve/reject enforce 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 already APPROVED/REJECTED).
  • HR facade reassignment: hr-approval.service.actionApproval lets a manager/HR act on a pending row even if it was created for a different user, by reassigning approverId first (§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).
  • normalizeLevels enforces: ≥1 level, positive-integer unique order, unique role per policy.
  • Seeded on company creationCompanyService.create calls approvalPolicySvc.seedDefaults(companyId) (company.service.ts:268), writing the defaults from approval-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):

  1. Map WorkflowTaskKind → HrApprovalKind; load the policy for { kind, companyId } (throws if absent / no levels).
  2. Load the employee + (optionally) their department.
  3. Resolve each role to a User _id:
    • MANAGERemployee.reportingTo (an Employee _id) → looked up to that employee's userId.
    • HEAD_OF_DEPARTMENTdepartment.hodId (already a User _id — no lookup).
    • HRemployee.hrId (an Employee _id) → looked up to its userId.
  4. Sort levels by order, drop levels with no resolvable approver, build DynamicStage { name: ROLE_LABEL, order, approveBy, approverIds: [userId] }.
  5. 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.approverId is matched against the acting user's User._id. reportingTo/hrId are Employee ids, so they must be re-resolved; department.hodId is 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 has approvalThresholdsEnabled (OFF by default → a no-op for every company today).
  • Loads the enabled policy for order.kind; flags required if totalAmount > amountThreshold or (discountValueType === "PERCENTAGE" and discountValue > discountPercentageThreshold), returning the policy's workflowId.

Two integration points in OrderService:

  1. On createmaybeRequireApproval(order): if evaluate().required and no task yet for refId, 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).
  2. On postpostInvoice: if evaluate().required, load the order's tasks, compute getWorkflowStatus, and block posting with 403 "This order requires approval before it can be posted" unless the status includes APPROVED (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 with dynamicStages (resolved per-employee). The engine handles both via the one submit signature.


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 builderWorkflowDashboard/WorkflowCard/WorkflowDrawer list definitions; StageProgress visualises a run's stage chain. Stages are reordered by drag-and-drop (stage/SortableStageList.tsx, @dnd-kit) — the order field is rewritten on drop. Each stage row shows an approveBy tag ("Any one" / "All must approve"). stage/components/create.tsx picks the approver users; WorkflowHealthZone/WorkflowToast surface run health.
  • Task inbox (task/context.tsx, task/page.tsx): fetchWorkflowTaskPage, findWorkflowTask, updateWorkflowApprovalStatus. taskAction.tsx drives approve/reject: approve = Formik form requiring a remarkupdateWorkflowApprovalStatus({ taskId, status: APPROVED, remark }); reject = rejection-modal.tsx which lets the approver pick a prior stage to return to (priorStages = stages with order < current, default = nearest prior) → { status: REJECTED, returnToStageId }. viewKind.tsx renders the source document inline by kind. After acting it re-fetches the task and toasts.
  • Submitted view (submitted/): history-timeline.tsx renders the workflow_events for a refId as a vertical timeline.
  • Notifications (notification/): bell.tsx (unread badge + dropdown) and alert-bar.tsx, fed by findMyNotifications and the live workflowNotificationAdded subscription; markNotificationRead / markAllNotificationsRead flip read.
  • HR approvals (hr/approval): a flat inbox over approvalPage / myPendingApprovals; actionApproval approves/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 the normalizeLevels rules; this is the only HR "workflow design" surface (no stage/approver tables).

8. Dependencies & integrations

Engine calls into:

  • UserServicegetActorName for event/notification copy, and approver name resolution.
  • WorkflowTaskFactory → the registered IWorkflowTaskKindService (leave/claim/loan/advance) or the hard-wired OrderService for 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:

  • HRleave, claims/loans/advances via HrApprovalOrchestratorServiceengine.submit(dynamicStages); the HR approval policy supplies the levels; employee/department supply reportingTo/hrId/hodId.
  • InventoryOrderService via ApprovalThresholdService.evaluateengine.submit(workflowId); completion sets the order POSTED (see inventory stock flow).
  • HR dashboardWorkflowTaskService.countPendingByKind(companyId) (counts AWAITING_APPROVAL tasks 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

  1. WorkflowTaskStatus.ARCHIVED serialises to "ARCHIEVED" (typo). Filters/queries must use the misspelled literal (task.schema.ts:24).
  2. Two submission modes, one engine. submit({ workflowId }) = stored stages + workflow_approvers (inventory). submit({ dynamicStages }) = inline per-employee approvers, no workflowId/stageId (HR). The reject path branches on isDynamic = !task.stageId.
  3. 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 returnToStageId pointing at an approver-less stage — is terminal and triggers workflowRejected(refId).
  4. ALL is the only "parallel". WorkflowStageStageTypes (SEQUENTIAL/PARALLEL) is dead (commented out). Stages always run sequentially by order; concurrency only exists within a stage via approveBy: ALL.
  5. No engine-level transaction. submit/approve/reject are sequences of separate writes (many Promise.alls), not wrapped in withRetryTransaction. A mid-sequence failure can leave a partially-advanced run. The callbacks (e.g. LeaveService.workflowCompleted) run their own transactions.
  6. Inventory has no workflowRejected. OrderService implements only workflowCompleted (→ POSTED). A terminal rejection of an order does nothing to the order's status — it simply never gets posted.
  7. Approver reassignment in the HR facade. actionApproval rewrites a pending WorkflowApproval.approverId to 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.
  8. Dead enum members. WorkflowApprovalStatus.COMPLETED and WorkflowEventType.RESUBMITTED are declared/registered but never set/emitted.
  9. approverIds on the stage schema is transient. It has no @Prop; real approvers are workflow_approvers rows. Reading stage.approverIds off a fetched stage will be empty.
  10. 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.approvalThresholdsEnabled is turned on. Two very different defaults for the same engine.
  11. Self-scoping is the access control. There's no CASL gate on tasks — non-privileged users are silently restricted by buildQuery to tasks where they're approver/creator/submitter. Don't assume workflowTaskPage returns everything.
  12. newWorkflowTask starts a whole run, not one task. The misleading mutation name maps to engine.submit (stored-workflow). It is the admin/inventory manual-submit entry point.