Files
legal-ai/web-ui/src/lib/api/cases.ts
Chaim 2b591f5018
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
fix(case-page): resolve full-page review findings (bugs + RTL + resilience)
Full code review of the case-detail page (14 components) surfaced these,
all fixed here:

Logic bugs
- case-edit-dialog: form.reset ran on the 5s useCase refetch while the dialog
  was open, clobbering in-progress edits. Now resets only on open→true.
- status-changer: `selected` never synced to async/external `currentStatus`
  (stale dropdown). Reworked to track currentStatus until an explicit pick;
  resets to tracking after save.
- decision-blocks-panel: `block.content`/`word_count` accessed without null
  guards (endpoint has no response model) → potential render crash. Coerced
  with `?? ""` / `?? 0`. `STATUS_LABELS[status]` now falls back to the raw
  status instead of rendering literal "undefined".
- document-type-editor: `await mutateAsync()` in async click handlers without
  try/catch → unhandled promise rejection. Wrapped (errors still surface via
  isError).

Resilience / hygiene
- page.tsx: a transient 5xx on the background poll flipped the WHOLE page to
  the error card and discarded loaded data. Now gated on `!data`, plus a
  "נסה שוב" retry.
- cases.ts useUpdateCase: invalidated casesKeys.all, which re-invalidated the
  detail it had just optimistically patched. Scoped to the list prefix.

RTL correctness (logical properties)
- agent-status-widget `mr-auto`→`ms-auto`; agent-activity-feed `mr-auto`→
  `me-auto`, icon `ml-1/ml-2`→`me-1/me-2`, required `*` `mr-1`→`ms-1`;
  document-type-editor list `pr-4`→`ps-4`.

Minors
- drafts-panel: `<a href>`→`next/link` (operations + citation links) for SPA
  nav. agent-activity-feed: issueMap memoized; comment Textarea aria-label.
  upload-sheet: `unknown` status no longer shown as green success (neutral
  icon + "רענן לאישור"). citations.ts: case_name typed `string | null`.

