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
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
"""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}},
|
||||
)
|
||||
Reference in New Issue
Block a user