src/features/reviews/commands.ts
Linked repository file from this build.
import { AccessError, canAccessReview } from "@/lib/auth/scope";
import { actorRef } from "@/lib/auth/actor";
import { LEGACY_CALC_VERSION, LEGACY_CHECK_VERSION } from "@/programs/snap-negative/rule-versions";
import { normalizeReviewAnswers } from "@/programs/snap-negative/field-catalog";
import type { SnapReview } from "@/programs/snap-negative/types";
import type { ReviewCommand } from "./command-schema";
import { factHandlers } from "./handlers/facts";
import { evidenceHandlers } from "./handlers/evidence";
import { workflowHandlers } from "./handlers/workflow";
import type { CommandContext, CommandHandlers } from "./handlers/shared";
import type { Activity, Actor } from "./types";
export type { ReviewCommand } from "./command-schema";
export { captureReceivedSnapshot } from "./received-snapshot";
export {
canAddNote,
canEdit,
canRefuseFinding,
canSubmitPolicyDecision,
canUpdateNote,
openFindingsBlockApproval,
} from "./permissions";
export {
activeStoredFindings,
finalCurrent,
requiredInputComplete as requiredInputCurrent,
validationCurrent,
} from "@/programs/snap-negative/validation";
const handlers: CommandHandlers = { ...factHandlers, ...evidenceHandlers, ...workflowHandlers };
/** One command updates the case and appends its evidence atomically. IDs/time come from the caller. */
export function execute(
review: SnapReview,
command: ReviewCommand,
actor: Actor,
meta: { id: string; at: string },
options: { clone?: boolean } = {},
): SnapReview {
if (!canAccessReview(actor, review)) throw new AccessError();
// Records keep who acted, never the session scope or preferences.
const by = actorRef(actor);
const source = {
...review,
calcVersion: review.calcVersion ?? LEGACY_CALC_VERSION,
checkVersion: review.checkVersion ?? LEGACY_CHECK_VERSION,
};
let next = options.clone === false ? source : structuredClone(source);
next.facts.answers = normalizeReviewAnswers(next.facts.answers);
const event: Activity = {
id: meta.id,
kind: "edit",
actor: by,
createdAt: meta.at,
description: "",
};
// Each handler is typed for its own command; the map guarantees the pairing.
const handle = handlers[command.type] as (
context: CommandContext<ReviewCommand>,
) => SnapReview | void;
next = handle({ review, next, command, actor, by, meta, event }) ?? next;
return { ...next, activity: [...next.activity, event] };
}