submitDecision (stepApproval)
submitDecision is where a unit head votes on a pending approval task. Given one approvalId, a decision (approved or rejected) and an optional comment, it:
- records the vote on the
stepApproval, - re-evaluates the whole step with evaluateStepStatus,
- and then automates the consequences โ advance to the next step (creating its pending approvals), approve the whole PO, or reject it.
This single act implements the entire AND/OR decision engine of the app, so it's the deepest one in the chapter. Only Manager/Admin, or UnitHead with the canApprovePurchaseOrder feature, can vote.
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 and @model are the tutorial's aliases for utils/ and models/.
The validator (submitDecision.val.ts)โ
approvalId (the stepApproval document), decision locked to enums(["approved", "rejected"]), optional comment. The get returns the purchase order, not the approval โ because that's the document this act mutates.
import { enums, object, objectIdValidation, optional, string } from "lesan";
import { selectStruct } from "../../../mod.ts";
import { activeRoleMixin } from "@lib";
export const submitDecisionValidator = () => {
return object({
set: object({
...activeRoleMixin,
approvalId: objectIdValidation,
decision: enums(["approved", "rejected"]),
comment: optional(string()),
}),
get: selectStruct("purchaseOrder", 1),
});
};
The implementation (submitDecision.fn.ts)โ
The fn performs these steps in order:
- Loads the approval with a rich projection โ its own
status, plus the embeddedpurchaseOrder.status/currentStep, theprocessStep'sgroupsOperator/assigneeGroups, and theunit._id. Missing โapproval not found. - Guards the vote โ the approval must still be
status: "pending", elsethis approval has already been decided; and the PO must bestatus: "InProgress", elsethe purchase order is not in progress. - Records the decision โ
stepApproval.findOneAndUpdatesetsstatus: decision,decidedAt: new Date(), andcommentwhen provided. (Note:decisionis stored directly intostatusโ the validator already locked it toapproved|rejected.) - Links the decider โ when the decision is
approved, it adds adecidedByrelation to the currentuser(with back-referencestepDecisions: true,replace: true). - Reloads the step โ
processStep.findOnefororder,groupsOperator,assigneeGroups. Missing โprocess step not found. - Gathers every approval for this PO+step via aggregation, projecting each one's
unitId(as a string) andstatus. - Evaluates the step with
evaluateStepStatus(approvals, step.groupsOperator, step.assigneeGroups)โ"approved" | "rejected" | "pending"(see the step-evaluator page for the truth table). - Reloads the PO to read its
process._id(needed to find the next step). - Defines a
pushHistory(action, details?)helper that appends an attributed history entry.
Then it branches on the step status:
rejectedโ PO becomesRejectedwithcompletedAt, historyrejected(+stepOrder,decision). Workflow ends.approvedโ find the next step of the process withorder: { $gt: currentStep.order }sorted ascending, limit 1:- no next step โ PO becomes
ApprovedwithcompletedAt, historyapproved. Workflow done โ finalize becomes available. - next step exists โ create one
pendingstepApprovalper unique unit in the next step'sassigneeGroups(exactly likesubmitdoes for step 1), then setcurrentStepto the next step's order and push historyapproved.
- no next step โ PO becomes
pendingโ nothing structural changes; just push historydecisionwithstepOrder+decision(arejectedvote that didn't flip the AND/OR group still gets recorded, but the PO keeps waiting).
import { type ActFn, ObjectId } from "lesan";
import {
coreApp,
processStep,
purchaseOrder,
stepApproval,
} from "../../../mod.ts";
import { evaluateStepStatus, throwError } from "@lib";
import type { MyContext } from "@lib";
export const submitDecisionFn: ActFn = async (body) => {
const {
set: { approvalId, decision, comment },
get,
} = body.details;
const { user }: MyContext = coreApp.contextFns.getContextModel() as MyContext;
const approvalIdObj = new ObjectId(approvalId as string);
const approval = await stepApproval.findOne({
filters: { _id: approvalIdObj },
projection: {
_id: 1,
status: 1,
"purchaseOrder._id": 1,
"purchaseOrder.status": 1,
"purchaseOrder.currentStep": 1,
"processStep._id": 1,
"processStep.groupsOperator": 1,
"processStep.assigneeGroups": 1,
"unit._id": 1,
},
});
!approval && throwError("approval not found");
if (approval!.status !== "pending") {
throwError("this approval has already been decided");
}
const poStatus = (approval as any)?.purchaseOrder?.status;
if (poStatus !== "InProgress") {
throwError("the purchase order is not in progress");
}
const poId = new ObjectId((approval as any).purchaseOrder._id as string);
const stepId = new ObjectId((approval as any).processStep._id as string);
await stepApproval.findOneAndUpdate({
filter: { _id: approvalIdObj },
update: {
$set: {
status: decision,
...(comment && { comment }),
decidedAt: new Date(),
},
},
projection: { _id: 1 },
});
if (decision === "approved") {
await stepApproval.addRelation({
filters: { _id: approvalIdObj },
relations: {
decidedBy: {
_ids: user._id,
relatedRelations: { stepDecisions: true },
},
},
projection: { _id: 1 },
replace: true,
});
}
const stepDoc = await processStep.findOne({
filters: { _id: stepId },
projection: {
_id: 1,
order: 1,
groupsOperator: 1,
assigneeGroups: 1,
},
});
!stepDoc && throwError("process step not found");
const approvals = await stepApproval
.aggregation({
pipeline: [
{
$match: {
"purchaseOrder._id": poId,
"processStep._id": stepId,
},
},
{ $project: { unitId: { $toString: "$unit._id" }, status: 1 } },
],
})
.toArray();
const stepStatus = evaluateStepStatus(
approvals.map((a) => ({
unitId: (a as any).unitId as string,
status: (a as any).status as "pending" | "approved" | "rejected",
})),
(stepDoc as any).groupsOperator as "AND" | "OR",
((stepDoc as any).assigneeGroups || []) as {
operator: "AND" | "OR";
unitIds: string[];
}[],
);
const po = await purchaseOrder.findOne({
filters: { _id: poId },
projection: { _id: 1, status: 1, currentStep: 1, "process._id": 1 },
});
const performerName = `${(user as any).first_name ?? ""} ${(user as any).last_name ?? ""}`.trim();
const pushHistory = (action: string, details?: Record<string, unknown>) => ({
$push: {
history: {
action,
performed: {
by: user._id.toString(),
name: performerName,
at: new Date(),
role: { id: "", name: "" },
},
...(details && { details }),
},
},
});
if (stepStatus === "rejected") {
return await purchaseOrder.findOneAndUpdate({
filter: { _id: poId },
update: {
$set: { status: "Rejected", completedAt: new Date() },
...pushHistory("rejected", { stepOrder: (stepDoc as any).order, decision }),
},
projection: get,
});
}
if (stepStatus === "approved") {
const nextSteps = await processStep
.aggregation({
pipeline: [
{ $match: { "process._id": (po as any)?.process?._id, order: { $gt: (stepDoc as any).order } } },
{ $sort: { order: 1 } },
{ $limit: 1 },
],
projection: { _id: 1, order: 1, groupsOperator: 1, assigneeGroups: 1 },
})
.toArray();
const nextStep = nextSteps[0];
if (!nextStep) {
return await purchaseOrder.findOneAndUpdate({
filter: { _id: poId },
update: {
$set: { status: "Approved", completedAt: new Date() },
...pushHistory("approved", { stepOrder: (stepDoc as any).order, decision }),
},
projection: get,
});
}
const nextUnits = (nextStep as any).assigneeGroups.flatMap(
(g: { unitIds: string[] }) => g.unitIds,
);
const uniqueUnits = [...new Set(nextUnits.map((u: unknown) => String(u)))];
for (const unitId of uniqueUnits) {
await stepApproval.insertOne({
doc: { status: "pending" },
relations: {
purchaseOrder: {
_ids: poId,
relatedRelations: { stepApprovals: true },
},
processStep: {
_ids: new ObjectId(nextStep._id as string),
relatedRelations: { approvals: true },
},
unit: {
_ids: new ObjectId(String(unitId)),
relatedRelations: { stepApprovals: true },
},
},
projection: { _id: 1 },
});
}
return await purchaseOrder.findOneAndUpdate({
filter: { _id: poId },
update: {
$set: { currentStep: (nextStep as any).order },
...pushHistory("approved", { stepOrder: (stepDoc as any).order, decision }),
},
projection: get,
});
}
return await purchaseOrder.findOneAndUpdate({
filter: { _id: poId },
update: {
...pushHistory("decision", { stepOrder: (stepDoc as any).order, decision }),
},
projection: get,
});
};
Why the whole step is re-evaluatedโ
Each stepApproval only records one unit's vote. Whether the step is satisfied depends on all the votes together โ and that's evaluateStepStatus's job. After every vote it recomputes the step outcome from the current state of all approvals for that PO+step, which means:
- a lone
rejectedvote on an OR group is not fatal (another unit can still approve), - but a
rejectedvote on an AND group immediately rejects the step, - and the step only "advances" once
evaluateStepStatusreturnsapproved.
That's also why the pending fallthrough exists: the vote was recorded but the step isn't settled, so the PO keeps InProgress and stays in getPendingByUnit.
Why unitId is stringifiedโ
The aggregation projects unitId: { $toString: "$unit._id" } and the next-step units are deduped with String(u). evaluateStepStatus compares approval unitId strings against group.unitIds โ which the processStep validator stores as array(string()). Matching strings (not ObjectIds) keeps the comparison consistent between the two sides.
In the workflowโ
submitDecision is the pump of the workflow. Every unit's vote runs it; the branch it takes moves the PO forward:
PO submitted โโถ step 1 approvals created
โ
submitDecision (per unit) โโถ evaluateStepStatus
โ
โโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโ
rejected pending approved
โ โ โ
Rejected (end) waits for next step? โโyesโโโถ create its approvals, currentStep++
(PO terminal) more votes โ
no
Approved โโถ finalize โโถ Completed
The seed test runs exactly this: approve step 1 โ PO currentStep becomes 2 and a second approval appears; approve step 2 โ PO becomes Approved.
Links: overview, stepApproval model, purchaseOrder model, processStep model, step-evaluator, po-submit, get-step-approvals, get-pending-by-unit.
Run itโ
First find a pending approval (from getPendingByUnit or getStepApprovals):
curl -X POST http://localhost:1380/lesan \
-H "Content-Type: application/json" \
-H "token: $TOKEN" \
-d '{
"model": "stepApproval",
"act": "submitDecision",
"details": {
"set": {
"activeRoleId": "ghost-role",
"approvalId": "<approvalId>",
"decision": "approved",
"comment": "Approved. Stock is sufficient."
},
"get": {
"_id": 1,
"status": 1,
"currentStep": 1
}
}
}'
Approve every step to reach Approved; or pass decision: "rejected" to see the PO flip to Rejected.
Errors & fixesโ
| Error | Meaning | Fix |
|---|---|---|
approval not found | No stepApproval with that id | Verify the id via getStepApprovals |
this approval has already been decided | The approval status is already approved/rejected | Each approval accepts exactly one vote โ find the other pending approval |
the purchase order is not in progress | The PO's status isn't InProgress (e.g. it was cancelled) | A cancelled/approved/rejected PO no longer accepts votes |
process step not found | The approval's processStep was deleted | Recreate the step, or check for a broken relation |
You cant do this | Active role isn't Manager/Admin/UnitHead (with feature) | Use a role that can vote |
Missing feature: canApprovePurchaseOrder | A UnitHead without the feature tried to vote | Grant the feature or use a Manager/Admin role |
Shared auth-chain errors apply too.