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:
root
2026-08-26 02:26:33 -04:00
parent 7a5603b495
commit 7a62b0b340
46 changed files with 11004 additions and 35 deletions
+417 -16
View File
@@ -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,