- 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).
160 lines
7.0 KiB
Python
160 lines
7.0 KiB
Python
"""Intake endpoint — POST /api/intake. Creates client (+reuse by email) + debtor + claim.
|
|
Writes audit rows, SYSTEM note, emails the DRE team (best-effort).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Request, status
|
|
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
|
|
|
|
logger = logging.getLogger("dre.intake")
|
|
router = APIRouter()
|
|
|
|
|
|
def _money_display(cents: int) -> str:
|
|
return f"${cents / 100.0:,.2f}"
|
|
|
|
|
|
@router.post("/api/intake")
|
|
async def intake(request: Request):
|
|
# Rate limit
|
|
ip = authmod.get_client_ip(request)
|
|
if not authmod.rate_limit_intake(ip):
|
|
return _json_error("rate_limited", "Too many requests. Please try again later.",
|
|
status.HTTP_429_TOO_MANY_REQUESTS)
|
|
# Parse JSON body
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
return _json_error("validation_error", "Invalid JSON body.", status.HTTP_422_UNPROCESSABLE_ENTITY)
|
|
# Validate
|
|
try:
|
|
req = IntakeRequest.model_validate(body)
|
|
except ValidationError as exc:
|
|
return _json_error("validation_error", _format_validation_error(exc),
|
|
status.HTTP_422_UNPROCESSABLE_ENTITY)
|
|
|
|
client_in = req.client
|
|
debtor_in = req.debtor
|
|
claim_in = req.claim
|
|
email_lc = client_in.email.lower()
|
|
|
|
with get_conn() as conn:
|
|
now = utcnow_iso()
|
|
# Reuse client by email or create new
|
|
existing = conn.execute("SELECT id, client_number FROM clients WHERE email = ?", (email_lc,)).fetchone()
|
|
if existing is not None:
|
|
client_id = existing["id"]
|
|
client_number = existing["client_number"]
|
|
conn.execute(
|
|
"UPDATE clients SET company_name = ?, contact_name = ?, phone = ?, tos_accepted_at = COALESCE(tos_accepted_at, ?), updated_at = ? WHERE id = ?",
|
|
(client_in.company_name, client_in.contact_name, client_in.phone, now, now, client_id),
|
|
)
|
|
# audit update
|
|
conn.execute(
|
|
"INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, reason, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(new_uuid(), "client", client_id, "update", "contact_info", None, None, "intake", "reused existing client", now),
|
|
)
|
|
else:
|
|
client_id = new_uuid()
|
|
client_number = next_sequence_number(conn, "CLT")
|
|
conn.execute(
|
|
"INSERT INTO clients (id, client_number, company_name, contact_name, email, phone, tos_accepted_at, twentycrm_id, created_at, updated_at) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)",
|
|
(client_id, client_number, client_in.company_name, client_in.contact_name,
|
|
email_lc, client_in.phone, now, now, now),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO audit_log (id, entity_type, entity_id, action, actor, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
(new_uuid(), "client", client_id, "create", "intake", now),
|
|
)
|
|
|
|
# Debtor (always new per spec — denormalized avoided, but new claim = new debtor row)
|
|
debtor_id = new_uuid()
|
|
conn.execute(
|
|
"INSERT INTO debtors (id, name, business_type, contact_email, contact_phone, physical_address, twentycrm_id, created_at, updated_at) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?)",
|
|
(debtor_id, debtor_in.name, debtor_in.business_type, debtor_in.contact_email,
|
|
debtor_in.contact_phone, debtor_in.physical_address, now, now),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO audit_log (id, entity_type, entity_id, action, actor, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
(new_uuid(), "debtor", debtor_id, "create", "intake", now),
|
|
)
|
|
|
|
# Claim
|
|
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, 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 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 (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(new_uuid(), "claim", claim_id, "create", None, claim_number, "intake", now),
|
|
)
|
|
|
|
# SYSTEM shared note
|
|
note_id = new_uuid()
|
|
conn.execute(
|
|
"INSERT INTO case_notes (id, claim_id, author_type, author_name, subject, content, visibility, twentycrm_id, created_at) "
|
|
"VALUES (?, ?, 'SYSTEM', 'System', NULL, 'Claim received.', 'SHARED', NULL, ?)",
|
|
(note_id, claim_id, now),
|
|
)
|
|
conn.execute(
|
|
"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
|
|
try:
|
|
dreemail.notify_team_intake(claim_number, client_number, client_in.company_name,
|
|
claim_in.amount_cents, debtor_in.name)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.error("intake team notify failed: %s", exc)
|
|
|
|
return {
|
|
"claim_number": claim_number,
|
|
"client_number": client_number,
|
|
"status": "NEW",
|
|
"message": "Claim received. Our team will review and contact you shortly.",
|
|
}
|
|
|
|
|
|
def _format_validation_error(exc: ValidationError) -> str:
|
|
parts = []
|
|
for err in exc.errors():
|
|
loc = ".".join(str(x) for x in err["loc"])
|
|
parts.append(f"{loc}: {err['msg']}")
|
|
return "; ".join(parts)
|
|
|
|
|
|
def _json_error(code: str, message: str, status_code: int):
|
|
from fastapi.responses import JSONResponse
|
|
return JSONResponse(
|
|
status_code=status_code,
|
|
content={"error": {"code": code, "message": message}},
|
|
)
|