Design gate: visual-touching items (RTL gap side, retry button, neutral
upload icon) were chair-authorized via the reviewed-findings approval ("fix
all"); none alter an approved page layout — they are correctness fixes.
tsc clean; eslint clean (1 pre-existing form.watch warning, untouched line).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:57:43 +00:00

275 lines
8.2 KiB
TypeScript

/**
* Cases domain hooks.
*
* Note on types: the FastAPI `/api/cases` endpoint doesn't declare a response
* model, so openapi-typescript emits `unknown` for its payload. Until the
* backend is annotated (see out-of-scope in the rewrite plan), we maintain a
* small local type that matches what the running API returns today. Any drift
* surfaces as a runtime TypeScript error the first time a property is touched.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "./client";
import type { CaseCreateInput, CaseUpdateInput } from "@/lib/schemas/case";
import type { PracticeArea, AppealSubtype } from "@/lib/practice-area";
/* CaseStatus + the status list/labels/phases are defined once in ./case-status
* (single source of truth, UI-B1). Re-exported here so existing
* `import { CaseStatus } from "@/lib/api/cases"` sites keep working. */
export type { CaseStatus } from "./case-status";
import type { CaseStatus } from "./case-status";
export type Case = {
case_number: string;
title: string;
status: CaseStatus;
subject?: string | null;
expected_outcome?: string | null;
created_at?: string;
updated_at?: string;
/** ISO timestamp; null when active */
archived_at?: string | null;
/* Multi-tenant axis — populated by backfill + server-side derive */
practice_area?: PracticeArea;
appeal_subtype?: AppealSubtype;
/* Present when loaded with detail=true */
document_count?: number;
processing_count?: number;
committee_type?: string | null;
hearing_date?: string | null;
appellants?: string[] | null;
respondents?: string[] | null;
property_address?: string | null;
permit_number?: string | null;
/* 'ערר' = regular appeal, 'בל"מ' = extension-of-time request */
proceeding_type?: "ערר" | 'בל"מ';
};
export type CaseDocument = {
id: string;
case_id: string;
doc_type: string;
title: string;
file_path: string;
page_count: number | null;
extraction_status: string;
created_at: string;
practice_area?: PracticeArea;
appeal_subtype?: AppealSubtype;
/** Free-form JSONB. Known keys: appraiser_side, is_post_hearing, references. */
metadata?: Record<string, unknown>;
};
export type CaseDetail = Case & {
documents?: CaseDocument[];
blocks?: Array<{ code: string; status?: string; char_count?: number }>;
};
export type CasesScope = "active" | "archived" | "all";
export const casesKeys = {
all: ["cases"] as const,
list: (detail: boolean, scope: CasesScope = "active") =>
[...casesKeys.all, "list", { detail, scope }] as const,
detail: (caseNumber: string) =>
[...casesKeys.all, "detail", caseNumber] as const,
};
export function useCases(detail = false, scope: CasesScope = "active") {
return useQuery({
queryKey: casesKeys.list(detail, scope),
queryFn: ({ signal }) => {
const params = new URLSearchParams();
if (detail) params.set("detail", "true");
if (scope === "archived") params.set("archived_only", "true");
else if (scope === "all") params.set("include_archived", "true");
const qs = params.toString();
return apiRequest<Case[]>(`/api/cases${qs ? `?${qs}` : ""}`, { signal });
},
});
}
export type ArchiveResult = {
status: string;
case_number: string;
archived_at?: string | null;
paperclip?: {
status: string;
project_id?: string;
archived_at?: string | null;
message?: string;
issues_cancelled?: Array<{ identifier: string; title: string }>;
};
};
export function useArchiveCase(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: () =>
apiRequest<ArchiveResult>(`/api/cases/${caseNumber}/archive`, {
method: "POST",
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: casesKeys.all });
},
});
}
export function useRestoreCase(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: () =>
apiRequest<ArchiveResult>(`/api/cases/${caseNumber}/restore`, {
method: "POST",
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: casesKeys.all });
},
});
}
export function useCase(caseNumber: string | undefined) {
return useQuery({
queryKey: casesKeys.detail(caseNumber ?? ""),
queryFn: ({ signal }) =>
apiRequest<CaseDetail>(`/api/cases/${caseNumber}/details`, { signal }),
enabled: Boolean(caseNumber),
/* Replaces the old 5s polling from vanilla index.html */
staleTime: 5_000,
refetchInterval: 5_000,
});
}
export type WorkflowStatus = {
case_number?: string;
status?: CaseStatus | string;
current_step?: string;
steps?: Array<{
key: string;
label?: string;
status: "done" | "current" | "pending" | string;
}>;
/* FastAPI returns free-form JSON; keep it permissive */
[key: string]: unknown;
};
export function useCreateCase() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CaseCreateInput) =>
apiRequest<{ case_number?: string; message?: string; [k: string]: unknown }>(
`/api/cases/create`,
{ method: "POST", body: input },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: casesKeys.all });
},
});
}
export function useUpdateCase(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CaseUpdateInput) =>
apiRequest<CaseDetail>(`/api/cases/${caseNumber}`, {
method: "PUT",
body: input,
}),
onSuccess: (data) => {
/* Patch cached detail, then invalidate only the LIST queries — not
* casesKeys.all, which would also invalidate the detail we just patched
* and throw away the optimistic merge. */
if (caseNumber) {
qc.setQueryData<CaseDetail | undefined>(
casesKeys.detail(caseNumber),
(prev) => (prev ? { ...prev, ...data } : prev),
);
}
qc.invalidateQueries({ queryKey: [...casesKeys.all, "list"] });
},
});
}
export type GitSyncStatus = {
synced: boolean;
has_remote: boolean;
remote_url?: string | null;
dirty_files: number;
commits_ahead: number;
last_commit_time?: string | null;
last_commit_msg?: string | null;
error?: string;
};
export function useGitStatus(caseNumber: string | undefined) {
return useQuery({
queryKey: [...casesKeys.all, "git-status", caseNumber ?? ""] as const,
queryFn: ({ signal }) =>
apiRequest<GitSyncStatus>(`/api/cases/${caseNumber}/git-status`, { signal }),
enabled: Boolean(caseNumber),
staleTime: 30_000,
refetchInterval: 60_000,
});
}
export type CreateGiteaRepoResult = {
repo_url: string;
clone_url: string;
pushed: boolean;
};
export function useCreateGiteaRepo(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: { title: string; description?: string }) =>
apiRequest<CreateGiteaRepoResult>(`/api/integrations/gitea/create-repo`, {
method: "POST",
body: {
case_number: caseNumber,
title: input.title,
description: input.description ?? "",
},
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: [...casesKeys.all, "git-status", caseNumber ?? ""] });
},
});
}
export type StartWorkflowResult = {
case_number: string;
status: string;
issue_id: string;
issue_identifier: string;
project_url: string;
wakeup: Record<string, unknown>;
};
export function useStartWorkflow(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: () =>
apiRequest<StartWorkflowResult>(
`/api/cases/${caseNumber}/start-workflow`,
{ method: "POST" },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: casesKeys.all });
if (caseNumber) {
qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) });
}
},
});
}
export function useWorkflowStatus(caseNumber: string | undefined) {
return useQuery({
queryKey: [...casesKeys.all, "workflow", caseNumber ?? ""] as const,
queryFn: ({ signal }) =>
apiRequest<WorkflowStatus>(`/api/cases/${caseNumber}/status`, { signal }),
enabled: Boolean(caseNumber),
staleTime: 5_000,
refetchInterval: 5_000,
});
}