Case archive/restore with Paperclip sync
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m27s
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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user