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.
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:
- Inside a group (
group.operator) โ how the units of oneassigneeGroupcombine. - 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 isrejected(one no kills it) - else any
pendingโ group ispending - else all
approvedโ group isapproved - empty edge case โ
pending
- any
-
operator === "OR"(any may approve):- any
approvedโ group isapproved - else any
pendingโ group ispending - else (all voted, all
rejected) โ group isrejected
- any
Layer 2 โ across the groupsโ
-
groupsOperator === "AND":- any group
rejectedโ steprejected - every group
approvedโ stepapproved - otherwise โ
pending
- any group
-
groupsOperator === "OR":- any group
approvedโ stepapproved - every group
rejectedโ steprejected - otherwise โ
pending
- any group
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.