src/features/reviews/handlers/workflow.ts
Linked repository file from this build.
import { captureReceivedSnapshot } from "../received-snapshot";
import { can } from "@/lib/auth/scope";
import { RuleError } from "@/lib/errors";
import {
requiredInputComplete as requiredInputCurrent,
finalCurrent,
} from "@/programs/snap-negative/validation";
import { resolveReviewPolicy } from "@/programs/snap-negative/policy";
import { transition } from "../workflow";
import { autoGenerateMemo, assertSummaryRelease, memoEligibility } from "../downstream";
import { canSubmitPolicyDecision, openFindingsBlockApproval } from "../permissions";
import { workflowLabels, type Actor } from "../types";
import type { SnapReview } from "@/programs/snap-negative/types";
import {
clearApprovals,
roleTag,
storeEditCheck,
type CommandContext,
type CommandHandlers,
} from "./shared";
import type { ReviewCommand } from "../command-schema";
function assertMemoEligible(review: SnapReview, actor: Actor) {
const eligibility = memoEligibility(review, actor);
if (!eligibility.canCreate) throw new RuleError(eligibility.message);
}
/** Handlers for routing, PEC decisions, the CAO memo cycle and release. */
/** The CAO deadline and response share one permission check. */
function caoTracking({
review,
next,
command,
actor,
by,
meta,
event,
}: CommandContext<Extract<ReviewCommand, { type: "cao_deadline" | "cao_response" }>>) {
if (
actor.role !== "program_manager" ||
review.state !== "awaiting_cao_response" ||
!review.memoApproved
)
throw new RuleError("The program manager tracks the CAO response after memo approval.");
if (command.type === "cao_deadline") {
next.caoResponseDeadline = command.value;
event.description = `${roleTag(actor)} set the CAO response deadline to ${command.value}.`;
} else {
if (command.status === "agree" && !command.rootCause.trim())
throw new RuleError("Record the CAO-reported root cause.");
if (command.status === "dispute" && !command.note.trim())
throw new RuleError("Record the CAO dispute.");
if (command.status === "no_response" && review.caoResponse?.status === "dispute")
throw new RuleError("Resolve the CAO dispute before recording no response.");
if (
command.status === "no_response" &&
(!review.caoResponseDeadline || meta.at.slice(0, 10) <= review.caoResponseDeadline)
)
throw new RuleError("The response deadline must pass before recording no response.");
next.caoResponse = {
status: command.status,
rootCause: command.status === "agree" ? command.rootCause.trim() : "",
note: command.note.trim(),
actor: by,
at: meta.at,
};
event.description = `${roleTag(actor)} recorded CAO response: ${command.status}.`;
}
}
export const workflowHandlers = {
prepare_decision({ review, next, actor, meta, event }) {
if (
!(
(can(actor, "seniorReview") && review.state === "awaiting_review") ||
canSubmitPolicyDecision(review, actor)
)
)
throw new RuleError("Decision checks are only available for a pending approval.");
storeEditCheck(next, meta.at);
event.kind = "validation";
event.description = `${roleTag(actor)} checked the current review before an approval decision.`;
},
policy_decision({ review, next, command, actor, by, meta, event }) {
if (!canSubmitPolicyDecision(review, actor))
throw new RuleError("Policy decisions are only available while awaiting PEC review.");
const note = command.policyNote.trim();
if (review.outcome === "drop") throw new RuleError("Drop cannot receive PEC decisions.");
if ((command.decision === "deny" || command.decision === "conditional") && !note)
throw new RuleError("A policy note is required for denial or conditional approval.");
if (command.decision !== "deny") storeEditCheck(next, meta.at);
if (
command.decision !== "deny" &&
(openFindingsBlockApproval(next) || !finalCurrent(next) || !requiredInputCurrent(next))
)
throw new RuleError(
"Resolve current fields, Final Results and open edit-check findings before policy approval.",
);
next.state =
command.decision === "deny"
? "needs_changes"
: command.decision === "conditional"
? "awaiting_pec_approval"
: "awaiting_manager";
if (command.decision === "deny") clearApprovals(next);
if (command.decision === "approve") {
if (!review.supervisorApproved)
throw new RuleError("Supervisor approval is required before PEC approval.");
next.pecApproved = { actor: by, at: meta.at };
next.memos = [...(next.memos ?? []), autoGenerateMemo(next, by, meta)];
next.receivedSnapshots = {
...next.receivedSnapshots,
program_manager: captureReceivedSnapshot(next),
};
}
if (command.decision === "conditional") next.conditionalNotes = note;
event.from = review.state;
event.to = next.state;
next.pecDecision = {
decision: command.decision,
policyNote: note,
actor: by,
createdAt: meta.at,
};
event.kind = "policy_decision";
event.policyNote = note;
event.policyDecision = command.decision;
event.description = `${roleTag(actor)} recorded PEC ${command.decision}.`;
},
conditional_notes({ review, next, command, actor, event }) {
if (
!can(actor, "addNotes") ||
review.pecDecision?.decision !== "conditional" ||
review.state === "dropped"
)
throw new RuleError("Conditional approval text is not available for this review.");
if (!command.body.trim())
throw new RuleError("Keep the conditions or explicitly describe their resolution.");
next.conditionalNotes = command.body.trim();
event.kind = "note";
event.description = `${roleTag(actor)} updated PEC conditions: ${next.conditionalNotes}`;
},
memo({ review, next, command, actor, event }) {
assertMemoEligible(review, actor);
if (!command.body.trim()) throw new RuleError("Enter the CAO letter.");
const memo = next.memos?.at(-1);
if (!memo) throw new RuleError("PEC approval must generate the memo first.");
memo.body = command.body.trim();
event.description = `${roleTag(actor)} updated the CAO memo.`;
},
approve_memo({ review, next, actor, by, meta, event }) {
assertMemoEligible(review, actor);
const memo = next.memos?.at(-1);
if (!memo?.type)
throw new RuleError(
"Management must confirm the defect map before memo classification and approval.",
);
next.memoApproved = { actor: by, at: meta.at };
memo.outboundAt = meta.at;
next.state = "awaiting_cao_response";
const days = resolveReviewPolicy(next).memoResponseDays;
if (!next.caoResponseDeadline && days != null) {
const date = new Date(meta.at);
date.setUTCDate(date.getUTCDate() + days);
next.caoResponseDeadline = date.toISOString().slice(0, 10);
}
event.kind = "transition";
event.from = review.state;
event.to = next.state;
event.description = `${roleTag(actor)} approved the memo and recorded its outbound status.`;
},
release_summary({ review, next, actor, by, meta, event }) {
if (actor.role !== "program_manager")
throw new RuleError("Only the program manager can release a review to reporting.");
assertSummaryRelease(review, meta.at);
next.errorSummaryReleased = { actor: by, at: meta.at };
next.state = "released_to_reporting";
event.kind = "transition";
event.from = review.state;
event.to = next.state;
event.description = `${roleTag(actor)} released the review to Program Error Summary and the transmittal file.`;
},
transition({ review, next, command, actor, by, meta, event }) {
const isSubmit =
command.action === "submit" &&
actor.role === "examiner" &&
(review.state === "new_active" || review.state === "needs_changes");
if (isSubmit && review.outcome === "drop" && !review.dropKind)
throw new RuleError("Choose a drop kind before submission.");
if (
review.outcome !== "drop" &&
(isSubmit ||
(can(actor, "seniorReview") &&
review.state === "awaiting_review" &&
["approve", "route_pec"].includes(command.action)))
) {
storeEditCheck(next, meta.at);
}
const state = transition(
{
state: review.state,
outcome: review.outcome,
role: actor.role,
requiredInputCurrent: requiredInputCurrent(next),
computationCurrent: finalCurrent(next),
openFindingsBlockApproval: openFindingsBlockApproval(next),
note: command.note,
},
command.action,
);
if (command.note.trim()) {
next.notes.push({ id: meta.id, body: command.note.trim(), actor: by, createdAt: meta.at });
event.noteId = meta.id;
}
event.kind = "transition";
event.from = review.state;
event.to = state;
event.description = `${roleTag(actor)}: ${workflowLabels[review.state]} → ${workflowLabels[state]}.`;
next.state = state;
if (isSubmit)
next.receivedSnapshots = {
...next.receivedSnapshots,
supervisor: captureReceivedSnapshot(next),
};
if (state === "needs_changes") clearApprovals(next);
if (review.state === "awaiting_review" && state === "awaiting_manager") {
next.supervisorApproved = { actor: by, at: meta.at };
next.receivedSnapshots = {
...next.receivedSnapshots,
program_manager: captureReceivedSnapshot(next),
};
}
if (state === "dropped") {
if (!review.supervisorApproved)
throw new RuleError("Supervisor approval is required before manager approval of a drop.");
next.dropApproved = { actor: by, at: meta.at };
}
},
cao_deadline: caoTracking,
cao_response: caoTracking,
} satisfies Partial<CommandHandlers>;