Files
dre/backend/intake.py
T
root be0750d001 DRE customer portal: FastAPI + SQLite backend (18 endpoints)
- Magic-link auth (sha256-only token storage, 15-min single-use, 7-day sessions)
- Staff-key auth via X-DRE-Staff-Key (constant-time compare)
- SQLite WAL, foreign_keys, parameterized queries, atomic DRE/CLT sequence allocation
- Intake validator rejects SSN/PAN patterns (FDCPA/TDCPA compliance)
- Document upload allowlist + magic-byte check, 20MB cap
- Unified error envelope, money as integer cents
- systemd unit (port 8093, User=root, hardening directives)
- Fixes import bug (auth.py relative imports) and audit_log placeholder mismatch
2026-08-21 18:43:33 -04:00

148 lines
6.4 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 .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, 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),
)
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),
)
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}},
)