Skip to main content

evaluateStepStatus

evaluateStepStatus is the pure function that decides whether an approval step is approved, rejected, or still pending, given all the units' votes and the step's AND/OR configuration. It's not an act โ€” it's a shared utility in utils/stepEvaluator.ts used by submitDecision after every vote. If you understand this one function, you understand the whole decision engine of the app.

note

Package import The tutorial source imports the framework as "lesan" โ€” in this repo that alias maps to the local framework source (deno.json โ†’ ../../src/mod.ts). In your own app import from @hemedani/lesan (npm/Bun) or jsr:@hemedani/lesan (Deno). @lib is the tutorial's alias for utils/.

The functionโ€‹

type StepApprovalInfo = {
unitId: string;
status: "pending" | "approved" | "rejected";
};

type AssigneeGroupInfo = {
operator: "AND" | "OR";
unitIds: string[];
};

export function evaluateStepStatus(
approvals: StepApprovalInfo[],
groupsOperator: "AND" | "OR",
assigneeGroups: AssigneeGroupInfo[],
): "pending" | "approved" | "rejected" {
const groupResults: ("pending" | "approved" | "rejected")[] = [];

for (const group of assigneeGroups) {
let hasApproved = false;
let hasRejected = false;
let hasPending = false;

for (const unitId of group.unitIds) {
const approval = approvals.find((a) => a.unitId === unitId);
if (!approval) {
hasPending = true;
} else if (approval.status === "approved") {
hasApproved = true;
} else if (approval.status === "rejected") {
hasRejected = true;
}
}

if (group.operator === "AND") {
if (hasRejected) {
groupResults.push("rejected");
} else if (hasPending) {
groupResults.push("pending");
} else if (hasApproved) {
groupResults.push("approved");
} else {
groupResults.push("pending");
}
} else {
if (hasApproved) {
groupResults.push("approved");
} else if (hasPending) {
groupResults.push("pending");
} else {
groupResults.push("rejected");
}
}
}

if (groupsOperator === "AND") {
if (groupResults.some((r) => r === "rejected")) return "rejected";
if (groupResults.every((r) => r === "approved")) return "approved";
return "pending";
} else {
if (groupResults.some((r) => r === "approved")) return "approved";
if (groupResults.every((r) => r === "rejected")) return "rejected";
return "pending";
}
}

The two-level logicโ€‹

There are two layers of AND/OR, and each is evaluated separately:

  1. Inside a group (group.operator) โ€” how the units of one assigneeGroup combine.
  2. Across groups (groupsOperator) โ€” how the group results combine.

Layer 1 โ€” inside one groupโ€‹

For each group, it scans the group's unitIds, finds each unit's approval (a missing approval counts as pending), and flips three booleans. Then:

  • operator === "AND" (all must approve):

    • any rejected โ†’ group is rejected (one no kills it)
    • else any pending โ†’ group is pending
    • else all approved โ†’ group is approved
    • empty edge case โ†’ pending
  • operator === "OR" (any may approve):

    • any approved โ†’ group is approved
    • else any pending โ†’ group is pending
    • else (all voted, all rejected) โ†’ group is rejected

Layer 2 โ€” across the groupsโ€‹

  • groupsOperator === "AND":

    • any group rejected โ†’ step rejected
    • every group approved โ†’ step approved
    • otherwise โ†’ pending
  • groupsOperator === "OR":

    • any group approved โ†’ step approved
    • every group rejected โ†’ step rejected
    • otherwise โ†’ pending

Worked exampleโ€‹

Take the two-step process from the seed test. Step 1, Purchasing Approval, is configured:

groupsOperator: "OR"
assigneeGroups: [ { operator: "OR", unitIds: ["unitPurchasing", "unitWarehouse"] } ]

And step 2, Finance Review:

groupsOperator: "OR"
assigneeGroups: [ { operator: "OR", unitIds: ["unitFinance"] } ]

Case A โ€” step 1, Purchasing unit approves (OR group)โ€‹

approvals = [{ unitId: "unitPurchasing", status: "approved" }] (Warehouse hasn't voted โ†’ treated as pending).

Group evaluation (operator: "OR"): hasApproved = true โ†’ group result approved. Across groups (groupsOperator: "OR"): some group approved โ†’ step status approved.

โ†’ submitDecision advances the PO to step 2. One vote was enough because the group is OR.

Case B โ€” step 1, Warehouse votes rejected first (OR group)โ€‹

approvals = [{ unitId: "unitWarehouse", status: "rejected" }] (Purchasing pending).

Group evaluation (operator: "OR"): hasRejected = true, no hasApproved, but hasPending = true โ†’ group pending. Across groups: not approved, not all rejected โ†’ step pending.

โ†’ The PO stays InProgress โ€” the rejected vote is recorded but Purchasing can still approve and flip the step.

Case C โ€” step 1, both units rejected (OR group)โ€‹

approvals = both rejected.

Group evaluation: no approved, no pending, so โ†’ group rejected. Across groups (OR): every group rejected โ†’ step rejected.

โ†’ The PO becomes Rejected. Every assigned unit had to reject for an OR group to fail.

Case D โ€” an AND stepโ€‹

Suppose step 2 were groupsOperator: "AND" with one group { operator: "AND", unitIds: ["unitFinance", "unitHead"] }. Finance approves, UnitHead hasn't voted:

Group evaluation (operator: "AND"): hasApproved = true, hasPending = true โ†’ group pending. Step โ†’ pending until UnitHead also approves. If UnitHead rejects, the group becomes rejected and the step fails even though Finance approved โ€” the AND semantics make every assigned unit's approval mandatory.

In the workflowโ€‹

evaluateStepStatus is invoked from submitDecision (step 7 in its walkthrough) with the live approvals for the PO+step. Its return value decides the branch: rejected โ†’ PO Rejected, approved โ†’ advance to next step or PO Approved, pending โ†’ keep waiting.

The inputs come from the processStep model: groupsOperator and assigneeGroups are stored on the step, and unitIds are strings (array(string())), which is why the approvals' unitId are stringified before comparison.

Links: overview, stepApproval model, processStep model, submit-decision, add-process-step.