/** * Missing precedents — citations the parties brought up but that aren't * yet in the corpus. * * Lifecycle: 'open' → researcher logs gap → chair uploads decision via * the dialog → POST /upload routes to internal_decision_upload (ערר/בל"מ) * or precedent_library_upload (court rulings), then status flips to * 'closed' with linked_case_law_id set. * * Endpoints touched: * - POST /api/missing-precedents create (JSON body) * - GET /api/missing-precedents?status=open list (filters) * - GET /api/missing-precedents/{id} detail * - PATCH /api/missing-precedents/{id} metadata edit * - DELETE /api/missing-precedents/{id} remove * - POST /api/missing-precedents/{id}/upload multipart upload + close */ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ApiError, apiRequest } from "./client"; export type CitedByParty = | "appellant" | "respondent" | "committee" | "permit_applicant" | "unknown"; export type MissingPrecedentStatus = | "open" | "uploaded" | "closed" | "irrelevant"; export type MissingPrecedent = { id: string; citation: string; case_name: string | null; cited_in_case_id: string | null; cited_in_case_number: string | null; // joined cited_in_document_id: string | null; cited_by_party: CitedByParty | null; cited_by_party_name: string | null; legal_topic: string | null; legal_issue: string | null; claim_quote: string | null; status: MissingPrecedentStatus; linked_case_law_id: string | null; linked_case_law_number: string | null; linked_case_law_name: string | null; closed_at: string | null; created_at: string; updated_at: string; notes: string | null; }; export type MissingPrecedentListResponse = { items: MissingPrecedent[]; count: number; by_status: Partial>; total_open: number; }; export type MissingPrecedentCreateInput = { citation: string; case_number?: string; cited_in_document_id?: string; cited_by_party?: CitedByParty; cited_by_party_name?: string; legal_topic?: string; legal_issue?: string; claim_quote?: string; case_name?: string; notes?: string; }; export type MissingPrecedentPatch = Partial<{ legal_topic: string; legal_issue: string; notes: string; cited_by_party: CitedByParty; cited_by_party_name: string; case_name: string; status: MissingPrecedentStatus; citation: string; claim_quote: string; }>; export type MissingPrecedentFilters = { status?: MissingPrecedentStatus | ""; caseNumber?: string; caseId?: string; legalTopic?: string; limit?: number; }; export const missingPrecedentKeys = { all: ["missing-precedents"] as const, list: (filters: MissingPrecedentFilters) => [...missingPrecedentKeys.all, "list", filters] as const, detail: (id: string) => [...missingPrecedentKeys.all, "detail", id] as const, }; export function useMissingPrecedents(filters: MissingPrecedentFilters = {}) { return useQuery({ queryKey: missingPrecedentKeys.list(filters), queryFn: ({ signal }) => { const p = new URLSearchParams(); if (filters.status) p.set("status", filters.status); if (filters.caseNumber) p.set("case_number", filters.caseNumber); if (filters.caseId) p.set("case_id", filters.caseId); if (filters.legalTopic) p.set("legal_topic", filters.legalTopic); if (filters.limit) p.set("limit", String(filters.limit)); const qs = p.toString(); return apiRequest( `/api/missing-precedents${qs ? `?${qs}` : ""}`, { signal }, ); }, staleTime: 15_000, }); } /** Counter for the sidebar / nav badge — open rows only. */ export function useMissingPrecedentsOpenCount() { return useQuery({ queryKey: [...missingPrecedentKeys.all, "open-count"] as const, queryFn: ({ signal }) => apiRequest( "/api/missing-precedents?status=open&limit=1", { signal }, ), staleTime: 30_000, select: (data) => data.total_open, }); } export function useMissingPrecedent(id: string | null) { return useQuery({ queryKey: missingPrecedentKeys.detail(id ?? ""), queryFn: ({ signal }) => apiRequest( `/api/missing-precedents/${encodeURIComponent(id!)}`, { signal }, ), enabled: Boolean(id), staleTime: 15_000, }); } export function useCreateMissingPrecedent() { const qc = useQueryClient(); return useMutation({ mutationFn: (input: MissingPrecedentCreateInput) => apiRequest("/api/missing-precedents", { method: "POST", body: input, }), onSuccess: () => { qc.invalidateQueries({ queryKey: missingPrecedentKeys.all }); }, }); } export function useUpdateMissingPrecedent() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ id, patch }: { id: string; patch: MissingPrecedentPatch }) => apiRequest( `/api/missing-precedents/${encodeURIComponent(id)}`, { method: "PATCH", body: patch }, ), onSuccess: (_, { id }) => { qc.invalidateQueries({ queryKey: missingPrecedentKeys.detail(id) }); qc.invalidateQueries({ queryKey: missingPrecedentKeys.all }); }, }); } export function useDeleteMissingPrecedent() { const qc = useQueryClient(); return useMutation({ mutationFn: (id: string) => apiRequest<{ deleted: boolean }>( `/api/missing-precedents/${encodeURIComponent(id)}`, { method: "DELETE" }, ), onSuccess: () => { qc.invalidateQueries({ queryKey: missingPrecedentKeys.all }); }, }); } export type MissingPrecedentUploadInput = { id: string; file: File; case_number?: string; chair_name?: string; district?: string; case_name?: string; court?: string; decision_date?: string; practice_area?: string; appeal_subtype?: string; subject_tags?: string[]; is_binding?: boolean; headnote?: string; summary?: string; precedent_level?: string; source_type?: string; }; export function useUploadMissingPrecedent() { const qc = useQueryClient(); return useMutation({ mutationFn: async (input: MissingPrecedentUploadInput) => { const fd = new FormData(); fd.append("file", input.file); if (input.case_number) fd.append("case_number", input.case_number); if (input.chair_name) fd.append("chair_name", input.chair_name); if (input.district) fd.append("district", input.district); if (input.case_name) fd.append("case_name", input.case_name); if (input.court) fd.append("court", input.court); if (input.decision_date) fd.append("decision_date", input.decision_date); if (input.practice_area) fd.append("practice_area", input.practice_area); if (input.appeal_subtype) fd.append("appeal_subtype", input.appeal_subtype); if (input.subject_tags && input.subject_tags.length) { fd.append("subject_tags", JSON.stringify(input.subject_tags)); } fd.append("is_binding", String(input.is_binding ?? true)); if (input.headnote) fd.append("headnote", input.headnote); if (input.summary) fd.append("summary", input.summary); if (input.precedent_level) fd.append("precedent_level", input.precedent_level); if (input.source_type) fd.append("source_type", input.source_type); const res = await fetch( `/api/missing-precedents/${encodeURIComponent(input.id)}/upload`, { method: "POST", body: fd }, ); const parsed = await res.json().catch(() => null); if (!res.ok) { throw new ApiError( `Upload failed with ${res.status}`, res.status, parsed, ); } return parsed as { missing_precedent: MissingPrecedent; case_law_id: string; route: "internal_committee" | "external_upload"; }; }, onSuccess: (_, { id }) => { qc.invalidateQueries({ queryKey: missingPrecedentKeys.detail(id) }); qc.invalidateQueries({ queryKey: missingPrecedentKeys.all }); qc.invalidateQueries({ queryKey: ["precedent-library"] }); }, }); } /** Hebrew labels for display. */ export const CITED_BY_PARTY_LABELS: Record = { appellant: "עורר", respondent: "משיב", committee: "ועדה", permit_applicant: "מבקש היתר", unknown: "לא ידוע", }; export const STATUS_LABELS: Record = { open: "פתוח", uploaded: "הועלה", closed: "נסגר", irrelevant: "לא רלוונטי", };