Backend Aug 22-25: AI analysis, welcome-packet templating, LetterStream, DocuSeal, staff RBAC + tier gate
- analysis.py: deterministic claim scorer + /analyze /approve /letter /advance-tier endpoints (auto-runs on intake) - packet.py + packet_fields.json: welcome-packet templating engine (6 onboarding docs, field catalog) - letterstream.py + letters.py: certified-mail send pipeline + letter lifecycle (webhook verified) - docuseal.py: DocuSeal signing integration - staff.py/models.py/schema.sql/auth.py: approval actor from staff key, tier gate (APPROVED+ACTIVE+onboarding docs), onboarding_docs table - frontend/: dependency-free static portal (intake, magic-link login/verify, dashboard) - landing-mockups/: 4 design-stance mockups + favicons - legal/: aup/privacy/sms-terms/terms HTML - docs/: letter-queue scope, letterstream API contract, 6 welcome-packet templates - review-dre-landing-2026-08-21.md: 3-variant landing feedback sprint - compliance/DRE_Compliance_Manual.md: updated Source synced from deployed /opt/dre-portal/app/ (was 4 days ahead of git).
This commit is contained in:
+417
-16
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
@@ -14,16 +15,172 @@ 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 StaffClaimPatch, StaffNoteCreate
|
||||
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
|
||||
# ---------------------------------------------------------------
|
||||
@@ -48,8 +205,9 @@ async def staff_list_claims(request: Request, _staff=Depends(authmod.require_sta
|
||||
params.extend([like, like, like])
|
||||
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
|
||||
sql = (
|
||||
"SELECT c.claim_number, c.status, c.tier, c.amount_cents, c.created_at, c.date_resolved, "
|
||||
"c.date_assigned, cl.company_name, cl.client_number, d.name AS debtor_name, d.business_type "
|
||||
"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 ?"
|
||||
)
|
||||
@@ -57,15 +215,20 @@ async def staff_list_claims(request: Request, _staff=Depends(authmod.require_sta
|
||||
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"]
|
||||
return {
|
||||
"claims": [
|
||||
{
|
||||
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"],
|
||||
@@ -73,9 +236,14 @@ async def staff_list_claims(request: Request, _staff=Depends(authmod.require_sta
|
||||
"created_at": r["created_at"],
|
||||
"date_assigned": r["date_assigned"],
|
||||
"date_resolved": r["date_resolved"],
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
"onboarding": {
|
||||
"received": ob["received"],
|
||||
"total": ob["total"],
|
||||
"complete": ob["complete"],
|
||||
},
|
||||
})
|
||||
return {
|
||||
"claims": claims,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
@@ -113,12 +281,16 @@ async def staff_get_claim(claim_number: str, _staff=Depends(authmod.require_staf
|
||||
"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"],
|
||||
@@ -127,6 +299,25 @@ async def staff_get_claim(claim_number: str, _staff=Depends(authmod.require_staf
|
||||
"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"],
|
||||
@@ -167,7 +358,7 @@ async def staff_get_claim(claim_number: str, _staff=Depends(authmod.require_staf
|
||||
# PATCH /api/staff/claims/{claim_number}
|
||||
# ---------------------------------------------------------------
|
||||
@router.patch("/api/staff/claims/{claim_number}")
|
||||
async def staff_patch_claim(claim_number: str, request: Request, _staff=Depends(authmod.require_staff)):
|
||||
async def staff_patch_claim(claim_number: str, request: Request, staff_name: str = Depends(authmod.require_staff)):
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -193,7 +384,7 @@ async def staff_patch_claim(claim_number: str, request: Request, _staff=Depends(
|
||||
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", patch.reason, now),
|
||||
(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
|
||||
@@ -210,10 +401,20 @@ async def staff_patch_claim(claim_number: str, request: Request, _staff=Depends(
|
||||
)
|
||||
# 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", patch.reason, now),
|
||||
(new_uuid(), "claim", claim_id, "status_change", "tier", row["tier"], patch.tier, staff_name, patch.reason, now),
|
||||
)
|
||||
new_tier = patch.tier
|
||||
conn.execute(
|
||||
@@ -252,11 +453,210 @@ async def staff_get_claim_inner(claim_number: str):
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 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=Depends(authmod.require_staff)):
|
||||
async def staff_add_note(claim_number: str, request: Request, staff_name: str = Depends(authmod.require_staff)):
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -266,6 +666,7 @@ async def staff_add_note(claim_number: str, request: Request, _staff=Depends(aut
|
||||
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:
|
||||
@@ -276,17 +677,17 @@ async def staff_add_note(claim_number: str, request: Request, _staff=Depends(aut
|
||||
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, note.author_name, note.content, note.visibility, now),
|
||||
(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:{note.author_name}", now),
|
||||
(new_uuid(), "note", note_id, "note_add", f"staff:{actor}", now),
|
||||
)
|
||||
conn.commit()
|
||||
return {
|
||||
"id": note_id,
|
||||
"author_type": "STAFF",
|
||||
"author_name": note.author_name,
|
||||
"author_name": actor,
|
||||
"content": html.escape(note.content),
|
||||
"visibility": note.visibility,
|
||||
"created_at": now,
|
||||
|
||||
Reference in New Issue
Block a user