"""Internal staff endpoints (staff-key auth): claims list/detail/patch, notes, documents, stats, audit.""" from __future__ import annotations import hashlib import html import json import logging import os import uuid from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status from fastapi.responses import JSONResponse from pydantic import ValidationError from . import auth as authmod from . import db from .db import get_conn, get_upload_dir, new_uuid, utcnow_iso from .models import StaffApproval, StaffClaimPatch, StaffLetterUpdate, StaffNoteCreate, StaffOnboardingUpdate from .claims import ( # noqa: E402 MAX_FILE_BYTES, ALLOWED_EXT, MAGIC_BYTES, EXT_TO_KIND, STATUS_LABELS, TIER_STEPS, _money, _err, ) from . import analysis from . import packet from . import docuseal logger = logging.getLogger("dre.staff") router = APIRouter() def _json_or_none(raw: str | None): """Parse a JSON TEXT column safely, returning None on empty/invalid.""" if not raw: return None try: import json return json.loads(raw) except (ValueError, TypeError): return None def _tier_advance_gate(conn, row) -> str | None: """Return a human-readable blocker if recovery may NOT advance tier, else None. Rules: the claim must be approved & accepted (approval APPROVED + status ACTIVE) AND all onboarding paperwork must be returned before escalation. """ if row["approval_status"] != "APPROVED": return "Cannot advance tier until the claim is approved and accepted." if row["status"] != "ACTIVE": return "Cannot advance tier until the claim is accepted (status ACTIVE)." ob = packet.onboarding_status(conn, row["id"]) if not ob["complete"]: outstanding = ", ".join(i["title"] for i in ob["outstanding"]) return f"Cannot advance tier until all onboarding paperwork is returned. Outstanding: {outstanding}." return None # --------------------------------------------------------------- # GET /api/staff/me (acting staff identity) # --------------------------------------------------------------- @router.get("/api/staff/me") async def staff_me(staff_name: str = Depends(authmod.require_staff)): return {"name": staff_name} # --------------------------------------------------------------- # POST /api/staff/claims/{claim_number}/onboarding/send # Create DocuSeal signature requests for all pending onboarding docs, # prefilled from the client's submission, and email the signing links. # NOTE: registered BEFORE the {doc_key} route below so "send" is not # swallowed as a doc_key path segment. # --------------------------------------------------------------- @router.post("/api/staff/claims/{claim_number}/onboarding/send") async def staff_send_onboarding(claim_number: str, request: Request, staff_name: str = Depends(authmod.require_staff)): try: body = await request.json() except Exception: body = {} send_email = bool(body.get("send_email", True)) with get_conn() as conn: row = conn.execute("SELECT id FROM claims WHERE claim_number = ?", (claim_number,)).fetchone() if row is None: return _err("not_found", "Claim not found.", status.HTTP_404_NOT_FOUND) claim_id = row["id"] packet.ensure_onboarding_docs(conn, claim_id) client = conn.execute( "SELECT email, contact_name FROM clients cl JOIN claims c ON c.client_id = cl.id WHERE c.id = ?", (claim_id,), ).fetchone() if client is None: return _err("validation_error", "Claim has no linked client.", status.HTTP_422_UNPROCESSABLE_ENTITY) if not client["email"]: return _err("validation_error", "Client has no email on file.", status.HTTP_422_UNPROCESSABLE_ENTITY) values, _vals_row = packet.merged_values(conn, claim_number) pending = conn.execute( "SELECT doc_key FROM onboarding_docs WHERE claim_id = ? AND status != 'RECEIVED' ORDER BY rowid", (claim_id,), ).fetchall() if not pending: return {"claim_number": claim_number, "sent": [], "errors": [], "message": "No pending onboarding documents to send."} title_by_key = dict(packet.ONBOARDING_DOCS) sent = [] errors = [] for p in pending: doc_key = p["doc_key"] try: sub = docuseal.create_submission( doc_key, client["email"], client["contact_name"] or client["email"], values, send_email=send_email, ) except docuseal.DocuSealError as exc: errors.append({"doc_key": doc_key, "error": str(exc)}) continue now = utcnow_iso() conn.execute( "UPDATE onboarding_docs SET docuseal_submission_id = ?, docuseal_submitter_id = ?, " "docuseal_slug = ?, docuseal_embed_src = ?, docuseal_status = ?, docuseal_sent_at = ? " "WHERE claim_id = ? AND doc_key = ?", (str(sub.get("submission_id")), str(sub.get("id")), sub.get("slug"), sub.get("embed_src"), sub.get("status"), sub.get("sent_at") or now, claim_id, doc_key), ) sent.append({ "doc_key": doc_key, "title": title_by_key.get(doc_key, doc_key), "submission_id": sub.get("submission_id"), "status": sub.get("status"), "signing_url": docuseal.signing_url(sub.get("slug"), sub.get("embed_src")), "sent_email": send_email, }) conn.execute( "INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) " "VALUES (?, 'claim', ?, 'onboarding_send', NULL, NULL, ?, ?, ?)", (new_uuid(), claim_id, f"{len(sent)} doc(s) sent", staff_name, utcnow_iso()), ) conn.commit() return {"claim_number": claim_number, "sent": sent, "errors": errors} # --------------------------------------------------------------- # POST /api/staff/claims/{claim_number}/onboarding/{doc_key} # Mark a welcome-packet document received (or not). # --------------------------------------------------------------- @router.post("/api/staff/claims/{claim_number}/onboarding/{doc_key}") async def staff_set_onboarding(claim_number: str, doc_key: str, request: Request, staff_name: str = Depends(authmod.require_staff)): if doc_key not in [k for k, _t in packet.ONBOARDING_DOCS]: return _err("validation_error", "Unknown document key.", status.HTTP_422_UNPROCESSABLE_ENTITY) try: body = await request.json() except Exception: return _err("validation_error", "Invalid JSON body.", status.HTTP_422_UNPROCESSABLE_ENTITY) try: upd = StaffOnboardingUpdate.model_validate(body) except ValidationError as exc: 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) with get_conn() as conn: row = conn.execute("SELECT id FROM claims WHERE claim_number = ?", (claim_number,)).fetchone() if row is None: return _err("not_found", "Claim not found.", status.HTTP_404_NOT_FOUND) packet.ensure_onboarding_docs(conn, row["id"]) now = utcnow_iso() new_status = "RECEIVED" if upd.received else "PENDING" received_by = (upd.received_by or staff_name) if upd.received else None received_at = now if upd.received else None conn.execute( "UPDATE onboarding_docs SET status = ?, received_by = ?, received_at = ? " "WHERE claim_id = ? AND doc_key = ?", (new_status, received_by, received_at, row["id"], doc_key), ) conn.execute( "INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) " "VALUES (?, 'claim', ?, 'onboarding_doc', ?, NULL, ?, ?, ?)", (new_uuid(), row["id"], doc_key, new_status, staff_name, now), ) conn.commit() return await staff_get_claim(claim_number) # --------------------------------------------------------------- # GET /api/staff/claims # --------------------------------------------------------------- @router.get("/api/staff/claims") async def staff_list_claims(request: Request, _staff=Depends(authmod.require_staff)): status_filter = request.query_params.get("status") tier_filter = request.query_params.get("tier") q = request.query_params.get("q", "").strip() limit = min(int(request.query_params.get("limit", "50")), 200) offset = max(int(request.query_params.get("offset", "0")), 0) where = [] params: list = [] if status_filter: where.append("c.status = ?") params.append(status_filter) if tier_filter: where.append("c.tier = ?") params.append(tier_filter) if q: where.append("(c.claim_number LIKE ? OR cl.company_name LIKE ? OR d.name LIKE ?)") like = f"%{q}%" params.extend([like, like, like]) where_sql = ("WHERE " + " AND ".join(where)) if where else "" sql = ( "SELECT c.id AS claim_id, c.claim_number, c.status, c.tier, c.amount_cents, c.created_at, c.date_resolved, " "c.date_assigned, c.analysis_score, c.approval_status, c.recommended_tier, " "cl.company_name, cl.client_number, d.name AS debtor_name, d.business_type " "FROM claims c JOIN clients cl ON cl.id = c.client_id JOIN debtors d ON d.id = c.debtor_id " f"{where_sql} ORDER BY c.created_at DESC LIMIT ? OFFSET ?" ) params.extend([limit, offset]) with get_conn() as conn: rows = conn.execute(sql, tuple(params)).fetchall() total = conn.execute(f"SELECT COUNT(*) AS n FROM claims c JOIN clients cl ON cl.id=c.client_id JOIN debtors d ON d.id=c.debtor_id {where_sql}", tuple(params[:-2])).fetchone()["n"] claims = [] for r in rows: ob = packet.onboarding_status(conn, r["claim_id"]) claims.append({ "claim_number": r["claim_number"], "status": r["status"], "status_label": STATUS_LABELS.get(r["status"], r["status"]), "tier": r["tier"], "tier_label": analysis.TIER_LABELS.get(r["tier"], r["tier"]), "amount_cents": r["amount_cents"], "amount_display": _money(r["amount_cents"]), "analysis_score": r["analysis_score"], "approval_status": r["approval_status"], "recommended_tier": r["recommended_tier"], "company_name": r["company_name"], "client_number": r["client_number"], "debtor_name": r["debtor_name"], "business_type": r["business_type"], "created_at": r["created_at"], "date_assigned": r["date_assigned"], "date_resolved": r["date_resolved"], "onboarding": { "received": ob["received"], "total": ob["total"], "complete": ob["complete"], }, }) return { "claims": claims, "total": total, "limit": limit, "offset": offset, } # --------------------------------------------------------------- # GET /api/staff/claims/{claim_number} # --------------------------------------------------------------- @router.get("/api/staff/claims/{claim_number}") async def staff_get_claim(claim_number: str, _staff=Depends(authmod.require_staff)): with get_conn() as conn: row = conn.execute( "SELECT c.*, cl.company_name, cl.contact_name, cl.email AS client_email, cl.phone AS client_phone, " "cl.client_number, d.name AS debtor_name, d.business_type, d.contact_email AS debtor_email, " "d.contact_phone AS debtor_phone, d.physical_address AS debtor_address " "FROM claims c JOIN clients cl ON cl.id = c.client_id JOIN debtors d ON d.id = c.debtor_id " "WHERE c.claim_number = ?", (claim_number,), ).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, visibility, created_at FROM case_notes " "WHERE claim_id = ? ORDER BY created_at ASC", (row["id"],), ).fetchall() audit = conn.execute( "SELECT entity_type, entity_id, action, field, old_value, new_value, actor, reason, created_at " "FROM audit_log WHERE entity_type IN ('claim','document','note') AND entity_id = ? " "ORDER BY created_at ASC", (row["id"],), ).fetchall() onboarding = packet.onboarding_status(conn, row["id"]) 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), "tier_label": analysis.TIER_LABELS.get(row["tier"], row["tier"]), "tier_description": analysis.TIER_DESCRIPTIONS.get(row["tier"], ""), "next_tier": analysis.next_tier(row["tier"]), "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"], "created_at": row["created_at"], "analysis": { "score": row["analysis_score"], "summary": row["analysis_summary"], "components": _json_or_none(row["analysis_components"]), "at": row["analysis_at"], "recommended_tier": row["recommended_tier"], }, "approval": { "status": row["approval_status"], "decision_by": row["approval_decision_by"], "decision_at": row["approval_decision_at"], }, "letter": { "subject": row["letter_subject"], "body": row["letter_body"], "tier": row["letter_tier"], "updated_at": row["letter_updated_at"], }, "onboarding": onboarding, "client": { "client_number": row["client_number"], "company_name": row["company_name"], "contact_name": row["contact_name"], "email": row["client_email"], "phone": row["client_phone"], }, "debtor": { "name": row["debtor_name"], "business_type": row["business_type"], "contact_email": row["debtor_email"], "contact_phone": row["debtor_phone"], "physical_address": row["debtor_address"], }, "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"], "content": html.escape(n["content"]), "visibility": n["visibility"], "created_at": n["created_at"], } for n in notes ], "audit": [ { "entity_type": a["entity_type"], "entity_id": a["entity_id"], "action": a["action"], "field": a["field"], "old_value": a["old_value"], "new_value": a["new_value"], "actor": a["actor"], "reason": a["reason"], "created_at": a["created_at"], } for a in audit ], } # --------------------------------------------------------------- # PATCH /api/staff/claims/{claim_number} # --------------------------------------------------------------- @router.patch("/api/staff/claims/{claim_number}") async def staff_patch_claim(claim_number: str, request: Request, staff_name: str = Depends(authmod.require_staff)): try: body = await request.json() except Exception: return _err("validation_error", "Invalid JSON body.", status.HTTP_422_UNPROCESSABLE_ENTITY) try: patch = StaffClaimPatch.model_validate(body) except ValidationError as exc: 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) with get_conn() as conn: row = conn.execute("SELECT * FROM claims WHERE claim_number = ?", (claim_number,)).fetchone() if row is None: return _err("not_found", "Claim not found.", status.HTTP_404_NOT_FOUND) claim_id = row["id"] now = utcnow_iso() updates: list[tuple] = [] new_status = row["status"] new_tier = row["tier"] date_assigned = row["date_assigned"] date_resolved = row["date_resolved"] # Status change if patch.status is not None and patch.status != row["status"]: conn.execute( "INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, reason, created_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (new_uuid(), "claim", claim_id, "status_change", "status", row["status"], patch.status, staff_name, patch.reason, now), ) new_status = patch.status # set date_assigned on first move out of NEW if row["status"] == "NEW" and patch.status != "NEW" and not date_assigned: date_assigned = now # set date_resolved on resolved states if patch.status in ("SETTLED", "CLOSED", "WRITE_OFF", "REJECTED") and not date_resolved: date_resolved = now # auto-add SYSTEM/SHARED note so client sees status change 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"Status updated to: {STATUS_LABELS.get(patch.status, patch.status)}", now), ) # Tier change if patch.tier is not None and patch.tier != row["tier"]: # Gate escalation: recovery may not escalate until the claim is # approved & accepted AND all onboarding paperwork is returned. try: escalating = analysis.TIER_ORDER.index(patch.tier) > analysis.TIER_ORDER.index(row["tier"]) except ValueError: escalating = False if escalating: gate = _tier_advance_gate(conn, row) if gate is not None: return _err("conflict", gate, status.HTTP_409_CONFLICT) conn.execute( "INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, reason, created_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (new_uuid(), "claim", claim_id, "status_change", "tier", row["tier"], patch.tier, staff_name, patch.reason, now), ) new_tier = patch.tier conn.execute( "UPDATE claims SET status = ?, tier = ?, date_assigned = ?, date_resolved = ?, updated_at = ? WHERE id = ?", (new_status, new_tier, date_assigned, date_resolved, now, claim_id), ) conn.commit() # Return updated detail return await staff_get_claim_inner(claim_number) async def staff_get_claim_inner(claim_number: str): """Reuse detail query without re-checking staff auth.""" with get_conn() as conn: row = conn.execute( "SELECT c.*, cl.company_name, cl.contact_name, cl.email AS client_email, cl.phone AS client_phone, " "cl.client_number, d.name AS debtor_name, d.business_type " "FROM claims c JOIN clients cl ON cl.id = c.client_id JOIN debtors d ON d.id = c.debtor_id " "WHERE c.claim_number = ?", (claim_number,), ).fetchone() if row is None: return JSONResponse(status_code=status.HTTP_404_NOT_FOUND, content={"error": {"code": "not_found", "message": "Claim not found."}}) 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"]), "date_assigned": row["date_assigned"], "date_resolved": row["date_resolved"], "updated_at": row["updated_at"], } # --------------------------------------------------------------- # POST /api/staff/claims/{claim_number}/analyze (AI score) # --------------------------------------------------------------- @router.post("/api/staff/claims/{claim_number}/analyze") async def staff_analyze_claim(claim_number: str, _staff=Depends(authmod.require_staff)): with get_conn() as conn: row = conn.execute( "SELECT id FROM claims WHERE claim_number = ?", (claim_number,) ).fetchone() if row is None: return _err("not_found", "Claim not found.", status.HTTP_404_NOT_FOUND) analysis.analyze_and_store(conn, row["id"], actor="staff") conn.commit() return await staff_get_claim(claim_number) # --------------------------------------------------------------- # POST /api/staff/claims/{claim_number}/approve (accept / reject) # --------------------------------------------------------------- @router.post("/api/staff/claims/{claim_number}/approve") async def staff_approve_claim(claim_number: str, request: Request, staff_name: str = Depends(authmod.require_staff)): try: body = await request.json() except Exception: return _err("validation_error", "Invalid JSON body.", status.HTTP_422_UNPROCESSABLE_ENTITY) try: approval = StaffApproval.model_validate(body) except ValidationError as exc: 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) with get_conn() as conn: row = conn.execute("SELECT * FROM claims WHERE claim_number = ?", (claim_number,)).fetchone() if row is None: return _err("not_found", "Claim not found.", status.HTTP_404_NOT_FOUND) if row["analysis_score"] is None: return _err("conflict", "Claim has not been analyzed yet. Run analysis first.", status.HTTP_409_CONFLICT) claim_id = row["id"] actor = approval.staff_name or staff_name now = utcnow_iso() if approval.decision == "APPROVE": new_approval = "APPROVED" new_status = "ACTIVE" # Ensure date_assigned is set on first move out of NEW date_assigned = row["date_assigned"] or now conn.execute( "UPDATE claims SET approval_status = ?, approval_decision_by = ?, approval_decision_at = ?, " "status = ?, date_assigned = ?, updated_at = ? WHERE id = ?", (new_approval, actor, now, new_status, date_assigned, now, claim_id), ) 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"Claim approved by {actor} and moved to In Progress.", now), ) # Seed the onboarding paperwork checklist on approval. packet.ensure_onboarding_docs(conn, claim_id) else: # REJECT new_approval = "REJECTED" new_status = "REJECTED" conn.execute( "UPDATE claims SET approval_status = ?, approval_decision_by = ?, approval_decision_at = ?, " "status = ?, date_resolved = ?, updated_at = ? WHERE id = ?", (new_approval, actor, now, new_status, now, now, claim_id), ) 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"Claim rejected by {actor}.", now), ) conn.execute( "INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, reason, created_at) " "VALUES (?, 'claim', ?, 'status_change', 'approval_status', ?, ?, ?, ?, ?)", (new_uuid(), claim_id, row["approval_status"], new_approval, actor, approval.reason, now), ) conn.commit() return await staff_get_claim(claim_number) # --------------------------------------------------------------- # POST /api/staff/claims/{claim_number}/letter (generate recommended letter) # Optional JSON body: {"tier": "TIER_2"} to generate for an explicit tier. # Defaults to the claim's current tier when omitted. # --------------------------------------------------------------- @router.post("/api/staff/claims/{claim_number}/letter") async def staff_generate_letter(claim_number: str, request: Request, _staff=Depends(authmod.require_staff)): try: body = await request.json() except Exception: body = {} override_tier = (body or {}).get("tier") if override_tier is not None and override_tier not in analysis.TIER_ORDER: return _err("validation_error", "tier must be one of: " + ", ".join(analysis.TIER_ORDER), status.HTTP_422_UNPROCESSABLE_ENTITY) with get_conn() as conn: row = conn.execute( "SELECT c.id, c.claim_number, c.tier, c.amount_cents, c.client_reference, c.invoice_date, " "cl.company_name, d.name AS debtor_name, d.physical_address " "FROM claims c JOIN clients cl ON cl.id = c.client_id JOIN debtors d ON d.id = c.debtor_id " "WHERE c.claim_number = ?", (claim_number,), ).fetchone() if row is None: return _err("not_found", "Claim not found.", status.HTTP_404_NOT_FOUND) letter = analysis.recommend_letter( tier=override_tier or row["tier"], client_name=row["company_name"], debtor_name=row["debtor_name"], amount_display=_money(row["amount_cents"]), invoice_ref=row["client_reference"], claim_number=row["claim_number"], debtor_address=row["physical_address"], ) now = utcnow_iso() conn.execute( "UPDATE claims SET letter_subject = ?, letter_body = ?, letter_tier = ?, letter_updated_at = ?, updated_at = ? WHERE id = ?", (letter["subject"], letter["body"], letter["tier"], now, now, row["id"]), ) conn.execute( "INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) " "VALUES (?, 'claim', ?, 'letter_generate', 'letter', NULL, ?, 'staff', ?)", (new_uuid(), row["id"], letter["tier"], now), ) conn.commit() return await staff_get_claim(claim_number) # --------------------------------------------------------------- # PUT /api/staff/claims/{claim_number}/letter (save customized letter) # --------------------------------------------------------------- @router.put("/api/staff/claims/{claim_number}/letter") async def staff_save_letter(claim_number: str, request: Request, staff_name: str = Depends(authmod.require_staff)): try: body = await request.json() except Exception: return _err("validation_error", "Invalid JSON body.", status.HTTP_422_UNPROCESSABLE_ENTITY) try: letter = StaffLetterUpdate.model_validate(body) except ValidationError as exc: 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) actor = letter.staff_name or staff_name with get_conn() as conn: row = conn.execute("SELECT id FROM claims WHERE claim_number = ?", (claim_number,)).fetchone() if row is None: return _err("not_found", "Claim not found.", status.HTTP_404_NOT_FOUND) now = utcnow_iso() conn.execute( "UPDATE claims SET letter_subject = ?, letter_body = ?, letter_updated_at = ?, updated_at = ? WHERE id = ?", (letter.subject, letter.body, now, now, row["id"]), ) conn.execute( "INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) " "VALUES (?, 'claim', ?, 'letter_edit', 'letter_body', NULL, 'edited', ?, ?)", (new_uuid(), row["id"], actor, now), ) conn.commit() return await staff_get_claim(claim_number) # --------------------------------------------------------------- # POST /api/staff/claims/{claim_number}/advance-tier (manual escalation) # --------------------------------------------------------------- @router.post("/api/staff/claims/{claim_number}/advance-tier") async def staff_advance_tier(claim_number: str, request: Request, staff_name: str = Depends(authmod.require_staff)): try: body = await request.json() except Exception: body = {} actor = (body or {}).get("staff_name") or staff_name reason = (body or {}).get("reason") with get_conn() as conn: row = conn.execute("SELECT * FROM claims WHERE claim_number = ?", (claim_number,)).fetchone() if row is None: return _err("not_found", "Claim not found.", status.HTTP_404_NOT_FOUND) nxt = analysis.next_tier(row["tier"]) if nxt is None: return _err("conflict", "Claim is already at the final tier (Tier 4 - Legal Action).", status.HTTP_409_CONFLICT) gate = _tier_advance_gate(conn, row) if gate is not None: return _err("conflict", gate, status.HTTP_409_CONFLICT) now = utcnow_iso() conn.execute( "UPDATE claims SET tier = ?, updated_at = ? WHERE id = ?", (nxt, now, row["id"]), ) conn.execute( "INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, reason, created_at) " "VALUES (?, 'claim', ?, 'status_change', 'tier', ?, ?, ?, ?, ?)", (new_uuid(), row["id"], row["tier"], nxt, actor, reason, now), ) 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(), row["id"], f"Case escalated from {analysis.TIER_LABELS.get(row['tier'], row['tier'])} to {analysis.TIER_LABELS.get(nxt, nxt)}.", now), ) conn.commit() return await staff_get_claim(claim_number) # --------------------------------------------------------------- # POST /api/staff/claims/{claim_number}/notes # --------------------------------------------------------------- @router.post("/api/staff/claims/{claim_number}/notes") async def staff_add_note(claim_number: str, request: Request, staff_name: str = Depends(authmod.require_staff)): try: body = await request.json() except Exception: return _err("validation_error", "Invalid JSON body.", status.HTTP_422_UNPROCESSABLE_ENTITY) try: note = StaffNoteCreate.model_validate(body) except ValidationError as exc: 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) actor = note.author_name or staff_name with get_conn() as conn: row = conn.execute("SELECT id FROM claims WHERE claim_number = ?", (claim_number,)).fetchone() if row is None: return _err("not_found", "Claim not found.", status.HTTP_404_NOT_FOUND) claim_id = row["id"] 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 (?, ?, 'STAFF', ?, NULL, ?, ?, NULL, ?)", (note_id, claim_id, actor, note.content, note.visibility, now), ) conn.execute( "INSERT INTO audit_log (id, entity_type, entity_id, action, actor, created_at) VALUES (?, ?, ?, ?, ?, ?)", (new_uuid(), "note", note_id, "note_add", f"staff:{actor}", now), ) conn.commit() return { "id": note_id, "author_type": "STAFF", "author_name": actor, "content": html.escape(note.content), "visibility": note.visibility, "created_at": now, } # --------------------------------------------------------------- # POST /api/staff/claims/{claim_number}/documents # --------------------------------------------------------------- @router.post("/api/staff/claims/{claim_number}/documents") async def staff_upload_document(claim_number: str, request: Request, file: UploadFile = File(...), _staff=Depends(authmod.require_staff)): 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 = ?", (claim_number,)).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 >= 50: 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 (?, ?, ?, ?, ?, ?, ?, 'STAFF', NULL, ?)", (doc_id, claim_id, os.path.basename(file.filename or "file"), stored_path, mime, total, sha.hexdigest(), now), ) conn.execute( "INSERT INTO audit_log (id, entity_type, entity_id, action, actor, created_at) VALUES (?, ?, ?, ?, ?, ?)", (new_uuid(), "document", doc_id, "upload", "staff", now), ) conn.commit() return { "id": doc_id, "original_name": os.path.basename(file.filename or "file"), "size_bytes": total, "mime_type": mime, "uploaded_by": "STAFF", "created_at": now, } # --------------------------------------------------------------- # GET /api/staff/stats # --------------------------------------------------------------- @router.get("/api/staff/stats") async def staff_stats(_staff=Depends(authmod.require_staff)): with get_conn() as conn: total = conn.execute("SELECT COUNT(*) AS n FROM claims").fetchone()["n"] by_status = {} for r in conn.execute("SELECT status, COUNT(*) AS n FROM claims GROUP BY status").fetchall(): by_status[r["status"]] = r["n"] by_tier = {} for r in conn.execute("SELECT tier, COUNT(*) AS n FROM claims GROUP BY tier").fetchall(): by_tier[r["tier"]] = r["n"] total_amount = conn.execute("SELECT COALESCE(SUM(amount_cents),0) AS s FROM claims").fetchone()["s"] # recovered = claims in SETTLED/CLOSED recovered = conn.execute( "SELECT COALESCE(SUM(amount_cents),0) AS s FROM claims WHERE status IN ('SETTLED','CLOSED')" ).fetchone()["s"] open_amount = total_amount - recovered # aging: claims not resolved, by age now_iso = utcnow_iso() over_30 = conn.execute( "SELECT COUNT(*) AS n FROM claims WHERE date_resolved IS NULL AND created_at < ?", (over_30_cutoff(),), ).fetchone()["n"] over_60 = conn.execute( "SELECT COUNT(*) AS n FROM claims WHERE date_resolved IS NULL AND created_at < ?", (over_60_cutoff(),), ).fetchone()["n"] return { "total_claims": total, "by_status": by_status, "by_tier": by_tier, "total_amount_cents": total_amount, "total_amount_display": _money(total_amount), "recovered_amount_cents": recovered, "recovered_amount_display": _money(recovered), "open_amount_cents": open_amount, "open_amount_display": _money(open_amount), "aging": {"over_30_days": over_30, "over_60_days": over_60}, } def over_30_cutoff() -> str: from datetime import datetime, timedelta, timezone return (datetime.now(timezone.utc) - timedelta(days=30)).strftime("%Y-%m-%dT%H:%M:%SZ") def over_60_cutoff() -> str: from datetime import datetime, timedelta, timezone return (datetime.now(timezone.utc) - timedelta(days=60)).strftime("%Y-%m-%dT%H:%M:%SZ") # --------------------------------------------------------------- # GET /api/staff/audit # --------------------------------------------------------------- @router.get("/api/staff/audit") async def staff_audit(request: Request, _staff=Depends(authmod.require_staff)): entity_type = request.query_params.get("entity_type") entity_id = request.query_params.get("entity_id") limit = min(int(request.query_params.get("limit", "100")), 500) offset = max(int(request.query_params.get("offset", "0")), 0) where = [] params: list = [] if entity_type: where.append("entity_type = ?") params.append(entity_type) if entity_id: where.append("entity_id = ?") params.append(entity_id) where_sql = ("WHERE " + " AND ".join(where)) if where else "" with get_conn() as conn: rows = conn.execute( f"SELECT * FROM audit_log {where_sql} ORDER BY created_at DESC LIMIT ? OFFSET ?", tuple(params + [limit, offset]), ).fetchall() total = conn.execute(f"SELECT COUNT(*) AS n FROM audit_log {where_sql}", tuple(params)).fetchone()["n"] return { "audit": [ { "id": r["id"], "entity_type": r["entity_type"], "entity_id": r["entity_id"], "action": r["action"], "field": r["field"], "old_value": r["old_value"], "new_value": r["new_value"], "actor": r["actor"], "reason": r["reason"], "created_at": r["created_at"], } for r in rows ], "total": total, "limit": limit, "offset": offset, }