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:
@@ -0,0 +1,320 @@
|
||||
"""Deterministic claim analysis: a 0-100 score, a plain-English narrative,
|
||||
a recommended starting tier, and a recommended collection letter assembled
|
||||
from the tier templates in /root/.hermes/references/dre-letter-templates.md.
|
||||
|
||||
Transparent rules engine (no external LLM) so every score is explainable and
|
||||
auditable for FDCPA/TDCPA compliance. To move to an LLM later, swap the
|
||||
generator behind these functions and keep the return shape identical.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
TIER_ORDER = ["TIER_1", "TIER_2", "TIER_2_5", "TIER_3", "TIER_4"]
|
||||
|
||||
TIER_LABELS = {
|
||||
"TIER_1": "Tier 1 - Soft Touch",
|
||||
"TIER_2": "Tier 2 - Formal Demand",
|
||||
"TIER_2_5": "Tier 2.5 - Lien Threat",
|
||||
"TIER_3": "Tier 3 - Escalation",
|
||||
"TIER_4": "Tier 4 - Legal Action",
|
||||
}
|
||||
|
||||
TIER_DESCRIPTIONS = {
|
||||
"TIER_1": "Friendly reminder email + ACH payment link (Day 1-5)",
|
||||
"TIER_2": "Formal demand letter via certified mail (Day 7-14)",
|
||||
"TIER_2_5": "Pre-lien notice for construction claims (Day 15-21)",
|
||||
"TIER_3": "Final notice before legal action (Day 21-30)",
|
||||
"TIER_4": "Referral to partner law firm (Day 30+)",
|
||||
}
|
||||
|
||||
|
||||
def next_tier(current: str) -> str | None:
|
||||
"""Return the tier after `current` in the escalation order, or None at Tier 4."""
|
||||
if current not in TIER_ORDER:
|
||||
return None
|
||||
i = TIER_ORDER.index(current)
|
||||
return TIER_ORDER[i + 1] if i + 1 < len(TIER_ORDER) else None
|
||||
|
||||
|
||||
def _age_days(invoice_date: str | None) -> int | None:
|
||||
if not invoice_date:
|
||||
return None
|
||||
try:
|
||||
d = datetime.strptime(invoice_date[:10], "%Y-%m-%d")
|
||||
d = d.replace(tzinfo=timezone.utc)
|
||||
return (datetime.now(timezone.utc) - d).days
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def score_claim(*, amount_cents: int, business_type: str, doc_count: int,
|
||||
description: str | None, invoice_date: str | None,
|
||||
client_reference: str | None) -> dict:
|
||||
"""Compute a 0-100 analysis score with a component breakdown and narrative.
|
||||
|
||||
Returns:
|
||||
{"score": int, "components": {name: {points, max, note}}, "summary": str,
|
||||
"recommended_tier": str}
|
||||
"""
|
||||
components: dict[str, dict] = {}
|
||||
|
||||
# 1. Amount (0-25) — higher balance = higher recovery priority.
|
||||
if amount_cents >= 1_000_000:
|
||||
components["amount"] = {"points": 25, "max": 25, "note": "High-value claim ($10k+)"}
|
||||
elif amount_cents >= 500_000:
|
||||
components["amount"] = {"points": 20, "max": 25, "note": "Significant balance ($5k-$10k)"}
|
||||
elif amount_cents >= 250_000:
|
||||
components["amount"] = {"points": 15, "max": 25, "note": "Moderate balance ($2.5k-$5k)"}
|
||||
elif amount_cents >= 100_000:
|
||||
components["amount"] = {"points": 10, "max": 25, "note": "Low balance ($1k-$2.5k)"}
|
||||
elif amount_cents >= 50_000:
|
||||
components["amount"] = {"points": 6, "max": 25, "note": "Small balance ($500-$1k)"}
|
||||
else:
|
||||
components["amount"] = {"points": 3, "max": 25, "note": "Minimal balance (<$500)"}
|
||||
|
||||
# 2. Documentation (0-25) — evidence quality drives collectability.
|
||||
if doc_count >= 4:
|
||||
components["documentation"] = {"points": 25, "max": 25, "note": f"{doc_count} documents on file"}
|
||||
elif doc_count >= 2:
|
||||
components["documentation"] = {"points": 18, "max": 25, "note": f"{doc_count} documents on file"}
|
||||
elif doc_count == 1:
|
||||
components["documentation"] = {"points": 12, "max": 25, "note": "1 document on file"}
|
||||
else:
|
||||
components["documentation"] = {"points": 5, "max": 25, "note": "No evidence uploaded yet"}
|
||||
|
||||
# 3. Collectability by debtor entity type (0-20).
|
||||
collect = {
|
||||
"LLC": (20, "Registered LLC - assets traceable"),
|
||||
"CORPORATION": (20, "Registered corporation - assets traceable"),
|
||||
"PARTNERSHIP": (16, "Partnership"),
|
||||
"SOLE_PROPRIETORSHIP": (12, "Sole proprietorship"),
|
||||
"INDIVIDUAL": (8, "Individual debtor"),
|
||||
"OTHER": (10, "Other entity type"),
|
||||
}
|
||||
pts, note = collect.get(business_type, (10, "Other entity type"))
|
||||
components["collectability"] = {"points": pts, "max": 20, "note": note}
|
||||
|
||||
# 4. Claim completeness (0-20) — how fully the intake form was filled.
|
||||
comp = 0
|
||||
if description:
|
||||
comp += 8
|
||||
if invoice_date:
|
||||
comp += 6
|
||||
if client_reference:
|
||||
comp += 6
|
||||
components["completeness"] = {
|
||||
"points": comp, "max": 20,
|
||||
"note": "Description, invoice date, and client reference provided",
|
||||
}
|
||||
|
||||
# 5. Debt age (0-10) — fresher debt is more collectable.
|
||||
age = _age_days(invoice_date)
|
||||
if age is None:
|
||||
age_pts, age_note = 2, "Invoice date not provided"
|
||||
elif age <= 90:
|
||||
age_pts, age_note = 10, f"{age} days old (fresh)"
|
||||
elif age <= 180:
|
||||
age_pts, age_note = 8, f"{age} days old"
|
||||
elif age <= 365:
|
||||
age_pts, age_note = 6, f"{age} days old"
|
||||
elif age <= 730:
|
||||
age_pts, age_note = 3, f"{age} days old (stale)"
|
||||
else:
|
||||
age_pts, age_note = 2, f"{age} days old (very stale)"
|
||||
components["age"] = {"points": age_pts, "max": 10, "note": age_note}
|
||||
|
||||
score = sum(c["points"] for c in components.values())
|
||||
|
||||
# Recommended starting tier from score band.
|
||||
if score >= 75:
|
||||
rec = "TIER_1"
|
||||
rec_note = "Strong case; soft touch should resolve"
|
||||
elif score >= 60:
|
||||
rec = "TIER_2"
|
||||
rec_note = "Solid case; begin with formal demand"
|
||||
elif score >= 40:
|
||||
rec = "TIER_3"
|
||||
rec_note = "Moderate case; escalate if initial contact fails"
|
||||
else:
|
||||
rec = "TIER_4"
|
||||
rec_note = "Weak or high-effort case; review carefully before accepting"
|
||||
|
||||
summary = (
|
||||
f"Analysis score {score}/100. {rec_note}. "
|
||||
f"Key factors: {components['amount']['note']}; "
|
||||
f"{components['documentation']['note']}; "
|
||||
f"{components['collectability']['note']}; "
|
||||
f"{components['age']['note']}."
|
||||
)
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
"components": components,
|
||||
"summary": summary,
|
||||
"recommended_tier": rec,
|
||||
}
|
||||
|
||||
|
||||
def analyze_and_store(conn, claim_id: str, actor: str = "staff") -> dict:
|
||||
"""Run score_claim for a claim and persist score/approval + audit row.
|
||||
|
||||
Single source of truth for analysis persistence, shared by the staff
|
||||
/analyze endpoint (actor="staff") and post-intake auto-analysis
|
||||
(actor="system"). Returns the score_claim() result dict.
|
||||
|
||||
Assumes the caller owns the transaction and will commit.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT c.id, c.amount_cents, c.description, c.invoice_date, c.client_reference, "
|
||||
"d.business_type FROM claims c JOIN debtors d ON d.id = c.debtor_id "
|
||||
"WHERE c.id = ?",
|
||||
(claim_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ValueError(f"claim not found: {claim_id}")
|
||||
doc_count = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM documents WHERE claim_id = ?", (claim_id,)
|
||||
).fetchone()["n"]
|
||||
result = score_claim(
|
||||
amount_cents=row["amount_cents"],
|
||||
business_type=row["business_type"],
|
||||
doc_count=doc_count,
|
||||
description=row["description"],
|
||||
invoice_date=row["invoice_date"],
|
||||
client_reference=row["client_reference"],
|
||||
)
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
conn.execute(
|
||||
"UPDATE claims SET analysis_score = ?, analysis_summary = ?, analysis_components = ?, "
|
||||
"analysis_at = ?, recommended_tier = ?, approval_status = 'PENDING', updated_at = ? WHERE id = ?",
|
||||
(result["score"], result["summary"], json.dumps(result["components"]),
|
||||
now, result["recommended_tier"], now, claim_id),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, reason, created_at) "
|
||||
"VALUES (?, 'claim', ?, 'analysis', 'analysis_score', NULL, ?, ?, NULL, ?)",
|
||||
(str(uuid.uuid4()), claim_id, str(result["score"]), actor, now),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Letter templates (Tier 1-4 + 2.5). Placeholders are filled from claim data.
|
||||
# FDCPA disclosure + payment link required on every queued letter.
|
||||
# ---------------------------------------------------------------
|
||||
_FDCPA = "This communication is from a debt collector attempting to collect a debt. Any information obtained will be used for that purpose."
|
||||
_PAY = "Payment can be made at: https://pay.debtrecoveryexperts.com"
|
||||
|
||||
|
||||
def _letter_template(tier: str) -> tuple[str, str]:
|
||||
"""Return (subject, body) for the given tier. Body uses {placeholders}."""
|
||||
if tier == "TIER_1":
|
||||
subject = "Outstanding Balance - {debtor_name}"
|
||||
body = (
|
||||
"This is a friendly reminder regarding an outstanding balance with one of our clients.\n\n"
|
||||
"Client: {client_name}\n"
|
||||
"Amount Due: {amount_display}\n"
|
||||
"Invoice Reference: {invoice_ref}\n\n"
|
||||
"We understand things get overlooked. Please remit payment or contact us to discuss a "
|
||||
"resolution within 5 business days.\n\n"
|
||||
+ _PAY + "\n\n" + _FDCPA + "\n\n"
|
||||
"- Debt Recovery Experts"
|
||||
)
|
||||
elif tier == "TIER_2":
|
||||
subject = "FORMAL DEMAND FOR PAYMENT - {debtor_name}"
|
||||
body = (
|
||||
"This letter constitutes formal demand for full payment of the outstanding balance described below.\n\n"
|
||||
"Client: {client_name}\n"
|
||||
"Amount Due: {amount_display}\n"
|
||||
"Invoice Reference: {invoice_ref}\n\n"
|
||||
"Despite previous attempts to resolve this matter amicably, the amount remains unpaid.\n\n"
|
||||
"PLEASE TAKE NOTICE that if the full balance is not received within fourteen (14) calendar days, "
|
||||
"we will proceed with escalated collection measures, including but not limited to referral to our "
|
||||
"legal department, filing of a civil suit to obtain judgment, and placement of liens against real "
|
||||
"or personal property.\n\n"
|
||||
"Contact our offices immediately to arrange payment or discuss a resolution.\n\n"
|
||||
+ _PAY + "\n\n" + _FDCPA + "\n\n"
|
||||
"- Debt Recovery Experts\nCollections Department\ncollections@debtrecoveryexperts.com"
|
||||
)
|
||||
elif tier == "TIER_2_5":
|
||||
subject = "NOTICE OF INTENT TO FILE LIEN - {debtor_name}"
|
||||
body = (
|
||||
"This letter serves as formal notice of our intent to file a lien against the property described below.\n\n"
|
||||
"Client: {client_name}\n"
|
||||
"Property/Project: {debtor_address}\n"
|
||||
"Unpaid Amount: {amount_display}\n\n"
|
||||
"Texas Property Code allows for the filing of a mechanic's lien against real property where "
|
||||
"improvements were made and remain unpaid. We have been authorized to take the following actions:\n\n"
|
||||
"- Filing a Sworn Statement of Account with the county clerk\n"
|
||||
"- Recording a mechanic's lien against the property\n"
|
||||
"- Pursuing foreclosure on the lien if necessary\n\n"
|
||||
"A mechanic's lien will attach to the property title, affect your ability to sell or refinance, "
|
||||
"and appear on title searches.\n\n"
|
||||
"To avoid lien filing, full payment must be received within ten (10) calendar days.\n\n"
|
||||
+ _PAY + "\n\n" + _FDCPA + "\n\n"
|
||||
"- Debt Recovery Experts\nCollections Department"
|
||||
)
|
||||
elif tier == "TIER_3":
|
||||
subject = "FINAL NOTICE - IMMEDIATE ACTION REQUIRED"
|
||||
body = (
|
||||
"FINAL NOTICE - This is your last opportunity to resolve this matter before legal action.\n\n"
|
||||
"Client: {client_name}\n"
|
||||
"Amount Due: {amount_display}\n"
|
||||
"Original Invoice Date: {invoice_date}\n\n"
|
||||
"Multiple attempts have been made to collect this debt. Despite these efforts, the full balance "
|
||||
"remains unpaid.\n\n"
|
||||
"UNLESS FULL PAYMENT IS RECEIVED WITHIN TEN (10) CALENDAR DAYS, we will refer this matter to our "
|
||||
"legal counsel, initiate civil litigation to obtain a judgment, pursue all available post-judgment "
|
||||
"remedies including wage garnishment, bank account levy, and asset seizure, and report this debt to "
|
||||
"credit reporting agencies.\n\n"
|
||||
"You may be held liable for court costs, attorney's fees, and additional interest.\n\n"
|
||||
"Contact our offices immediately. This is your final opportunity to resolve this without court "
|
||||
"intervention.\n\n"
|
||||
+ _PAY + "\n\n" + _FDCPA + "\n\n"
|
||||
"- Debt Recovery Experts\nCollections Department"
|
||||
)
|
||||
else: # TIER_4
|
||||
subject = "LEGAL ACTION - {debtor_name}"
|
||||
body = (
|
||||
"This letter confirms that your account has been referred for legal action.\n\n"
|
||||
"Client: {client_name}\n"
|
||||
"Amount Due: {amount_display}\n"
|
||||
"Legal Reference: {claim_number}\n\n"
|
||||
"Effective immediately, this matter has been forwarded to our legal counsel for lawsuit "
|
||||
"preparation. A civil petition will be filed seeking judgment for the full amount owed, "
|
||||
"pre-judgment interest as allowed by law, court costs and filing fees, and attorney's fees.\n\n"
|
||||
"Upon obtaining a judgment, we will pursue collection through all available legal channels, "
|
||||
"including wage garnishment, bank account levy, lien against real property, and post-judgment "
|
||||
"discovery of assets.\n\n"
|
||||
"All further communication regarding this matter should be directed to our legal counsel.\n\n"
|
||||
+ _FDCPA + "\n\n"
|
||||
"- Debt Recovery Experts\nLegal Liaison Division"
|
||||
)
|
||||
return subject, body
|
||||
|
||||
|
||||
def recommend_letter(*, tier: str, client_name: str, debtor_name: str,
|
||||
amount_display: str, invoice_ref: str, claim_number: str,
|
||||
debtor_address: str = "") -> dict:
|
||||
"""Assemble the recommended letter for a claim's current tier."""
|
||||
if tier not in _letter_tier_map():
|
||||
tier = "TIER_1"
|
||||
subject, body = _letter_template(tier)
|
||||
subject = subject.format(debtor_name=debtor_name)
|
||||
body = body.format(
|
||||
debtor_name=debtor_name,
|
||||
client_name=client_name,
|
||||
amount_display=amount_display,
|
||||
invoice_ref=invoice_ref or "N/A",
|
||||
invoice_date=invoice_ref or "N/A",
|
||||
claim_number=claim_number,
|
||||
debtor_address=debtor_address or "Property address on file",
|
||||
)
|
||||
return {"subject": subject, "body": body, "tier": tier}
|
||||
|
||||
|
||||
def _letter_tier_map() -> set[str]:
|
||||
return {"TIER_1", "TIER_2", "TIER_2_5", "TIER_3", "TIER_4"}
|
||||
+157
-5
@@ -5,10 +5,14 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections import defaultdict, deque
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -108,6 +112,106 @@ def verify_staff_key(provided: str | None) -> bool:
|
||||
return _compare_hash(provided, key)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Stack Auth (auth2 / Hexclave) — per-user staff SSO
|
||||
# ---------------------------------------------------------------
|
||||
STACK_AUTH_API_URL = os.environ.get("STACK_AUTH_API_URL", "https://auth2-api.itpropartner.com").rstrip("/")
|
||||
STACK_AUTH_PUBLISHABLE_KEY = os.environ.get("STACK_AUTH_PUBLISHABLE_KEY", "")
|
||||
STACK_AUTH_PROJECT_ID = os.environ.get("STACK_AUTH_PROJECT_ID", "internal")
|
||||
STACK_AUTH_TEAM = os.environ.get("STACK_AUTH_TEAM", "dre-staff")
|
||||
# Emergency owner bypass: these emails are always admitted even if the team
|
||||
# membership API is down or membership was accidentally removed.
|
||||
STACK_AUTH_OWNER_EMAILS = [
|
||||
e.strip().lower()
|
||||
for e in os.environ.get("STACK_AUTH_OWNER_EMAILS", "").split(",")
|
||||
if e.strip()
|
||||
]
|
||||
|
||||
|
||||
def _stack_auth_request(method: str, path: str, access_token: str | None = None,
|
||||
body: dict | None = None):
|
||||
"""Call the auth2 Stack Auth REST API (client mode). Returns (status, parsed|raw).
|
||||
|
||||
Unauthenticated calls use the publishable client key; authenticated calls use
|
||||
the opaque access token instead. Both carry access-type/project-id headers.
|
||||
"""
|
||||
url = STACK_AUTH_API_URL + path
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-hexclave-access-type": "client",
|
||||
"x-hexclave-project-id": STACK_AUTH_PROJECT_ID,
|
||||
}
|
||||
if access_token:
|
||||
headers["x-hexclave-access-token"] = access_token
|
||||
else:
|
||||
headers["x-hexclave-publishable-client-key"] = STACK_AUTH_PUBLISHABLE_KEY
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=8)
|
||||
raw = resp.read().decode("utf-8", "replace")
|
||||
code = resp.status
|
||||
except urllib.error.HTTPError as e:
|
||||
code = e.code
|
||||
raw = e.read().decode("utf-8", "replace")
|
||||
except Exception: # noqa: BLE001
|
||||
return None, None
|
||||
try:
|
||||
return code, json.loads(raw)
|
||||
except Exception: # noqa: BLE001
|
||||
return code, raw
|
||||
|
||||
|
||||
def stack_auth_user(access_token: str | None) -> tuple[str, dict | None]:
|
||||
"""Validate an opaque Stack Auth access token and confirm dre-staff membership.
|
||||
|
||||
Returns one of:
|
||||
("ok", {"name","email","user_id"}) — valid session AND team member
|
||||
("denied", None) — valid session but NOT in the team
|
||||
("invalid", None) — bad/expired token or API unreachable
|
||||
"""
|
||||
if not access_token or not STACK_AUTH_PUBLISHABLE_KEY:
|
||||
return "invalid", None
|
||||
code, user = _stack_auth_request("GET", "/api/latest/users/me", access_token=access_token)
|
||||
if code != 200 or not isinstance(user, dict):
|
||||
return "invalid", None
|
||||
q = urllib.parse.quote(STACK_AUTH_TEAM)
|
||||
code, teams = _stack_auth_request("GET", f"/api/latest/teams?user_id=me&query={q}", access_token=access_token)
|
||||
items = teams.get("items") if isinstance(teams, dict) else None
|
||||
member = (code == 200 and isinstance(items, list) and any(
|
||||
str(t.get("display_name", "")).lower() == STACK_AUTH_TEAM.lower() for t in items))
|
||||
email = user.get("primary_email") or ""
|
||||
is_owner = email.lower() in STACK_AUTH_OWNER_EMAILS
|
||||
if not member and not is_owner:
|
||||
return "denied", None
|
||||
return "ok", {
|
||||
"name": user.get("display_name") or email or "DRE Staff",
|
||||
"email": email,
|
||||
"user_id": user.get("id") or "",
|
||||
}
|
||||
|
||||
|
||||
def stack_auth_sign_in(email: str, password: str) -> tuple[str, dict | None]:
|
||||
"""Password sign-in against Stack Auth, gated to the dre-staff team.
|
||||
|
||||
Returns ("ok", {"token","name","email","user_id"}), ("invalid", None) for bad
|
||||
credentials, or ("denied", None) for a valid account outside the team.
|
||||
"""
|
||||
code, d = _stack_auth_request(
|
||||
"POST", "/api/latest/auth/password/sign-in",
|
||||
body={"email": email, "password": password},
|
||||
)
|
||||
if code != 200 or not isinstance(d, dict):
|
||||
return "invalid", None
|
||||
token = d.get("access_token")
|
||||
if not token:
|
||||
return "invalid", None
|
||||
state, user = stack_auth_user(token)
|
||||
if state != "ok":
|
||||
return "denied", None
|
||||
return "ok", {"token": token, **user}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# FastAPI dependencies
|
||||
# ---------------------------------------------------------------
|
||||
@@ -119,14 +223,62 @@ def get_client_ip(request: Request) -> str:
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def require_staff(request: Request) -> None:
|
||||
"""Staff-key auth dependency. Raises 403 if missing/wrong."""
|
||||
provided = request.headers.get("x-dre-staff-key")
|
||||
if not verify_staff_key(provided):
|
||||
def staff_identity(provided: str | None) -> str:
|
||||
"""Resolve the acting staff member's display name from the presented key.
|
||||
|
||||
Priority:
|
||||
1. DRE_STAFF_DIRECTORY — 'KEY=Name <email>' entries (one per line or
|
||||
semicolon-separated). Enables real per-user RBAC once each staff
|
||||
member has their own key.
|
||||
2. DRE_STAFF_NAME — a single default name for the shared-key case.
|
||||
3. "DRE Staff" fallback.
|
||||
"""
|
||||
name = os.environ.get("DRE_STAFF_NAME", "").strip()
|
||||
directory = os.environ.get("DRE_STAFF_DIRECTORY", "").strip()
|
||||
if directory and provided:
|
||||
for entry in directory.replace(";", "\n").splitlines():
|
||||
entry = entry.strip()
|
||||
if not entry or "=" not in entry:
|
||||
continue
|
||||
key, _, label = entry.partition("=")
|
||||
if key.strip() == provided:
|
||||
name = label.strip()
|
||||
break
|
||||
return name or "DRE Staff"
|
||||
|
||||
|
||||
def require_staff(request: Request) -> str:
|
||||
"""Staff auth dependency. Raises 403 if missing/wrong.
|
||||
|
||||
Priority:
|
||||
1. Stack Auth access token (`x-dre-access-token`) → per-user auth2 SSO,
|
||||
validated server-side and gated to the dre-staff team.
|
||||
2. Legacy staff key (`x-dre-staff-key`) → script/fallback access.
|
||||
|
||||
Returns the acting staff member's display name so callers can record the
|
||||
actor without manual name entry.
|
||||
"""
|
||||
token = request.headers.get("x-dre-access-token")
|
||||
if token:
|
||||
state, user = stack_auth_user(token)
|
||||
if state == "ok":
|
||||
return user["name"]
|
||||
if state == "denied":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"code": "staff_forbidden", "message": "Account is not authorized for staff access."},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"code": "forbidden", "message": "Valid staff key required."},
|
||||
detail={"code": "staff_auth_expired", "message": "Staff session expired. Please sign in again."},
|
||||
)
|
||||
provided = request.headers.get("x-dre-staff-key")
|
||||
if verify_staff_key(provided):
|
||||
return staff_identity(provided)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"code": "forbidden", "message": "Valid staff credentials required."},
|
||||
)
|
||||
|
||||
|
||||
def require_client(request: Request) -> dict:
|
||||
|
||||
+44
-1
@@ -44,10 +44,11 @@ def get_conn() -> sqlite3.Connection:
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Create schema if absent and seed number_sequences for the current year."""
|
||||
"""Create schema if absent, migrate, and seed number_sequences for the current year."""
|
||||
with get_conn() as conn:
|
||||
schema_sql = _SCHEMA_FILE.read_text()
|
||||
conn.executescript(schema_sql)
|
||||
_migrate(conn)
|
||||
# Seed sequences for current year (idempotent)
|
||||
year = datetime.now(timezone.utc).year
|
||||
for prefix in ("DRE", "CLT"):
|
||||
@@ -58,6 +59,48 @@ def init_db() -> None:
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _migrate(conn: sqlite3.Connection) -> None:
|
||||
"""Add analysis/approval/letter columns to `claims` if they don't already exist.
|
||||
|
||||
SQLite lacks `ADD COLUMN IF NOT EXISTS`, so we inspect PRAGMA table_info first.
|
||||
Idempotent — safe to run on every boot.
|
||||
"""
|
||||
existing = {row["name"] for row in conn.execute("PRAGMA table_info(claims)").fetchall()}
|
||||
additions = {
|
||||
"no_other_agency_at": "TEXT",
|
||||
"no_prior_action_at": "TEXT",
|
||||
"analysis_score": "INTEGER",
|
||||
"analysis_summary": "TEXT",
|
||||
"analysis_components": "TEXT",
|
||||
"analysis_at": "TEXT",
|
||||
"recommended_tier": "TEXT",
|
||||
"approval_status": "TEXT NOT NULL DEFAULT 'NONE'",
|
||||
"approval_decision_by": "TEXT",
|
||||
"approval_decision_at": "TEXT",
|
||||
"letter_subject": "TEXT",
|
||||
"letter_body": "TEXT",
|
||||
"letter_tier": "TEXT",
|
||||
"letter_updated_at": "TEXT",
|
||||
}
|
||||
for col, ddl in additions.items():
|
||||
if col not in existing:
|
||||
conn.execute(f"ALTER TABLE claims ADD COLUMN {col} {ddl}")
|
||||
|
||||
# onboarding_docs -> DocuSeal e-sign state (added later than the base schema)
|
||||
existing_od = {row["name"] for row in conn.execute("PRAGMA table_info(onboarding_docs)").fetchall()}
|
||||
od_additions = {
|
||||
"docuseal_submission_id": "TEXT",
|
||||
"docuseal_submitter_id": "TEXT",
|
||||
"docuseal_slug": "TEXT",
|
||||
"docuseal_embed_src": "TEXT",
|
||||
"docuseal_status": "TEXT",
|
||||
"docuseal_sent_at": "TEXT",
|
||||
}
|
||||
for col, ddl in od_additions.items():
|
||||
if col not in existing_od:
|
||||
conn.execute(f"ALTER TABLE onboarding_docs ADD COLUMN {col} {ddl}")
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""DocuSeal e-signature integration.
|
||||
|
||||
Self-hosted DocuSeal: sign.debtrecoveryexperts.com (loopback 127.0.0.1:8094).
|
||||
Auth: X-Auth-Token header. We only CREATE submissions against the 6 pre-authored
|
||||
templates — template creation is not supported on self-hosted via the API, so the
|
||||
templates are authored once in the DocuSeal UI and referenced by name here.
|
||||
|
||||
Pre-fill: the portal's merged field values (packet.merged_values) map onto the
|
||||
DocuSeal text-field tokens via packet.DOC_PLACEHOLDERS. Signature/date/print-name
|
||||
fields are intentionally left blank — they are completed by the human at signing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from . import packet
|
||||
|
||||
BASE_URL = os.environ.get("DOCUSEAL_BASE_URL", "http://127.0.0.1:8094").rstrip("/")
|
||||
API_TOKEN = os.environ.get("DOCUSEAL_API_TOKEN", "")
|
||||
SIGN_HOST = os.environ.get("DOCUSEAL_SIGN_HOST", "https://sign.debtrecoveryexperts.com").rstrip("/")
|
||||
|
||||
# doc_key -> (DocuSeal template name, markdown source filename in docs/welcome-packet/).
|
||||
# Keys mirror packet.ONBOARDING_DOCS; names mirror packet.DOC_TITLES exactly.
|
||||
DOC_MAP = {
|
||||
"LPOA": ("LPOA - Limited Power of Attorney", "01-LPOA.md"),
|
||||
"TOS": ("Terms of Service", "02-Terms-of-Service.md"),
|
||||
"FEE_SCHEDULE": ("Fee Schedule (Schedule A)", "03-Fee-Schedule.md"),
|
||||
"THIRD_PARTY_CONSENT": ("Third-Party Sharing Consent", "04-Third-Party-Consent.md"),
|
||||
"DEBTOR_INFO": ("Debtor Information Sheet", "05-Debtor-Info-Sheet.md"),
|
||||
"ACH": ("ACH / Disbursement Authorization", "06-ACH-Authorization.md"),
|
||||
}
|
||||
|
||||
|
||||
class DocuSealError(Exception):
|
||||
"""Raised when the DocuSeal API call fails or a template is missing."""
|
||||
|
||||
|
||||
def _request(method: str, path: str, payload: dict | None = None):
|
||||
url = f"{BASE_URL}{path}"
|
||||
data = json.dumps(payload).encode() if payload is not None else None
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={
|
||||
"X-Auth-Token": API_TOKEN,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
body = resp.read().decode()
|
||||
return json.loads(body) if body else None
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode(errors="replace")
|
||||
raise DocuSealError(f"DocuSeal HTTP {e.code}: {body[:800]}") from e
|
||||
except urllib.error.URLError as e:
|
||||
raise DocuSealError(f"DocuSeal unreachable: {e.reason}") from e
|
||||
|
||||
|
||||
_template_cache: dict[str, int] = {}
|
||||
|
||||
|
||||
def template_id(doc_key: str) -> int:
|
||||
"""Resolve a doc_key to its DocuSeal template id (cached per process)."""
|
||||
if not API_TOKEN:
|
||||
raise DocuSealError("DOCUSEAL_API_TOKEN is not configured")
|
||||
if doc_key in _template_cache:
|
||||
return _template_cache[doc_key]
|
||||
tmpl_name = DOC_MAP[doc_key][0]
|
||||
resp = _request("GET", "/api/templates")
|
||||
data = resp.get("data", []) if isinstance(resp, dict) else resp
|
||||
for t in data:
|
||||
if t.get("name") == tmpl_name:
|
||||
_template_cache[doc_key] = t["id"]
|
||||
return t["id"]
|
||||
raise DocuSealError(f"No DocuSeal template named: {tmpl_name}")
|
||||
|
||||
|
||||
def build_prefill(doc_key: str, values: dict) -> tuple[dict, list[dict]]:
|
||||
"""Map portal merged field values to DocuSeal pre-fill tokens for one doc.
|
||||
|
||||
Returns (values, fields) for the submission payload. Only non-empty values
|
||||
are pre-filled; signature/date/print-name blocks stay blank for the signer.
|
||||
"""
|
||||
_name, filename = DOC_MAP[doc_key]
|
||||
out: dict[str, str] = {}
|
||||
for token, profile_key in packet.DOC_PLACEHOLDERS.get(filename, []):
|
||||
val = values.get(profile_key, "")
|
||||
if val:
|
||||
out[token] = str(val)
|
||||
fields = [{"name": k, "default_value": v} for k, v in out.items()]
|
||||
return out, fields
|
||||
|
||||
|
||||
def signing_url(slug: str | None, embed_src: str | None = None) -> str | None:
|
||||
"""Public signing URL for a submitter. Prefers the canonical /s/<slug> path."""
|
||||
if slug:
|
||||
return f"{SIGN_HOST}/s/{slug}"
|
||||
return embed_src
|
||||
|
||||
|
||||
def create_submission(doc_key: str, email: str, name: str, values: dict,
|
||||
send_email: bool = True, message: dict | None = None) -> dict:
|
||||
"""Create one signature request for the client. Returns the submitter object."""
|
||||
tid = template_id(doc_key)
|
||||
vals, fields = build_prefill(doc_key, values)
|
||||
tmpl_name = DOC_MAP[doc_key][0]
|
||||
payload = {
|
||||
"template_id": tid,
|
||||
"send_email": send_email,
|
||||
"message": message or {
|
||||
"subject": f"Please review and sign: {tmpl_name}",
|
||||
"body": (
|
||||
"Hi {{submitter.name}},\n\n"
|
||||
"Please open the link below to review the prefilled details and "
|
||||
"complete your signature.\n\n{{submitter.link}}\n\n"
|
||||
"Thank you,\nDebt Recovery Experts, LLC"
|
||||
),
|
||||
},
|
||||
"submitters": [
|
||||
{
|
||||
"role": "Client",
|
||||
"email": email,
|
||||
"name": name,
|
||||
"values": vals,
|
||||
"fields": fields,
|
||||
},
|
||||
],
|
||||
}
|
||||
resp = _request("POST", "/api/submissions", payload)
|
||||
if isinstance(resp, list) and resp:
|
||||
return resp[0]
|
||||
if isinstance(resp, dict):
|
||||
return resp
|
||||
raise DocuSealError(f"Unexpected DocuSeal response type: {type(resp).__name__}")
|
||||
+16
-4
@@ -18,6 +18,12 @@ def _env(name: str, default: str = "") -> str:
|
||||
return os.environ.get(name, default)
|
||||
|
||||
|
||||
def _team_recipients() -> list[str]:
|
||||
"""Fan-out list from DRE_TEAM_NOTIFY (comma-separated). Defaults to dre@."""
|
||||
raw = _env("DRE_TEAM_NOTIFY", "dre@debtrecoveryexperts.com")
|
||||
return [a.strip() for a in raw.split(",") if a.strip()]
|
||||
|
||||
|
||||
def send_email(to_addr: str, subject: str, body_text: str, html: str | None = None) -> bool:
|
||||
"""Send an email via the configured SMTP relay. Returns True on success, False on failure.
|
||||
Never raises — caller proceeds regardless."""
|
||||
@@ -47,7 +53,7 @@ def send_email(to_addr: str, subject: str, body_text: str, html: str | None = No
|
||||
|
||||
def notify_team_intake(claim_number: str, client_number: str, company_name: str,
|
||||
amount_cents: int, debtor_name: str) -> bool:
|
||||
team = _env("DRE_TEAM_NOTIFY", "dre@debtrecoveryexperts.com")
|
||||
team = _team_recipients()
|
||||
base = _env("DRE_BASE_URL", "https://my.debtrecoveryexperts.com")
|
||||
dollars = amount_cents / 100.0
|
||||
body = (
|
||||
@@ -66,7 +72,10 @@ def notify_team_intake(claim_number: str, client_number: str, company_name: str,
|
||||
f"<b>Amount:</b> ${dollars:,.2f}</p>"
|
||||
f"<p><a href=\"{base}/\">Review in portal</a></p>"
|
||||
)
|
||||
return send_email(team, f"New DRE Claim: {claim_number}", body, html)
|
||||
ok = True
|
||||
for addr in team:
|
||||
ok = send_email(addr, f"New DRE Claim: {claim_number}", body, html) and ok
|
||||
return ok
|
||||
|
||||
|
||||
def send_magic_link(to_addr: str, raw_token: str, client_number: str) -> bool:
|
||||
@@ -92,11 +101,14 @@ def send_magic_link(to_addr: str, raw_token: str, client_number: str) -> bool:
|
||||
|
||||
def notify_team_message(claim_number: str, subject: str, content: str,
|
||||
author: str) -> bool:
|
||||
team = _env("DRE_TEAM_NOTIFY", "dre@debtrecoveryexperts.com")
|
||||
team = _team_recipients()
|
||||
body = (
|
||||
f"New client message on claim {claim_number}.\n\n"
|
||||
f"From: {author}\n"
|
||||
f"Subject: {subject}\n\n"
|
||||
f"{content}\n"
|
||||
)
|
||||
return send_email(team, f"Client message on {claim_number}: {subject}", body)
|
||||
ok = True
|
||||
for addr in team:
|
||||
ok = send_email(addr, f"Client message on {claim_number}: {subject}", body) and ok
|
||||
return ok
|
||||
|
||||
+15
-3
@@ -11,6 +11,7 @@ from pydantic import ValidationError
|
||||
from . import auth as authmod
|
||||
from . import db
|
||||
from . import dreemail
|
||||
from . import analysis
|
||||
from .db import get_conn, new_uuid, next_sequence_number, utcnow_iso
|
||||
from .models import IntakeRequest
|
||||
|
||||
@@ -93,10 +94,12 @@ async def intake(request: Request):
|
||||
claim_id = new_uuid()
|
||||
claim_number = next_sequence_number(conn, "DRE")
|
||||
conn.execute(
|
||||
"INSERT INTO claims (id, claim_number, client_id, debtor_id, amount_cents, currency, status, tier, description, client_reference, invoice_date, date_assigned, date_resolved, twentycrm_id, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'USD', 'NEW', 'TIER_1', ?, ?, ?, NULL, NULL, NULL, ?, ?)",
|
||||
"INSERT INTO claims (id, claim_number, client_id, debtor_id, amount_cents, currency, status, tier, description, client_reference, invoice_date, date_assigned, date_resolved, no_other_agency_at, no_prior_action_at, twentycrm_id, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'USD', 'NEW', 'TIER_1', ?, ?, ?, NULL, NULL, ?, ?, NULL, ?, ?)",
|
||||
(claim_id, claim_number, client_id, debtor_id, claim_in.amount_cents,
|
||||
claim_in.description, claim_in.client_reference, claim_in.invoice_date, now, now),
|
||||
claim_in.description, claim_in.client_reference, claim_in.invoice_date,
|
||||
now if req.no_other_agency_accepted else None,
|
||||
now if req.no_prior_action_accepted else None, now, now),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO audit_log (id, entity_type, entity_id, action, old_value, new_value, actor, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
@@ -114,6 +117,15 @@ async def intake(request: Request):
|
||||
"INSERT INTO audit_log (id, entity_type, entity_id, action, actor, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(new_uuid(), "note", note_id, "note_add", "system", now),
|
||||
)
|
||||
|
||||
# Auto-run AI analysis on submission (score + recommended tier + approval=PENDING).
|
||||
# Deterministic rules engine; no external call. Best-effort: a failure must not
|
||||
# block intake, so we guard and let the claim persist without a score if it errors.
|
||||
try:
|
||||
analysis.analyze_and_store(conn, claim_id, actor="system")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("auto-analysis failed for claim %s: %s", claim_number, exc)
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Best-effort team notification
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
"""Physical mail queue + LetterStream send integration.
|
||||
|
||||
Letters are the paper collection notices mailed to debtors. One row per mailed
|
||||
letter in `letters` (lifecycle DRAFT -> APPROVED -> PREAUTH -> SENT, plus
|
||||
REJECTED / CANCELLED / ERROR), with tracking scan events in `letter_events`
|
||||
pushed by LetterStream's callback.
|
||||
|
||||
Send flow is human-in-the-loop on cost (production mode, real money):
|
||||
1. staff creates a draft letter (content defaults to claims.letter_*, recipient
|
||||
address supplied by staff after client-data + Super Search verification)
|
||||
2. staff approves -> APPROVED
|
||||
3. staff clicks "price & queue" -> LetterStream preauth=1, store authcode + cost
|
||||
(status PREAUTH); nothing is released or mailed yet
|
||||
4. staff confirms -> LetterStream doauth, status SENT, tracking begins
|
||||
|
||||
Signatory: Debt Recovery Experts LLC (LETTERSTREAM_SIGNATORY).
|
||||
Return address: LETTERSTREAM_RETURN_ADDRESS (colon-delimited addr1:addr2:city:state:zip).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
|
||||
|
||||
from . import auth as authmod
|
||||
from . import letterstream
|
||||
from .db import get_conn, new_uuid, utcnow_iso
|
||||
|
||||
logger = logging.getLogger("dre.letters")
|
||||
router = APIRouter()
|
||||
|
||||
SIGNATORY = os.environ.get("LETTERSTREAM_SIGNATORY", "Debt Recovery Experts LLC")
|
||||
RETURN_ADDR = os.environ.get("LETTERSTREAM_RETURN_ADDRESS", "") # addr1:addr2:city:state:zip
|
||||
CALLBACK_KEY = os.environ.get("LETTERSTREAM_CALLBACK_KEY", "")
|
||||
LETTERS_DIR = os.environ.get("DRE_LETTERS_DIR", "/opt/dre-portal/data/letters")
|
||||
|
||||
MAILTYPES = ("firstclass", "firstclass_hse", "certified", "certnoerr",
|
||||
"postcard", "flat", "propostcard")
|
||||
LETTER_STATUSES = ("DRAFT", "APPROVED", "PREAUTH", "SENT",
|
||||
"REJECTED", "CANCELLED", "ERROR")
|
||||
|
||||
|
||||
class LetterStreamError(Exception):
|
||||
"""Raised on any LetterStream or letter-lifecycle failure."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Request models (letter endpoints only — kept local to this router)
|
||||
# ---------------------------------------------------------------
|
||||
class RecipientAddress(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
name2: str | None = Field(None, max_length=200)
|
||||
addr1: str = Field(..., min_length=1, max_length=200)
|
||||
addr2: str | None = Field(None, max_length=200)
|
||||
city: str = Field(..., min_length=1, max_length=100)
|
||||
state: str = Field(..., min_length=2, max_length=2)
|
||||
zip: str = Field(..., min_length=5, max_length=10)
|
||||
|
||||
@field_validator("name", "name2", "addr1", "addr2", "city", "state", "zip")
|
||||
@classmethod
|
||||
def _v(cls, v):
|
||||
return v
|
||||
|
||||
|
||||
class LetterCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
recipient: RecipientAddress
|
||||
letter_type: str = Field("demand", max_length=40)
|
||||
subject: str | None = Field(None, max_length=200)
|
||||
body: str | None = Field(None, max_length=20000)
|
||||
mailtype: str = "firstclass"
|
||||
coversheet: bool = True
|
||||
|
||||
@field_validator("mailtype")
|
||||
@classmethod
|
||||
def _mt(cls, v):
|
||||
if v not in MAILTYPES:
|
||||
raise ValueError(f"mailtype must be one of {MAILTYPES}")
|
||||
return v
|
||||
|
||||
|
||||
class LetterAction(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
reason: str | None = Field(None, max_length=1000)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------
|
||||
def sender_parts() -> list[str]:
|
||||
"""Return [name1, name2, addr1, addr2, city, state, zip] for the `from` field."""
|
||||
if not RETURN_ADDR:
|
||||
raise LetterStreamError("LETTERSTREAM_RETURN_ADDRESS is not configured")
|
||||
parts = RETURN_ADDR.split(":")
|
||||
if len(parts) != 5:
|
||||
raise LetterStreamError(
|
||||
"LETTERSTREAM_RETURN_ADDRESS must be addr1:addr2:city:state:zip")
|
||||
addr1, addr2, city, state, zipc = parts
|
||||
return [SIGNATORY, "", addr1, addr2, city, state.upper(), zipc]
|
||||
|
||||
|
||||
def sender_string() -> str:
|
||||
return ":".join(sender_parts())
|
||||
|
||||
|
||||
def recipient_string(doc_id: str, r: dict) -> str:
|
||||
return ":".join([
|
||||
doc_id, r.get("name", ""), r.get("name2") or "", r.get("addr1", ""),
|
||||
r.get("addr2") or "", r.get("city", ""), r.get("state", "").upper(),
|
||||
r.get("zip", ""),
|
||||
])
|
||||
|
||||
|
||||
def render_letter_pdf(*, sender_block: list[str], recipient_block: list[str],
|
||||
date_str: str, subject: str, body: str) -> tuple[bytes, int]:
|
||||
"""Render a single-page (or multi-page) letter PDF. Returns (bytes, page_count).
|
||||
|
||||
Address blocks are placed in the standard #10 double-window positions
|
||||
(verified via LetterStream preflight before first production send).
|
||||
"""
|
||||
from fpdf import FPDF
|
||||
|
||||
def _safe(s: str) -> str:
|
||||
return (s or "").encode("latin-1", "replace").decode("latin-1")
|
||||
|
||||
pdf = FPDF(unit="mm", format="Letter")
|
||||
pdf.set_auto_page_break(auto=True, margin=15)
|
||||
pdf.add_page()
|
||||
# Return-address window (top-left)
|
||||
pdf.set_font("Helvetica", size=10)
|
||||
y = 14.0
|
||||
for line in sender_block:
|
||||
pdf.set_xy(14.3, y)
|
||||
pdf.cell(0, 4.2, _safe(line))
|
||||
y += 4.2
|
||||
# Recipient window
|
||||
y = 50.8
|
||||
for line in recipient_block:
|
||||
pdf.set_xy(14.3, y)
|
||||
pdf.cell(0, 4.2, _safe(line))
|
||||
y += 4.2
|
||||
# Date
|
||||
pdf.set_xy(14.3, 76.0)
|
||||
pdf.cell(0, 5, _safe(date_str))
|
||||
# Subject (bold)
|
||||
pdf.set_font("Helvetica", "B", size=12)
|
||||
pdf.set_xy(14.3, 84.0)
|
||||
pdf.multi_cell(180, 6, _safe(subject))
|
||||
# Body
|
||||
pdf.set_font("Helvetica", size=10)
|
||||
pdf.set_xy(14.3, 94.0)
|
||||
pdf.multi_cell(180, 4.6, _safe(body))
|
||||
out = pdf.output(dest="S")
|
||||
return out, len(pdf.pages)
|
||||
|
||||
|
||||
def _sender_block() -> list[str]:
|
||||
parts = sender_parts()
|
||||
name1, _name2, addr1, addr2, city, state, zipc = parts
|
||||
lines = [name1]
|
||||
if addr1:
|
||||
lines.append(addr1)
|
||||
if addr2:
|
||||
lines.append(addr2)
|
||||
lines.append(f"{city}, {state} {zipc}")
|
||||
return lines
|
||||
|
||||
|
||||
def _recipient_block(r: dict) -> list[str]:
|
||||
lines = [r.get("name", "")]
|
||||
if r.get("name2"):
|
||||
lines.append(r["name2"])
|
||||
lines.append(r.get("addr1", ""))
|
||||
if r.get("addr2"):
|
||||
lines.append(r["addr2"])
|
||||
lines.append(f"{r.get('city', '')}, {r.get('state', '')} {r.get('zip', '')}")
|
||||
return lines
|
||||
|
||||
|
||||
def _letter_row(conn, letter_id: str):
|
||||
return conn.execute(
|
||||
"SELECT l.*, c.claim_number FROM letters l "
|
||||
"JOIN claims c ON c.id = l.claim_id WHERE l.id = ?",
|
||||
(letter_id,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _serialize(row) -> dict:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"claim_id": row["claim_id"],
|
||||
"claim_number": row["claim_number"],
|
||||
"letter_type": row["letter_type"],
|
||||
"subject": row["subject"],
|
||||
"body": row["body"],
|
||||
"recipient": json.loads(row["recipient_json"]),
|
||||
"sender": json.loads(row["sender_json"]),
|
||||
"mailtype": row["mailtype"],
|
||||
"status": row["status"],
|
||||
"job_id": row["job_id"],
|
||||
"batch_id": row["batch_id"],
|
||||
"doc_id": row["doc_id"],
|
||||
"tracking_no": row["tracking_no"],
|
||||
"cost_cents": row["cost_cents"],
|
||||
"pages": row["pages"],
|
||||
"error": row["error"],
|
||||
"note": row["note"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"sent_at": row["sent_at"],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST /api/staff/claims/{claim_number}/letters (create draft)
|
||||
# ---------------------------------------------------------------
|
||||
@router.post("/api/staff/claims/{claim_number}/letters")
|
||||
async def create_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:
|
||||
lc = LetterCreate.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 c.id, c.letter_subject, c.letter_body, d.name AS debtor_name "
|
||||
"FROM claims c 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)
|
||||
subject = lc.subject or row["letter_subject"] or "Notice from Debt Recovery Experts, LLC"
|
||||
body_text = lc.body or row["letter_body"] or ""
|
||||
if not body_text.strip():
|
||||
return _err("validation_error",
|
||||
"Claim has no letter body; generate a letter first.",
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
recipient = lc.recipient.model_dump()
|
||||
# Sender address is global config (LETTERSTREAM_RETURN_ADDRESS), resolved at
|
||||
# send time. A draft records only the signatory; drafting must not block on
|
||||
# the return address, which may not be configured yet.
|
||||
sender = {"name": SIGNATORY, "name2": "", "addr1": "", "addr2": "",
|
||||
"city": "", "state": "", "zip": ""}
|
||||
now = utcnow_iso()
|
||||
letter_id = new_uuid()
|
||||
conn.execute(
|
||||
"INSERT INTO letters (id, claim_id, letter_type, subject, body, recipient_json, "
|
||||
"sender_json, mailtype, status, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'DRAFT', ?, ?)",
|
||||
(letter_id, row["id"], lc.letter_type, subject, body_text,
|
||||
json.dumps(recipient), json.dumps(sender), lc.mailtype, now, now),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) "
|
||||
"VALUES (?, 'letter', ?, 'create', 'status', NULL, 'DRAFT', ?, ?)",
|
||||
(new_uuid(), letter_id, staff_name, now),
|
||||
)
|
||||
conn.commit()
|
||||
return await _get_letter(letter_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# GET /api/staff/letters
|
||||
# ---------------------------------------------------------------
|
||||
@router.get("/api/staff/letters")
|
||||
async def list_letters(request: Request, _staff=Depends(authmod.require_staff)):
|
||||
status_filter = request.query_params.get("status")
|
||||
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 = "WHERE l.status = ?"
|
||||
params.append(status_filter)
|
||||
from_clause = "FROM letters l JOIN claims c ON c.id = l.claim_id"
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT l.*, c.claim_number {from_clause} {where} "
|
||||
f"ORDER BY l.created_at DESC LIMIT ? OFFSET ?",
|
||||
tuple(params + [limit, offset]),
|
||||
).fetchall()
|
||||
total = conn.execute(
|
||||
f"SELECT COUNT(*) AS n {from_clause} {where}", tuple(params),
|
||||
).fetchone()["n"]
|
||||
return {"letters": [_serialize(r) for r in rows], "total": total,
|
||||
"limit": limit, "offset": offset}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# GET /api/staff/letters/{letter_id}
|
||||
# ---------------------------------------------------------------
|
||||
@router.get("/api/staff/letters/{letter_id}")
|
||||
async def get_letter(letter_id: str, _staff=Depends(authmod.require_staff)):
|
||||
return await _get_letter(letter_id)
|
||||
|
||||
|
||||
async def _get_letter(letter_id: str):
|
||||
with get_conn() as conn:
|
||||
row = _letter_row(conn, letter_id)
|
||||
if row is None:
|
||||
return _err("not_found", "Letter not found.", status.HTTP_404_NOT_FOUND)
|
||||
events = conn.execute(
|
||||
"SELECT scan_code, scan_status, scan_date, scan_zip, scan_facility, tracking_id, "
|
||||
"created_at FROM letter_events WHERE letter_id = ? ORDER BY created_at ASC",
|
||||
(letter_id,),
|
||||
).fetchall()
|
||||
data = _serialize(row)
|
||||
data["events"] = [dict(e) for e in events]
|
||||
return data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST /api/staff/letters/{letter_id}/approve (DRAFT -> APPROVED)
|
||||
# ---------------------------------------------------------------
|
||||
@router.post("/api/staff/letters/{letter_id}/approve")
|
||||
async def approve_letter(letter_id: str, staff_name: str = Depends(authmod.require_staff)):
|
||||
with get_conn() as conn:
|
||||
row = _letter_row(conn, letter_id)
|
||||
if row is None:
|
||||
return _err("not_found", "Letter not found.", status.HTTP_404_NOT_FOUND)
|
||||
if row["status"] != "DRAFT":
|
||||
return _err("conflict", f"Letter is {row['status']}, not DRAFT.",
|
||||
status.HTTP_409_CONFLICT)
|
||||
now = utcnow_iso()
|
||||
conn.execute("UPDATE letters SET status = 'APPROVED', updated_at = ? WHERE id = ?",
|
||||
(now, letter_id))
|
||||
conn.execute(
|
||||
"INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) "
|
||||
"VALUES (?, 'letter', ?, 'approve', 'status', 'DRAFT', 'APPROVED', ?, ?)",
|
||||
(new_uuid(), letter_id, staff_name, now),
|
||||
)
|
||||
conn.commit()
|
||||
return await _get_letter(letter_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST /api/staff/letters/{letter_id}/send (preauth — price only)
|
||||
# ---------------------------------------------------------------
|
||||
@router.post("/api/staff/letters/{letter_id}/send")
|
||||
async def send_letter(letter_id: str, staff_name: str = Depends(authmod.require_staff)):
|
||||
with get_conn() as conn:
|
||||
row = _letter_row(conn, letter_id)
|
||||
if row is None:
|
||||
return _err("not_found", "Letter not found.", status.HTTP_404_NOT_FOUND)
|
||||
if row["status"] not in ("APPROVED", "PREAUTH", "ERROR"):
|
||||
return _err("conflict", f"Letter is {row['status']}; approve it first.",
|
||||
status.HTTP_409_CONFLICT)
|
||||
recipient = json.loads(row["recipient_json"])
|
||||
subject = row["subject"] or ""
|
||||
body = row["body"] or ""
|
||||
try:
|
||||
sender = {k: v for k, v in zip(
|
||||
("name", "name2", "addr1", "addr2", "city", "state", "zip"), sender_parts())}
|
||||
except LetterStreamError as exc:
|
||||
return _err("config_error", str(exc), status.HTTP_409_CONFLICT)
|
||||
# Render + persist PDF
|
||||
doc_id = new_uuid().replace("-", "")[:16]
|
||||
job = "DRE" + doc_id
|
||||
try:
|
||||
pdf_bytes, pages = render_letter_pdf(
|
||||
sender_block=_sender_block(), recipient_block=_recipient_block(recipient),
|
||||
date_str=utcnow_iso()[:10], subject=subject, body=body,
|
||||
)
|
||||
except Exception as exc:
|
||||
return _err("render_error", f"PDF render failed: {exc}", status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
os.makedirs(LETTERS_DIR, exist_ok=True)
|
||||
pdf_path = os.path.join(LETTERS_DIR, f"{letter_id}.pdf")
|
||||
with open(pdf_path, "wb") as fh:
|
||||
fh.write(pdf_bytes)
|
||||
try:
|
||||
resp = letterstream.send_single(
|
||||
pdf_bytes, f"{letter_id}.pdf", job, sender_string(),
|
||||
[recipient_string(doc_id, recipient)], pages,
|
||||
mailtype=row["mailtype"], preauth=True,
|
||||
)
|
||||
except letterstream.LetterStreamError as exc:
|
||||
now = utcnow_iso()
|
||||
conn.execute(
|
||||
"UPDATE letters SET status = 'ERROR', error = ?, updated_at = ? WHERE id = ?",
|
||||
(str(exc), now, letter_id),
|
||||
)
|
||||
conn.commit()
|
||||
return _err("letterstream_error", str(exc), status.HTTP_502_BAD_GATEWAY)
|
||||
m = letterstream._first(resp) if resp.get("messages") else {}
|
||||
authcode = m.get("authcode", "")
|
||||
cost = m.get("cost")
|
||||
batch = m.get("batch")
|
||||
docs = m.get("docs", [])
|
||||
ls_doc = docs[0].get("id") if docs else None
|
||||
cost_cents = int(round(float(cost) * 100)) if cost else None
|
||||
now = utcnow_iso()
|
||||
conn.execute(
|
||||
"UPDATE letters SET status = 'PREAUTH', job_id = ?, batch_id = ?, doc_id = ?, "
|
||||
"sender_json = ?, authcode = ?, cost_cents = ?, pages = ?, pdf_path = ?, error = NULL, updated_at = ? "
|
||||
"WHERE id = ?",
|
||||
(job, batch, ls_doc, json.dumps(sender), authcode, cost_cents, pages, pdf_path, now, letter_id),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) "
|
||||
"VALUES (?, 'letter', ?, 'preauth', 'status', NULL, 'PREAUTH', ?, ?)",
|
||||
(new_uuid(), letter_id, staff_name, now),
|
||||
)
|
||||
conn.commit()
|
||||
return await _get_letter(letter_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST /api/staff/letters/{letter_id}/confirm (doauth — release)
|
||||
# ---------------------------------------------------------------
|
||||
@router.post("/api/staff/letters/{letter_id}/confirm")
|
||||
async def confirm_letter(letter_id: str, staff_name: str = Depends(authmod.require_staff)):
|
||||
with get_conn() as conn:
|
||||
row = _letter_row(conn, letter_id)
|
||||
if row is None:
|
||||
return _err("not_found", "Letter not found.", status.HTTP_404_NOT_FOUND)
|
||||
if row["status"] != "PREAUTH" or not row["authcode"]:
|
||||
return _err("conflict", "Letter has no pending preauth to confirm.",
|
||||
status.HTTP_409_CONFLICT)
|
||||
try:
|
||||
letterstream.doauth(row["authcode"])
|
||||
except letterstream.LetterStreamError as exc:
|
||||
now = utcnow_iso()
|
||||
conn.execute(
|
||||
"UPDATE letters SET status = 'ERROR', error = ?, updated_at = ? WHERE id = ?",
|
||||
(str(exc), now, letter_id),
|
||||
)
|
||||
conn.commit()
|
||||
return _err("letterstream_error", str(exc), status.HTTP_502_BAD_GATEWAY)
|
||||
now = utcnow_iso()
|
||||
conn.execute(
|
||||
"UPDATE letters SET status = 'SENT', sent_at = ?, updated_at = ? WHERE id = ?",
|
||||
(now, now, letter_id),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) "
|
||||
"VALUES (?, 'letter', ?, 'confirm', 'status', 'PREAUTH', 'SENT', ?, ?)",
|
||||
(new_uuid(), letter_id, staff_name, now),
|
||||
)
|
||||
conn.commit()
|
||||
return await _get_letter(letter_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST /api/staff/letters/{letter_id}/reject (terminal — REJECTED)
|
||||
# ---------------------------------------------------------------
|
||||
@router.post("/api/staff/letters/{letter_id}/reject")
|
||||
async def reject_letter(letter_id: str, request: Request,
|
||||
staff_name: str = Depends(authmod.require_staff)):
|
||||
reason = ""
|
||||
try:
|
||||
body = await request.json()
|
||||
if isinstance(body, dict):
|
||||
reason = str(body.get("reason") or "").strip()
|
||||
except Exception:
|
||||
pass
|
||||
if not reason:
|
||||
return _err("validation_error", "A reason is required to reject a letter.",
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
reason = reason[:1000]
|
||||
with get_conn() as conn:
|
||||
row = _letter_row(conn, letter_id)
|
||||
if row is None:
|
||||
return _err("not_found", "Letter not found.", status.HTTP_404_NOT_FOUND)
|
||||
if row["status"] in ("SENT", "REJECTED", "CANCELLED"):
|
||||
return _err("conflict", f"Letter is {row['status']}; cannot reject.",
|
||||
status.HTTP_409_CONFLICT)
|
||||
old = row["status"]
|
||||
now = utcnow_iso()
|
||||
conn.execute(
|
||||
"UPDATE letters SET status = 'REJECTED', note = ?, error = NULL, updated_at = ? WHERE id = ?",
|
||||
(reason, now, letter_id),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) "
|
||||
"VALUES (?, 'letter', ?, 'reject', 'status', ?, 'REJECTED', ?, ?)",
|
||||
(new_uuid(), letter_id, old, staff_name, now),
|
||||
)
|
||||
conn.commit()
|
||||
return await _get_letter(letter_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST /api/staff/letters/{letter_id}/cancel (terminal — CANCELLED)
|
||||
# ---------------------------------------------------------------
|
||||
@router.post("/api/staff/letters/{letter_id}/cancel")
|
||||
async def cancel_letter(letter_id: str, request: Request,
|
||||
staff_name: str = Depends(authmod.require_staff)):
|
||||
reason = ""
|
||||
try:
|
||||
body = await request.json()
|
||||
if isinstance(body, dict):
|
||||
reason = str(body.get("reason") or "").strip()
|
||||
except Exception:
|
||||
pass
|
||||
reason = reason[:1000]
|
||||
with get_conn() as conn:
|
||||
row = _letter_row(conn, letter_id)
|
||||
if row is None:
|
||||
return _err("not_found", "Letter not found.", status.HTTP_404_NOT_FOUND)
|
||||
if row["status"] in ("SENT", "REJECTED", "CANCELLED"):
|
||||
return _err("conflict", f"Letter is {row['status']}; cannot cancel.",
|
||||
status.HTTP_409_CONFLICT)
|
||||
old = row["status"]
|
||||
now = utcnow_iso()
|
||||
conn.execute(
|
||||
"UPDATE letters SET status = 'CANCELLED', note = ?, error = NULL, updated_at = ? WHERE id = ?",
|
||||
(reason, now, letter_id),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) "
|
||||
"VALUES (?, 'letter', ?, 'cancel', 'status', ?, 'CANCELLED', ?, ?)",
|
||||
(new_uuid(), letter_id, old, staff_name, now),
|
||||
)
|
||||
conn.commit()
|
||||
return await _get_letter(letter_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST /api/letters/webhook (PUBLIC — LetterStream tracking callback)
|
||||
# ---------------------------------------------------------------
|
||||
@router.post("/api/letters/webhook")
|
||||
async def letter_webhook(request: Request):
|
||||
ctype = request.headers.get("content-type", "")
|
||||
data: dict = {}
|
||||
if "application/json" in ctype:
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
else:
|
||||
try:
|
||||
form = await request.form()
|
||||
data = {k: v for k, v in form.items()}
|
||||
except Exception:
|
||||
data = {}
|
||||
key = data.get("key", "")
|
||||
if not CALLBACK_KEY or key != CALLBACK_KEY:
|
||||
return JSONResponse(status_code=401,
|
||||
content={"success": False, "reason": "Invalid key"})
|
||||
raw_json = data.get("json", "")
|
||||
payload = None
|
||||
try:
|
||||
payload = json.loads(raw_json) if isinstance(raw_json, str) else raw_json
|
||||
except (ValueError, TypeError):
|
||||
payload = None
|
||||
events: list[dict] = []
|
||||
if isinstance(payload, list):
|
||||
events = [e for e in payload if isinstance(e, dict)]
|
||||
elif isinstance(payload, dict):
|
||||
if isinstance(payload.get("data"), list):
|
||||
events = [e for e in payload["data"] if isinstance(e, dict)]
|
||||
elif payload:
|
||||
events = [payload]
|
||||
with get_conn() as conn:
|
||||
for ev in events:
|
||||
doc_id = str(ev.get("doc_id", ""))
|
||||
job_id = str(ev.get("job_id", ""))
|
||||
letter = None
|
||||
if doc_id:
|
||||
letter = conn.execute("SELECT id FROM letters WHERE doc_id = ?", (doc_id,)).fetchone()
|
||||
if letter is None and job_id:
|
||||
letter = conn.execute("SELECT id FROM letters WHERE job_id = ?", (job_id,)).fetchone()
|
||||
if letter is None:
|
||||
logger.warning("letter webhook: no letter for doc_id=%s job_id=%s", doc_id, job_id)
|
||||
continue
|
||||
conn.execute(
|
||||
"INSERT INTO letter_events (id, letter_id, scan_code, scan_status, scan_date, "
|
||||
"scan_zip, scan_facility, tracking_id, batch_id, job_id, doc_id, raw_json, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(new_uuid(), letter["id"], ev.get("scan_code"), ev.get("scan_status"),
|
||||
ev.get("scan_date"), ev.get("scan_zip"), ev.get("scan_facility"),
|
||||
ev.get("tracking_id"), ev.get("batch_id"), job_id, doc_id,
|
||||
json.dumps(ev), utcnow_iso()),
|
||||
)
|
||||
if ev.get("tracking_id"):
|
||||
conn.execute(
|
||||
"UPDATE letters SET tracking_no = COALESCE(tracking_no, ?), updated_at = ? WHERE id = ?",
|
||||
(str(ev["tracking_id"]), utcnow_iso(), letter["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
return {"success": True, "reason": "Received data"}
|
||||
|
||||
|
||||
def _err(code: str, message: str, status_code: int):
|
||||
return JSONResponse(status_code=status_code,
|
||||
content={"error": {"code": code, "message": message}})
|
||||
@@ -0,0 +1,250 @@
|
||||
"""LetterStream mail-fulfillment integration.
|
||||
|
||||
Send endpoint: POST https://www.letterstream.com/apis/ (form-encoded or multipart).
|
||||
Auth (verified live 2026-08-25):
|
||||
t = unique numeric id (10-18 digits), unique per request
|
||||
s = t[-6:] + api_key + t[:6]
|
||||
h = md5(base64_encode(s))
|
||||
Response: XML <messages><message type><code><details>...</messages>.
|
||||
|
||||
Key codes: -100 success, -199 AUTHOK, -200 preauth success, -911 insufficient
|
||||
funding, -950 auth fail, -957 DUP, -958 IDOK, -998 improper submission, -999 error.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
ENDPOINT = "https://www.letterstream.com/apis/"
|
||||
API_ID = os.environ.get("LETTERSTREAM_API_ID", "")
|
||||
API_KEY = os.environ.get("LETTERSTREAM_API_KEY", "")
|
||||
UA = "DRE-integration/1.0"
|
||||
|
||||
|
||||
class LetterStreamError(Exception):
|
||||
"""Raised when LetterStream returns an error or is unreachable."""
|
||||
|
||||
|
||||
def auth() -> dict:
|
||||
"""Return {'a', 'h', 't'} for a single request. Call once per request."""
|
||||
if not API_ID or not API_KEY:
|
||||
raise LetterStreamError("LETTERSTREAM_API_ID/KEY not configured")
|
||||
t = str(int(time.time() * 1000)) # 13-digit millis, within 10-18 digit spec
|
||||
s = t[-6:] + API_KEY + t[:6]
|
||||
h = hashlib.md5(base64.b64encode(s.encode())).hexdigest()
|
||||
return {"a": API_ID, "h": h, "t": t}
|
||||
|
||||
|
||||
def _multipart(items: list[tuple[str, str]], files: dict) -> tuple[bytes, str]:
|
||||
"""Build a multipart/form-data body. files: {field: (filename, bytes, content_type)}."""
|
||||
boundary = "----DRE" + uuid.uuid4().hex
|
||||
chunks: list[bytes] = []
|
||||
|
||||
def add(data) -> None:
|
||||
chunks.append(data.encode("utf-8") if isinstance(data, str) else data)
|
||||
|
||||
for k, v in items:
|
||||
add(f"--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"\r\n\r\n")
|
||||
add(str(v))
|
||||
add("\r\n")
|
||||
for k, (filename, data, ctype) in files.items():
|
||||
add(f"--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"; "
|
||||
f"filename=\"{filename}\"\r\nContent-Type: {ctype}\r\n\r\n")
|
||||
add(data)
|
||||
add("\r\n")
|
||||
add(f"--{boundary}--\r\n")
|
||||
return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
|
||||
|
||||
|
||||
def _post(items: list[tuple[str, str]], files: dict | None = None,
|
||||
timeout: int = 60) -> bytes:
|
||||
if files:
|
||||
body, ctype = _multipart(items, files)
|
||||
else:
|
||||
body = urllib.parse.urlencode(items).encode()
|
||||
ctype = "application/x-www-form-urlencoded"
|
||||
req = urllib.request.Request(
|
||||
ENDPOINT, data=body, headers={"Content-Type": ctype, "User-Agent": UA},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.read()
|
||||
except urllib.error.URLError as e:
|
||||
raise LetterStreamError(f"LetterStream unreachable: {e.reason}") from e
|
||||
|
||||
|
||||
def _auth_items() -> list[tuple[str, str]]:
|
||||
a = auth()
|
||||
return [("a", a["a"]), ("h", a["h"]), ("t", a["t"])]
|
||||
|
||||
|
||||
def parse_xml(raw: bytes | str) -> dict:
|
||||
"""Parse the XML response into a convenient dict. Streams (PDF) pass through."""
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode("utf-8", "replace")
|
||||
out: dict = {"raw": raw, "messages": []}
|
||||
if raw.startswith("%PDF"):
|
||||
out["stream"] = "pdf"
|
||||
out["data"] = raw
|
||||
return out
|
||||
try:
|
||||
root = ET.fromstring(raw)
|
||||
except ET.ParseError:
|
||||
out["stream"] = "unknown"
|
||||
return out
|
||||
out["id"] = root.get("id", "")
|
||||
for msg in root.findall("message"):
|
||||
m: dict = {"type": msg.get("type", "")}
|
||||
for tag in ("code", "details", "batch", "quantity", "cost", "authcode",
|
||||
"date", "balance", "testmode", "id", "job"):
|
||||
el = msg.find(tag)
|
||||
if el is not None and el.text is not None:
|
||||
m[tag] = el.text.strip()
|
||||
docs = []
|
||||
for d in msg.findall("doc"):
|
||||
docs.append({c.tag: (c.text or "").strip() for c in d})
|
||||
if docs:
|
||||
m["docs"] = docs
|
||||
out["messages"].append(m)
|
||||
return out
|
||||
|
||||
|
||||
def _first(response: dict) -> dict:
|
||||
return response["messages"][0] if response.get("messages") else {}
|
||||
|
||||
|
||||
def _raise_on_error(response: dict) -> None:
|
||||
"""Raise LetterStreamError if the response is an error message."""
|
||||
for m in response.get("messages", []):
|
||||
code = m.get("code")
|
||||
if m.get("type") == "error" and code not in (None, "-100", "-200"):
|
||||
raise LetterStreamError(f"LetterStream {code}: {m.get('details', '')}")
|
||||
|
||||
|
||||
def account_status() -> dict:
|
||||
"""Return {'date','id','balance','testmode'}."""
|
||||
items = _auth_items() + [("accountstatus", "1")]
|
||||
resp = parse_xml(_post(items))
|
||||
_raise_on_error(resp)
|
||||
for m in resp.get("messages", []):
|
||||
if m.get("type") == "accountstatus":
|
||||
return {k: m.get(k) for k in ("date", "id", "balance", "testmode")}
|
||||
raise LetterStreamError(f"Unexpected account status response: {resp.get('raw', '')[:200]}")
|
||||
|
||||
|
||||
def send_single(pdf_bytes: bytes, filename: str, job: str, sender: str,
|
||||
recipients: list[str], pages: int, mailtype: str = "firstclass",
|
||||
coversheet: str | None = None, duplex: str | None = None,
|
||||
ink: str | None = None, paper: str | None = None,
|
||||
returnenv: str | None = None, preauth: bool = False) -> dict:
|
||||
"""Submit one PDF to one or more recipients (method 2).
|
||||
|
||||
sender: 'name_1:name_2:addr_1:addr_2:city:state:zip'
|
||||
recipients: list of 'doc_id:name_1:name_2:addr_1:addr_2:city:state:zip'
|
||||
"""
|
||||
items = _auth_items() + [("job", job), ("from", sender), ("pages", str(pages))]
|
||||
for r in recipients:
|
||||
items.append(("to[]", r))
|
||||
if mailtype:
|
||||
items.append(("mailtype", mailtype))
|
||||
if coversheet is not None:
|
||||
items.append(("coversheet", coversheet))
|
||||
if duplex is not None:
|
||||
items.append(("duplex", duplex))
|
||||
if ink is not None:
|
||||
items.append(("ink", ink))
|
||||
if paper is not None:
|
||||
items.append(("paper", paper))
|
||||
if returnenv is not None:
|
||||
items.append(("returnenv", returnenv))
|
||||
if preauth:
|
||||
items.append(("preauth", "1"))
|
||||
files = {"single_file": (filename, pdf_bytes, "application/pdf")}
|
||||
resp = parse_xml(_post(items, files=files))
|
||||
_raise_on_error(resp)
|
||||
return resp
|
||||
|
||||
|
||||
def send_batch(zip_bytes: bytes, filename: str) -> dict:
|
||||
"""Submit a ZIP archive (PDFs + CSV) via batch method (method 1)."""
|
||||
items = _auth_items()
|
||||
files = {"multi_file": (filename, zip_bytes, "application/zip")}
|
||||
resp = parse_xml(_post(items, files=files))
|
||||
_raise_on_error(resp)
|
||||
return resp
|
||||
|
||||
|
||||
def doauth(authcode: str) -> dict:
|
||||
"""Release a preauth job into production."""
|
||||
items = _auth_items() + [("doauth", authcode)]
|
||||
resp = parse_xml(_post(items))
|
||||
_raise_on_error(resp)
|
||||
return resp
|
||||
|
||||
|
||||
def _tracking_query(**kwargs) -> dict:
|
||||
items = _auth_items() + [(k, v) for k, v in kwargs.items() if v]
|
||||
resp = parse_xml(_post(items))
|
||||
_raise_on_error(resp)
|
||||
return resp
|
||||
|
||||
|
||||
def tracking(cert: str | None = None, doc_id: str | None = None,
|
||||
fmt: str = "xml") -> dict:
|
||||
"""Tracking info by certified number or doc_id. fmt: 'html'|'xml'|'json'."""
|
||||
args = {}
|
||||
if cert:
|
||||
args["cert"] = cert
|
||||
args["getinfo"] = "trackx" if fmt == "xml" else "track"
|
||||
elif doc_id:
|
||||
args["doc_id"] = doc_id
|
||||
args["getinfo"] = "trackx" if fmt == "xml" else "track"
|
||||
else:
|
||||
raise LetterStreamError("tracking() requires cert or doc_id")
|
||||
if fmt == "json":
|
||||
args["responseformat"] = "json"
|
||||
return _tracking_query(**args)
|
||||
|
||||
|
||||
def signature(cert: str | None = None, doc_id: str | None = None) -> bytes:
|
||||
"""Return the certified signature file as PDF bytes."""
|
||||
args = {"getinfo": "sig"}
|
||||
if cert:
|
||||
args["cert"] = cert
|
||||
elif doc_id:
|
||||
args["doc_id"] = doc_id
|
||||
else:
|
||||
raise LetterStreamError("signature() requires cert or doc_id")
|
||||
items = _auth_items() + list(args.items())
|
||||
raw = _post(items)
|
||||
if raw[:4] == b"%PDF":
|
||||
return raw
|
||||
resp = parse_xml(raw)
|
||||
_raise_on_error(resp)
|
||||
raise LetterStreamError("No signature file returned")
|
||||
|
||||
|
||||
def proof(doc_id: str) -> bytes:
|
||||
"""Return the document proof as PDF bytes (base64-decoded when needed)."""
|
||||
items = _auth_items() + [("doc_id", doc_id), ("getinfo", "proof")]
|
||||
raw = _post(items)
|
||||
if raw[:4] == b"%PDF":
|
||||
return raw
|
||||
text = raw.decode("utf-8", "replace").strip()
|
||||
if len(text) > 10000:
|
||||
try:
|
||||
return base64.b64decode(text)
|
||||
except Exception as e:
|
||||
raise LetterStreamError(f"Proof base64 decode failed: {e}") from e
|
||||
resp = parse_xml(raw)
|
||||
_raise_on_error(resp)
|
||||
raise LetterStreamError("No proof returned")
|
||||
+8
-1
@@ -28,14 +28,18 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers (intake, claims, staff)
|
||||
# Include routers (intake, claims, staff, packet)
|
||||
from .intake import router as intake_router # noqa: E402
|
||||
from .claims import router as claims_router # noqa: E402
|
||||
from .staff import router as staff_router # noqa: E402
|
||||
from .packet import router as packet_router # noqa: E402
|
||||
from .letters import router as letters_router # noqa: E402
|
||||
|
||||
app.include_router(intake_router)
|
||||
app.include_router(claims_router)
|
||||
app.include_router(staff_router)
|
||||
app.include_router(packet_router)
|
||||
app.include_router(letters_router)
|
||||
|
||||
|
||||
def _err(code: str, message: str, status_code: int):
|
||||
@@ -46,6 +50,9 @@ def _err(code: str, message: str, status_code: int):
|
||||
@app.on_event("startup")
|
||||
async def _startup():
|
||||
db.init_db()
|
||||
# Ensure the welcome-packet overrides table exists on existing DBs.
|
||||
from . import packet
|
||||
packet.ensure_packet_table()
|
||||
logger.info("DRE portal started; db=%s", db.get_db_path())
|
||||
|
||||
|
||||
|
||||
+49
-4
@@ -103,13 +103,15 @@ class IntakeRequest(BaseModel):
|
||||
debtor: IntakeDebtor
|
||||
claim: IntakeClaim
|
||||
tos_accepted: bool = True
|
||||
no_other_agency_accepted: bool
|
||||
no_prior_action_accepted: bool
|
||||
turnstile_token: str | None = None
|
||||
|
||||
@field_validator("tos_accepted")
|
||||
@field_validator("tos_accepted", "no_other_agency_accepted", "no_prior_action_accepted")
|
||||
@classmethod
|
||||
def _tos(cls, v):
|
||||
def _bool_req(cls, v):
|
||||
if v is not True:
|
||||
raise ValueError("tos_accepted must be true")
|
||||
raise ValueError("must be true")
|
||||
return v
|
||||
|
||||
|
||||
@@ -175,7 +177,7 @@ class StaffNoteCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
content: str = Field(..., min_length=1, max_length=10000)
|
||||
visibility: str = "INTERNAL"
|
||||
author_name: str = Field(..., min_length=1, max_length=200)
|
||||
author_name: str | None = Field(None, max_length=200)
|
||||
|
||||
@field_validator("visibility")
|
||||
@classmethod
|
||||
@@ -188,3 +190,46 @@ class StaffNoteCreate(BaseModel):
|
||||
@classmethod
|
||||
def _v(cls, v):
|
||||
return _scan_pii(v)
|
||||
|
||||
|
||||
class StaffApproval(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
decision: str
|
||||
staff_name: str | None = Field(None, max_length=200)
|
||||
reason: str | None = Field(None, max_length=500)
|
||||
|
||||
@field_validator("decision")
|
||||
@classmethod
|
||||
def _dec(cls, v):
|
||||
v = v.upper()
|
||||
if v not in ("APPROVE", "REJECT"):
|
||||
raise ValueError("decision must be APPROVE or REJECT")
|
||||
return v
|
||||
|
||||
@field_validator("staff_name", "reason")
|
||||
@classmethod
|
||||
def _v(cls, v):
|
||||
return _scan_pii(v)
|
||||
|
||||
|
||||
class StaffLetterUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
subject: str = Field(..., min_length=1, max_length=200)
|
||||
body: str = Field(..., min_length=1, max_length=20000)
|
||||
staff_name: str | None = Field(None, max_length=200)
|
||||
|
||||
@field_validator("subject", "body", "staff_name")
|
||||
@classmethod
|
||||
def _v(cls, v):
|
||||
return _scan_pii(v)
|
||||
|
||||
|
||||
class StaffOnboardingUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
received: bool
|
||||
received_by: str | None = Field(None, max_length=200)
|
||||
|
||||
@field_validator("received_by")
|
||||
@classmethod
|
||||
def _v(cls, v):
|
||||
return _scan_pii(v)
|
||||
|
||||
@@ -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"'})
|
||||
@@ -0,0 +1,228 @@
|
||||
{
|
||||
"account_holder_entity_type": {
|
||||
"label": "Account Holder Entity Type",
|
||||
"recovery_impact": false,
|
||||
"required": false,
|
||||
"source": null,
|
||||
"type": "text"
|
||||
},
|
||||
"account_holder_name": {
|
||||
"label": "Account Holder Legal Name",
|
||||
"recovery_impact": false,
|
||||
"required": true,
|
||||
"source": "client.company_name",
|
||||
"type": "text"
|
||||
},
|
||||
"account_number": {
|
||||
"label": "Account Number",
|
||||
"recovery_impact": false,
|
||||
"required": true,
|
||||
"source": null,
|
||||
"type": "text"
|
||||
},
|
||||
"account_type": {
|
||||
"label": "Account Type",
|
||||
"options": [
|
||||
"Checking",
|
||||
"Savings"
|
||||
],
|
||||
"recovery_impact": false,
|
||||
"required": true,
|
||||
"source": null,
|
||||
"type": "checkbox"
|
||||
},
|
||||
"bank_name": {
|
||||
"label": "Bank Name",
|
||||
"recovery_impact": false,
|
||||
"required": true,
|
||||
"source": null,
|
||||
"type": "text"
|
||||
},
|
||||
"claim_amount_cents": {
|
||||
"label": "Claim Amount",
|
||||
"recovery_impact": true,
|
||||
"required": true,
|
||||
"source": "claim.amount_cents",
|
||||
"type": "money"
|
||||
},
|
||||
"client_company_name": {
|
||||
"label": "Client Legal Name",
|
||||
"recovery_impact": false,
|
||||
"required": true,
|
||||
"source": "client.company_name",
|
||||
"type": "text"
|
||||
},
|
||||
"client_contact_name": {
|
||||
"label": "Client Contact / Print Name",
|
||||
"recovery_impact": false,
|
||||
"required": true,
|
||||
"source": "client.contact_name",
|
||||
"type": "text"
|
||||
},
|
||||
"client_entity_type": {
|
||||
"label": "Client Entity Type",
|
||||
"recovery_impact": false,
|
||||
"required": false,
|
||||
"source": null,
|
||||
"type": "text"
|
||||
},
|
||||
"debtor_address": {
|
||||
"label": "Debtor Business Address",
|
||||
"recovery_impact": true,
|
||||
"required": false,
|
||||
"source": "debtor.physical_address",
|
||||
"type": "text"
|
||||
},
|
||||
"debtor_alt_address": {
|
||||
"label": "Debtor Alternate Address",
|
||||
"recovery_impact": false,
|
||||
"required": false,
|
||||
"source": null,
|
||||
"type": "text"
|
||||
},
|
||||
"debtor_ar_lenders": {
|
||||
"label": "Debtor Known AR / Lenders",
|
||||
"recovery_impact": false,
|
||||
"required": false,
|
||||
"source": null,
|
||||
"type": "text"
|
||||
},
|
||||
"debtor_bank": {
|
||||
"label": "Debtor Bank / FI",
|
||||
"recovery_impact": false,
|
||||
"required": false,
|
||||
"source": null,
|
||||
"type": "text"
|
||||
},
|
||||
"debtor_city_state_zip": {
|
||||
"label": "Debtor City / State / ZIP",
|
||||
"recovery_impact": true,
|
||||
"required": false,
|
||||
"source": null,
|
||||
"type": "text"
|
||||
},
|
||||
"debtor_contact_email": {
|
||||
"label": "Debtor Email",
|
||||
"recovery_impact": true,
|
||||
"required": false,
|
||||
"source": "debtor.contact_email",
|
||||
"type": "text"
|
||||
},
|
||||
"debtor_contact_name": {
|
||||
"label": "Debtor Primary Contact",
|
||||
"recovery_impact": true,
|
||||
"required": false,
|
||||
"source": null,
|
||||
"type": "text"
|
||||
},
|
||||
"debtor_contact_phone": {
|
||||
"label": "Debtor Direct Phone",
|
||||
"recovery_impact": true,
|
||||
"required": false,
|
||||
"source": "debtor.contact_phone",
|
||||
"type": "text"
|
||||
},
|
||||
"debtor_contact_title": {
|
||||
"label": "Debtor Contact Title",
|
||||
"recovery_impact": false,
|
||||
"required": false,
|
||||
"source": null,
|
||||
"type": "text"
|
||||
},
|
||||
"debtor_dba": {
|
||||
"label": "DBA / Trade Name",
|
||||
"recovery_impact": true,
|
||||
"required": false,
|
||||
"source": null,
|
||||
"type": "text"
|
||||
},
|
||||
"debtor_ein": {
|
||||
"label": "EIN / Tax ID",
|
||||
"recovery_impact": true,
|
||||
"required": false,
|
||||
"source": null,
|
||||
"type": "text"
|
||||
},
|
||||
"debtor_entity_type": {
|
||||
"label": "Debtor Entity Type",
|
||||
"recovery_impact": true,
|
||||
"required": false,
|
||||
"source": "debtor.business_type",
|
||||
"type": "text"
|
||||
},
|
||||
"debtor_name": {
|
||||
"label": "Debtor Legal Name",
|
||||
"recovery_impact": true,
|
||||
"required": true,
|
||||
"source": "debtor.name",
|
||||
"type": "text"
|
||||
},
|
||||
"debtor_registered_agent": {
|
||||
"label": "Registered Agent Name",
|
||||
"recovery_impact": true,
|
||||
"required": false,
|
||||
"source": null,
|
||||
"type": "text"
|
||||
},
|
||||
"debtor_registered_agent_address": {
|
||||
"label": "Registered Agent Address",
|
||||
"recovery_impact": true,
|
||||
"required": false,
|
||||
"source": null,
|
||||
"type": "text"
|
||||
},
|
||||
"debtor_state": {
|
||||
"label": "State of Formation",
|
||||
"recovery_impact": true,
|
||||
"required": false,
|
||||
"source": null,
|
||||
"type": "text"
|
||||
},
|
||||
"debtor_website": {
|
||||
"label": "Debtor Website",
|
||||
"recovery_impact": false,
|
||||
"required": false,
|
||||
"source": null,
|
||||
"type": "text"
|
||||
},
|
||||
"dre_claim_number": {
|
||||
"label": "DRE Claim Number",
|
||||
"recovery_impact": false,
|
||||
"required": true,
|
||||
"source": "claim.claim_number",
|
||||
"type": "text"
|
||||
},
|
||||
"invoice_date": {
|
||||
"label": "Invoice / Contract Date",
|
||||
"recovery_impact": true,
|
||||
"required": false,
|
||||
"source": "claim.invoice_date",
|
||||
"type": "date"
|
||||
},
|
||||
"personal_guarantee": {
|
||||
"label": "Personal Guarantee Exists?",
|
||||
"options": [
|
||||
"Yes",
|
||||
"No",
|
||||
"Unsure"
|
||||
],
|
||||
"recovery_impact": true,
|
||||
"required": true,
|
||||
"source": null,
|
||||
"type": "checkbox"
|
||||
},
|
||||
"personal_guarantee_signer": {
|
||||
"label": "Personal Guarantee Signer",
|
||||
"recovery_impact": true,
|
||||
"required": false,
|
||||
"source": null,
|
||||
"type": "text"
|
||||
},
|
||||
"routing_number": {
|
||||
"label": "Routing (ABA) Number",
|
||||
"recovery_impact": false,
|
||||
"required": true,
|
||||
"source": null,
|
||||
"type": "text"
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,24 @@ CREATE TABLE IF NOT EXISTS claims (
|
||||
invoice_date TEXT, -- ISO date
|
||||
date_assigned TEXT, -- set when moved out of NEW
|
||||
date_resolved TEXT, -- set on SETTLED/CLOSED/WRITE_OFF
|
||||
-- Client signed representations at intake (added via db._migrate on existing DBs)
|
||||
no_other_agency_at TEXT, -- confirmed no other agency/attorney engaged
|
||||
no_prior_action_at TEXT, -- confirmed no prior litigation/judgment/bankruptcy
|
||||
-- AI analysis + approval (added via db._migrate on existing DBs)
|
||||
analysis_score INTEGER, -- 0-100, NULL until analyzed
|
||||
analysis_summary TEXT, -- plain-English narrative
|
||||
analysis_components TEXT, -- JSON breakdown of component scores
|
||||
analysis_at TEXT, -- ISO timestamp of analysis
|
||||
recommended_tier TEXT, -- AI-recommended starting tier
|
||||
approval_status TEXT NOT NULL DEFAULT 'NONE'
|
||||
CHECK (approval_status IN ('NONE','PENDING','APPROVED','REJECTED')),
|
||||
approval_decision_by TEXT, -- staff name who approved/rejected
|
||||
approval_decision_at TEXT,
|
||||
-- AI-recommended letter (staff-customizable)
|
||||
letter_subject TEXT,
|
||||
letter_body TEXT,
|
||||
letter_tier TEXT, -- tier the letter was generated for
|
||||
letter_updated_at TEXT,
|
||||
twentycrm_id TEXT, -- nullable
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
@@ -137,3 +155,80 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Welcome-packet field overrides: client's final value per profile field,
|
||||
-- merged over pre-populated submission values at render time.
|
||||
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)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_packet_overrides_claim ON packet_field_overrides(claim_id);
|
||||
|
||||
-- Onboarding paperwork receipt tracking. One row per welcome-packet document
|
||||
-- per claim; staff mark each doc "received" once the client returns it.
|
||||
CREATE TABLE IF NOT EXISTS onboarding_docs (
|
||||
id TEXT PRIMARY KEY,
|
||||
claim_id TEXT NOT NULL REFERENCES claims(id) ON DELETE CASCADE,
|
||||
doc_key TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING','RECEIVED')),
|
||||
received_by TEXT,
|
||||
received_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(claim_id, doc_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_onboarding_claim ON onboarding_docs(claim_id);
|
||||
|
||||
-- Physical mail queue (LetterStream). Lifecycle: DRAFT -> APPROVED -> PREAUTH -> SENT
|
||||
-- (+ REJECTED / CANCELLED / ERROR). One row per mailed letter.
|
||||
CREATE TABLE IF NOT EXISTS letters (
|
||||
id TEXT PRIMARY KEY, -- uuid4
|
||||
claim_id TEXT NOT NULL REFERENCES claims(id) ON DELETE CASCADE,
|
||||
letter_type TEXT NOT NULL DEFAULT 'demand',
|
||||
subject TEXT,
|
||||
body TEXT, -- rendered letter body snapshot
|
||||
recipient_json TEXT NOT NULL, -- structured {name,name2,addr1,addr2,city,state,zip}
|
||||
sender_json TEXT NOT NULL, -- structured {name,addr1,addr2,city,state,zip}
|
||||
mailtype TEXT NOT NULL DEFAULT 'firstclass',
|
||||
status TEXT NOT NULL DEFAULT 'DRAFT'
|
||||
CHECK (status IN ('DRAFT','APPROVED','PREAUTH','SENT','REJECTED','CANCELLED','ERROR')),
|
||||
job_id TEXT, -- LetterStream unique job name
|
||||
batch_id TEXT, -- LetterStream batch id
|
||||
doc_id TEXT, -- LetterStream doc id
|
||||
tracking_no TEXT, -- USPS tracking (certified mail)
|
||||
cost_cents INTEGER, -- pricing from preauth/response
|
||||
authcode TEXT, -- preauth authcode (release via doauth)
|
||||
pages INTEGER,
|
||||
pdf_path TEXT, -- rendered PDF on disk
|
||||
error TEXT,
|
||||
note TEXT, -- reject/cancel reason (terminal states)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
sent_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_letters_claim ON letters(claim_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_letters_status ON letters(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_letters_doc ON letters(doc_id);
|
||||
|
||||
-- LetterStream tracking scan events (pushed via callback every 4h).
|
||||
CREATE TABLE IF NOT EXISTS letter_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
letter_id TEXT NOT NULL REFERENCES letters(id) ON DELETE CASCADE,
|
||||
scan_code TEXT,
|
||||
scan_status TEXT,
|
||||
scan_date TEXT,
|
||||
scan_zip TEXT,
|
||||
scan_facility TEXT,
|
||||
tracking_id TEXT,
|
||||
batch_id TEXT,
|
||||
job_id TEXT,
|
||||
doc_id TEXT,
|
||||
raw_json TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_letter_events_letter ON letter_events(letter_id);
|
||||
|
||||
+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