"""Client-facing claim endpoints: list, detail, document upload/download, messages. All queries scoped by session client_id. 404 (not 403) for other clients' claims. """ from __future__ import annotations import hashlib import html import logging import os import uuid from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status from fastapi.responses import FileResponse, JSONResponse from . import auth as authmod from . import db from . import dreemail from .db import get_conn, get_upload_dir, new_uuid, utcnow_iso from .models import MessageCreate logger = logging.getLogger("dre.claims") router = APIRouter() # Document constraints MAX_FILE_BYTES = 20 * 1024 * 1024 # 20 MB MAX_DOCS_PER_CLAIM = 50 ALLOWED_EXT = {".pdf", ".jpg", ".jpeg", ".png", ".doc", ".docx"} MAGIC_BYTES = { "pdf": (b"%PDF",), "jpg": (b"\xff\xd8\xff",), "png": (b"\x89PNG\r\n\x1a\n", b"\x89PNG"), "docx": (b"PK\x03\x04",), "doc": (b"PK\x03\x04", b"\xd0\xcf\x11\xe0"), # docx is ZIP; legacy .doc is OLE } EXT_TO_KIND = { ".pdf": "pdf", ".jpg": "jpg", ".jpeg": "jpg", ".png": "png", ".doc": "doc", ".docx": "docx", } STATUS_LABELS = { "NEW": "Received", "UNDER_REVIEW": "Under Review", "ACTIVE": "In Progress", "NEGOTIATION": "In Negotiation", "LEGAL": "Legal Action", "SETTLED": "Settled", "CLOSED": "Closed — Recovered", "WRITE_OFF": "Closed — Uncollectible", "REJECTED": "Not Accepted", } TIER_STEPS = {"TIER_1": 1, "TIER_2": 2, "TIER_2_5": 2, "TIER_3": 3, "TIER_4": 4} def _money(cents: int) -> str: return f"${cents / 100.0:,.2f}" def _err(code: str, message: str, status_code: int): return JSONResponse(status_code=status_code, content={"error": {"code": code, "message": message}}) # --------------------------------------------------------------- # GET /api/claims # --------------------------------------------------------------- @router.get("/api/claims") async def list_claims(session: dict = Depends(authmod.require_client)): client_id = session["client_id"] with get_conn() as conn: rows = conn.execute( "SELECT c.claim_number, c.status, c.tier, c.amount_cents, c.created_at, c.date_resolved, " "d.name AS debtor_name FROM claims c JOIN debtors d ON d.id = c.debtor_id " "WHERE c.client_id = ? ORDER BY c.created_at DESC", (client_id,), ).fetchall() claims = [ { "claim_number": r["claim_number"], "status": r["status"], "status_label": STATUS_LABELS.get(r["status"], r["status"]), "tier": r["tier"], "amount_cents": r["amount_cents"], "amount_display": _money(r["amount_cents"]), "debtor_name": r["debtor_name"], "created_at": r["created_at"], "date_resolved": r["date_resolved"], } for r in rows ] return {"claims": claims} # --------------------------------------------------------------- # GET /api/claims/{claim_number} # --------------------------------------------------------------- @router.get("/api/claims/{claim_number}") async def get_claim(claim_number: str, session: dict = Depends(authmod.require_client)): client_id = session["client_id"] with get_conn() as conn: row = conn.execute( "SELECT c.*, d.name AS debtor_name, d.business_type FROM claims c " "JOIN debtors d ON d.id = c.debtor_id " "WHERE c.claim_number = ? AND c.client_id = ?", (claim_number, client_id), ).fetchone() if row is None: return _err("not_found", "Claim not found.", status.HTTP_404_NOT_FOUND) docs = conn.execute( "SELECT id, original_name, size_bytes, mime_type, uploaded_by, created_at FROM documents WHERE claim_id = ?", (row["id"],), ).fetchall() notes = conn.execute( "SELECT author_type, author_name, subject, content, created_at FROM case_notes " "WHERE claim_id = ? AND visibility = 'SHARED' ORDER BY created_at ASC", (row["id"],), ).fetchall() return { "claim_number": row["claim_number"], "status": row["status"], "status_label": STATUS_LABELS.get(row["status"], row["status"]), "tier": row["tier"], "tier_step": TIER_STEPS.get(row["tier"], 1), "amount_cents": row["amount_cents"], "amount_display": _money(row["amount_cents"]), "description": row["description"], "client_reference": row["client_reference"], "invoice_date": row["invoice_date"], "date_assigned": row["date_assigned"], "date_resolved": row["date_resolved"], "debtor": {"name": row["debtor_name"], "business_type": row["business_type"]}, "documents": [ { "id": d["id"], "original_name": d["original_name"], "size_bytes": d["size_bytes"], "mime_type": d["mime_type"], "uploaded_by": d["uploaded_by"], "created_at": d["created_at"], } for d in docs ], "notes": [ { "author_type": n["author_type"], "author_name": n["author_name"], "subject": n["subject"], # XSS: escape content server-side; frontend renders via textContent "content": html.escape(n["content"]), "created_at": n["created_at"], } for n in notes ], } # --------------------------------------------------------------- # POST /api/claims/{claim_number}/documents (upload) # --------------------------------------------------------------- @router.post("/api/claims/{claim_number}/documents") async def upload_document(claim_number: str, request: Request, file: UploadFile = File(...), session: dict = Depends(authmod.require_client)): client_id = session["client_id"] # Check content length early cl = request.headers.get("content-length") if cl and int(cl) > MAX_FILE_BYTES + 4096: return _err("payload_too_large", "File exceeds 20 MB limit.", status.HTTP_413_REQUEST_ENTITY_TOO_LARGE) ext = os.path.splitext(file.filename or "")[1].lower() if ext not in ALLOWED_EXT: return _err("unsupported_media_type", "File type not allowed.", status.HTTP_415_UNSUPPORTED_MEDIA_TYPE) kind = EXT_TO_KIND.get(ext) if not kind: return _err("unsupported_media_type", "File type not allowed.", status.HTTP_415_UNSUPPORTED_MEDIA_TYPE) with get_conn() as conn: row = conn.execute( "SELECT id FROM claims WHERE claim_number = ? AND client_id = ?", (claim_number, client_id), ).fetchone() if row is None: return _err("not_found", "Claim not found.", status.HTTP_404_NOT_FOUND) claim_id = row["id"] count = conn.execute("SELECT COUNT(*) AS n FROM documents WHERE claim_id = ?", (claim_id,)).fetchone()["n"] if count >= MAX_DOCS_PER_CLAIM: return _err("conflict", "Document limit reached for this claim.", status.HTTP_409_CONFLICT) upload_dir = os.path.join(get_upload_dir(), claim_id) os.makedirs(upload_dir, exist_ok=True) stored_uuid = str(uuid.uuid4()) stored_name = f"{stored_uuid}{ext}" stored_path = os.path.join(upload_dir, stored_name) sha = hashlib.sha256() total = 0 magic_seen = False with open(stored_path, "wb") as f: while True: chunk = await file.read(64 * 1024) if not chunk: break total += len(chunk) if total > MAX_FILE_BYTES: f.close() os.remove(stored_path) return _err("payload_too_large", "File exceeds 20 MB limit.", status.HTTP_413_REQUEST_ENTITY_TOO_LARGE) if not magic_seen: sigs = MAGIC_BYTES.get(kind, ()) if sigs and any(chunk.startswith(s) for s in sigs): magic_seen = True elif sigs: f.close() os.remove(stored_path) return _err("unsupported_media_type", "File content does not match extension.", status.HTTP_415_UNSUPPORTED_MEDIA_TYPE) sha.update(chunk) f.write(chunk) os.chmod(stored_path, 0o640) now = utcnow_iso() doc_id = new_uuid() mime = file.content_type or "application/octet-stream" conn.execute( "INSERT INTO documents (id, claim_id, original_name, stored_path, mime_type, size_bytes, sha256, uploaded_by, twentycrm_id, created_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, 'CLIENT', NULL, ?)", (doc_id, claim_id, os.path.basename(file.filename or "file"), stored_path, mime, total, sha.hexdigest(), now), ) # SYSTEM note + audit conn.execute( "INSERT INTO case_notes (id, claim_id, author_type, author_name, subject, content, visibility, twentycrm_id, created_at) " "VALUES (?, ?, 'SYSTEM', 'System', NULL, ?, 'SHARED', NULL, ?)", (new_uuid(), claim_id, f"Document uploaded: {os.path.basename(file.filename or 'file')}", now), ) conn.execute( "INSERT INTO audit_log (id, entity_type, entity_id, action, actor, created_at) VALUES (?, ?, ?, ?, ?, ?)", (new_uuid(), "document", doc_id, "upload", session["client_number"], now), ) conn.commit() return { "id": doc_id, "original_name": os.path.basename(file.filename or "file"), "size_bytes": total, "mime_type": mime, "uploaded_by": "CLIENT", "created_at": now, } # --------------------------------------------------------------- # GET /api/claims/{claim_number}/documents/{document_id} (download) # --------------------------------------------------------------- @router.get("/api/claims/{claim_number}/documents/{document_id}") async def download_document(claim_number: str, document_id: str, request: Request, session: dict | None = None): # Auth: client OR staff is_staff = authmod.verify_staff_key(request.headers.get("x-dre-staff-key")) client_session = None if not is_staff: try: client_session = authmod.require_client(request) except HTTPException: return _err("unauthorized", "Authentication required.", status.HTTP_401_UNAUTHORIZED) with get_conn() as conn: row = conn.execute( "SELECT d.*, c.claim_number FROM documents d JOIN claims c ON c.id = d.claim_id " "WHERE d.id = ? AND c.claim_number = ?", (document_id, claim_number), ).fetchone() if row is None: return _err("not_found", "Document not found.", status.HTTP_404_NOT_FOUND) if not is_staff and row["claim_id"]: # verify ownership owner = conn.execute("SELECT client_id FROM claims WHERE id = ?", (row["claim_id"],)).fetchone() if owner is None or owner["client_id"] != client_session["client_id"]: return _err("not_found", "Document not found.", status.HTTP_404_NOT_FOUND) if not os.path.exists(row["stored_path"]): return _err("not_found", "File missing on disk.", status.HTTP_404_NOT_FOUND) return FileResponse( row["stored_path"], media_type=row["mime_type"] or "application/octet-stream", filename=row["original_name"], ) # --------------------------------------------------------------- # POST /api/claims/{claim_number}/messages # --------------------------------------------------------------- @router.post("/api/claims/{claim_number}/messages") async def create_message(claim_number: str, request: Request, session: dict = Depends(authmod.require_client)): client_id = session["client_id"] try: body = await request.json() except Exception: return _err("validation_error", "Invalid JSON body.", status.HTTP_422_UNPROCESSABLE_ENTITY) try: msg = MessageCreate.model_validate(body) except Exception as exc: from pydantic import ValidationError if isinstance(exc, ValidationError): parts = [f"{'.'.join(str(x) for x in e['loc'])}: {e['msg']}" for e in exc.errors()] return _err("validation_error", "; ".join(parts), status.HTTP_422_UNPROCESSABLE_ENTITY) return _err("validation_error", str(exc), status.HTTP_422_UNPROCESSABLE_ENTITY) with get_conn() as conn: row = conn.execute( "SELECT id FROM claims WHERE claim_number = ? AND client_id = ?", (claim_number, client_id), ).fetchone() if row is None: return _err("not_found", "Claim not found.", status.HTTP_404_NOT_FOUND) claim_id = row["id"] # Get client contact name for author c = conn.execute("SELECT contact_name, company_name FROM clients WHERE id = ?", (client_id,)).fetchone() if c is None: return _err("not_found", "Client not found.", status.HTTP_404_NOT_FOUND) now = utcnow_iso() note_id = new_uuid() conn.execute( "INSERT INTO case_notes (id, claim_id, author_type, author_name, subject, content, visibility, twentycrm_id, created_at) " "VALUES (?, ?, 'CLIENT', ?, ?, ?, 'SHARED', NULL, ?)", (note_id, claim_id, c["contact_name"], msg.subject, msg.content, now), ) conn.execute( "INSERT INTO audit_log (id, entity_type, entity_id, action, actor, created_at) VALUES (?, ?, ?, ?, ?, ?)", (new_uuid(), "note", note_id, "note_add", session["client_number"], now), ) conn.commit() # Best-effort team notification try: dreemail.notify_team_message(claim_number, msg.subject, msg.content, c["contact_name"]) except Exception as exc: # noqa: BLE001 logger.error("message team notify failed: %s", exc) return { "id": note_id, "author_type": "CLIENT", "author_name": c["contact_name"], "subject": msg.subject, "content": html.escape(msg.content), "visibility": "SHARED", "created_at": now, }