"""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}}, )