fix: confirm dynamic birth-time candidates

This commit is contained in:
Jesse_Chen
2026-07-20 14:58:57 +08:00
parent 877a221aff
commit 8f98e8585d
13 changed files with 542 additions and 2 deletions
@@ -184,6 +184,15 @@ export async function POST(request: Request) {
resultId: parsed.data.resultId,
time: parsed.data.time,
}), "turn_advanced");
case "confirm_dynamic_candidate":
return responseWithJourneyMetric(service.confirmDynamicCandidate({
userId: user.id,
caseId: parsed.data.caseId,
actionId: parsed.data.actionId,
expectedVersion: parsed.data.turnVersion,
resultId: parsed.data.resultId,
time: parsed.data.time,
}), "turn_advanced");
default: {
const exhaustive: never = parsed.data;
return exhaustive;
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
answerDynamicBirthTimeChoice,
confirmDynamicBirthTimeCandidate,
confirmBirthTimeEvidenceDraft,
draftBirthTimeEvidence,
finishBirthTimeRectification,
@@ -225,8 +226,10 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG
(actionId) => saveGuidedBirthTimeCandidate({ caseId: turn.caseId, actionId, turnVersion: turn.turnVersion, resultId }),
));
const confirmCandidate = (resultId: string, time: string) => operate((turn) => preview ? Promise.resolve(turn) : stableAction(
turn, "confirm_guided_candidate", [resultId, time],
(actionId) => confirmGuidedBirthTimeCandidate({ caseId: turn.caseId, actionId, turnVersion: turn.turnVersion, resultId, time }),
turn, turn.journeyProtocol === "dynamic-choice-v2" ? "confirm_dynamic_candidate" : "confirm_guided_candidate", [resultId, time],
(actionId) => turn.journeyProtocol === "dynamic-choice-v2"
? confirmDynamicBirthTimeCandidate({ caseId: turn.caseId, actionId, turnVersion: turn.turnVersion, resultId, time })
: confirmGuidedBirthTimeCandidate({ caseId: turn.caseId, actionId, turnVersion: turn.turnVersion, resultId, time }),
));
const selectOption = (optionId: string) => operate((turn) => {
if (turn.journeyProtocol !== "dynamic-choice-v2"
@@ -20,6 +20,7 @@ export type DynamicActionReceipt = ReceiptBase & (
| { readonly kind: "pause" }
| { readonly kind: "finish" }
| { readonly kind: "resume" }
| { readonly kind: "confirm_candidate"; readonly resultId: string; readonly time: string }
);
const receiptBase = {
@@ -64,4 +65,10 @@ export const dynamicActionReceiptSchema: z.ZodType<DynamicActionReceipt> = z.uni
z.object({ ...receiptBase, kind: z.literal("pause") }).strict(),
z.object({ ...receiptBase, kind: z.literal("finish") }).strict(),
z.object({ ...receiptBase, kind: z.literal("resume") }).strict(),
z.object({
...receiptBase,
kind: z.literal("confirm_candidate"),
resultId: z.string().uuid(),
time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
}).strict(),
]).readonly();
@@ -0,0 +1,76 @@
import { withConfirmedCandidate } from "./birth-time-evidence.ts";
import { replayedDynamicAction } from "./birth-time-dynamic-action-replay.ts";
import { withDynamicAction } from "./birth-time-dynamic-transitions.ts";
import { BirthTimeDynamicActionError } from "./birth-time-dynamic-actions.ts";
import { storedDynamicJourneyResponse } from "./birth-time-journey-response.ts";
import type {
BirthTimeJourneyPorts,
DynamicCandidateConfirmationCommand,
DynamicStoredRectificationCase,
} from "./birth-time-journey-service.ts";
import { StaleJourneyTurnError } from "./birth-time-journey-store-errors.ts";
function stale(stored: DynamicStoredRectificationCase, expectedVersion: number) {
return new StaleJourneyTurnError(stored.id, expectedVersion, stored.turnVersion);
}
export function createDynamicCandidateConfirmation(ports: BirthTimeJourneyPorts) {
return {
async confirm(input: DynamicCandidateConfirmationCommand) {
const stored = await ports.store.loadCase(input.userId, input.caseId);
if (!stored) throw new BirthTimeDynamicActionError("case_not_found");
if (stored.journeyProtocol !== "dynamic-choice-v2") {
throw new BirthTimeDynamicActionError("invalid_turn");
}
const receipt = stored.dynamicControl.lastActionReceipt;
if (replayedDynamicAction(stored, input.actionId, input.expectedVersion, () => (
stored.dynamicTurnState.nextAction.kind === "ready"
&& stored.dynamicTurnState.nextAction.activeTime === input.time
&& stored.snapshot.activeTime === input.time
&& receipt?.kind === "confirm_candidate"
&& receipt.actionId === input.actionId.toLowerCase()
&& receipt.turnVersion === input.expectedVersion
&& receipt.resultId === input.resultId
&& receipt.time === input.time
))) return storedDynamicJourneyResponse(stored);
const action = stored.dynamicTurnState.nextAction;
const candidate = stored.candidateResult;
if (
stored.turnVersion !== input.expectedVersion
|| action.kind !== "request_candidate_confirmation"
|| action.resultId !== input.resultId
|| !stored.dynamicTurnState.permissions.canConfirmCandidate
|| candidate?.resultId !== input.resultId
|| candidate.canApply !== true
|| candidate.winningSegment?.representativeTime !== input.time
) throw stale(stored, input.expectedVersion);
const transitioned = withDynamicAction(
stored,
{ kind: "ready", activeTime: input.time },
input.expectedVersion + 1,
);
const updated = {
...transitioned,
snapshot: withConfirmedCandidate(stored.snapshot, candidate, input.time),
currentChoiceQuestion: null,
dynamicTurnState: {
...transitioned.dynamicTurnState,
permissions: { canConfirmCandidate: false },
},
dynamicControl: {
...stored.dynamicControl,
lastActionReceipt: {
actionId: input.actionId.toLowerCase(),
kind: "confirm_candidate" as const,
turnVersion: input.expectedVersion,
resultId: input.resultId,
time: input.time,
},
},
} satisfies DynamicStoredRectificationCase;
return storedDynamicJourneyResponse(await ports.store.confirmDynamicCandidate(updated, input));
},
};
}
@@ -161,6 +161,16 @@ export function confirmBirthTimeCandidate(
return sendJourneyEvent({ type: "confirm_candidate", caseId, resultId, time });
}
export function confirmDynamicBirthTimeCandidate(input: {
readonly caseId: string;
readonly actionId: string;
readonly turnVersion: number;
readonly resultId: string;
readonly time: string;
}) {
return sendJourneyEvent({ type: "confirm_dynamic_candidate", ...input });
}
export function confirmBirthTimeEvidenceDraft(
caseId: string,
actionId: string,
@@ -8,6 +8,7 @@ import type { DynamicJourneyTurnState } from "./birth-time-journey-turn-protocol
import type {
DynamicScoringJobCommand,
DynamicScoringJobFailureCommand,
DynamicCandidateConfirmationCommand,
DynamicStoredRectificationCase,
LegacyStoredRectificationCase,
StoredRectificationCase,
@@ -72,6 +73,8 @@ function privateState(value: DynamicStoredRectificationCase): DynamicPrivateJour
function isStaleRpc(error: RpcError): boolean {
return error.message.includes("stale_birth_time_dynamic_turn")
|| error.message.includes("stale_birth_time_dynamic_scoring_job")
|| error.message.includes("stale_birth_time_dynamic_candidate")
|| error.message.includes("birth_time_dynamic_candidate_invalid")
|| error.message.includes("stale_birth_time_legacy_upgrade");
}
@@ -126,6 +129,45 @@ export function createDynamicTurnPersistence(
}
return {
async confirmDynamicCandidate(
value: DynamicStoredRectificationCase,
command: DynamicCandidateConfirmationCommand,
): Promise<DynamicStoredRectificationCase> {
const receipt = actionIdSchema.parse(command.actionId).toLowerCase();
const result = await client.rpc("confirm_birth_time_dynamic_candidate", {
p_user_id: value.userId,
p_case_id: value.id,
p_result_id: command.resultId,
p_time: command.time,
p_action_id: receipt,
p_expected_version: command.expectedVersion,
p_snapshot: value.snapshot,
p_turn_state: publicTurn(value, command.expectedVersion + 1),
});
const current = await loadCase(value.userId, value.id);
if (result.error) {
if (current?.journeyProtocol === "dynamic-choice-v2"
&& current.processedActionIds.includes(receipt)
&& samePersistedDynamicReceipt(value, current, receipt, command.expectedVersion)) {
return current;
}
if (isStaleRpc(result.error)) {
throw new StaleJourneyTurnError(value.id, command.expectedVersion, current?.turnVersion ?? 0);
}
throw new BirthTimeJourneyStoreError("update_case");
}
const version = rpcVersionSchema.safeParse(result.data);
if (!current || current.journeyProtocol !== "dynamic-choice-v2") {
throw new BirthTimeJourneyStoreError("load_case");
}
if (!version.success || version.data !== command.expectedVersion + 1
|| !current.processedActionIds.includes(receipt)
|| !samePersistedDynamicReceipt(value, current, receipt, command.expectedVersion)) {
throw new StaleJourneyTurnError(value.id, command.expectedVersion, current.turnVersion);
}
return current;
},
async saveDynamicTurn(
value: DynamicStoredRectificationCase,
expectedVersion: number,
@@ -72,6 +72,12 @@ export const birthTimeJourneyRequestSchema = z.discriminatedUnion("type", [
resultId: z.string().uuid(),
time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
}).strict(),
z.object({
type: z.literal("confirm_dynamic_candidate"),
...mutationFields,
resultId: z.string().uuid(),
time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
}).strict(),
]).superRefine((value, context) => {
if (value.type === "revise_evidence_draft" && !lifeEventSchema.safeParse({
id: revisionValidationId,
@@ -17,6 +17,7 @@ import type { TimeRange } from "./birth-time-dynamic-choice.ts";
import type { DynamicStoredFields, LegacyStoredFields } from "./birth-time-journey-stored-protocol.ts";
import { RectificationCaseNotFoundError, RectificationQuestionsUnavailableError } from "./birth-time-journey-errors.ts";
import { createDynamicJourneyMethods } from "./birth-time-dynamic-service-methods.ts";
import { createDynamicCandidateConfirmation } from "./birth-time-dynamic-candidate-confirmation.ts";
export { RectificationCaseNotFoundError, RectificationQuestionsUnavailableError };
@@ -139,12 +140,22 @@ export type DynamicScoringJobCommand = {
export type DynamicScoringJobFailureCommand = DynamicScoringJobCommand & { readonly failureCode: string };
export type DynamicCandidateConfirmationCommand = {
readonly userId: string;
readonly caseId: string;
readonly actionId: string;
readonly expectedVersion: number;
readonly resultId: string;
readonly time: string;
};
export interface BirthTimeJourneyStore {
saveAssessment(value: PersistedJourneyAssessment): Promise<string>;
loadCase(userId: string, caseId: string): Promise<StoredRectificationCase | null>;
saveScoring(value: LegacyStoredRectificationCase): Promise<void>;
saveTurn(value: LegacyStoredRectificationCase, expectedVersion: number, actionId: string): Promise<StoredRectificationCase>;
saveDynamicTurn(value: DynamicStoredRectificationCase, expectedVersion: number, actionId: string): Promise<DynamicStoredRectificationCase>;
confirmDynamicCandidate(value: DynamicStoredRectificationCase, command: DynamicCandidateConfirmationCommand): Promise<DynamicStoredRectificationCase>;
completeDynamicScoringJob(value: DynamicStoredRectificationCase, command: DynamicScoringJobCommand): Promise<DynamicStoredRectificationCase>;
failDynamicScoringJob(value: DynamicStoredRectificationCase, command: DynamicScoringJobFailureCommand): Promise<DynamicStoredRectificationCase>;
createDynamicScoringJob?(value: DynamicStoredRectificationCase, expectedVersion: number, actionId: string, questionId: string, job: DynamicScoringJobSpec): Promise<DynamicStoredRectificationCase>;
@@ -193,6 +204,7 @@ export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) {
const guidedCandidates = createGuidedCandidateActions(ports);
const draftRevisions = createGuidedDraftRevisionActions(ports, turnActions.proposeEvidenceDraft);
const dynamicMethods = createDynamicJourneyMethods(ports);
const dynamicCandidates = createDynamicCandidateConfirmation(ports);
return {
async assess(userId: string, assessment: BirthTimeAssessment): Promise<VersionedJourneyResponse | DynamicVersionedJourneyResponse> {
const scan = await scanAssessment(ports.engine, assessment);
@@ -276,6 +288,7 @@ export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) {
reviseEvidenceDraft: draftRevisions.revise,
saveGuidedCandidate: guidedCandidates.save,
confirmGuidedCandidate: guidedCandidates.confirm,
confirmDynamicCandidate: dynamicCandidates.confirm,
pollScoringJob: scoringActions.pollScoringJob,
...dynamicMethods,
};