/** * Client-side rectification entry routing contracts. * * Every disposition decision comes from the server Case open API. The browser * never guesses "resume vs create" from session lists or message counts. */ export type RectificationEntrySummary = Readonly<{ hasResumableCase: boolean; hasTerminalCaseWithTime: boolean; latestResumable: Readonly<{ caseId: string; status: string; lastActivityAt: string; }> | null; latestTerminal: Readonly<{ caseId: string; status: string; hasUsableTime: boolean; }> | null; }>; export type RectificationCardAction = "start" | "restart"; export const rectificationEntryLabels: Readonly> = { start: "开始新的生时校正", restart: "再次校正", }; export function resolveRectificationEntryAction( summary: RectificationEntrySummary, ): RectificationCardAction { if (summary.hasTerminalCaseWithTime) return "restart"; return "start"; } export type OpenRectificationDisposition = "created" | "resumed" | "readonly"; export type OpenRectificationCaseResponse = Readonly<{ disposition: OpenRectificationDisposition; caseId: string; sessionId: string; status: string; shouldStartOpening: boolean; skillVersion: string; }>; const RESUMABLE_STATUSES = new Set([ "draft", "collecting_evidence", "candidate_ready", "candidate_accepted", "needs_rebaseline", "paused", ]); const TERMINAL_STATUSES = new Set([ "confirmed", "closed", "abandoned", "superseded", ]); export function isResumableRectificationStatus(status: string): boolean { return RESUMABLE_STATUSES.has(status); } export function isTerminalRectificationStatus(status: string): boolean { return TERMINAL_STATUSES.has(status); } /* `GET /api/rectification/cases/entry-summary` answers with the camelCase object `getRectificationEntrySummary` returns; only the RPC underneath speaks snake_case. This parser used to read the snake_case keys alone, so every real response parsed as "nothing on record" and the homepage never saw a resumable or finished case (BUG-1041). The route's camelCase shape wins; snake_case is still read so an RPC-shaped payload does not silently collapse either. */ function field(row: Record, camel: string, snake: string): unknown { return camel in row ? row[camel] : row[snake]; } function objectField(row: Record, camel: string, snake: string): Record | null { const value = field(row, camel, snake); return value && typeof value === "object" ? value as Record : null; } export function entrySummaryFromResponse(value: unknown): RectificationEntrySummary { if (!value || typeof value !== "object") { return { hasResumableCase: false, hasTerminalCaseWithTime: false, latestResumable: null, latestTerminal: null }; } const row = value as Record; const latestResumable = objectField(row, "latestResumable", "latest_resumable"); const latestTerminal = objectField(row, "latestTerminal", "latest_terminal"); const resumableCaseId = latestResumable ? field(latestResumable, "caseId", "case_id") : null; const terminalCaseId = latestTerminal ? field(latestTerminal, "caseId", "case_id") : null; return { hasResumableCase: field(row, "hasResumableCase", "has_resumable_case") === true, hasTerminalCaseWithTime: field(row, "hasTerminalCaseWithTime", "has_terminal_case_with_time") === true, latestResumable: latestResumable && typeof resumableCaseId === "string" ? { caseId: resumableCaseId, status: String(latestResumable.status ?? ""), lastActivityAt: String(field(latestResumable, "lastActivityAt", "last_activity_at") ?? ""), } : null, latestTerminal: latestTerminal && typeof terminalCaseId === "string" ? { caseId: terminalCaseId, status: String(latestTerminal.status ?? ""), hasUsableTime: field(latestTerminal, "hasUsableTime", "has_usable_time") === true, } : null, }; } export function openResponseFromPayload(value: unknown): OpenRectificationCaseResponse | null { if (!value || typeof value !== "object") return null; const row = value as Record; const disposition = row.disposition; const caseId = typeof row.caseId === "string" ? row.caseId : ""; const sessionId = typeof row.sessionId === "string" ? row.sessionId : ""; const status = typeof row.status === "string" ? row.status : ""; if ( (disposition !== "created" && disposition !== "resumed" && disposition !== "readonly") || !caseId || !sessionId || !status ) { return null; } return { disposition, caseId, sessionId, status, shouldStartOpening: row.shouldStartOpening === true, skillVersion: typeof row.skillVersion === "string" ? row.skillVersion : "", }; } export type RectificationEntryOpenIntent = "homepage" | "session" | "new"; /** Build the server open request body; the browser never adds business state. */ export function openRectificationRequestBody( intent: RectificationEntryOpenIntent, sessionId: string | null, sourceSessionId: string | null = null, ): { intent: RectificationEntryOpenIntent; requestId: string; sessionId?: string; sourceSessionId?: string } { const requestId = globalThis.crypto?.randomUUID?.() ?? fallbackUuid(); if (intent === "session" && sessionId) { return { intent, requestId, sessionId }; } return { intent, requestId, ...(intent === "homepage" && sourceSessionId ? { sourceSessionId } : {}) }; } function fallbackUuid(): string { return "00000000-0000-4000-8000-000000000000"; }