Case archive/restore with Paperclip sync
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m27s

Adds a comprehensive archive flow for closed cases — separate /archive
screen in the UI, archive/restore actions on the case detail page, and
automatic two-way sync with Paperclip.

Backend (web/app.py + mcp-server/services/db.py):
- New SCHEMA_V6 migration: cases.archived_at TIMESTAMPTZ + partial index
- list_cases gains include_archived/archived_only flags; default excludes
  archived rows so the main /api/cases list hides closed cases
- archive_case / restore_case helpers in db.py
- POST /api/cases/{n}/archive sets archived_at and calls
  pc_archive_project (sets Paperclip projects.archived_at via direct DB)
- POST /api/cases/{n}/restore clears archived_at and calls
  pc_restore_project (clears Paperclip archived_at)
- archive_project / restore_project in paperclip_client.py — name-based
  match consistent with create_project's lookup

Frontend (web-ui):
- cases.ts: scope param ("active"|"archived"|"all") on useCases;
  useArchiveCase / useRestoreCase mutations
- /archive page (new): table of archived cases with restore button +
  search, sort, empty state matching the editorial aesthetic of /
- case-archive-action.tsx: button on case detail header. Active case →
  confirm dialog → archive. Archived case → restore (no confirm).
  Toast announces both legal-ai and Paperclip outcomes (synced, not
  found in pc, error)
- case-header shows "בארכיון" badge when archived_at is set
- Nav: ארכיון link added to AppShell after בית

Tested end-to-end against the live DB:
- 1130-25 archive → list_cases(include_archived=False) excludes it,
  list_cases(archived_only=True) includes it, restore reverses
- pc archive/restore on 1194-25 verified via direct DB lookup
- TypeScript compiles clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 18:54:52 +00:00
parent 8b816c8b61
commit 2b7f291928
8 changed files with 629 additions and 21 deletions

View File

@@ -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(