/** * 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. */ import { RESUMABLE_CASE_STATUSES, TERMINAL_CASE_STATUSES, type OpenRectificationCaseResponseWire, type OpenRectificationDisposition, type RectificationEntrySummaryWire, } from "@/lib/contracts/rectification-entry"; export type { OpenRectificationDisposition }; export type RectificationEntrySummary = RectificationEntrySummaryWire; export type OpenRectificationCaseResponse = OpenRectificationCaseResponseWire; const resumableStatuses = new Set(RESUMABLE_CASE_STATUSES); const terminalStatuses = new Set(TERMINAL_CASE_STATUSES); export function isResumableRectificationStatus(status: string): boolean { return resumableStatuses.has(status); } export function isTerminalRectificationStatus(status: string): boolean { return terminalStatuses.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"; }