Files
Jyotisha/frontend/src/lib/birth-time-guided-candidate.ts
T
2026-07-19 06:42:17 +08:00

131 lines
5.0 KiB
TypeScript

import { withConfirmedCandidate } from "./birth-time-evidence.ts";
import { assertNotDynamicJourneyMutation } from "./birth-time-evidence-service.ts";
import { currentJourneyTurn, storedJourneyResponse } from "./birth-time-journey-response.ts";
import type {
BirthTimeJourneyStore,
LegacyStoredRectificationCase as StoredRectificationCase,
} from "./birth-time-journey-service.ts";
import type { JourneyTurnState } from "./birth-time-journey-turn.ts";
import { StaleJourneyTurnError } from "./birth-time-journey-turn-persistence.ts";
export type GuidedCandidateCommit = {
readonly kind: "save" | "confirm";
readonly expectedVersion: number;
readonly actionId: string;
};
export class GuidedCandidateActionError extends Error {
readonly name = "GuidedCandidateActionError";
readonly reason: "case_not_found" | "invalid_turn" | "invalid_candidate";
constructor(reason: "case_not_found" | "invalid_turn" | "invalid_candidate") {
super(`Guided candidate action ${reason}`);
this.reason = reason;
}
}
type CandidateMutation = {
readonly userId: string;
readonly caseId: string;
readonly actionId: string;
readonly expectedVersion: number;
readonly resultId: string;
};
type CandidateConfirmation = CandidateMutation & { readonly time: string };
type GuidedCandidatePorts = { readonly store: BirthTimeJourneyStore };
async function ownedCase(ports: GuidedCandidatePorts, userId: string, caseId: string) {
const stored = await ports.store.loadCase(userId, caseId);
if (!stored) throw new GuidedCandidateActionError("case_not_found");
assertNotDynamicJourneyMutation(stored);
return stored;
}
function replayed(stored: StoredRectificationCase, actionId: string): boolean {
return stored.processedActionIds?.includes(actionId.toLowerCase()) === true;
}
function assertCurrentVersion(stored: StoredRectificationCase, expectedVersion: number): void {
const currentVersion = stored.turnVersion ?? 0;
if (currentVersion !== expectedVersion) {
throw new StaleJourneyTurnError(stored.id, expectedVersion, currentVersion);
}
}
function savedTurn(stored: StoredRectificationCase, nextVersion: number): JourneyTurnState {
const current = currentJourneyTurn(stored);
const result = stored.candidateResult;
if (
current.nextAction.kind !== "present_medium_result"
|| result?.confidence !== "medium"
|| current.nextAction.resultId !== result.resultId
) throw new GuidedCandidateActionError("invalid_turn");
return {
...current,
turnVersion: nextVersion,
nextAction: { kind: "candidate_saved", resultId: result.resultId },
};
}
function confirmedTurn(
input: {
readonly stored: StoredRectificationCase;
readonly resultId: string;
readonly time: string;
readonly nextVersion: number;
},
): StoredRectificationCase {
const current = currentJourneyTurn(input.stored);
const result = input.stored.candidateResult;
if (
current.nextAction.kind !== "request_candidate_confirmation"
|| !current.permissions.canConfirmCandidate
|| result?.confidence !== "high"
|| result.resultId !== input.resultId
|| current.nextAction.resultId !== input.resultId
) throw new GuidedCandidateActionError("invalid_turn");
const snapshot = withConfirmedCandidate(input.stored.snapshot, result, input.time);
const turnState: JourneyTurnState = {
...current,
turnVersion: input.nextVersion,
nextAction: { kind: "ready", activeTime: input.time },
progress: { ...current.progress, phase: "ready" },
permissions: { canConfirmCandidate: false },
evidenceDraft: null,
};
return { ...input.stored, snapshot, turnState, evidenceDraft: null };
}
export function createGuidedCandidateActions(ports: GuidedCandidatePorts) {
return {
async save(input: CandidateMutation) {
const stored = await ownedCase(ports, input.userId, input.caseId);
if (replayed(stored, input.actionId)) return storedJourneyResponse(stored);
assertCurrentVersion(stored, input.expectedVersion);
if (stored.candidateResult?.resultId !== input.resultId) {
throw new GuidedCandidateActionError("invalid_candidate");
}
const value = {
...stored,
turnState: savedTurn(stored, input.expectedVersion + 1),
} satisfies StoredRectificationCase;
return storedJourneyResponse(await ports.store.commitGuidedCandidate(value, {
kind: "save", expectedVersion: input.expectedVersion, actionId: input.actionId,
}));
},
async confirm(input: CandidateConfirmation) {
const stored = await ownedCase(ports, input.userId, input.caseId);
if (replayed(stored, input.actionId)) return storedJourneyResponse(stored);
assertCurrentVersion(stored, input.expectedVersion);
const value = confirmedTurn({
stored,
resultId: input.resultId,
time: input.time,
nextVersion: input.expectedVersion + 1,
});
return storedJourneyResponse(await ports.store.commitGuidedCandidate(value, {
kind: "confirm", expectedVersion: input.expectedVersion, actionId: input.actionId,
}));
},
};
}