/** * Document upload + progress hooks. * * Upload hits `POST /api/cases/{n}/documents/upload-tagged` as multipart * form-data (FastAPI UploadFile), and receives a `task_id` that streams * progress events via `GET /api/progress/{task_id}` (SSE). We expose * both as a single `useUploadDocument` mutation returning the task id * plus a `useProgress(taskId)` hook that subscribes to the stream. */ import { useEffect, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { ApiError } from "./client"; import { casesKeys } from "./cases"; import { openSSE } from "@/lib/sse"; export type UploadTaggedResponse = { task_id: string; filename: string; original_name: string; doc_type: string; }; export type ProgressEvent = { /* "unknown" is sent by the backend when the task TTL expired or the * caller subscribed before any state was published. Treat it as a * terminal hint to refetch case state from the source of truth. */ status: "queued" | "processing" | "completed" | "failed" | "unknown" | string; filename?: string; step?: string; error?: string; result?: unknown; case_number?: string; doc_type?: string; }; export type UploadVars = { caseNumber: string; file: File; docType?: string; partyName?: string; title?: string; }; async function uploadTagged({ caseNumber, file, docType = "auto", partyName = "", title = "", }: UploadVars): Promise { const fd = new FormData(); fd.append("file", file); fd.append("doc_type", docType); fd.append("party_name", partyName); fd.append("title", title); const res = await fetch( `/api/cases/${encodeURIComponent(caseNumber)}/documents/upload-tagged`, { method: "POST", body: fd }, ); const contentType = res.headers.get("content-type") ?? ""; const parsed = contentType.includes("application/json") ? await res.json().catch(() => null) : await res.text().catch(() => null); if (!res.ok) { throw new ApiError( `Upload failed with ${res.status}`, res.status, parsed, ); } return parsed as UploadTaggedResponse; } export function useUploadDocument(caseNumber: string) { const qc = useQueryClient(); return useMutation({ mutationFn: (vars: Omit) => uploadTagged({ caseNumber, ...vars }), onSuccess: () => { /* Nudge the case detail to refetch so the new document row appears * immediately — the actual "processing" badge will update once the * SSE stream reports status=completed. */ qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) }); }, }); } // ── PATCH document tags ─────────────────────────────────────────── export type DocumentPatch = { doc_type?: string; appraiser_side?: string; // "" clears; "committee" | "appellant" | "deciding" sets }; export type PatchDocumentResponse = { status: "completed" | "noop"; document: { id: string; doc_type: string; title: string; metadata?: Record; }; }; async function patchDocument( caseNumber: string, docId: string, patch: DocumentPatch, ): Promise { const res = await fetch( `/api/cases/${encodeURIComponent(caseNumber)}/documents/${encodeURIComponent(docId)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }, ); const contentType = res.headers.get("content-type") ?? ""; const parsed = contentType.includes("application/json") ? await res.json().catch(() => null) : await res.text().catch(() => null); if (!res.ok) { throw new ApiError(`Patch failed with ${res.status}`, res.status, parsed); } return parsed as PatchDocumentResponse; } export function usePatchDocument(caseNumber: string) { const qc = useQueryClient(); return useMutation({ mutationFn: ({ docId, patch }: { docId: string; patch: DocumentPatch }) => patchDocument(caseNumber, docId, patch), onSuccess: () => { qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) }); }, }); } // ── Extract appraiser facts (on-demand, per case) ────────────────── export type ExtractAppraiserFactsResponse = | { status: "completed"; appraisal_count: number; total_facts: number; conflicts: unknown[]; by_document?: unknown[]; } | { status: "no_appraisals"; appraisal_count: 0; total_facts: 0; conflicts: unknown[]; } | { status: "sides_missing"; appraisal_count: number; missing: { document_id: string; title: string; current_side: string }[]; message: string; }; async function extractAppraiserFacts( caseNumber: string, ): Promise { const res = await fetch( `/api/cases/${encodeURIComponent(caseNumber)}/extract-appraiser-facts`, { method: "POST" }, ); const contentType = res.headers.get("content-type") ?? ""; const parsed = contentType.includes("application/json") ? await res.json().catch(() => null) : await res.text().catch(() => null); if (!res.ok) { throw new ApiError( `Extraction failed with ${res.status}`, res.status, parsed, ); } return parsed as ExtractAppraiserFactsResponse; } export function useExtractAppraiserFacts(caseNumber: string) { const qc = useQueryClient(); return useMutation({ mutationFn: () => extractAppraiserFacts(caseNumber), onSuccess: () => { qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) }); }, }); } export function useProgress(taskId: string | null, caseNumber?: string) { const [event, setEvent] = useState(null); const qc = useQueryClient(); useEffect(() => { if (!taskId) return; setEvent(null); /* Self-heal fallback: if no SSE message arrives within 10s — usually * because the proxy chain held the chunks or the EventSource is * silently retrying — synthesize a refresh by invalidating the case * detail. The actual document state is in the case detail anyway, so * the UI heals from the source of truth without depending on SSE. */ let firstMessageReceived = false; const fallback = window.setTimeout(() => { if (firstMessageReceived) return; if (caseNumber) qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) }); setEvent({ status: "completed" }); }, 10_000); const close = openSSE( `/api/progress/${encodeURIComponent(taskId)}`, { onMessage: (data) => { firstMessageReceived = true; setEvent(data); if ( data.status === "completed" || data.status === "failed" || data.status === "unknown" ) { /* Close from within the callback so EventSource does not * auto-reconnect after the server's EOF. For "unknown" we * also nudge a case-detail refetch — the task state is gone * but the document row will tell us the truth. */ if (data.status === "unknown" && caseNumber) { qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) }); } close(); } }, }, ); return () => { window.clearTimeout(fallback); close(); }; }, [taskId, caseNumber, qc]); return event; }