/** * 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 = { status: "queued" | "processing" | "completed" | "failed" | 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) }); }, }); } export function useProgress(taskId: string | null) { const [event, setEvent] = useState(null); useEffect(() => { if (!taskId) return; setEvent(null); const close = openSSE( `/api/progress/${encodeURIComponent(taskId)}`, { onMessage: (data) => { setEvent(data); if (data.status === "completed" || data.status === "failed") { /* Close from within the callback — the backend ends the stream * naturally, but closing eagerly avoids the auto-reconnect loop * EventSource does after EOF. */ close(); } }, }, ); return () => close(); }, [taskId]); return event; }