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
+659
View File
@@ -0,0 +1,659 @@
"""
DRE Welcome Packet — document templating engine.
Three jobs:
1. Pre-populate the 6 welcome-packet documents from the client's original
submission (claims/clients/debtors tables).
2. Let the client edit any field; recovery-impacting changes are written to
audit_log so recovery staff can see exactly what changed.
3. Render the filled documents as HTML (live) and PDF (print/sign).
Data model:
- FIELD_CATALOG : canonical profile fields (label, source column, type,
required, recovery_impact).
- DOC_PLACEHOLDERS : per document, ordered list of (placeholder_token -> profile_key).
- packet_field_overrides table : stores the client's final value per field,
merged over pre-populated submission values.
The markdown templates in docs/welcome-packet/ use {{profile_key}} tokens in the
cells/words that should be pre-populated. Signature blocks, notary blocks, and
checkbox lists are completed at signing time and are left as-is.
"""
from __future__ import annotations
import os
import re
import subprocess
import uuid
from datetime import datetime
import markdown
from . import db as dbmod
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import HTMLResponse, JSONResponse, Response
from . import auth as authmod
from .db import get_conn
router = APIRouter()
DOCS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "docs", "welcome-packet")
DOC_ORDER = [
"01-LPOA.md",
"02-Terms-of-Service.md",
"03-Fee-Schedule.md",
"04-Third-Party-Consent.md",
"05-Debtor-Info-Sheet.md",
"06-ACH-Authorization.md",
]
DOC_TITLES = {
"01-LPOA.md": "Limited Power of Attorney",
"02-Terms-of-Service.md": "Terms of Service",
"03-Fee-Schedule.md": "Schedule A — Fee Schedule",
"04-Third-Party-Consent.md": "Third-Party Sharing Consent",
"05-Debtor-Info-Sheet.md": "Debtor Information Sheet",
"06-ACH-Authorization.md": "ACH / Disbursement Authorization",
}
# ---------------------------------------------------------------------------
# Onboarding paperwork receipt tracking. One row per welcome-packet document
# per claim; staff mark each doc "received" once the client returns it
# (e-sign via DocuSeal or manual upload). Recovery escalation is gated on
# every document being received.
# ---------------------------------------------------------------------------
ONBOARDING_DOCS = [
("LPOA", "Limited Power of Attorney"),
("TOS", "Terms of Service"),
("FEE_SCHEDULE", "Schedule A — Fee Schedule"),
("THIRD_PARTY_CONSENT", "Third-Party Sharing Consent"),
("DEBTOR_INFO", "Debtor Information Sheet"),
("ACH", "ACH / Disbursement Authorization"),
]
def ensure_onboarding_docs(conn, claim_id: str) -> None:
"""Seed the 6 onboarding-doc rows for a claim if they don't exist yet."""
now = dbmod.utcnow_iso()
for key, _title in ONBOARDING_DOCS:
conn.execute(
"INSERT OR IGNORE INTO onboarding_docs (id, claim_id, doc_key, status, created_at) "
"VALUES (?, ?, ?, 'PENDING', ?)",
(dbmod.new_uuid(), claim_id, key, now),
)
def onboarding_status(conn, claim_id: str) -> dict:
"""Compute receipt status for a claim's onboarding paperwork."""
ensure_onboarding_docs(conn, claim_id)
rows = conn.execute(
"SELECT doc_key, status, docuseal_submission_id, docuseal_submitter_id, "
"docuseal_slug, docuseal_embed_src, docuseal_status, docuseal_sent_at "
"FROM onboarding_docs WHERE claim_id = ? ORDER BY rowid",
(claim_id,),
).fetchall()
title_by_key = {k: t for k, t in ONBOARDING_DOCS}
items = []
for r in rows:
item = {
"key": r["doc_key"],
"title": title_by_key.get(r["doc_key"], r["doc_key"]),
"status": r["status"],
}
if r["docuseal_submission_id"] is not None:
item["docuseal"] = {
"submission_id": r["docuseal_submission_id"],
"submitter_id": r["docuseal_submitter_id"],
"slug": r["docuseal_slug"],
"status": r["docuseal_status"],
"sent_at": r["docuseal_sent_at"],
"signing_url": _docuseal_signing_url(r["docuseal_slug"], r["docuseal_embed_src"]),
}
items.append(item)
received = sum(1 for i in items if i["status"] == "RECEIVED")
return {
"received": received,
"total": len(items),
"complete": received == len(items),
"outstanding": [i for i in items if i["status"] != "RECEIVED"],
"items": items,
}
def _docuseal_signing_url(slug: str | None, embed_src: str | None) -> str | None:
"""Canonical public signing URL for a DocuSeal submitter, or None if unsigned."""
if slug:
return f"https://sign.debtrecoveryexperts.com/s/{slug}"
return embed_src
# ---------------------------------------------------------------------------
# Field catalog. `source` is a "table.column" path resolvable against a claim
# row joined with client + debtor. `recovery_impact` marks fields where a change
# alters WHO we pursue, HOW MUCH, WHEN it was due, or whether a personal
# guarantee exists — i.e. anything that changes our collection strategy.
# ---------------------------------------------------------------------------
FIELD_CATALOG = {
# --- Client identity ---
"client_company_name": {"label": "Client Legal Name", "source": "client.company_name", "type": "text", "required": True, "recovery_impact": False},
"client_entity_type": {"label": "Client Entity Type", "source": None, "type": "text", "required": False, "recovery_impact": False},
"client_contact_name": {"label": "Client Contact / Print Name", "source": "client.contact_name", "type": "text", "required": True, "recovery_impact": False},
# --- Debtor identity (recovery-critical) ---
"debtor_name": {"label": "Debtor Legal Name", "source": "debtor.name", "type": "text", "required": True, "recovery_impact": True},
"debtor_entity_type": {"label": "Debtor Entity Type", "source": "debtor.business_type", "type": "text", "required": False, "recovery_impact": True},
"debtor_dba": {"label": "DBA / Trade Name", "source": None, "type": "text", "required": False, "recovery_impact": True},
"debtor_state": {"label": "State of Formation", "source": None, "type": "text", "required": False, "recovery_impact": True},
"debtor_registered_agent": {"label": "Registered Agent Name", "source": None, "type": "text", "required": False, "recovery_impact": True},
"debtor_registered_agent_address": {"label": "Registered Agent Address", "source": None, "type": "text", "required": False, "recovery_impact": True},
"debtor_ein": {"label": "EIN / Tax ID", "source": None, "type": "text", "required": False, "recovery_impact": True},
"debtor_website": {"label": "Debtor Website", "source": None, "type": "text", "required": False, "recovery_impact": False},
"debtor_contact_name": {"label": "Debtor Primary Contact", "source": None, "type": "text", "required": False, "recovery_impact": True},
"debtor_contact_title": {"label": "Debtor Contact Title", "source": None, "type": "text", "required": False, "recovery_impact": False},
"debtor_contact_phone": {"label": "Debtor Direct Phone", "source": "debtor.contact_phone", "type": "text", "required": False, "recovery_impact": True},
"debtor_contact_email": {"label": "Debtor Email", "source": "debtor.contact_email", "type": "text", "required": False, "recovery_impact": True},
"debtor_address": {"label": "Debtor Business Address", "source": "debtor.physical_address", "type": "text", "required": False, "recovery_impact": True},
"debtor_city_state_zip": {"label": "Debtor City / State / ZIP", "source": None, "type": "text", "required": False, "recovery_impact": True},
"debtor_alt_address": {"label": "Debtor Alternate Address", "source": None, "type": "text", "required": False, "recovery_impact": False},
"personal_guarantee": {"label": "Personal Guarantee Exists?", "source": None, "type": "checkbox", "options": ["Yes", "No", "Unsure"], "required": True, "recovery_impact": True},
"personal_guarantee_signer": {"label": "Personal Guarantee Signer", "source": None, "type": "text", "required": False, "recovery_impact": True},
"debtor_bank": {"label": "Debtor Bank / FI", "source": None, "type": "text", "required": False, "recovery_impact": False},
"debtor_ar_lenders": {"label": "Debtor Known AR / Lenders", "source": None, "type": "text", "required": False, "recovery_impact": False},
# --- Claim substance (recovery-critical) ---
"claim_amount_cents": {"label": "Claim Amount", "source": "claim.amount_cents", "type": "money", "required": True, "recovery_impact": True},
"invoice_date": {"label": "Invoice / Contract Date", "source": "claim.invoice_date", "type": "date", "required": False, "recovery_impact": True},
"dre_claim_number": {"label": "DRE Claim Number", "source": "claim.claim_number", "type": "text", "required": True, "recovery_impact": False},
# --- ACH / disbursement (not recovery strategy, but completion-gated) ---
"account_holder_name": {"label": "Account Holder Legal Name", "source": "client.company_name", "type": "text", "required": True, "recovery_impact": False},
"account_holder_entity_type": {"label": "Account Holder Entity Type", "source": None, "type": "text", "required": False, "recovery_impact": False},
"bank_name": {"label": "Bank Name", "source": None, "type": "text", "required": True, "recovery_impact": False},
"account_type": {"label": "Account Type", "source": None, "type": "checkbox", "options": ["Checking", "Savings"], "required": True, "recovery_impact": False},
"routing_number": {"label": "Routing (ABA) Number", "source": None, "type": "text", "required": True, "recovery_impact": False},
"account_number": {"label": "Account Number", "source": None, "type": "text", "required": True, "recovery_impact": False},
}
# Ordered placeholders per document. Each token in the markdown maps to a
# profile key. Only text/money/date fields are substituted inline; checkboxes
# and signatures are completed at signing.
DOC_PLACEHOLDERS = {
"01-LPOA.md": [
("client_legal_name", "client_company_name"),
("client_entity_type", "client_entity_type"),
("debtor_legal_name", "debtor_name"),
("claim_amount", "claim_amount_cents"),
("invoice_date", "invoice_date"),
("dre_claim_number", "dre_claim_number"),
],
"02-Terms-of-Service.md": [],
"03-Fee-Schedule.md": [],
"04-Third-Party-Consent.md": [],
"05-Debtor-Info-Sheet.md": [
("debtor_legal_name", "debtor_name"),
("debtor_entity_type", "debtor_entity_type"),
("debtor_dba", "debtor_dba"),
("debtor_state", "debtor_state"),
("debtor_registered_agent", "debtor_registered_agent"),
("debtor_registered_agent_address", "debtor_registered_agent_address"),
("debtor_ein", "debtor_ein"),
("debtor_website", "debtor_website"),
("debtor_contact_name", "debtor_contact_name"),
("debtor_contact_title", "debtor_contact_title"),
("debtor_contact_phone", "debtor_contact_phone"),
("debtor_contact_email", "debtor_contact_email"),
("debtor_address", "debtor_address"),
("debtor_city_state_zip", "debtor_city_state_zip"),
("debtor_alt_address", "debtor_alt_address"),
("debtor_bank", "debtor_bank"),
("debtor_ar_lenders", "debtor_ar_lenders"),
("personal_guarantee_signer", "personal_guarantee_signer"),
],
"06-ACH-Authorization.md": [
("account_holder_name", "account_holder_name"),
("account_holder_entity_type", "account_holder_entity_type"),
("bank_name", "bank_name"),
("routing_number", "routing_number"),
("account_number", "account_number"),
],
}
# ---------------------------------------------------------------------------
# Migration
# ---------------------------------------------------------------------------
PACKET_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS packet_field_overrides (
id TEXT PRIMARY KEY,
claim_id TEXT NOT NULL REFERENCES claims(id) ON DELETE CASCADE,
profile_key TEXT NOT NULL,
value TEXT,
updated_by TEXT NOT NULL DEFAULT 'CLIENT' CHECK (updated_by IN ('CLIENT','STAFF')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(claim_id, profile_key)
);
"""
def ensure_packet_table():
with dbmod.get_conn() as conn:
conn.execute(PACKET_TABLE_SQL)
conn.commit()
# ---------------------------------------------------------------------------
# Value resolution
# ---------------------------------------------------------------------------
def _format_value(profile_key: str, raw) -> str:
"""Format a raw DB value for display in the document."""
if raw is None:
return ""
if FIELD_CATALOG[profile_key]["type"] == "money":
try:
cents = int(raw)
return f"${cents / 100:,.2f}"
except (TypeError, ValueError):
return str(raw)
if FIELD_CATALOG[profile_key]["type"] == "date":
s = str(raw)
# dates may be ISO or already human; try to render nicely
for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%SZ"):
try:
return datetime.strptime(s, fmt).strftime("%B %d, %Y")
except ValueError:
continue
return s
return str(raw)
def resolve_prepopulated(claim_row: dict, profile_key: str) -> str:
"""Pull the pre-populated value for a field from the original submission row."""
source = FIELD_CATALOG[profile_key]["source"]
if not source:
return ""
table, column = source.split(".", 1)
raw = claim_row.get(column)
return _format_value(profile_key, raw)
def load_claim_row(conn, claim_number: str) -> dict:
row = conn.execute(
"""
SELECT c.claim_number, c.amount_cents, c.invoice_date, c.client_reference,
c.description, c.status, c.tier, c.created_at,
cl.company_name AS company_name, cl.contact_name AS contact_name,
cl.email AS client_email, cl.phone AS client_phone,
d.name AS name, d.business_type AS business_type,
d.contact_email AS contact_email, d.contact_phone AS contact_phone,
d.physical_address AS 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:
raise KeyError(claim_number)
return dict(row)
def merged_values(conn, claim_number: str) -> tuple[dict, dict]:
"""Pre-populated submission values, overlaid with client overrides."""
row = load_claim_row(conn, claim_number)
values = {}
for key in FIELD_CATALOG:
values[key] = resolve_prepopulated(row, key)
claim_id = _claim_id(conn, claim_number)
overrides = conn.execute(
"SELECT profile_key, value FROM packet_field_overrides WHERE claim_id = ?",
(claim_id,),
).fetchall()
for o in overrides:
values[o["profile_key"]] = o["value"] or ""
return values, row
def _claim_id(conn, claim_number: str) -> str:
return conn.execute("SELECT id FROM claims WHERE claim_number = ?", (claim_number,)).fetchone()["id"]
# ---------------------------------------------------------------------------
# Rendering
# ---------------------------------------------------------------------------
def fill_document(doc_filename: str, values: dict) -> str:
"""Substitute {{token}} placeholders in a markdown doc with values."""
path = os.path.join(DOCS_DIR, doc_filename)
with open(path, "r", encoding="utf-8") as fh:
text = fh.read()
placeholders = DOC_PLACEHOLDERS.get(doc_filename, [])
for token, profile_key in placeholders:
val = values.get(profile_key, "")
# Pre-populated value, or a blank fill-line if the client hasn't supplied it.
display = val if val != "" else "________________________"
text = text.replace("{{" + token + "}}", display)
# Any leftover un-mapped tokens -> blank
text = re.sub(r"\{\{[a-z0-9_]+\}\}", "________________________", text)
return text
def document_to_html(doc_filename: str, values: dict) -> str:
md_text = fill_document(doc_filename, values)
html = markdown.markdown(md_text, extensions=["tables", "sane_lists"])
return html
def packet_html(conn, claim_number: str) -> str:
"""Render all 6 documents as one styled HTML page (live preview)."""
values, row = merged_values(conn, claim_number)
sections = []
for doc in DOC_ORDER:
body = document_to_html(doc, values)
sections.append(
f'<section class="doc" id="{doc.split(".")[0]}">'
f'<div class="doc-head"><h2>{DOC_TITLES[doc]}</h2></div>'
f'<div class="doc-body">{body}</div></section>'
)
return _wrap_html(row, "\n".join(sections))
def _wrap_html(row: dict, body: str) -> str:
client = row.get("company_name") or row.get("contact_name") or "Client"
debtor = row.get("name") or "Debtor"
return f"""<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Welcome Packet — {row.get('claim_number')}</title>
<style>
:root {{ --ink:#1a2233; --muted:#5b6472; --accent:#b45309; --rule:#e2e8f0; }}
* {{ box-sizing:border-box; }}
body {{ font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif; color:var(--ink);
margin:0; background:#f4f6f9; line-height:1.55; }}
.packet-head {{ background:linear-gradient(135deg,#16213a,#2b3d5f); color:#fff; padding:2rem 2.5rem; }}
.packet-head h1 {{ margin:0 0 .25rem; font-size:1.6rem; }}
.packet-head .meta {{ color:#cbd5e1; font-size:.9rem; }}
.wrap {{ max-width:900px; margin:0 auto; padding:1.5rem; }}
.doc {{ background:#fff; border:1px solid var(--rule); border-radius:10px; margin-bottom:1.5rem;
box-shadow:0 1px 3px rgba(16,24,40,.06); overflow:hidden; }}
.doc-head {{ padding:1rem 2rem; border-bottom:1px solid var(--rule); background:#fafbfc; }}
.doc-head h2 {{ margin:0; font-size:1.15rem; color:#111827; }}
.doc-body {{ padding:1.5rem 2.5rem; }}
.doc-body h1 {{ font-size:1.4rem; margin-top:0; }}
.doc-body h2 {{ font-size:1.2rem; }}
.doc-body h3 {{ font-size:1.05rem; color:#374151; }}
.doc-body table {{ width:100%; border-collapse:collapse; margin:1rem 0; font-size:.92rem; }}
.doc-body th,.doc-body td {{ border:1px solid #d7dde6; padding:.55rem .7rem; text-align:left; vertical-align:top; }}
.doc-body th {{ background:#f1f5f9; }}
.doc-body hr {{ border:none; border-top:1px solid var(--rule); margin:1.5rem 0; }}
.doc-body em {{ color:var(--muted); }}
.packet-foot {{ text-align:center; color:var(--muted); font-size:.8rem; padding:1rem 2rem 3rem; }}
@media print {{ body {{ background:#fff; }} .doc {{ box-shadow:none; border:none; page-break-after:always; }}
.doc:last-child {{ page-break-after:auto; }} .wrap {{ padding:0; }} }}
</style></head>
<body>
<div class="packet-head">
<h1>Debt Recovery Experts — Welcome Packet</h1>
<div class="meta">Claim {row.get('claim_number')} · Client: {client} · Debtor: {debtor}</div>
</div>
<div class="wrap">
{body}
</div>
<div class="packet-foot">Debt Recovery Experts, LLC · Confidential · Prepared {datetime.utcnow().strftime('%B %d, %Y')}</div>
</body></html>"""
def render_pdf(html: str, out_path: str) -> str:
"""Render HTML -> PDF via headless Chromium. Returns the output path."""
tmp_html = out_path + ".html"
with open(tmp_html, "w", encoding="utf-8") as fh:
fh.write(html)
subprocess.run(
[
"/usr/bin/chromium", "--headless", "--no-sandbox", "--disable-gpu",
"--print-to-pdf=" + out_path, "--no-pdf-header-footer", tmp_html,
],
check=True, capture_output=True, timeout=60,
)
os.remove(tmp_html)
return out_path
# ---------------------------------------------------------------------------
# Change logging
# ---------------------------------------------------------------------------
def log_field_change(conn, claim_id: str, profile_key: str, old_value: str,
new_value: str, actor: str) -> None:
"""Write an audit_log entry. recovery_impact is recorded in `reason` so it's
queryable: recovery-impacting changes carry reason='recovery-impacting'."""
field = FIELD_CATALOG.get(profile_key, {})
reason = "recovery-impacting" if field.get("recovery_impact") else None
conn.execute(
"""
INSERT INTO audit_log (id, entity_type, entity_id, action, field,
old_value, new_value, actor, reason, created_at)
VALUES (?, 'claim', ?, 'packet_field_update', ?, ?, ?, ?, ?, ?)
""",
(
uuid.uuid4().hex, claim_id, profile_key,
old_value if old_value is not None else None,
new_value if new_value is not None else None,
actor, reason, dbmod.utcnow_iso(),
),
)
# ---------------------------------------------------------------------------
# Submission status — the "visual indicator" of what the client has submitted.
# ---------------------------------------------------------------------------
def submission_status(conn, claim_id: str, values: dict) -> dict:
"""Compute which documents the client has submitted so far.
Sources:
- `documents` table: claim substantiation uploads (uploaded_by=CLIENT).
- effective field values (pre-populated + client overrides): a required
field counts as supplied whenever it has a non-empty value.
- `tos_accepted_at` / `no_other_agency_at` / `no_prior_action_at`: checkboxes
the client confirmed during intake.
"""
docs = conn.execute(
"SELECT original_name, uploaded_by, created_at FROM documents WHERE claim_id = ? ORDER BY created_at ASC",
(claim_id,),
).fetchall()
client_docs = [d for d in docs if d["uploaded_by"] == "CLIENT"]
overrides = conn.execute(
"SELECT profile_key, value, updated_by FROM packet_field_overrides WHERE claim_id = ?",
(claim_id,),
).fetchall()
overridden_keys = {o["profile_key"] for o in overrides if (o["value"] or "").strip()}
# tos_accepted_at lives on clients; no_other_agency_at / no_prior_action_at on claims.
row = conn.execute(
"SELECT cl.tos_accepted_at, c.no_other_agency_at, c.no_prior_action_at "
"FROM claims c JOIN clients cl ON cl.id = c.client_id WHERE c.id = ?",
(claim_id,),
).fetchone()
tos = bool(row and row["tos_accepted_at"])
no_other_agency = bool(row and row["no_other_agency_at"])
no_prior_action = bool(row and row["no_prior_action_at"])
# A required field is "supplied" when its effective value is non-empty
# (either pre-populated from the submission or overridden by the client).
required = [key for key, meta in FIELD_CATALOG.items() if meta.get("required")]
supplied_fields = [k for k in required if (values.get(k) or "").strip()]
empty_required = [k for k in required if not (values.get(k) or "").strip()]
return {
"documents_submitted": len(client_docs),
"documents": [
{"name": d["original_name"], "uploaded_by": d["uploaded_by"], "at": d["created_at"]}
for d in docs
],
"fields_supplied": len(supplied_fields),
"fields_supplied_list": supplied_fields,
"fields_overridden_list": sorted(overridden_keys),
"fields_required_total": len(required),
"fields_required_missing": empty_required,
"tos_accepted": tos,
"no_other_agency_confirmed": no_other_agency,
"no_prior_action_confirmed": no_prior_action,
"complete": len(empty_required) == 0 and len(client_docs) > 0,
}
# ---------------------------------------------------------------------------
# API endpoints (client-scoped + staff)
# ---------------------------------------------------------------------------
def _claim_row_for_client(conn, claim_number: str, client_id: str):
row = conn.execute(
"SELECT id FROM claims WHERE claim_number = ? AND client_id = ?",
(claim_number, client_id),
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail={"code": "not_found", "message": "Claim not found."})
return row["id"]
def _claim_row_any(conn, claim_number: str):
row = conn.execute("SELECT id FROM claims WHERE claim_number = ?", (claim_number,)).fetchone()
if row is None:
raise HTTPException(status_code=404, detail={"code": "not_found", "message": "Claim not found."})
return row["id"]
@router.get("/api/claims/{claim_number}/packet/fields")
def get_packet_fields(claim_number: str, session: dict = Depends(authmod.require_client)):
"""Return the field catalog with pre-populated + client-overridden values,
plus a submission-status summary (the visual indicator)."""
with get_conn() as conn:
claim_id = _claim_row_for_client(conn, claim_number, session["client_id"])
values, row = merged_values(conn, claim_number)
overrides = {
o["profile_key"]: o["value"]
for o in conn.execute(
"SELECT profile_key, value FROM packet_field_overrides WHERE claim_id = ?", (claim_id,)
).fetchall()
}
status_summary = submission_status(conn, claim_id, values)
fields = []
for key, meta in FIELD_CATALOG.items():
fields.append({
"key": key,
"label": meta["label"],
"type": meta["type"],
"required": meta["required"],
"recovery_impact": meta["recovery_impact"],
"options": meta.get("options"),
"value": values.get(key, ""),
"source": meta.get("source"),
"is_override": key in overrides,
})
return {"claim_number": claim_number, "fields": fields, "submission": status_summary}
@router.patch("/api/claims/{claim_number}/packet/fields")
async def update_packet_field(claim_number: str, request: Request,
session: dict = Depends(authmod.require_client)):
"""Client edits a packet field. Recovery-impacting changes are written to
audit_log so recovery staff see exactly what changed."""
try:
body = await request.json()
except Exception:
return JSONResponse(status_code=422, content={"error": {"code": "validation_error", "message": "Invalid JSON body."}})
key = body.get("key")
value = body.get("value")
if key not in FIELD_CATALOG:
return JSONResponse(status_code=422, content={"error": {"code": "validation_error", "message": "Unknown field."}})
if value is None or (isinstance(value, str) and value.strip() == ""):
value = ""
else:
value = str(value).strip()
with get_conn() as conn:
claim_id = _claim_row_for_client(conn, claim_number, session["client_id"])
old = conn.execute(
"SELECT value FROM packet_field_overrides WHERE claim_id = ? AND profile_key = ?",
(claim_id, key),
).fetchone()
old_value = old["value"] if old else None
# If no override exists yet, the document was showing the pre-populated
# submission value — capture that as the true "before" for the audit trail.
if old_value is None:
try:
values, _ = merged_values(conn, claim_number)
old_value = values.get(key) or None
except KeyError:
old_value = None
now = dbmod.utcnow_iso()
conn.execute(
"""
INSERT INTO packet_field_overrides (id, claim_id, profile_key, value, updated_by, created_at, updated_at)
VALUES (?, ?, ?, ?, 'CLIENT', ?, ?)
ON CONFLICT(claim_id, profile_key) DO UPDATE SET value=excluded.value,
updated_by=excluded.updated_by, updated_at=excluded.updated_at
""",
(uuid.uuid4().hex, claim_id, key, value, now, now),
)
# Log recovery-impacting changes only when the value actually changed.
if old_value != value:
log_field_change(conn, claim_id, key, old_value, value, "client")
conn.commit()
return {"key": key, "value": value, "saved": True}
@router.get("/api/claims/{claim_number}/packet/preview")
def get_packet_preview(claim_number: str, session: dict = Depends(authmod.require_client)):
"""Live HTML preview of the filled packet."""
with get_conn() as conn:
_claim_row_for_client(conn, claim_number, session["client_id"])
html_out = packet_html(conn, claim_number)
return HTMLResponse(content=html_out)
@router.get("/api/claims/{claim_number}/packet.pdf")
def get_packet_pdf(claim_number: str, session: dict = Depends(authmod.require_client)):
"""PDF of the filled packet (generated on demand via headless Chromium)."""
import tempfile
with get_conn() as conn:
_claim_row_for_client(conn, claim_number, session["client_id"])
html_out = packet_html(conn, claim_number)
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tf:
out_path = tf.name
try:
render_pdf(html_out, out_path)
with open(out_path, "rb") as fh:
data = fh.read()
finally:
os.remove(out_path)
return Response(content=data, media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{claim_number}-welcome-packet.pdf"'})
# --- Staff endpoints (staff key auth) ---
@router.get("/api/staff/claims/{claim_number}/packet/preview")
def staff_packet_preview(claim_number: str, _staff: None = Depends(authmod.require_staff)):
with get_conn() as conn:
_claim_row_any(conn, claim_number)
html_out = packet_html(conn, claim_number)
return HTMLResponse(content=html_out)
@router.get("/api/staff/claims/{claim_number}/packet.pdf")
def staff_packet_pdf(claim_number: str, _staff: None = Depends(authmod.require_staff)):
import tempfile
with get_conn() as conn:
_claim_row_any(conn, claim_number)
html_out = packet_html(conn, claim_number)
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tf:
out_path = tf.name
try:
render_pdf(html_out, out_path)
with open(out_path, "rb") as fh:
data = fh.read()
finally:
os.remove(out_path)
return Response(content=data, media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{claim_number}-welcome-packet.pdf"'})