diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index f817789..9cab861 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -505,6 +505,18 @@ CREATE INDEX IF NOT EXISTS idx_appraiser_facts_side ON appraiser_facts(case_id, -- No schema change needed — uses existing JSONB metadata column. """ +# ── V6: Case archiving ──────────────────────────────────────────── + +SCHEMA_V6_SQL = """ +-- archived_at: timestamp when the case was moved to the archive screen. +-- NULL = active (default). Set via POST /api/cases/{case_number}/archive. +-- Cleared via POST /api/cases/{case_number}/restore. +-- The /api/cases endpoint filters out archived cases by default; +-- pass ?include_archived=true (or use /api/cases/archived) to see them. +ALTER TABLE cases ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ; +CREATE INDEX IF NOT EXISTS idx_cases_archived ON cases(archived_at) WHERE archived_at IS NOT NULL; +""" + async def init_schema() -> None: pool = await get_pool() @@ -515,7 +527,8 @@ async def init_schema() -> None: await conn.execute(SCHEMA_V3_SQL) await conn.execute(SCHEMA_V4_SQL) await conn.execute(SCHEMA_V5_SQL) - logger.info("Database schema initialized (v1 + v2 + v3 + v4 + v5)") + await conn.execute(SCHEMA_V6_SQL) + logger.info("Database schema initialized (v1-v6)") # ── Case CRUD ─────────────────────────────────────────────────────── @@ -593,18 +606,27 @@ async def get_case_by_number(case_number: str) -> dict | None: return _row_to_case(row) -async def list_cases(status: str | None = None, limit: int = 50) -> list[dict]: +async def list_cases( + status: str | None = None, + limit: int = 50, + include_archived: bool = False, + archived_only: bool = False, +) -> list[dict]: pool = await get_pool() + where = [] + args: list = [] + if status: + where.append(f"status = ${len(args) + 1}") + args.append(status) + if archived_only: + where.append("archived_at IS NOT NULL") + elif not include_archived: + where.append("archived_at IS NULL") + where_clause = f"WHERE {' AND '.join(where)}" if where else "" + args.append(limit) + sql = f"SELECT * FROM cases {where_clause} ORDER BY updated_at DESC LIMIT ${len(args)}" async with pool.acquire() as conn: - if status: - rows = await conn.fetch( - "SELECT * FROM cases WHERE status = $1 ORDER BY updated_at DESC LIMIT $2", - status, limit, - ) - else: - rows = await conn.fetch( - "SELECT * FROM cases ORDER BY updated_at DESC LIMIT $1", limit - ) + rows = await conn.fetch(sql, *args) return [_row_to_case(r) for r in rows] @@ -635,6 +657,30 @@ def _row_to_case(row: asyncpg.Record) -> dict: return d +async def archive_case(case_id: UUID) -> dict | None: + """Mark a case as archived. Returns updated row, or None if not found.""" + pool = await get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + "UPDATE cases SET archived_at = now(), updated_at = now() " + "WHERE id = $1 RETURNING *", + case_id, + ) + return _row_to_case(row) if row else None + + +async def restore_case(case_id: UUID) -> dict | None: + """Clear the archived_at timestamp. Returns updated row, or None if not found.""" + pool = await get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + "UPDATE cases SET archived_at = NULL, updated_at = now() " + "WHERE id = $1 RETURNING *", + case_id, + ) + return _row_to_case(row) if row else None + + # ── Document CRUD ─────────────────────────────────────────────────── async def create_document( diff --git a/web-ui/src/app/archive/page.tsx b/web-ui/src/app/archive/page.tsx new file mode 100644 index 0000000..43bf69d --- /dev/null +++ b/web-ui/src/app/archive/page.tsx @@ -0,0 +1,268 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Link from "next/link"; +import { + flexRender, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + useReactTable, + type ColumnDef, + type SortingState, +} from "@tanstack/react-table"; +import { toast } from "sonner"; +import { AppShell } from "@/components/app-shell"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useCases, useRestoreCase, type Case } from "@/lib/api/cases"; +import { APPEAL_SUBTYPE_LABELS } from "@/lib/practice-area"; + +function formatDate(iso?: string | null) { + if (!iso) return "—"; + try { + return new Date(iso).toLocaleDateString("he-IL", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + } catch { + return iso; + } +} + +function RestoreButton({ caseNumber }: { caseNumber: string }) { + const restore = useRestoreCase(caseNumber); + return ( + + ); +} + +const columns: ColumnDef[] = [ + { + accessorKey: "case_number", + header: "מס׳ ערר", + cell: ({ row }) => ( + + {row.original.case_number} + + ), + }, + { + accessorKey: "title", + header: "כותרת", + cell: ({ row }) => ( +
+ {row.original.title} +
+ ), + }, + { + accessorKey: "appeal_subtype", + header: "תחום", + cell: ({ row }) => { + const s = row.original.appeal_subtype; + if (!s || s === "unknown") + return ; + return ( + {APPEAL_SUBTYPE_LABELS[s]} + ); + }, + }, + { + accessorKey: "archived_at", + header: "תאריך ארכוב", + cell: ({ row }) => ( + + {formatDate(row.original.archived_at)} + + ), + }, + { + id: "actions", + header: "", + cell: ({ row }) => , + }, +]; + +export default function ArchivePage() { + const { data, isPending, error } = useCases(true, "archived"); + const [sorting, setSorting] = useState([ + { id: "archived_at", desc: true }, + ]); + const [globalFilter, setGlobalFilter] = useState(""); + + const rows = useMemo(() => data ?? [], [data]); + + const table = useReactTable({ + data: rows, + columns, + state: { sorting, globalFilter }, + onSortingChange: setSorting, + onGlobalFilterChange: setGlobalFilter, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + globalFilterFn: (row, _colId, filterValue: string) => { + if (!filterValue) return true; + const needle = filterValue.toLowerCase(); + return ( + row.original.case_number.toLowerCase().includes(needle) || + row.original.title.toLowerCase().includes(needle) + ); + }, + }); + + return ( + +
+
+
+ ארכיון תיקי ערר +
+

תיקים סגורים

+

+ תיקים שסגרו את הטיפול בהם. שחזור מחזיר את התיק לרשימה הראשית + ופותח מחדש את הפרויקט המקביל ב-Paperclip. +

+
+ +
+ + + +
+ setGlobalFilter(e.target.value)} + placeholder="חיפוש לפי מס׳ ערר או כותרת…" + className="max-w-sm bg-surface" + dir="rtl" + /> + + {table.getFilteredRowModel().rows.length} תיקים בארכיון + +
+ +
+ + + {table.getHeaderGroups().map((hg) => ( + + {hg.headers.map((header) => ( + + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + {{ asc: " ▲", desc: " ▼" }[ + header.column.getIsSorted() as string + ] ?? ""} + + ))} + + ))} + + + {isPending ? ( + Array.from({ length: 3 }).map((_, i) => ( + + {columns.map((_c, j) => ( + + + + ))} + + )) + ) : error ? ( + + + שגיאה בטעינת ארכיון: {error.message} + + + ) : table.getRowModel().rows.length === 0 ? ( + + +
+ ❦ +
+ {globalFilter + ? "אין תיקים תואמים לחיפוש" + : "אין תיקים בארכיון"} +
+
+ ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + )} +
+
+
+
+
+
+
+ ); +} diff --git a/web-ui/src/components/app-shell.tsx b/web-ui/src/components/app-shell.tsx index b8c13c6..ac03e0f 100644 --- a/web-ui/src/components/app-shell.tsx +++ b/web-ui/src/components/app-shell.tsx @@ -24,6 +24,7 @@ type NavItem = { const NAV_ITEMS: NavItem[] = [ { href: "/", label: "בית" }, + { href: "/archive", label: "ארכיון" }, { href: "/training", label: "אימון סגנון" }, { href: "/methodology", label: "מתודולוגיה" }, { href: "/skills", label: "מיומנויות" }, diff --git a/web-ui/src/components/cases/case-archive-action.tsx b/web-ui/src/components/cases/case-archive-action.tsx new file mode 100644 index 0000000..3c10c1e --- /dev/null +++ b/web-ui/src/components/cases/case-archive-action.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { Archive, RotateCcw, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { useArchiveCase, useRestoreCase, type ArchiveResult } from "@/lib/api/cases"; + +function paperclipMessage(res: ArchiveResult, action: "archive" | "restore"): string { + const verb = action === "archive" ? "אורכן" : "שוחזר"; + switch (res.paperclip?.status) { + case "archived": + case "restored": + return `התיק ${verb}. גם הפרויקט ב-Paperclip ${verb}.`; + case "not_found": + return `התיק ${verb}. לא נמצא פרויקט מקביל ב-Paperclip.`; + case "error": + return `התיק ${verb} ב-legal-ai, אך סנכרון Paperclip נכשל${ + res.paperclip?.message ? `: ${res.paperclip.message}` : "." + }`; + default: + return `התיק ${verb}.`; + } +} + +export function CaseArchiveAction({ + caseNumber, + archivedAt, +}: { + caseNumber: string; + archivedAt?: string | null; +}) { + const [open, setOpen] = useState(false); + const archive = useArchiveCase(caseNumber); + const restore = useRestoreCase(caseNumber); + + const isArchived = Boolean(archivedAt); + const pending = archive.isPending || restore.isPending; + + function handleArchive() { + archive.mutate(undefined, { + onSuccess: (res) => { + setOpen(false); + toast.success(paperclipMessage(res, "archive")); + }, + onError: (err) => + toast.error(err instanceof Error ? err.message : "שגיאה בארכוב"), + }); + } + + function handleRestore() { + restore.mutate(undefined, { + onSuccess: (res) => { + toast.success(paperclipMessage(res, "restore")); + }, + onError: (err) => + toast.error(err instanceof Error ? err.message : "שגיאה בשחזור"), + }); + } + + if (isArchived) { + return ( + + ); + } + + return ( + + + + + + + ארכון תיק {caseNumber} + + התיק יוסר מרשימת התיקים הראשית ויעבור לעמוד הארכיון. הפרויקט + המקביל ב-Paperclip יסומן גם הוא כארכוב. ניתן לשחזר בכל עת. + + + + + + + + + + + ); +} diff --git a/web-ui/src/components/cases/case-header.tsx b/web-ui/src/components/cases/case-header.tsx index 2d3ce56..e2b70d4 100644 --- a/web-ui/src/components/cases/case-header.tsx +++ b/web-ui/src/components/cases/case-header.tsx @@ -3,6 +3,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { StatusBadge } from "@/components/cases/status-badge"; import { SyncIndicator } from "@/components/cases/sync-indicator"; +import { CaseArchiveAction } from "@/components/cases/case-archive-action"; import { PRACTICE_AREA_LABELS, APPEAL_SUBTYPE_LABELS, @@ -41,6 +42,14 @@ export function CaseHeader({ data }: { data?: CaseDetail }) { ערר {data?.case_number ?? "—"} {data?.status && } + {data?.archived_at && ( + + בארכיון + + )} {data?.practice_area && ( )} + {data?.case_number && ( + + )}

{data?.title ?? "טוען…"} diff --git a/web-ui/src/lib/api/cases.ts b/web-ui/src/lib/api/cases.ts index 7ff63f3..b3316fb 100644 --- a/web-ui/src/lib/api/cases.ts +++ b/web-ui/src/lib/api/cases.ts @@ -40,6 +40,8 @@ export type Case = { 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; @@ -70,20 +72,60 @@ export type CaseDetail = Case & { 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) => [...casesKeys.all, "list", { detail }] 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) { +export function useCases(detail = false, scope: CasesScope = "active") { return useQuery({ - queryKey: casesKeys.list(detail), - queryFn: ({ signal }) => - apiRequest(`/api/cases${detail ? "?detail=true" : ""}`, { - signal, + 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(`/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 }; +}; + +export function useArchiveCase(caseNumber: string | undefined) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => + apiRequest(`/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(`/api/cases/${caseNumber}/restore`, { + method: "POST", + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: casesKeys.all }); + }, }); } diff --git a/web/app.py b/web/app.py index b95710c..1ec980c 100644 --- a/web/app.py +++ b/web/app.py @@ -36,6 +36,7 @@ _web_dir = Path(__file__).resolve().parent sys.path.insert(0, str(_web_dir.parent)) from web.gitea_client import create_repo, setup_remote_and_push from web.paperclip_client import ( + archive_project as pc_archive_project, create_project as pc_create_project, create_workflow_issue as pc_create_workflow_issue, get_agents_for_case as pc_get_agents_for_case, @@ -44,6 +45,7 @@ from web.paperclip_client import ( get_issue_comments as pc_get_issue_comments, get_project_url, post_comment as pc_post_comment, + restore_project as pc_restore_project, wake_ceo_agent as pc_wake_ceo, ) @@ -1021,12 +1023,25 @@ async def health(): @app.get("/api/cases") -async def list_cases(detail: bool = False): - """List existing cases. With detail=true, includes doc counts and integration URLs.""" - cases = await db.list_cases() +async def list_cases( + detail: bool = False, + include_archived: bool = False, + archived_only: bool = False, +): + """List existing cases. By default excludes archived (use include_archived=true + or archived_only=true to see them). With detail=true, includes doc counts.""" + cases = await db.list_cases( + include_archived=include_archived, + archived_only=archived_only, + ) if not detail: return [ - {"case_number": c["case_number"], "title": c["title"], "status": c["status"]} + { + "case_number": c["case_number"], + "title": c["title"], + "status": c["status"], + "archived_at": c["archived_at"].isoformat() if c.get("archived_at") else None, + } for c in cases ] # Enhanced listing with document counts @@ -1049,6 +1064,7 @@ async def list_cases(detail: bool = False): "expected_outcome": c.get("expected_outcome", ""), "committee_type": c.get("committee_type", ""), "hearing_date": str(c["hearing_date"]) if c.get("hearing_date") else "", + "archived_at": c["archived_at"].isoformat() if c.get("archived_at") else None, "document_count": doc_count, "processing_count": processing_count, "gitea_url": f"https://gitea.nautilus.marcusgroup.org/cases/{c['case_number']}", @@ -1056,6 +1072,53 @@ async def list_cases(detail: bool = False): return result +@app.post("/api/cases/{case_number}/archive") +async def api_archive_case(case_number: str): + """Move a case to the archive. Also archives the matching Paperclip project.""" + case = await db.get_case_by_number(case_number) + if not case: + raise HTTPException(404, f"Case {case_number} not found") + + updated = await db.archive_case(UUID(case["id"])) + + paperclip_result: dict = {"status": "skipped"} + try: + paperclip_result = await pc_archive_project(case_number) + except Exception as e: + logger.warning("paperclip archive sync failed for %s: %s", case_number, e) + paperclip_result = {"status": "error", "message": str(e)} + + return { + "status": "archived", + "case_number": case_number, + "archived_at": updated["archived_at"].isoformat() if updated and updated.get("archived_at") else None, + "paperclip": paperclip_result, + } + + +@app.post("/api/cases/{case_number}/restore") +async def api_restore_case(case_number: str): + """Restore an archived case. Also restores the matching Paperclip project.""" + case = await db.get_case_by_number(case_number) + if not case: + raise HTTPException(404, f"Case {case_number} not found") + + await db.restore_case(UUID(case["id"])) + + paperclip_result: dict = {"status": "skipped"} + try: + paperclip_result = await pc_restore_project(case_number) + except Exception as e: + logger.warning("paperclip restore sync failed for %s: %s", case_number, e) + paperclip_result = {"status": "error", "message": str(e)} + + return { + "status": "restored", + "case_number": case_number, + "paperclip": paperclip_result, + } + + # ── Paperclip Integration API ───────────────────────────────────── diff --git a/web/paperclip_client.py b/web/paperclip_client.py index 9497937..4325f03 100644 --- a/web/paperclip_client.py +++ b/web/paperclip_client.py @@ -138,6 +138,59 @@ async def create_project( await conn.close() +async def archive_project(case_number: str) -> dict: + """Set archived_at on the Paperclip project matching this case number. + + The project is identified by `name LIKE '%{case_number}%'` (consistent with + `create_project`'s lookup). Idempotent — if already archived, returns the + existing timestamp. + """ + conn = await asyncpg.connect(PAPERCLIP_DB_URL) + try: + row = await conn.fetchrow( + """UPDATE projects + SET archived_at = COALESCE(archived_at, now()), + updated_at = now() + WHERE name LIKE $1 RETURNING id, name, archived_at""", + f"%{case_number}%", + ) + if not row: + return {"status": "not_found", "case_number": case_number} + return { + "status": "archived", + "project_id": str(row["id"]), + "name": row["name"], + "archived_at": row["archived_at"].isoformat() if row["archived_at"] else None, + } + finally: + await conn.close() + + +async def restore_project(case_number: str) -> dict: + """Clear archived_at on the Paperclip project matching this case number. + + Idempotent — if already active, returns success without changes. + """ + conn = await asyncpg.connect(PAPERCLIP_DB_URL) + try: + row = await conn.fetchrow( + """UPDATE projects + SET archived_at = NULL, + updated_at = now() + WHERE name LIKE $1 RETURNING id, name""", + f"%{case_number}%", + ) + if not row: + return {"status": "not_found", "case_number": case_number} + return { + "status": "restored", + "project_id": str(row["id"]), + "name": row["name"], + } + finally: + await conn.close() + + async def _create_issue( conn: asyncpg.Connection, company_id: str,