diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..eecf111 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,23 @@ +# DRE Portal backend environment — copy to /opt/dre-portal/.env and fill in secrets (chmod 600) +# Generated fresh DRE_STAFF_KEY on deploy (do NOT commit this file). + +# 64-hex random staff key (X-DRE-Staff-Key header) — generate with: python3 -c "import secrets; print(secrets.token_hex(32))" +DRE_STAFF_KEY=__GENERATE_ME__ + +# SQLite DB path + uploads dir +DRE_DB_PATH=/opt/dre-portal/data/dre.db +DRE_UPLOAD_DIR=/opt/dre-portal/data/uploads + +# Base URL for magic-link emails +DRE_BASE_URL=https://portal.debtrecoveryexperts.com + +# SMTP relay (germainebrown.com:2525 STARTTLS) — best-effort, failures never fail the request +DRE_SMTP_HOST=mail.germainebrown.com +DRE_SMTP_PORT=2525 +DRE_SMTP_FROM=dre@debtrecoveryexperts.com +DRE_TEAM_NOTIFY=dre@debtrecoveryexperts.com +DRE_SMTP_USER=shonuff@germainebrown.com +DRE_SMTP_PASS=__FROM_HIMALAYA_PASSFILE__ + +# Optional Cloudflare Turnstile (intake skips captcha if unset) +# TURNSTILE_SECRET= diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..6978a01 --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +# DRE Portal API package diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..5fce051 --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,227 @@ +"""Magic-link auth, sessions, staff-key, rate limiting (in-memory sliding window). +Constant-time comparison via hmac.compare_digest for token hashes + staff key. +""" +from __future__ import annotations + +import hashlib +import hmac +import logging +import os +import secrets +import time +from collections import defaultdict, deque +from datetime import datetime, timedelta, timezone + +from fastapi import Depends, HTTPException, Request, status + +from . import db +from .db import get_conn, new_uuid, utcnow_iso + +logger = logging.getLogger("dre.auth") + +MAGIC_TOKEN_TTL_MIN = 15 +SESSION_TTL_DAYS = 7 + +# --------------------------------------------------------------- +# Rate limiting — in-memory sliding window (single-instance v1) +# --------------------------------------------------------------- +class RateLimiter: + def __init__(self) -> None: + self._by_email: dict[str, deque[float]] = defaultdict(deque) + self._by_ip: dict[str, deque[float]] = defaultdict(deque) + + def _prune(self, dq: deque[float], window_sec: float) -> None: + cutoff = time.time() - window_sec + while dq and dq[0] < cutoff: + dq.popleft() + + def check_email(self, email: str, max_count: int, window_sec: float) -> bool: + dq = self._by_email[email] + self._prune(dq, window_sec) + if len(dq) >= max_count: + return False + dq.append(time.time()) + return True + + def check_ip(self, ip: str, max_count: int, window_sec: float) -> bool: + dq = self._by_ip[ip] + self._prune(dq, window_sec) + if len(dq) >= max_count: + return False + dq.append(time.time()) + return True + + def check(self, email: str | None, ip: str, max_email: int, email_window: float, + max_ip: int, ip_window: float) -> bool: + if email and not self.check_email(email, max_email, email_window): + return False + if not self.check_ip(ip, max_ip, ip_window): + return False + return True + + +_limiter = RateLimiter() + + +def rate_limit_auth_request(email: str | None, ip: str) -> bool: + return _limiter.check(email, ip, max_email=3, email_window=900, max_ip=10, ip_window=3600) + + +def rate_limit_auth_verify(ip: str) -> bool: + return _limiter.check(None, ip, max_email=999, email_window=1, max_ip=10, ip_window=900) + + +def rate_limit_intake(ip: str) -> bool: + return _limiter.check_ip(ip, max_count=20, window_sec=3600) + + +# --------------------------------------------------------------- +# Token / hash helpers +# --------------------------------------------------------------- +def _sha256_hex(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def generate_magic_token() -> str: + return secrets.token_urlsafe(32) + + +def generate_session_token() -> str: + return secrets.token_urlsafe(32) + + +def _compare_hash(a: str, b: str) -> bool: + return hmac.compare_digest(a, b) + + +# --------------------------------------------------------------- +# Staff key +# --------------------------------------------------------------- +def _staff_key() -> str: + return os.environ.get("DRE_STAFF_KEY", "") + + +def verify_staff_key(provided: str | None) -> bool: + key = _staff_key() + if not key or not provided: + return False + return _compare_hash(provided, key) + + +# --------------------------------------------------------------- +# FastAPI dependencies +# --------------------------------------------------------------- +def get_client_ip(request: Request) -> str: + # Cloudflare / Caddy may set X-Forwarded-For; use first hop + xff = request.headers.get("x-forwarded-for") + if xff: + return xff.split(",")[0].strip() + 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): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"code": "forbidden", "message": "Valid staff key required."}, + ) + + +def require_client(request: Request) -> dict: + """Client session auth dependency. Returns {'client_id':..., 'client_number':...}. + Raises 401 if missing/invalid/expired.""" + auth = request.headers.get("authorization", "") + if not auth.lower().startswith("bearer "): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"code": "unauthorized", "message": "Authentication required."}, + ) + raw_token = auth.split(" ", 1)[1].strip() + if not raw_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"code": "unauthorized", "message": "Authentication required."}, + ) + token_hash = _sha256_hex(raw_token) + now = utcnow_iso() + with get_conn() as conn: + row = conn.execute( + "SELECT s.id, s.client_id, s.expires_at, s.revoked_at, c.client_number " + "FROM sessions s JOIN clients c ON c.id = s.client_id " + "WHERE s.session_hash = ?", + (token_hash,), + ).fetchone() + if row is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"code": "unauthorized", "message": "Invalid or expired session."}, + ) + if row["revoked_at"] is not None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"code": "unauthorized", "message": "Session revoked."}, + ) + if row["expires_at"] < now: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"code": "unauthorized", "message": "Session expired."}, + ) + # Touch last_seen + conn.execute( + "UPDATE sessions SET last_seen_at = ? WHERE id = ?", + (now, row["id"]), + ) + conn.commit() + return {"client_id": row["client_id"], "client_number": row["client_number"]} + + +def create_magic_token(conn, client_id: str, ip: str) -> str: + """Create a magic-link token row. Returns the RAW token (caller emails it, never stores it).""" + raw = generate_magic_token() + token_hash = _sha256_hex(raw) + now = utcnow_iso() + expires_at = (datetime.now(timezone.utc) + timedelta(minutes=MAGIC_TOKEN_TTL_MIN)).strftime("%Y-%m-%dT%H:%M:%SZ") + conn.execute( + "INSERT INTO auth_tokens (id, client_id, token_hash, expires_at, consumed_at, requested_ip, created_at) " + "VALUES (?, ?, ?, ?, NULL, ?, ?)", + (new_uuid(), client_id, token_hash, expires_at, ip, now), + ) + return raw + + +def prune_expired_tokens(conn) -> None: + """Delete auth_token rows older than 1 day (lazy sweep on verify).""" + cutoff = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ") + conn.execute("DELETE FROM auth_tokens WHERE expires_at < ?", (cutoff,)) + + +def verify_magic_token(conn, raw_token: str) -> str | None: + """Hash token, find unexpired+unconsumed row. If found: mark consumed, return client_id. + Returns None if no match.""" + token_hash = _sha256_hex(raw_token) + now = utcnow_iso() + prune_expired_tokens(conn) + row = conn.execute( + "SELECT id, client_id FROM auth_tokens WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > ?", + (token_hash, now), + ).fetchone() + if row is None: + return None + conn.execute("UPDATE auth_tokens SET consumed_at = ? WHERE id = ?", (now, row["id"])) + return row["client_id"] + + +def create_session(conn, client_id: str) -> tuple[str, str]: + """Create a session. Returns (raw_session_token, expires_at_iso).""" + raw = generate_session_token() + session_hash = _sha256_hex(raw) + now = utcnow_iso() + expires_at = (datetime.now(timezone.utc) + timedelta(days=SESSION_TTL_DAYS)).strftime("%Y-%m-%dT%H:%M:%SZ") + conn.execute( + "INSERT INTO sessions (id, client_id, session_hash, expires_at, revoked_at, created_at, last_seen_at) " + "VALUES (?, ?, ?, ?, NULL, ?, NULL)", + (new_uuid(), client_id, session_hash, expires_at, now), + ) + return raw, expires_at diff --git a/backend/claims.py b/backend/claims.py new file mode 100644 index 0000000..4e68db5 --- /dev/null +++ b/backend/claims.py @@ -0,0 +1,331 @@ +"""Client-facing claim endpoints: list, detail, document upload/download, messages. +All queries scoped by session client_id. 404 (not 403) for other clients' claims. +""" +from __future__ import annotations + +import hashlib +import html +import logging +import os +import uuid + +from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status +from fastapi.responses import FileResponse, JSONResponse + +from . import auth as authmod +from . import db +from . import dreemail +from .db import get_conn, get_upload_dir, new_uuid, utcnow_iso +from .models import MessageCreate + +logger = logging.getLogger("dre.claims") +router = APIRouter() + +# Document constraints +MAX_FILE_BYTES = 20 * 1024 * 1024 # 20 MB +MAX_DOCS_PER_CLAIM = 50 +ALLOWED_EXT = {".pdf", ".jpg", ".jpeg", ".png", ".doc", ".docx"} +MAGIC_BYTES = { + "pdf": (b"%PDF",), + "jpg": (b"\xff\xd8\xff",), + "png": (b"\x89PNG\r\n\x1a\n", b"\x89PNG"), + "docx": (b"PK\x03\x04",), + "doc": (b"PK\x03\x04", b"\xd0\xcf\x11\xe0"), # docx is ZIP; legacy .doc is OLE +} +EXT_TO_KIND = { + ".pdf": "pdf", ".jpg": "jpg", ".jpeg": "jpg", ".png": "png", + ".doc": "doc", ".docx": "docx", +} + +STATUS_LABELS = { + "NEW": "Received", "UNDER_REVIEW": "Under Review", "ACTIVE": "In Progress", + "NEGOTIATION": "In Negotiation", "LEGAL": "Legal Action", "SETTLED": "Settled", + "CLOSED": "Closed — Recovered", "WRITE_OFF": "Closed — Uncollectible", + "REJECTED": "Not Accepted", +} +TIER_STEPS = {"TIER_1": 1, "TIER_2": 2, "TIER_2_5": 2, "TIER_3": 3, "TIER_4": 4} + + +def _money(cents: int) -> str: + return f"${cents / 100.0:,.2f}" + + +def _err(code: str, message: str, status_code: int): + return JSONResponse(status_code=status_code, + content={"error": {"code": code, "message": message}}) + + +# --------------------------------------------------------------- +# GET /api/claims +# --------------------------------------------------------------- +@router.get("/api/claims") +async def list_claims(session: dict = Depends(authmod.require_client)): + client_id = session["client_id"] + with get_conn() as conn: + rows = conn.execute( + "SELECT c.claim_number, c.status, c.tier, c.amount_cents, c.created_at, c.date_resolved, " + "d.name AS debtor_name FROM claims c JOIN debtors d ON d.id = c.debtor_id " + "WHERE c.client_id = ? ORDER BY c.created_at DESC", + (client_id,), + ).fetchall() + claims = [ + { + "claim_number": r["claim_number"], + "status": r["status"], + "status_label": STATUS_LABELS.get(r["status"], r["status"]), + "tier": r["tier"], + "amount_cents": r["amount_cents"], + "amount_display": _money(r["amount_cents"]), + "debtor_name": r["debtor_name"], + "created_at": r["created_at"], + "date_resolved": r["date_resolved"], + } + for r in rows + ] + return {"claims": claims} + + +# --------------------------------------------------------------- +# GET /api/claims/{claim_number} +# --------------------------------------------------------------- +@router.get("/api/claims/{claim_number}") +async def get_claim(claim_number: str, session: dict = Depends(authmod.require_client)): + client_id = session["client_id"] + with get_conn() as conn: + row = conn.execute( + "SELECT c.*, d.name AS debtor_name, d.business_type FROM claims c " + "JOIN debtors d ON d.id = c.debtor_id " + "WHERE c.claim_number = ? AND c.client_id = ?", + (claim_number, client_id), + ).fetchone() + if row is None: + return _err("not_found", "Claim not found.", status.HTTP_404_NOT_FOUND) + docs = conn.execute( + "SELECT id, original_name, size_bytes, mime_type, uploaded_by, created_at FROM documents WHERE claim_id = ?", + (row["id"],), + ).fetchall() + notes = conn.execute( + "SELECT author_type, author_name, subject, content, created_at FROM case_notes " + "WHERE claim_id = ? AND visibility = 'SHARED' ORDER BY created_at ASC", + (row["id"],), + ).fetchall() + 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), + "amount_cents": row["amount_cents"], + "amount_display": _money(row["amount_cents"]), + "description": row["description"], + "client_reference": row["client_reference"], + "invoice_date": row["invoice_date"], + "date_assigned": row["date_assigned"], + "date_resolved": row["date_resolved"], + "debtor": {"name": row["debtor_name"], "business_type": row["business_type"]}, + "documents": [ + { + "id": d["id"], + "original_name": d["original_name"], + "size_bytes": d["size_bytes"], + "mime_type": d["mime_type"], + "uploaded_by": d["uploaded_by"], + "created_at": d["created_at"], + } + for d in docs + ], + "notes": [ + { + "author_type": n["author_type"], + "author_name": n["author_name"], + "subject": n["subject"], + # XSS: escape content server-side; frontend renders via textContent + "content": html.escape(n["content"]), + "created_at": n["created_at"], + } + for n in notes + ], + } + + +# --------------------------------------------------------------- +# POST /api/claims/{claim_number}/documents (upload) +# --------------------------------------------------------------- +@router.post("/api/claims/{claim_number}/documents") +async def upload_document(claim_number: str, request: Request, file: UploadFile = File(...), + session: dict = Depends(authmod.require_client)): + client_id = session["client_id"] + # Check content length early + cl = request.headers.get("content-length") + if cl and int(cl) > MAX_FILE_BYTES + 4096: + return _err("payload_too_large", "File exceeds 20 MB limit.", status.HTTP_413_REQUEST_ENTITY_TOO_LARGE) + ext = os.path.splitext(file.filename or "")[1].lower() + if ext not in ALLOWED_EXT: + return _err("unsupported_media_type", "File type not allowed.", status.HTTP_415_UNSUPPORTED_MEDIA_TYPE) + kind = EXT_TO_KIND.get(ext) + if not kind: + return _err("unsupported_media_type", "File type not allowed.", status.HTTP_415_UNSUPPORTED_MEDIA_TYPE) + with get_conn() as conn: + row = conn.execute( + "SELECT id FROM claims WHERE claim_number = ? AND client_id = ?", + (claim_number, client_id), + ).fetchone() + if row is None: + return _err("not_found", "Claim not found.", status.HTTP_404_NOT_FOUND) + claim_id = row["id"] + count = conn.execute("SELECT COUNT(*) AS n FROM documents WHERE claim_id = ?", (claim_id,)).fetchone()["n"] + if count >= MAX_DOCS_PER_CLAIM: + return _err("conflict", "Document limit reached for this claim.", status.HTTP_409_CONFLICT) + upload_dir = os.path.join(get_upload_dir(), claim_id) + os.makedirs(upload_dir, exist_ok=True) + stored_uuid = str(uuid.uuid4()) + stored_name = f"{stored_uuid}{ext}" + stored_path = os.path.join(upload_dir, stored_name) + sha = hashlib.sha256() + total = 0 + magic_seen = False + with open(stored_path, "wb") as f: + while True: + chunk = await file.read(64 * 1024) + if not chunk: + break + total += len(chunk) + if total > MAX_FILE_BYTES: + f.close() + os.remove(stored_path) + return _err("payload_too_large", "File exceeds 20 MB limit.", + status.HTTP_413_REQUEST_ENTITY_TOO_LARGE) + if not magic_seen: + sigs = MAGIC_BYTES.get(kind, ()) + if sigs and any(chunk.startswith(s) for s in sigs): + magic_seen = True + elif sigs: + f.close() + os.remove(stored_path) + return _err("unsupported_media_type", "File content does not match extension.", + status.HTTP_415_UNSUPPORTED_MEDIA_TYPE) + sha.update(chunk) + f.write(chunk) + os.chmod(stored_path, 0o640) + now = utcnow_iso() + doc_id = new_uuid() + mime = file.content_type or "application/octet-stream" + conn.execute( + "INSERT INTO documents (id, claim_id, original_name, stored_path, mime_type, size_bytes, sha256, uploaded_by, twentycrm_id, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, 'CLIENT', NULL, ?)", + (doc_id, claim_id, os.path.basename(file.filename or "file"), stored_path, mime, total, sha.hexdigest(), now), + ) + # SYSTEM note + audit + 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"Document uploaded: {os.path.basename(file.filename or 'file')}", now), + ) + conn.execute( + "INSERT INTO audit_log (id, entity_type, entity_id, action, actor, created_at) VALUES (?, ?, ?, ?, ?, ?)", + (new_uuid(), "document", doc_id, "upload", session["client_number"], now), + ) + conn.commit() + return { + "id": doc_id, + "original_name": os.path.basename(file.filename or "file"), + "size_bytes": total, + "mime_type": mime, + "uploaded_by": "CLIENT", + "created_at": now, + } + + +# --------------------------------------------------------------- +# GET /api/claims/{claim_number}/documents/{document_id} (download) +# --------------------------------------------------------------- +@router.get("/api/claims/{claim_number}/documents/{document_id}") +async def download_document(claim_number: str, document_id: str, + request: Request, session: dict | None = None): + # Auth: client OR staff + is_staff = authmod.verify_staff_key(request.headers.get("x-dre-staff-key")) + client_session = None + if not is_staff: + try: + client_session = authmod.require_client(request) + except HTTPException: + return _err("unauthorized", "Authentication required.", status.HTTP_401_UNAUTHORIZED) + with get_conn() as conn: + row = conn.execute( + "SELECT d.*, c.claim_number FROM documents d JOIN claims c ON c.id = d.claim_id " + "WHERE d.id = ? AND c.claim_number = ?", + (document_id, claim_number), + ).fetchone() + if row is None: + return _err("not_found", "Document not found.", status.HTTP_404_NOT_FOUND) + if not is_staff and row["claim_id"]: + # verify ownership + owner = conn.execute("SELECT client_id FROM claims WHERE id = ?", (row["claim_id"],)).fetchone() + if owner is None or owner["client_id"] != client_session["client_id"]: + return _err("not_found", "Document not found.", status.HTTP_404_NOT_FOUND) + if not os.path.exists(row["stored_path"]): + return _err("not_found", "File missing on disk.", status.HTTP_404_NOT_FOUND) + return FileResponse( + row["stored_path"], + media_type=row["mime_type"] or "application/octet-stream", + filename=row["original_name"], + ) + + +# --------------------------------------------------------------- +# POST /api/claims/{claim_number}/messages +# --------------------------------------------------------------- +@router.post("/api/claims/{claim_number}/messages") +async def create_message(claim_number: str, request: Request, + session: dict = Depends(authmod.require_client)): + client_id = session["client_id"] + try: + body = await request.json() + except Exception: + return _err("validation_error", "Invalid JSON body.", status.HTTP_422_UNPROCESSABLE_ENTITY) + try: + msg = MessageCreate.model_validate(body) + except Exception as exc: + from pydantic import ValidationError + if isinstance(exc, ValidationError): + 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) + return _err("validation_error", str(exc), status.HTTP_422_UNPROCESSABLE_ENTITY) + with get_conn() as conn: + row = conn.execute( + "SELECT id FROM claims WHERE claim_number = ? AND client_id = ?", + (claim_number, client_id), + ).fetchone() + if row is None: + return _err("not_found", "Claim not found.", status.HTTP_404_NOT_FOUND) + claim_id = row["id"] + # Get client contact name for author + c = conn.execute("SELECT contact_name, company_name FROM clients WHERE id = ?", (client_id,)).fetchone() + if c is None: + return _err("not_found", "Client not found.", status.HTTP_404_NOT_FOUND) + now = utcnow_iso() + 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 (?, ?, 'CLIENT', ?, ?, ?, 'SHARED', NULL, ?)", + (note_id, claim_id, c["contact_name"], msg.subject, msg.content, now), + ) + conn.execute( + "INSERT INTO audit_log (id, entity_type, entity_id, action, actor, created_at) VALUES (?, ?, ?, ?, ?, ?)", + (new_uuid(), "note", note_id, "note_add", session["client_number"], now), + ) + conn.commit() + # Best-effort team notification + try: + dreemail.notify_team_message(claim_number, msg.subject, msg.content, c["contact_name"]) + except Exception as exc: # noqa: BLE001 + logger.error("message team notify failed: %s", exc) + return { + "id": note_id, + "author_type": "CLIENT", + "author_name": c["contact_name"], + "subject": msg.subject, + "content": html.escape(msg.content), + "visibility": "SHARED", + "created_at": now, + } diff --git a/backend/db.py b/backend/db.py new file mode 100644 index 0000000..ffcd73e --- /dev/null +++ b/backend/db.py @@ -0,0 +1,99 @@ +"""SQLite connection helper — stdlib sqlite3, parameterized queries only. + +PRAGMA foreign_keys=ON, journal_mode=WAL, busy_timeout=5000 on every connection. +No ORM. Initializes schema on first boot and seeds number_sequences for current year. +""" +from __future__ import annotations + +import os +import sqlite3 +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +_SCHEMA_FILE = Path(__file__).resolve().parent / "schema.sql" + + +def _utcnow_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def get_db_path() -> str: + return os.environ.get("DRE_DB_PATH", "/opt/dre-portal/data/dre.db") + + +def get_upload_dir() -> str: + return os.environ.get("DRE_UPLOAD_DIR", "/opt/dre-portal/data/uploads") + + +def _apply_pragmas(conn: sqlite3.Connection) -> None: + conn.execute("PRAGMA foreign_keys = ON;") + conn.execute("PRAGMA journal_mode = WAL;") + conn.execute("PRAGMA busy_timeout = 5000;") + + +def get_conn() -> sqlite3.Connection: + """Return a connection with pragmas applied and row factory.""" + db_path = get_db_path() + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(db_path, timeout=5.0) + conn.row_factory = sqlite3.Row + _apply_pragmas(conn) + return conn + + +def init_db() -> None: + """Create schema if absent and seed number_sequences for the current year.""" + with get_conn() as conn: + schema_sql = _SCHEMA_FILE.read_text() + conn.executescript(schema_sql) + # Seed sequences for current year (idempotent) + year = datetime.now(timezone.utc).year + for prefix in ("DRE", "CLT"): + conn.execute( + "INSERT OR IGNORE INTO number_sequences (prefix, year, last_value) VALUES (?, ?, 0)", + (prefix, year), + ) + conn.commit() + + +def new_uuid() -> str: + return str(uuid.uuid4()) + + +def utcnow_iso() -> str: + return _utcnow_iso() + + +def next_sequence_number(conn: sqlite3.Connection, prefix: str) -> str: + """Atomically allocate the next DRE-YYYY-NNNN / CLT-YYYY-NNNN in a single transaction. + Uses UPDATE ... RETURNING (SQLite 3.35+) — no races.""" + year = datetime.now(timezone.utc).year + # Ensure row exists + conn.execute( + "INSERT OR IGNORE INTO number_sequences (prefix, year, last_value) VALUES (?, ?, 0)", + (prefix, year), + ) + cur = conn.execute( + "UPDATE number_sequences SET last_value = last_value + 1 WHERE prefix = ? AND year = ? " + "RETURNING last_value", + (prefix, year), + ) + row = cur.fetchone() + if row is None: + # Should not happen, but handle defensively + raise RuntimeError(f"failed to allocate sequence for {prefix}/{year}") + n = row[0] if isinstance(row, tuple) else row["last_value"] + val = n if isinstance(n, int) else int(n) + return f"{prefix}-{year}-{val:04d}" + + +def query_one(conn: sqlite3.Connection, sql: str, params: tuple[Any, ...] = ()) -> sqlite3.Row | None: + cur = conn.execute(sql, params) + return cur.fetchone() + + +def query_all(conn: sqlite3.Connection, sql: str, params: tuple[Any, ...] = ()) -> list[sqlite3.Row]: + cur = conn.execute(sql, params) + return cur.fetchall() diff --git a/backend/dre-portal.service b/backend/dre-portal.service new file mode 100644 index 0000000..1d92a1d --- /dev/null +++ b/backend/dre-portal.service @@ -0,0 +1,22 @@ +[Unit] +Description=DRE Customer Portal API (FastAPI/uvicorn) +After=network.target + +[Service] +Type=simple +WorkingDirectory=/opt/dre-portal +EnvironmentFile=/opt/dre-portal/.env +ExecStart=/opt/dre-portal/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8093 +Restart=on-failure +RestartSec=3 +User=root +# hardening +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ReadWritePaths=/opt/dre-portal/data +# NOTE: ProtectHome=true would block reading /root; omitted so service can read env. +# (ops-portal.service runs as root without ProtectHome for the same reason.) + +[Install] +WantedBy=multi-user.target diff --git a/backend/dreemail.py b/backend/dreemail.py new file mode 100644 index 0000000..df58664 --- /dev/null +++ b/backend/dreemail.py @@ -0,0 +1,102 @@ +"""Best-effort SMTP email via the germainebrown.com relay (mail.germainebrown.com:2525, STARTTLS). +Email failure must NEVER fail the API request — wrap every send in try/except, log, continue. +Per conductor decision #3. +""" +from __future__ import annotations + +import logging +import os +import smtplib +import ssl +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + +logger = logging.getLogger("dre.email") + + +def _env(name: str, default: str = "") -> str: + return os.environ.get(name, default) + + +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.""" + host = _env("DRE_SMTP_HOST", "mail.germainebrown.com") + port = int(_env("DRE_SMTP_PORT", "2525")) + user = _env("DRE_SMTP_USER", "") + pw = _env("DRE_SMTP_PASS", "") + sender = _env("DRE_SMTP_FROM", "dre@debtrecoveryexperts.com") + try: + msg = MIMEMultipart("alternative") + msg["From"] = sender + msg["To"] = to_addr + msg["Subject"] = subject + msg.attach(MIMEText(body_text, "plain", "utf-8")) + if html: + msg.attach(MIMEText(html, "html", "utf-8")) + with smtplib.SMTP(host, port, timeout=15) as server: + server.starttls(context=ssl.create_default_context()) + if user and pw: + server.login(user, pw) + server.sendmail(sender, [to_addr], msg.as_string()) + return True + except Exception as exc: # noqa: BLE001 — best-effort + logger.error("email send failed to=%s subject=%s err=%s", to_addr, subject, exc) + return False + + +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") + base = _env("DRE_BASE_URL", "https://portal.debtrecoveryexperts.com") + dollars = amount_cents / 100.0 + body = ( + f"New claim submitted via portal.\n\n" + f"Claim: {claim_number}\n" + f"Client: {client_number} — {company_name}\n" + f"Debtor: {debtor_name}\n" + f"Amount: ${dollars:,.2f}\n\n" + f"Review at: {base}/\n" + ) + html = ( + f"

New claim submitted

" + f"

Claim: {claim_number}
" + f"Client: {client_number} — {company_name}
" + f"Debtor: {debtor_name}
" + f"Amount: ${dollars:,.2f}

" + f"

Review in portal

" + ) + return send_email(team, f"New DRE Claim: {claim_number}", body, html) + + +def send_magic_link(to_addr: str, raw_token: str, client_number: str) -> bool: + base = _env("DRE_BASE_URL", "https://portal.debtrecoveryexperts.com") + link = f"{base}/portal/verify?token={raw_token}" + body = ( + f"Hello,\n\n" + f"Click the link below to log in to your DRE client portal. " + f"This link expires in 15 minutes and can only be used once.\n\n" + f"{link}\n\n" + f"If you did not request this link, you can ignore this email.\n" + ) + html = ( + f"

Hello,

" + f"

Click the button below to log in to your DRE client portal. " + f"This link expires in 15 minutes and can only be used once.

" + f"

Log In

" + f"

If the button doesn't work, copy this link: {link}

" + f"

If you did not request this link, you can ignore this email.

" + ) + return send_email(to_addr, "Your DRE Portal Login Link", body, html) + + +def notify_team_message(claim_number: str, subject: str, content: str, + author: str) -> bool: + team = _env("DRE_TEAM_NOTIFY", "dre@debtrecoveryexperts.com") + 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) diff --git a/backend/intake.py b/backend/intake.py new file mode 100644 index 0000000..8e3f805 --- /dev/null +++ b/backend/intake.py @@ -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}}, + ) diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..6a4561f --- /dev/null +++ b/backend/main.py @@ -0,0 +1,195 @@ +"""DRE Customer Portal API — FastAPI app + all 18 routers. +Port 127.0.0.1:8093. systemd: dre-portal.service (User=root). +""" +from __future__ import annotations + +import logging + +from fastapi import Depends, FastAPI, HTTPException, Request, status +from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from pydantic import ValidationError + +from . import auth as authmod +from . import db +from . import dreemail +from .db import get_conn, utcnow_iso + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") +logger = logging.getLogger("dre.main") + +app = FastAPI(title="DRE Customer Portal API", version="1.0.0", docs_url="/docs", redoc_url=None) + +app.add_middleware( + CORSMiddleware, + allow_origins=["https://portal.debtrecoveryexperts.com", "http://127.0.0.1:8093"], + allow_methods=["GET", "POST", "PATCH"], + allow_headers=["*"], +) + +# Include routers (intake, claims, staff) +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 + +app.include_router(intake_router) +app.include_router(claims_router) +app.include_router(staff_router) + + +def _err(code: str, message: str, status_code: int): + return JSONResponse(status_code=status_code, + content={"error": {"code": code, "message": message}}) + + +@app.on_event("startup") +async def _startup(): + db.init_db() + logger.info("DRE portal started; db=%s", db.get_db_path()) + + +# --------------------------------------------------------------- +# Error envelopes +# --------------------------------------------------------------- +@app.exception_handler(RequestValidationError) +async def _validation_handler(request: Request, exc: RequestValidationError): + errors = exc.errors() + parts = [] + for e in errors: + loc = ".".join(str(x) for x in e.get("loc", [])) + parts.append(f"{loc}: {e.get('msg', 'invalid')}") + msg = "; ".join(parts) if parts else "Validation error" + return _err("validation_error", msg, status.HTTP_422_UNPROCESSABLE_ENTITY) + + +@app.exception_handler(ValidationError) +async def _pyd_validation_handler(request: Request, exc: ValidationError): + 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) + + +@app.exception_handler(HTTPException) +async def _http_handler(request: Request, exc: HTTPException): + """Map auth/forbidden HTTPExceptions onto the unified {'error':{...}} envelope.""" + detail = exc.detail + if isinstance(detail, dict) and "code" in detail and "message" in detail: + return _err(detail["code"], detail["message"], exc.status_code) + return _err("internal_error", "An internal error occurred.", exc.status_code) + + +@app.exception_handler(Exception) +async def _internal_handler(request: Request, exc: Exception): + logger.exception("internal error: %s", exc) + return _err("internal_error", "An internal error occurred.", status.HTTP_500_INTERNAL_SERVER_ERROR) + + +# --------------------------------------------------------------- +# 1. GET /api/health +# --------------------------------------------------------------- +@app.get("/api/health") +async def health(): + return {"status": "ok", "time": utcnow_iso()} + + +# --------------------------------------------------------------- +# 2-5. Auth endpoints (magic-link) +# --------------------------------------------------------------- +@app.post("/api/auth/request") +async def auth_request(request: Request): + try: + body = await request.json() + except Exception: + return _err("validation_error", "Invalid JSON body.", status.HTTP_422_UNPROCESSABLE_ENTITY) + email_in = body.get("email") if isinstance(body, dict) else None + if not email_in: + return _err("validation_error", "email is required.", status.HTTP_422_UNPROCESSABLE_ENTITY) + ip = authmod.get_client_ip(request) + if not authmod.rate_limit_auth_request(email_in.lower(), ip): + return _err("rate_limited", "Too many requests. Please try again later.", + status.HTTP_429_TOO_MANY_REQUESTS) + email_lc = email_in.lower() + with get_conn() as conn: + row = conn.execute("SELECT id, contact_name, company_name FROM clients WHERE email = ?", (email_lc,)).fetchone() + if row is not None: + raw_token = authmod.create_magic_token(conn, row["id"], ip) + conn.commit() + # Best-effort email + try: + dreemail.send_magic_link(email_lc, raw_token, "") + except Exception as exc: # noqa: BLE001 + logger.error("magic link email failed: %s", exc) + # Anti-enumeration: always same response + return {"message": "If an account exists, a login link has been sent."} + + +@app.post("/api/auth/verify") +async def auth_verify(request: Request): + try: + body = await request.json() + except Exception: + return _err("validation_error", "Invalid JSON body.", status.HTTP_422_UNPROCESSABLE_ENTITY) + token = body.get("token") if isinstance(body, dict) else None + if not token: + return _err("validation_error", "token is required.", status.HTTP_422_UNPROCESSABLE_ENTITY) + ip = authmod.get_client_ip(request) + if not authmod.rate_limit_auth_verify(ip): + return _err("rate_limited", "Too many attempts. Please try again later.", + status.HTTP_429_TOO_MANY_REQUESTS) + with get_conn() as conn: + client_id = authmod.verify_magic_token(conn, token) + if client_id is None: + return _err("unauthorized", "Invalid or expired token.", status.HTTP_401_UNAUTHORIZED) + session_token, expires_at = authmod.create_session(conn, client_id) + client = conn.execute( + "SELECT client_number, company_name, contact_name FROM clients WHERE id = ?", (client_id,) + ).fetchone() + conn.commit() + return { + "session_token": session_token, + "expires_at": expires_at, + "client": { + "client_number": client["client_number"], + "company_name": client["company_name"], + "contact_name": client["contact_name"], + }, + } + + +@app.post("/api/auth/logout") +async def auth_logout(request: Request, session: dict = Depends(authmod.require_client)): + """Revoke the current session by hash.""" + auth = request.headers.get("authorization", "") + raw_token = auth.split(" ", 1)[1].strip() if auth.lower().startswith("bearer ") else "" + import hashlib + session_hash = hashlib.sha256(raw_token.encode("utf-8")).hexdigest() + with get_conn() as conn: + conn.execute( + "UPDATE sessions SET revoked_at = ? WHERE session_hash = ?", + (utcnow_iso(), session_hash), + ) + conn.commit() + return {"message": "Logged out."} + + +@app.get("/api/auth/me") +async def auth_me(session: dict = Depends(authmod.require_client)): + import hashlib + client_id = session["client_id"] + with get_conn() as conn: + c = conn.execute( + "SELECT client_number, company_name, contact_name, email, phone, created_at FROM clients WHERE id = ?", + (client_id,), + ).fetchone() + claim_count = conn.execute("SELECT COUNT(*) AS n FROM claims WHERE client_id = ?", (client_id,)).fetchone()["n"] + return { + "client": { + "client_number": c["client_number"], + "company_name": c["company_name"], + "contact_name": c["contact_name"], + "email": c["email"], + "phone": c["phone"], + "member_since": c["created_at"], + }, + "claim_count": claim_count, + } diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..dded1c4 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,190 @@ +"""Pydantic v2 request/response models. extra='forbid' on every request body. +Includes PII rejection (SSN/PAN regex) on all free-text fields. +""" +from __future__ import annotations + +import re +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator + +# --------------------------------------------------------------- +# PII rejection — compliance-critical +# --------------------------------------------------------------- +_SSN_RE = re.compile(r"\b\d{3}-?\d{2}-?\d{4}\b") +_PAN_RE = re.compile(r"\b(?:\d[ -]?){13,19}\b") + + +def _scan_pii(value: str) -> str: + """Raise ValueError if value matches SSN or PAN regex.""" + if value is None: + return value + if _SSN_RE.search(value): + raise ValueError("Do not include Social Security or bank/card numbers.") + if _PAN_RE.search(value): + raise ValueError("Do not include Social Security or bank/card numbers.") + return value + + +def _pii_validator(field_name: str): + return field_validator(field_name)(lambda v: _scan_pii(v)) + + +# --------------------------------------------------------------- +# Intake request +# --------------------------------------------------------------- +BUSINESS_TYPES = ( + "INDIVIDUAL", "SOLE_PROPRIETORSHIP", "LLC", "CORPORATION", "PARTNERSHIP", "OTHER" +) +MESSAGE_SUBJECTS = ( + "Question about my claim", + "New information about the debtor", + "Payment received / want to stop recovery", + "Update my contact info", + "Complaint or concern", + "Other", +) +CLAIM_STATUSES = ( + "NEW", "UNDER_REVIEW", "ACTIVE", "NEGOTIATION", "LEGAL", + "SETTLED", "CLOSED", "WRITE_OFF", "REJECTED", +) +TIERS = ("TIER_1", "TIER_2", "TIER_2_5", "TIER_3", "TIER_4") + + +class IntakeClient(BaseModel): + model_config = ConfigDict(extra="forbid") + company_name: str = Field(..., min_length=1, max_length=200) + contact_name: str = Field(..., min_length=1, max_length=200) + email: EmailStr + phone: str | None = Field(None, max_length=50) + + @field_validator("company_name", "contact_name", "phone") + @classmethod + def _v(cls, v): + return _scan_pii(v) + + +class IntakeDebtor(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str = Field(..., min_length=1, max_length=200) + business_type: str = Field("OTHER") + contact_email: str | None = Field(None, max_length=254) + contact_phone: str | None = Field(None, max_length=50) + physical_address: str | None = Field(None, max_length=500) + + @field_validator("business_type") + @classmethod + def _bt(cls, v): + v = v.upper() + if v not in BUSINESS_TYPES: + raise ValueError(f"business_type must be one of {BUSINESS_TYPES}") + return v + + @field_validator("name", "contact_email", "contact_phone", "physical_address") + @classmethod + def _v(cls, v): + return _scan_pii(v) + + +class IntakeClaim(BaseModel): + model_config = ConfigDict(extra="forbid") + amount_cents: int = Field(..., gt=0, le=100_000_000) + description: str | None = Field(None, max_length=5000) + client_reference: str | None = Field(None, max_length=200) + invoice_date: str | None = Field(None, max_length=20) + + @field_validator("description", "client_reference", "invoice_date") + @classmethod + def _v(cls, v): + return _scan_pii(v) + + +class IntakeRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + client: IntakeClient + debtor: IntakeDebtor + claim: IntakeClaim + tos_accepted: bool = True + turnstile_token: str | None = None + + @field_validator("tos_accepted") + @classmethod + def _tos(cls, v): + if v is not True: + raise ValueError("tos_accepted must be true") + return v + + +# --------------------------------------------------------------- +# Auth +# --------------------------------------------------------------- +class AuthRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + email: EmailStr + + +class AuthVerify(BaseModel): + model_config = ConfigDict(extra="forbid") + token: str = Field(..., min_length=10, max_length=200) + + +# --------------------------------------------------------------- +# Messages +# --------------------------------------------------------------- +class MessageCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + subject: str + content: str = Field(..., min_length=1, max_length=10000) + + @field_validator("subject") + @classmethod + def _subj(cls, v): + if v not in MESSAGE_SUBJECTS: + raise ValueError(f"subject must be one of {MESSAGE_SUBJECTS}") + return v + + @field_validator("content") + @classmethod + def _cont(cls, v): + return _scan_pii(v) + + +# --------------------------------------------------------------- +# Staff +# --------------------------------------------------------------- +class StaffClaimPatch(BaseModel): + model_config = ConfigDict(extra="forbid") + status: str | None = None + tier: str | None = None + reason: str | None = Field(None, max_length=500) + + @field_validator("status") + @classmethod + def _st(cls, v): + if v is not None and v not in CLAIM_STATUSES: + raise ValueError(f"status must be one of {CLAIM_STATUSES}") + return v + + @field_validator("tier") + @classmethod + def _tr(cls, v): + if v is not None and v not in TIERS: + raise ValueError(f"tier must be one of {TIERS}") + return v + + +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) + + @field_validator("visibility") + @classmethod + def _vis(cls, v): + if v not in ("SHARED", "INTERNAL"): + raise ValueError("visibility must be SHARED or INTERNAL") + return v + + @field_validator("content", "author_name") + @classmethod + def _v(cls, v): + return _scan_pii(v) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..7c3d73b --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.110 +uvicorn[standard]>=0.29 +python-multipart>=0.0.9 +email-validator>=2.0 +pydantic>=2.6 diff --git a/backend/schema.sql b/backend/schema.sql new file mode 100644 index 0000000..6849384 --- /dev/null +++ b/backend/schema.sql @@ -0,0 +1,139 @@ +-- ============================================================ +-- DRE Customer Portal — SQLite Schema (spec §1) +-- DB file: /opt/dre-portal/data/dre.db +-- Pragmas (set on every connection): foreign_keys=ON, journal_mode=WAL, busy_timeout=5000 +-- All timestamps ISO-8601 UTC TEXT. Money as INTEGER cents. PKs TEXT UUID4. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS clients ( + id TEXT PRIMARY KEY, -- uuid4 + client_number TEXT UNIQUE NOT NULL, -- CLT-YYYY-NNNN + company_name TEXT NOT NULL, + contact_name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, -- lowercased; magic-link identity + phone TEXT, + tos_accepted_at TEXT, -- set when ToS accepted at intake + twentycrm_id TEXT, -- nullable; set by future CRM sync + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_clients_email ON clients(email); + +CREATE TABLE IF NOT EXISTS debtors ( + id TEXT PRIMARY KEY, -- uuid4 + name TEXT NOT NULL, -- business or individual name + business_type TEXT NOT NULL DEFAULT 'OTHER' + CHECK (business_type IN + ('INDIVIDUAL','SOLE_PROPRIETORSHIP','LLC','CORPORATION','PARTNERSHIP','OTHER')), + contact_email TEXT, + contact_phone TEXT, + physical_address TEXT, -- free-text single line + twentycrm_id TEXT, -- nullable + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS claims ( + id TEXT PRIMARY KEY, -- uuid4 + claim_number TEXT UNIQUE NOT NULL, -- DRE-YYYY-NNNN + client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE RESTRICT, + debtor_id TEXT NOT NULL REFERENCES debtors(id) ON DELETE RESTRICT, + amount_cents INTEGER NOT NULL CHECK (amount_cents > 0), + currency TEXT NOT NULL DEFAULT 'USD', + status TEXT NOT NULL DEFAULT 'NEW' + CHECK (status IN + ('NEW','UNDER_REVIEW','ACTIVE','NEGOTIATION','LEGAL','SETTLED','CLOSED','WRITE_OFF','REJECTED')), + tier TEXT NOT NULL DEFAULT 'TIER_1' + CHECK (tier IN ('TIER_1','TIER_2','TIER_2_5','TIER_3','TIER_4')), + description TEXT, + client_reference TEXT, + invoice_date TEXT, -- ISO date + date_assigned TEXT, -- set when moved out of NEW + date_resolved TEXT, -- set on SETTLED/CLOSED/WRITE_OFF + twentycrm_id TEXT, -- nullable + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_claims_client ON claims(client_id); +CREATE INDEX IF NOT EXISTS idx_claims_status ON claims(status); +CREATE INDEX IF NOT EXISTS idx_claims_debtor ON claims(debtor_id); + +CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, -- uuid4 + claim_id TEXT NOT NULL REFERENCES claims(id) ON DELETE CASCADE, + original_name TEXT NOT NULL, -- sanitized display name + stored_path TEXT NOT NULL, -- absolute path on disk (uuid-named) + mime_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + sha256 TEXT NOT NULL, -- integrity + dedupe + uploaded_by TEXT NOT NULL DEFAULT 'CLIENT' -- CLIENT | STAFF + CHECK (uploaded_by IN ('CLIENT','STAFF')), + twentycrm_id TEXT, -- nullable + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_documents_claim ON documents(claim_id); + +CREATE TABLE IF NOT EXISTS case_notes ( + id TEXT PRIMARY KEY, -- uuid4 + claim_id TEXT NOT NULL REFERENCES claims(id) ON DELETE CASCADE, + author_type TEXT NOT NULL + CHECK (author_type IN ('CLIENT','STAFF','SYSTEM')), + author_name TEXT NOT NULL, + subject TEXT, -- for client->team structured messages + content TEXT NOT NULL, -- plaintext; rendered escaped + visibility TEXT NOT NULL DEFAULT 'SHARED' + CHECK (visibility IN ('SHARED','INTERNAL')), + twentycrm_id TEXT, -- nullable + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_notes_claim ON case_notes(claim_id); + +CREATE TABLE IF NOT EXISTS auth_tokens ( + id TEXT PRIMARY KEY, -- uuid4 + client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + token_hash TEXT UNIQUE NOT NULL, -- sha256 of raw token + expires_at TEXT NOT NULL, -- created_at + 15 min + consumed_at TEXT, -- NULL = unused + requested_ip TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_tokens_hash ON auth_tokens(token_hash); +CREATE INDEX IF NOT EXISTS idx_tokens_client ON auth_tokens(client_id); + +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, -- uuid4 + client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + session_hash TEXT UNIQUE NOT NULL, -- sha256 of raw session token + expires_at TEXT NOT NULL, -- + 7 days + revoked_at TEXT, + created_at TEXT NOT NULL, + last_seen_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_sessions_hash ON sessions(session_hash); + +CREATE TABLE IF NOT EXISTS audit_log ( + id TEXT PRIMARY KEY, -- uuid4 + entity_type TEXT NOT NULL, -- 'claim'|'client'|'document'|'note' + entity_id TEXT NOT NULL, + action TEXT NOT NULL, -- 'create'|'status_change'|'update'|'upload'|'note_add' + field TEXT, + old_value TEXT, + new_value TEXT, + actor TEXT NOT NULL, + reason TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id); + +CREATE TABLE IF NOT EXISTS number_sequences ( + prefix TEXT NOT NULL, -- 'DRE'|'CLT' + year INTEGER NOT NULL, + last_value INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (prefix, year) +); + +-- Migration tracking table +CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TEXT NOT NULL +); diff --git a/backend/staff.py b/backend/staff.py new file mode 100644 index 0000000..6b69ce0 --- /dev/null +++ b/backend/staff.py @@ -0,0 +1,460 @@ +"""Internal staff endpoints (staff-key auth): claims list/detail/patch, notes, documents, stats, audit.""" +from __future__ import annotations + +import hashlib +import html +import logging +import os +import uuid + +from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status +from fastapi.responses import JSONResponse +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 .claims import ( # noqa: E402 + MAX_FILE_BYTES, ALLOWED_EXT, MAGIC_BYTES, EXT_TO_KIND, STATUS_LABELS, + TIER_STEPS, _money, _err, +) + +logger = logging.getLogger("dre.staff") +router = APIRouter() + + +# --------------------------------------------------------------- +# GET /api/staff/claims +# --------------------------------------------------------------- +@router.get("/api/staff/claims") +async def staff_list_claims(request: Request, _staff=Depends(authmod.require_staff)): + status_filter = request.query_params.get("status") + tier_filter = request.query_params.get("tier") + q = request.query_params.get("q", "").strip() + 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.append("c.status = ?") + params.append(status_filter) + if tier_filter: + where.append("c.tier = ?") + params.append(tier_filter) + if q: + where.append("(c.claim_number LIKE ? OR cl.company_name LIKE ? OR d.name LIKE ?)") + like = f"%{q}%" + 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 " + "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 ?" + ) + params.extend([limit, offset]) + 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": [ + { + "claim_number": r["claim_number"], + "status": r["status"], + "status_label": STATUS_LABELS.get(r["status"], r["status"]), + "tier": r["tier"], + "amount_cents": r["amount_cents"], + "amount_display": _money(r["amount_cents"]), + "company_name": r["company_name"], + "client_number": r["client_number"], + "debtor_name": r["debtor_name"], + "business_type": r["business_type"], + "created_at": r["created_at"], + "date_assigned": r["date_assigned"], + "date_resolved": r["date_resolved"], + } + for r in rows + ], + "total": total, + "limit": limit, + "offset": offset, + } + + +# --------------------------------------------------------------- +# GET /api/staff/claims/{claim_number} +# --------------------------------------------------------------- +@router.get("/api/staff/claims/{claim_number}") +async def staff_get_claim(claim_number: str, _staff=Depends(authmod.require_staff)): + with get_conn() as conn: + row = conn.execute( + "SELECT c.*, cl.company_name, cl.contact_name, cl.email AS client_email, cl.phone AS client_phone, " + "cl.client_number, d.name AS debtor_name, d.business_type, d.contact_email AS debtor_email, " + "d.contact_phone AS debtor_phone, d.physical_address AS debtor_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) + docs = conn.execute( + "SELECT id, original_name, size_bytes, mime_type, uploaded_by, created_at FROM documents WHERE claim_id = ?", + (row["id"],), + ).fetchall() + notes = conn.execute( + "SELECT author_type, author_name, subject, content, visibility, created_at FROM case_notes " + "WHERE claim_id = ? ORDER BY created_at ASC", + (row["id"],), + ).fetchall() + audit = conn.execute( + "SELECT entity_type, entity_id, action, field, old_value, new_value, actor, reason, created_at " + "FROM audit_log WHERE entity_type IN ('claim','document','note') AND entity_id = ? " + "ORDER BY created_at ASC", + (row["id"],), + ).fetchall() + 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), + "amount_cents": row["amount_cents"], + "amount_display": _money(row["amount_cents"]), + "description": row["description"], + "client_reference": row["client_reference"], + "invoice_date": row["invoice_date"], + "date_assigned": row["date_assigned"], + "date_resolved": row["date_resolved"], + "created_at": row["created_at"], + "client": { + "client_number": row["client_number"], + "company_name": row["company_name"], + "contact_name": row["contact_name"], + "email": row["client_email"], + "phone": row["client_phone"], + }, + "debtor": { + "name": row["debtor_name"], + "business_type": row["business_type"], + "contact_email": row["debtor_email"], + "contact_phone": row["debtor_phone"], + "physical_address": row["debtor_address"], + }, + "documents": [ + { + "id": d["id"], "original_name": d["original_name"], "size_bytes": d["size_bytes"], + "mime_type": d["mime_type"], "uploaded_by": d["uploaded_by"], "created_at": d["created_at"], + } for d in docs + ], + "notes": [ + { + "author_type": n["author_type"], "author_name": n["author_name"], "subject": n["subject"], + "content": html.escape(n["content"]), "visibility": n["visibility"], "created_at": n["created_at"], + } for n in notes + ], + "audit": [ + { + "entity_type": a["entity_type"], "entity_id": a["entity_id"], "action": a["action"], + "field": a["field"], "old_value": a["old_value"], "new_value": a["new_value"], + "actor": a["actor"], "reason": a["reason"], "created_at": a["created_at"], + } for a in audit + ], + } + + +# --------------------------------------------------------------- +# 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)): + try: + body = await request.json() + except Exception: + return _err("validation_error", "Invalid JSON body.", status.HTTP_422_UNPROCESSABLE_ENTITY) + try: + patch = StaffClaimPatch.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) + claim_id = row["id"] + now = utcnow_iso() + updates: list[tuple] = [] + new_status = row["status"] + new_tier = row["tier"] + date_assigned = row["date_assigned"] + date_resolved = row["date_resolved"] + # Status change + if patch.status is not None and patch.status != row["status"]: + 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_status = patch.status + # set date_assigned on first move out of NEW + if row["status"] == "NEW" and patch.status != "NEW" and not date_assigned: + date_assigned = now + # set date_resolved on resolved states + if patch.status in ("SETTLED", "CLOSED", "WRITE_OFF", "REJECTED") and not date_resolved: + date_resolved = now + # auto-add SYSTEM/SHARED note so client sees status change + 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"Status updated to: {STATUS_LABELS.get(patch.status, patch.status)}", now), + ) + # Tier change + if patch.tier is not None and patch.tier != row["tier"]: + 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_tier = patch.tier + conn.execute( + "UPDATE claims SET status = ?, tier = ?, date_assigned = ?, date_resolved = ?, updated_at = ? WHERE id = ?", + (new_status, new_tier, date_assigned, date_resolved, now, claim_id), + ) + conn.commit() + # Return updated detail + return await staff_get_claim_inner(claim_number) + + +async def staff_get_claim_inner(claim_number: str): + """Reuse detail query without re-checking staff auth.""" + with get_conn() as conn: + row = conn.execute( + "SELECT c.*, cl.company_name, cl.contact_name, cl.email AS client_email, cl.phone AS client_phone, " + "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 " + "WHERE c.claim_number = ?", + (claim_number,), + ).fetchone() + if row is None: + return JSONResponse(status_code=status.HTTP_404_NOT_FOUND, + content={"error": {"code": "not_found", "message": "Claim not found."}}) + 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), + "amount_cents": row["amount_cents"], + "amount_display": _money(row["amount_cents"]), + "date_assigned": row["date_assigned"], + "date_resolved": row["date_resolved"], + "updated_at": row["updated_at"], + } + + +# --------------------------------------------------------------- +# 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)): + try: + body = await request.json() + except Exception: + return _err("validation_error", "Invalid JSON body.", status.HTTP_422_UNPROCESSABLE_ENTITY) + try: + note = StaffNoteCreate.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) + claim_id = row["id"] + now = utcnow_iso() + 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 (?, ?, 'STAFF', ?, NULL, ?, ?, NULL, ?)", + (note_id, claim_id, note.author_name, 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), + ) + conn.commit() + return { + "id": note_id, + "author_type": "STAFF", + "author_name": note.author_name, + "content": html.escape(note.content), + "visibility": note.visibility, + "created_at": now, + } + + +# --------------------------------------------------------------- +# POST /api/staff/claims/{claim_number}/documents +# --------------------------------------------------------------- +@router.post("/api/staff/claims/{claim_number}/documents") +async def staff_upload_document(claim_number: str, request: Request, file: UploadFile = File(...), + _staff=Depends(authmod.require_staff)): + cl = request.headers.get("content-length") + if cl and int(cl) > MAX_FILE_BYTES + 4096: + return _err("payload_too_large", "File exceeds 20 MB limit.", status.HTTP_413_REQUEST_ENTITY_TOO_LARGE) + ext = os.path.splitext(file.filename or "")[1].lower() + if ext not in ALLOWED_EXT: + return _err("unsupported_media_type", "File type not allowed.", status.HTTP_415_UNSUPPORTED_MEDIA_TYPE) + kind = EXT_TO_KIND.get(ext) + if not kind: + return _err("unsupported_media_type", "File type not allowed.", status.HTTP_415_UNSUPPORTED_MEDIA_TYPE) + 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"] + count = conn.execute("SELECT COUNT(*) AS n FROM documents WHERE claim_id = ?", (claim_id,)).fetchone()["n"] + if count >= 50: + return _err("conflict", "Document limit reached for this claim.", status.HTTP_409_CONFLICT) + upload_dir = os.path.join(get_upload_dir(), claim_id) + os.makedirs(upload_dir, exist_ok=True) + stored_uuid = str(uuid.uuid4()) + stored_name = f"{stored_uuid}{ext}" + stored_path = os.path.join(upload_dir, stored_name) + sha = hashlib.sha256() + total = 0 + magic_seen = False + with open(stored_path, "wb") as f: + while True: + chunk = await file.read(64 * 1024) + if not chunk: + break + total += len(chunk) + if total > MAX_FILE_BYTES: + f.close() + os.remove(stored_path) + return _err("payload_too_large", "File exceeds 20 MB limit.", status.HTTP_413_REQUEST_ENTITY_TOO_LARGE) + if not magic_seen: + sigs = MAGIC_BYTES.get(kind, ()) + if sigs and any(chunk.startswith(s) for s in sigs): + magic_seen = True + elif sigs: + f.close() + os.remove(stored_path) + return _err("unsupported_media_type", "File content does not match extension.", status.HTTP_415_UNSUPPORTED_MEDIA_TYPE) + sha.update(chunk) + f.write(chunk) + os.chmod(stored_path, 0o640) + now = utcnow_iso() + doc_id = new_uuid() + mime = file.content_type or "application/octet-stream" + conn.execute( + "INSERT INTO documents (id, claim_id, original_name, stored_path, mime_type, size_bytes, sha256, uploaded_by, twentycrm_id, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, 'STAFF', NULL, ?)", + (doc_id, claim_id, os.path.basename(file.filename or "file"), stored_path, mime, total, sha.hexdigest(), now), + ) + conn.execute( + "INSERT INTO audit_log (id, entity_type, entity_id, action, actor, created_at) VALUES (?, ?, ?, ?, ?, ?)", + (new_uuid(), "document", doc_id, "upload", "staff", now), + ) + conn.commit() + return { + "id": doc_id, + "original_name": os.path.basename(file.filename or "file"), + "size_bytes": total, + "mime_type": mime, + "uploaded_by": "STAFF", + "created_at": now, + } + + +# --------------------------------------------------------------- +# GET /api/staff/stats +# --------------------------------------------------------------- +@router.get("/api/staff/stats") +async def staff_stats(_staff=Depends(authmod.require_staff)): + with get_conn() as conn: + total = conn.execute("SELECT COUNT(*) AS n FROM claims").fetchone()["n"] + by_status = {} + for r in conn.execute("SELECT status, COUNT(*) AS n FROM claims GROUP BY status").fetchall(): + by_status[r["status"]] = r["n"] + by_tier = {} + for r in conn.execute("SELECT tier, COUNT(*) AS n FROM claims GROUP BY tier").fetchall(): + by_tier[r["tier"]] = r["n"] + total_amount = conn.execute("SELECT COALESCE(SUM(amount_cents),0) AS s FROM claims").fetchone()["s"] + # recovered = claims in SETTLED/CLOSED + recovered = conn.execute( + "SELECT COALESCE(SUM(amount_cents),0) AS s FROM claims WHERE status IN ('SETTLED','CLOSED')" + ).fetchone()["s"] + open_amount = total_amount - recovered + # aging: claims not resolved, by age + now_iso = utcnow_iso() + over_30 = conn.execute( + "SELECT COUNT(*) AS n FROM claims WHERE date_resolved IS NULL AND created_at < ?", + (over_30_cutoff(),), + ).fetchone()["n"] + over_60 = conn.execute( + "SELECT COUNT(*) AS n FROM claims WHERE date_resolved IS NULL AND created_at < ?", + (over_60_cutoff(),), + ).fetchone()["n"] + return { + "total_claims": total, + "by_status": by_status, + "by_tier": by_tier, + "total_amount_cents": total_amount, + "total_amount_display": _money(total_amount), + "recovered_amount_cents": recovered, + "recovered_amount_display": _money(recovered), + "open_amount_cents": open_amount, + "open_amount_display": _money(open_amount), + "aging": {"over_30_days": over_30, "over_60_days": over_60}, + } + + +def over_30_cutoff() -> str: + from datetime import datetime, timedelta, timezone + return (datetime.now(timezone.utc) - timedelta(days=30)).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def over_60_cutoff() -> str: + from datetime import datetime, timedelta, timezone + return (datetime.now(timezone.utc) - timedelta(days=60)).strftime("%Y-%m-%dT%H:%M:%SZ") + + +# --------------------------------------------------------------- +# GET /api/staff/audit +# --------------------------------------------------------------- +@router.get("/api/staff/audit") +async def staff_audit(request: Request, _staff=Depends(authmod.require_staff)): + entity_type = request.query_params.get("entity_type") + entity_id = request.query_params.get("entity_id") + limit = min(int(request.query_params.get("limit", "100")), 500) + offset = max(int(request.query_params.get("offset", "0")), 0) + where = [] + params: list = [] + if entity_type: + where.append("entity_type = ?") + params.append(entity_type) + if entity_id: + where.append("entity_id = ?") + params.append(entity_id) + where_sql = ("WHERE " + " AND ".join(where)) if where else "" + with get_conn() as conn: + rows = conn.execute( + f"SELECT * FROM audit_log {where_sql} ORDER BY created_at DESC LIMIT ? OFFSET ?", + tuple(params + [limit, offset]), + ).fetchall() + total = conn.execute(f"SELECT COUNT(*) AS n FROM audit_log {where_sql}", tuple(params)).fetchone()["n"] + return { + "audit": [ + { + "id": r["id"], "entity_type": r["entity_type"], "entity_id": r["entity_id"], + "action": r["action"], "field": r["field"], "old_value": r["old_value"], + "new_value": r["new_value"], "actor": r["actor"], "reason": r["reason"], + "created_at": r["created_at"], + } for r in rows + ], + "total": total, + "limit": limit, + "offset": offset, + } diff --git a/operations/architecture-2026-08-21.md b/operations/architecture-2026-08-21.md new file mode 100644 index 0000000..b059626 --- /dev/null +++ b/operations/architecture-2026-08-21.md @@ -0,0 +1,721 @@ +# DRE Customer Portal — Backend Architecture Specification + +**Author:** Claude Opus 4-8 (System Architect) +**Date:** 2026-08-21 +**Status:** BUILD-READY — hand off to GLM-5.2 (backend) + Sonnet 5 (frontend) +**Stack:** FastAPI + SQLite (single source of truth), magic-link auth, systemd + uvicorn behind Caddy + +--- + +## 0. Scope & Principles + +This spec defines the FIRST production backend for DRE. It replaces the 12 static mockups' dead +`
` with a live intake pipeline and adds a magic-link customer portal. + +**In scope:** self-serve claim intake → creates client + claim → emails DRE team → returns claim +number; email magic-link auth (no passwords); customer portal (claim list/detail, document upload, +messaging); internal staff read/write endpoints (staff-key auth) that back the existing dashboards. + +**Out of scope (fast-follow, do NOT block):** TwentyCRM sync, DocuSeal LPOA wiring, Stripe, AI +scoring, LetterStream, RON. Schema carries a nullable `twentycrm_id` on every synced entity so a +later one-way push is clean. + +**Non-negotiable compliance:** +- NEVER collect/store SSNs, full bank account numbers, or card data. No column exists for them; the + intake validator rejects any field that pattern-matches a 9-digit SSN or a 13-19 digit PAN. +- All debtor + client data is PII. HTTPS only (Caddy terminates TLS). Secrets via env only. +- Store only what recovery needs (contract/invoice metadata + uploaded docs). + +**Core conventions (locked, from platform spec):** +- Claim number: `DRE-YYYY-NNNN` (per-year sequence, zero-padded to 4). +- Client ID: `CLT-YYYY-NNNN` (per-year sequence, zero-padded to 4). +- Both generated server-side on first submission. Sequences are per calendar year. + +--- + +## 1. SQLite Schema + +**DB file:** `/opt/dre-portal/data/dre.db` +**Pragmas (set on every connection):** `PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; +PRAGMA busy_timeout = 5000;` + +All timestamps are ISO-8601 UTC strings (`YYYY-MM-DDTHH:MM:SSZ`), stored as TEXT. All monetary +amounts stored as INTEGER cents (never float). All primary keys are TEXT UUID4 unless noted. + +```sql +-- ============================================================ +-- clients : one row per customer account (the creditor / claimant) +-- ============================================================ +CREATE TABLE clients ( + id TEXT PRIMARY KEY, -- uuid4 + client_number TEXT UNIQUE NOT NULL, -- CLT-YYYY-NNNN + company_name TEXT NOT NULL, + contact_name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, -- lowercased; magic-link identity + phone TEXT, + tos_accepted_at TEXT, -- set when ToS accepted at intake + twentycrm_id TEXT, -- nullable; set by future CRM sync + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX idx_clients_email ON clients(email); + +-- ============================================================ +-- debtors : the party the money is owed by (denormalized per claim is avoided; +-- one debtor row, referenced by claims). Minimal PII. +-- ============================================================ +CREATE TABLE debtors ( + id TEXT PRIMARY KEY, -- uuid4 + name TEXT NOT NULL, -- business or individual name + business_type TEXT NOT NULL DEFAULT 'OTHER' -- enum below + CHECK (business_type IN + ('INDIVIDUAL','SOLE_PROPRIETORSHIP','LLC','CORPORATION','PARTNERSHIP','OTHER')), + contact_email TEXT, + contact_phone TEXT, + physical_address TEXT, -- free-text single line; NOT named "address" + twentycrm_id TEXT, -- nullable + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- ============================================================ +-- claims : the collection case. Belongs to one client, one debtor. +-- ============================================================ +CREATE TABLE claims ( + id TEXT PRIMARY KEY, -- uuid4 + claim_number TEXT UNIQUE NOT NULL, -- DRE-YYYY-NNNN + client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE RESTRICT, + debtor_id TEXT NOT NULL REFERENCES debtors(id) ON DELETE RESTRICT, + amount_cents INTEGER NOT NULL CHECK (amount_cents > 0), + currency TEXT NOT NULL DEFAULT 'USD', + status TEXT NOT NULL DEFAULT 'NEW' -- lifecycle enum below + CHECK (status IN + ('NEW','UNDER_REVIEW','ACTIVE','NEGOTIATION','LEGAL','SETTLED','CLOSED','WRITE_OFF','REJECTED')), + tier TEXT NOT NULL DEFAULT 'TIER_1' + CHECK (tier IN ('TIER_1','TIER_2','TIER_2_5','TIER_3','TIER_4')), + description TEXT, -- what the debt is for (invoice desc, service) + client_reference TEXT, -- customer's own invoice/PO number + invoice_date TEXT, -- ISO date; when debt originated + date_assigned TEXT, -- set when moved out of NEW + date_resolved TEXT, -- set on SETTLED/CLOSED/WRITE_OFF + twentycrm_id TEXT, -- nullable + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX idx_claims_client ON claims(client_id); +CREATE INDEX idx_claims_status ON claims(status); +CREATE INDEX idx_claims_debtor ON claims(debtor_id); + +-- ============================================================ +-- documents : uploaded evidence, stored on disk; row holds metadata only +-- ============================================================ +CREATE TABLE documents ( + id TEXT PRIMARY KEY, -- uuid4 + claim_id TEXT NOT NULL REFERENCES claims(id) ON DELETE CASCADE, + original_name TEXT NOT NULL, -- sanitized display name + stored_path TEXT NOT NULL, -- absolute path on disk (uuid-named) + mime_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + sha256 TEXT NOT NULL, -- integrity + dedupe + uploaded_by TEXT NOT NULL DEFAULT 'CLIENT' -- CLIENT | STAFF + CHECK (uploaded_by IN ('CLIENT','STAFF')), + twentycrm_id TEXT, -- nullable + created_at TEXT NOT NULL +); +CREATE INDEX idx_documents_claim ON documents(claim_id); + +-- ============================================================ +-- case_notes : messages + internal notes on a claim (threaded log) +-- visibility controls whether the client can see it in the portal. +-- ============================================================ +CREATE TABLE case_notes ( + id TEXT PRIMARY KEY, -- uuid4 + claim_id TEXT NOT NULL REFERENCES claims(id) ON DELETE CASCADE, + author_type TEXT NOT NULL -- who wrote it + CHECK (author_type IN ('CLIENT','STAFF','SYSTEM')), + author_name TEXT NOT NULL, -- display name (client contact, staff name, 'System') + subject TEXT, -- for client->team structured messages + content TEXT NOT NULL, -- plaintext; rendered escaped (see security) + visibility TEXT NOT NULL DEFAULT 'SHARED' -- SHARED = client sees it; INTERNAL = staff only + CHECK (visibility IN ('SHARED','INTERNAL')), + twentycrm_id TEXT, -- nullable + created_at TEXT NOT NULL +); +CREATE INDEX idx_notes_claim ON case_notes(claim_id); + +-- ============================================================ +-- auth_tokens : single-use magic-link tokens +-- ============================================================ +CREATE TABLE auth_tokens ( + id TEXT PRIMARY KEY, -- uuid4 + client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + token_hash TEXT UNIQUE NOT NULL, -- sha256 of the raw token (raw never stored) + expires_at TEXT NOT NULL, -- created_at + 15 min + consumed_at TEXT, -- set on successful verify; NULL = unused + requested_ip TEXT, -- for rate-limit audit + created_at TEXT NOT NULL +); +CREATE INDEX idx_tokens_hash ON auth_tokens(token_hash); +CREATE INDEX idx_tokens_client ON auth_tokens(client_id); + +-- ============================================================ +-- sessions : bearer session tokens issued after magic-link verify +-- ============================================================ +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, -- uuid4 + client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + session_hash TEXT UNIQUE NOT NULL, -- sha256 of the raw session token + expires_at TEXT NOT NULL, -- created_at + 7 days (sliding not required v1) + revoked_at TEXT, + created_at TEXT NOT NULL, + last_seen_at TEXT +); +CREATE INDEX idx_sessions_hash ON sessions(session_hash); + +-- ============================================================ +-- audit_log : append-only trail for claim/status changes (compliance) +-- ============================================================ +CREATE TABLE audit_log ( + id TEXT PRIMARY KEY, -- uuid4 + entity_type TEXT NOT NULL, -- 'claim' | 'client' | 'document' | 'note' + entity_id TEXT NOT NULL, + action TEXT NOT NULL, -- 'create' | 'status_change' | 'update' | 'upload' | 'note_add' + field TEXT, -- changed field name (nullable) + old_value TEXT, + new_value TEXT, + actor TEXT NOT NULL, -- staff email/name, client_number, or 'system' + reason TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX idx_audit_entity ON audit_log(entity_type, entity_id); + +-- ============================================================ +-- number_sequences : per-year counters for claim/client numbers +-- (avoids race by using an atomic UPDATE...RETURNING in a txn) +-- ============================================================ +CREATE TABLE number_sequences ( + prefix TEXT NOT NULL, -- 'DRE' | 'CLT' + year INTEGER NOT NULL, + last_value INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (prefix, year) +); +``` + +### 1.1 Claim lifecycle status enum (authoritative) + +| Value | Meaning | Client sees | +|----------------|-------------------------------------------------------------------|--------------------------| +| `NEW` | Just submitted via intake; awaiting DRE review | "Received" | +| `UNDER_REVIEW` | DRE reviewing docs / AI analysis | "Under Review" | +| `ACTIVE` | Approved; recovery in progress (tier drives the sub-stage) | "In Progress" | +| `NEGOTIATION` | Debtor engaged; settlement talks | "In Negotiation" | +| `LEGAL` | Referred to partner law firm (Tier 4) | "Legal Action" | +| `SETTLED` | Payment received / agreed; disbursement pending | "Settled" | +| `CLOSED` | Fully resolved & disbursed; binders generated | "Closed — Recovered" | +| `WRITE_OFF` | Uncollectible; case closed without recovery | "Closed — Uncollectible" | +| `REJECTED` | DRE declined the claim at intake review | "Not Accepted" | + +**Tier enum:** `TIER_1` (Soft Touch), `TIER_2` (Formal Demand), `TIER_2_5` (Lien Threat), `TIER_3` +(Escalation), `TIER_4` (Legal Action). Tier is independent of status: a claim can be `ACTIVE` at any +tier. The frontend 4-step progress bar maps TIER_1→TIER_4 (TIER_2_5 renders as a sub-badge on TIER_2). + +**Note on CRM enum divergence:** TwentyCRM Claims.status uses `NEW, ACTIVE, NEGOTIATION, LEGAL, +SETTLED, CLOSED, WRITE_OFF` and tier `TIER_1/2/3`. Our schema adds `UNDER_REVIEW`, `REJECTED`, +`TIER_2_5`. The future sync layer maps these: `UNDER_REVIEW`→`NEW`, `REJECTED`→`CLOSED`(+note), +`TIER_2_5`→`TIER_2`. This mapping is a sync-layer concern, not a schema constraint — build the +schema as specified above. **[CONDUCTOR DECISION #1 — see §7.]** + +--- + +## 2. REST API Contract + +**Base URL:** `https://portal.debtrecoveryexperts.com/api` (Caddy reverse-proxies `/api/*` to +`localhost:8090`; static HTML continues to be served by Caddy from `/var/www/capabilities/`). + +**Auth models:** +- **Public** — no auth (intake, magic-link request/verify). +- **Client** — `Authorization: Bearer `. Resolves to a `client_id`; every claim + query is scoped to that client. 401 if missing/invalid/expired. +- **Staff** — `X-DRE-Staff-Key: ` header, compared constant-time to env `DRE_STAFF_KEY`. + 403 if absent/wrong. (v1 uses a single shared staff key; internal pages are already behind + Cloudflare Access, so this is defense-in-depth, not the primary gate.) + +**Global conventions:** +- All request/response bodies are JSON (`Content-Type: application/json`) except document upload + (`multipart/form-data`). +- Errors: `{"error": {"code": "", "message": ""}}` with appropriate HTTP + status. Codes: `validation_error`, `not_found`, `unauthorized`, `forbidden`, `rate_limited`, + `payload_too_large`, `unsupported_media_type`, `conflict`, `internal_error`. +- Money in responses returned BOTH as `amount_cents` (int) and `amount_display` (e.g. `"$15,000.00"`). +- Timestamps returned as ISO-8601 UTC. + +### 2.1 Health + +**`GET /api/health`** — Public. → `200 {"status":"ok","time":""}`. No DB write. + +### 2.2 Intake (public) + +**`POST /api/intake`** — Public. Creates client (or reuses by email) + debtor + claim, writes a +`SYSTEM` case note, emails the DRE team, returns the claim number. This is what `debt-recovery.html` +posts to. + +Request body: +```json +{ + "client": { + "company_name": "Acme Builders LLC", + "contact_name": "Jane Doe", + "email": "jane@acmebuilders.com", + "phone": "512-555-0100" + }, + "debtor": { + "name": "Delinquent Corp", + "business_type": "LLC", + "contact_email": "ap@delinquent.com", + "contact_phone": "214-555-0199", + "physical_address": "100 Main St, Dallas, TX 75201" + }, + "claim": { + "amount_cents": 1500000, + "description": "Unpaid invoices for framing subcontract", + "client_reference": "INV-2048", + "invoice_date": "2026-03-15" + }, + "tos_accepted": true, + "turnstile_token": "" +} +``` + +Behavior: +- Validate all fields (see §6). `amount_cents` > 0 and ≤ 100_000_000 ($1M cap; larger flagged + `validation_error` — **[CONDUCTOR DECISION #2]**). Reject if any free-text field matches an + SSN or PAN regex. +- If a client with this (lowercased) email exists, reuse it and update contact fields; else create + a new client with a fresh `CLT-YYYY-NNNN`. `tos_accepted` must be `true` → set `tos_accepted_at`. +- Always create a new debtor row + new claim (`status=NEW`, `tier=TIER_1`) with `DRE-YYYY-NNNN`. +- Insert `audit_log` create rows; insert a `SYSTEM`/`SHARED` case note "Claim received." +- Send email to `dre@debtrecoveryexperts.com` (team notification) via the germainebrown.com relay + (`mail.germainebrown.com:2525`, per platform email pitfalls) with claim summary. Email send + failure must NOT fail the request — log it, still return success (**[CONDUCTOR DECISION #3]**). +- Fire-and-forget confirmation email to the client (optional v1). + +Response `201`: +```json +{ + "claim_number": "DRE-2026-0001", + "client_number": "CLT-2026-0001", + "status": "NEW", + "message": "Claim received. Our team will review and contact you shortly." +} +``` + +### 2.3 Magic-link auth (public) + +**`POST /api/auth/request`** — Public. Requests a login link. +```json +{ "email": "jane@acmebuilders.com" } +``` +- Always returns `200 {"message":"If an account exists, a login link has been sent."}` regardless of + whether the email exists (no account enumeration). +- If the email maps to a client: generate a 32-byte URL-safe random token, store only its sha256 + in `auth_tokens` with `expires_at = now + 15min`, email the link to the client via the DRE relay: + `https://portal.debtrecoveryexperts.com/portal/verify?token=`. +- Rate limits: max 3 requests per email per 15 min AND max 10 per IP per hour → `429 rate_limited`. + +**`POST /api/auth/verify`** — Public. Exchanges a magic-link token for a session. +```json +{ "token": "" } +``` +- Hash the token, look up an unconsumed, unexpired row. If none → `401 unauthorized`. +- Mark `consumed_at`, create a `sessions` row (7-day expiry), return the session token. +- Response `200`: `{ "session_token": "", "expires_at": "", "client": {"client_number":"CLT-2026-0001","company_name":"...","contact_name":"..."} }` +- Frontend stores `session_token` (localStorage or an HttpOnly cookie set by the backend — + **[CONDUCTOR DECISION #4: cookie vs bearer]**; spec defaults to bearer in localStorage for + simplicity, documented XSS mitigations in §6). + +**`POST /api/auth/logout`** — Client. Revokes current session. → `200 {"message":"Logged out."}` + +**`GET /api/auth/me`** — Client. → `200 { client: {...}, claim_count: N }`. Used by portal to +confirm session on load. + +### 2.4 Claims (client) + +**`GET /api/claims`** — Client. Lists the caller's claims (newest first). +```json +{ "claims": [ + { "claim_number":"DRE-2026-0001", "status":"ACTIVE", "status_label":"In Progress", + "tier":"TIER_2", "amount_cents":1500000, "amount_display":"$15,000.00", + "debtor_name":"Delinquent Corp", "created_at":"", "date_resolved":null } +]} +``` + +**`GET /api/claims/{claim_number}`** — Client. Full detail; 404 if not owned by caller (never leak +existence of other clients' claims — return 404, not 403). +```json +{ + "claim_number":"DRE-2026-0001", "status":"ACTIVE", "status_label":"In Progress", + "tier":"TIER_2", "tier_step":2, "amount_cents":1500000, "amount_display":"$15,000.00", + "description":"Unpaid invoices...", "client_reference":"INV-2048", + "invoice_date":"2026-03-15", "date_assigned":"", "date_resolved":null, + "debtor": { "name":"Delinquent Corp", "business_type":"LLC" }, + "documents": [ { "id":"...", "original_name":"invoice.pdf", "size_bytes":48210, + "mime_type":"application/pdf", "uploaded_by":"CLIENT", "created_at":"" } ], + "notes": [ { "author_type":"STAFF", "author_name":"Anita", "subject":null, + "content":"We've sent the first demand.", "created_at":"" } ] +} +``` +Notes list returns only `visibility='SHARED'` rows for client callers. Debtor block excludes +internal fields (contact/address hidden from client — **[CONDUCTOR DECISION #5]**; spec default: +client sees debtor name + type only). + +### 2.5 Documents + +**`POST /api/claims/{claim_number}/documents`** — Client. `multipart/form-data`, field `file`. +- Enforce: max 20 MB per file; allowed MIME/extensions `.pdf .jpg .jpeg .png .doc .docx`; verify + magic bytes, not just extension. Reject others → `415 unsupported_media_type` / `413 payload_too_large`. +- Store to `/opt/dre-portal/data/uploads//` (mode 0640), compute sha256, insert + `documents` row + audit + `SYSTEM` shared note "Document uploaded: ". +- Response `201`: the document metadata object. + +**`GET /api/claims/{claim_number}/documents/{document_id}`** — Client or Staff. Streams the file with +`Content-Disposition: attachment`. 404 if not owned (client) / not found (staff). Never serve uploads +via Caddy static — always through this authenticated endpoint. + +### 2.6 Messaging / case notes (client) + +**`POST /api/claims/{claim_number}/messages`** — Client. Structured message to the team. +```json +{ "subject": "New information about the debtor", "content": "They changed their address to..." } +``` +- `subject` must be one of the fixed options (validated): `Question about my claim`, + `New information about the debtor`, `Payment received / want to stop recovery`, + `Update my contact info`, `Complaint or concern`, `Other`. +- Insert `case_notes` (`author_type=CLIENT`, `visibility=SHARED`), audit, email the DRE team. +- Response `201`: the created note object. + +(Client reads notes via the claim-detail endpoint §2.4; no separate GET needed for v1.) + +### 2.7 Internal staff endpoints (staff-key auth) + +These back the existing internal dashboards (replace mock rows). + +**`GET /api/staff/claims`** — Staff. All claims with filters: +`?status=NEW&tier=TIER_2&q=&limit=50&offset=0`. Search matches claim_number, company_name, +debtor_name. Returns claims joined with client + debtor summary + counts. + +**`GET /api/staff/claims/{claim_number}`** — Staff. Full detail incl. INTERNAL notes, debtor contact +fields, all documents, and audit trail. + +**`PATCH /api/staff/claims/{claim_number}`** — Staff. Update status/tier and resolution dates. +```json +{ "status":"ACTIVE", "tier":"TIER_2", "reason":"Docs approved, moving to formal demand" } +``` +- Validate enum values. On status change to a resolved state, set `date_resolved`; on first move out + of `NEW`, set `date_assigned`. Write audit rows (old→new, actor=staff, reason). Optionally auto-add + a `SYSTEM`/`SHARED` note so the client sees the status change. **This is the write-back that flows + to the client portal.** +- Response `200`: updated claim detail. + +**`POST /api/staff/claims/{claim_number}/notes`** — Staff. Add a note. +```json +{ "content":"Called debtor, left VM", "visibility":"INTERNAL", "author_name":"Tony" } +``` +`visibility` defaults `INTERNAL`; set `SHARED` to make it client-visible. Response `201`. + +**`POST /api/staff/claims/{claim_number}/documents`** — Staff. Same as client upload but +`uploaded_by=STAFF`; may be marked to appear (or not) to client via a `client_visible` flag +(**[CONDUCTOR DECISION #6]**; spec default: staff uploads are internal-only, not shown to client). + +**`GET /api/staff/stats`** — Staff. Aggregate rollups for dashboard/analytics cards: +```json +{ + "total_claims": 12, "by_status": {"NEW":3,"ACTIVE":5,"SETTLED":2,"CLOSED":2}, + "by_tier": {"TIER_1":4,"TIER_2":5,"TIER_3":3}, + "total_amount_cents": 42000000, "total_amount_display":"$420,000.00", + "recovered_amount_cents": 12000000, "open_amount_cents": 30000000, + "aging": { "over_30_days": 2, "over_60_days": 1 } +} +``` + +**`GET /api/staff/audit?entity_type=claim&entity_id=`** — Staff. Audit trail for change history UI. + +### 2.8 Endpoint summary table + +| Method | Path | Auth | Purpose | +|--------|--------------------------------------------------|--------|----------------------------------| +| GET | `/api/health` | Public | Liveness | +| POST | `/api/intake` | Public | Create client+debtor+claim | +| POST | `/api/auth/request` | Public | Request magic link | +| POST | `/api/auth/verify` | Public | Exchange token → session | +| POST | `/api/auth/logout` | Client | Revoke session | +| GET | `/api/auth/me` | Client | Session/account check | +| GET | `/api/claims` | Client | List own claims | +| GET | `/api/claims/{claim_number}` | Client | Own claim detail | +| POST | `/api/claims/{claim_number}/documents` | Client | Upload document | +| GET | `/api/claims/{claim_number}/documents/{id}` | Client/Staff | Download document | +| POST | `/api/claims/{claim_number}/messages` | Client | Message the team | +| GET | `/api/staff/claims` | Staff | All claims + filters | +| GET | `/api/staff/claims/{claim_number}` | Staff | Full internal detail | +| PATCH | `/api/staff/claims/{claim_number}` | Staff | Update status/tier (write-back) | +| POST | `/api/staff/claims/{claim_number}/notes` | Staff | Add internal/shared note | +| POST | `/api/staff/claims/{claim_number}/documents` | Staff | Staff upload | +| GET | `/api/staff/stats` | Staff | Aggregate dashboard metrics | +| GET | `/api/staff/audit` | Staff | Change history | + +--- + +## 3. Magic-Link Auth Flow + +**Goal:** passwordless, secure-by-default client login. + +1. **Request.** Client enters email on `login.html` → `POST /api/auth/request {email}`. +2. **Generate.** Backend: if email matches a client, create `token = secrets.token_urlsafe(32)`. + Store ONLY `sha256(token)` in `auth_tokens` with `expires_at = now + 15 minutes`, `consumed_at=NULL`, + `requested_ip`. Never store or log the raw token. +3. **Deliver.** Email the client (via `mail.germainebrown.com:2525`, from `dre@debtrecoveryexperts.com`) + a link: `https://portal.debtrecoveryexperts.com/portal/verify?token=`. Always respond `200` + with a generic message (anti-enumeration). +4. **Click.** The `verify` page reads `token` from the query string and calls + `POST /api/auth/verify {token}`. +5. **Exchange.** Backend hashes the token, finds a row that is unexpired AND unconsumed. If found: + set `consumed_at=now` (single-use), create a `sessions` row (`session_token=token_urlsafe(32)`, + store `sha256`, `expires_at = now + 7 days`), return the raw session token + client summary. +6. **Authenticated calls.** Frontend sends `Authorization: Bearer ` on every portal + API call. Backend hashes it, looks up a non-revoked, unexpired session, resolves `client_id`, + updates `last_seen_at`. +7. **Logout.** `POST /api/auth/logout` sets `revoked_at`. + +**Security notes:** +- Tokens are 256-bit random (`secrets`), URL-safe. Only sha256 hashes are persisted → DB leak does + not yield usable tokens. +- Magic-link TTL 15 min; single-use (consumed on verify). Session TTL 7 days, revocable. +- Constant-time comparison for hashes and the staff key (`hmac.compare_digest`). +- Rate limit `/api/auth/request` (3/email/15min, 10/IP/hour) to stop link-spam / mailbox flooding. +- No account enumeration: identical `200` response whether or not the email exists. +- Verify page must POST the token (not GET-navigate to the API) so the raw token stays out of the + API's access logs / Referer chains; the page strips `?token=` from the URL after reading it. +- Expired/consumed tokens are pruned by a lightweight sweep on each verify attempt (delete rows + where `expires_at < now - 1 day`). + +--- + +## 4. Frontend Page Inventory + Data Mapping + +Existing files live in `/var/www/capabilities/` (public) and `/var/www/internal/` (staff). Sonnet 5 +wires these to the API. **New pages** are flagged NEW. + +| Page (file) | Location | Auth | Calls | Displays / Action | +|-------------------------------------|------------|-------------|----------------------------------------------------|-------------------| +| `debt-recovery.html` (intake) | public | none | `POST /api/intake` | Wire the dead ``: collect Your Info / Debtor Info / Claim Details, submit JSON, show returned claim number + confirmation. Optional Turnstile. | +| `login.html` | public | none | `POST /api/auth/request` | Add an email field + "Email me a login link" button. Replace/append to the SSO-only pattern. Show "check your email" state. | +| `portal/verify` (NEW) | public | none→client | `POST /api/auth/verify` | Reads `?token`, exchanges for session, stores session token, redirects to client dashboard. Handles invalid/expired token error state. | +| `dre-client-dashboard.html` | public* | client | `GET /api/auth/me`, `GET /api/claims` | Replace empty-state/mock rows with real active + past claims, stat cards (count, recovered, open), tier progress bar. Requires session; redirect to login if 401. | +| `portal/claim` (NEW or extend dash) | public* | client | `GET /api/claims/{n}`, `POST .../documents`, `POST .../messages` | Claim detail: status/tier progress, document list + upload dropzone (real ``), shared notes thread, "message the team" form with fixed subjects. | +| `dre-dashboard.html` (internal) | internal | staff | `GET /api/staff/claims`, `PATCH /api/staff/claims/{n}` | Replace mock claim rows with live data; status/tier update controls that write back. | +| `dre-case-aging.html` | internal | staff | `GET /api/staff/claims` (sort by age), `GET /api/staff/stats` | Aging buckets from real `created_at`/`date_assigned`. | +| `dre-analytics.html` | internal | staff | `GET /api/staff/stats` | Replace static charts with real by_status / by_tier / recovered totals. | +| `inbox.html` | internal | staff | (unchanged — IMAP poller JSON) | Out of scope; keep as-is. | +| `letter-queue.html` | internal | staff | (v1: unchanged; later reads `GET /api/staff/claims`) | Not wired in v1. | + +\* Client dashboard/claim pages are currently in the public docroot. Since they now require a session +token (enforced by the API — every data call is 401 without a valid session), they can stay in +`/var/www/capabilities/`; the pages themselves render an empty shell + "please log in" until the +session resolves. **[CONDUCTOR DECISION #7: keep client portal on `portal.` public docroot vs move +behind its own path.]** Spec default: keep in public docroot, gate by API session. + +**Frontend session handling:** store the session token in `localStorage` under `dre_session`. Send +as `Authorization: Bearer`. On any `401`, clear it and redirect to `login.html`. (If Conductor picks +HttpOnly cookies in Decision #4, backend sets `Set-Cookie: dre_session=...; HttpOnly; Secure; +SameSite=Lax` and frontend drops the localStorage logic.) + +--- + +## 5. Deployment Plan + +Mirror the `/opt/ops-portal` pattern: venv + uvicorn under systemd, localhost port, Caddy in front. + +### 5.1 Layout +``` +/opt/dre-portal/ +├── app/ # FastAPI code (from GLM-5.2) +│ ├── main.py # app + routers +│ ├── db.py # sqlite connection helper (pragmas), migrations runner +│ ├── schema.sql # the CREATE TABLE block from §1 +│ ├── auth.py, intake.py, claims.py, staff.py, email.py, ... +├── data/ +│ ├── dre.db # SQLite (WAL) +│ └── uploads// # uploaded docs, mode 0640 +├── .env # secrets (mode 0600) +└── venv/ # python venv +``` +Code home for git is `/root/projects/dre/` (repo). Deploy = `git pull` in the repo then rsync/symlink +the `app/` into `/opt/dre-portal/app/` (or clone the repo directly into `/opt/dre-portal` and run +from there — **[CONDUCTOR DECISION #8: run-from-repo vs deploy-copy]**; spec default: clone repo at +`/opt/dre-portal`, `data/` and `.env` gitignored). + +### 5.2 Environment (`/opt/dre-portal/.env`, chmod 600) +``` +DRE_STAFF_KEY=<64-hex random> +DRE_DB_PATH=/opt/dre-portal/data/dre.db +DRE_UPLOAD_DIR=/opt/dre-portal/data/uploads +DRE_BASE_URL=https://portal.debtrecoveryexperts.com +# Email relay (per platform email pitfalls — use germainebrown.com relay, NOT MXroute:587) +DRE_SMTP_HOST=mail.germainebrown.com +DRE_SMTP_PORT=2525 +DRE_SMTP_FROM=dre@debtrecoveryexperts.com +DRE_TEAM_NOTIFY=dre@debtrecoveryexperts.com +DRE_SMTP_USER= +DRE_SMTP_PASS=<...> +TURNSTILE_SECRET= +``` +Reuse existing `DRE_EMAIL_*` creds from `~/.hermes/.env` for SMTP if the relay needs auth. + +### 5.3 systemd unit — `/etc/systemd/system/dre-portal.service` +```ini +[Unit] +Description=DRE Customer Portal API (FastAPI/uvicorn) +After=network.target + +[Service] +Type=simple +WorkingDirectory=/opt/dre-portal +EnvironmentFile=/opt/dre-portal/.env +ExecStart=/opt/dre-portal/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8090 +Restart=on-failure +RestartSec=3 +# hardening +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ReadWritePaths=/opt/dre-portal/data +ProtectHome=true + +[Install] +WantedBy=multi-user.target +``` +Enable: `systemctl daemon-reload && systemctl enable --now dre-portal`. + +### 5.4 Caddy routing +Extend the existing `portal.debtrecoveryexperts.com` block so `/api/*` proxies to the app while +static files continue to serve. **Order matters** — the `handle /api/*` must precede `file_server`. +```caddyfile +portal.debtrecoveryexperts.com { + handle /api/* { + reverse_proxy localhost:8090 + } + handle { + root * /var/www/capabilities/ + try_files {path} {path}.html /index.html + file_server + } +} +``` +`pay.` and `internal.` blocks unchanged. After edit: `caddy validate --config /etc/caddy/Caddyfile +&& systemctl reload caddy`. **The app must NEVER be exposed on a public port — only 127.0.0.1:8090.** + +### 5.5 Init / migration steps +1. `python3 -m venv /opt/dre-portal/venv` +2. `venv/bin/pip install fastapi uvicorn[standard] python-multipart` (+ `email-validator`; stdlib + `sqlite3`, `secrets`, `hashlib`, `hmac`, `smtplib` cover the rest — no ORM in v1). +3. On first boot, `db.py` runs `schema.sql` if the DB is absent (idempotent `CREATE TABLE IF NOT + EXISTS`), then seeds `number_sequences` for the current year if missing. +4. Migrations: numbered SQL files in `app/migrations/NNNN_*.sql`, tracked in a `schema_migrations` + table; runner applies unapplied ones on startup. (v1 ships with `0001_init.sql` == schema.sql.) +5. `chown -R dre-portal:dre-portal /opt/dre-portal/data` (or the service user); `chmod 750 data`, + `chmod 640` on the db file. **[CONDUCTOR DECISION #9: dedicated service user vs run as existing + ops user like ops-portal.]** Spec default: reuse the ops-portal service user pattern. + +--- + +## 6. Security & Edge Cases + +**Input validation (all endpoints):** +- Use Pydantic models for every request body; reject unknown fields (`extra="forbid"`). +- Email validated (`email-validator`), lowercased before storage/lookup. +- `amount_cents`: positive int, ≤ 100_000_000 (see Decision #2). Reject non-integer / float. +- `business_type`, `status`, `tier`, message `subject` validated against their fixed enums server-side. +- String length caps: names ≤ 200, description ≤ 5000, note content ≤ 10000, address ≤ 500. +- **PII rejection:** run every free-text intake field through SSN regex `\b\d{3}-?\d{2}-?\d{4}\b` + and PAN regex `\b(?:\d[ -]?){13,19}\b`; if matched, reject with `validation_error` "Do not include + Social Security or bank/card numbers." (Compliance-critical.) + +**SQL injection:** ALL queries use parameterized statements (`?` placeholders / named params via +`sqlite3`). NEVER f-string/format user input into SQL. Table/column names are never taken from input. + +**XSS in case notes / messages:** store content as raw plaintext; the frontend renders it with +`textContent` (never `innerHTML`) OR the backend returns an `content_html` that is HTML-escaped +server-side. Spec: **store raw, escape on output**; API returns escaped `content` and the frontend +inserts via `textContent`. No markdown/HTML allowed in v1. Subject is enum-only (no free text). + +**Magic-link rate limiting:** in-process token-bucket / sliding-window counters keyed by email and +by IP (backed by a small in-memory dict with periodic cleanup; acceptable for single-instance v1). +`3/email/15min`, `10/IP/hour` on `/api/auth/request`; `10/IP/15min` on `/api/auth/verify` (brute-force +guard — though 256-bit tokens make guessing infeasible). Intake: `20/IP/hour` + Turnstile if configured. + +**File upload:** +- Max 20 MB/file (enforced by reading `Content-Length` AND streaming with a hard byte cap). +- Allowlist extensions + MIME + magic-byte sniff (`python-magic` optional; else check known + signatures for PDF `%PDF`, JPEG `FFD8`, PNG `89504E47`, ZIP-based docx `504B0304`). Reject on + mismatch. +- Store OUTSIDE any web-served directory (`/opt/dre-portal/data/uploads`), uuid-named to prevent + path traversal; never trust `original_name` for the path. Sanitize `original_name` for display. +- Serve only via the authenticated download endpoint with `Content-Disposition: attachment` and a + safe `Content-Type` (or `application/octet-stream`) to prevent inline execution. +- Per-claim document count cap (e.g. 50) to prevent abuse. + +**AuthZ / data isolation:** every client claim query filters by the session's `client_id`. Accessing +another client's `claim_number` returns `404` (not `403`) to avoid confirming existence. Staff key +compared with `hmac.compare_digest`. + +**Transport & secrets:** HTTPS enforced by Caddy (app only on localhost). Secrets only from `.env`; +never logged. Redact tokens/keys from logs. Access logs must not contain the `?token=` query value +(verify uses POST). + +**Other edge cases:** +- Duplicate email at intake → reuse client, still create new claim (a client can have many claims). +- Concurrent number generation → atomic `UPDATE number_sequences SET last_value = last_value + 1 ... + RETURNING last_value` inside the same transaction as the insert; retry on the (rare) SQLite busy. +- Year rollover → sequence keyed by `(prefix, year)`; new year starts at 0001 automatically. +- Email relay down → intake/messages still succeed (email is best-effort); failure logged + surfaced + in `audit_log` as a `note` action so staff can follow up. +- Clock/expiry → all comparisons in UTC; expired tokens/sessions rejected and lazily pruned. +- Empty portal (new client, no claims) → endpoints return empty arrays; frontend shows empty state. + +--- + +## 7. Open Decisions for the Conductor + +| # | Decision | Spec default (build this unless overridden) | +|---|----------|---------------------------------------------| +| 1 | Extra statuses (`UNDER_REVIEW`,`REJECTED`,`TIER_2_5`) diverge from TwentyCRM enums. Keep richer local enum? | **Yes** — keep richer enum; sync layer maps down later. | +| 2 | Max claim amount cap. | **$1,000,000** (100_000_000 cents); larger → validation error. | +| 3 | Should intake fail if the team-notification email fails to send? | **No** — email is best-effort; request still returns 201. | +| 4 | Session transport: Bearer token in localStorage vs HttpOnly cookie. | **Bearer in localStorage** (simpler; XSS mitigated by textContent rendering). | +| 5 | Does the client see debtor contact/address in claim detail? | **No** — client sees debtor name + type only. | +| 6 | Are staff-uploaded documents visible to the client? | **No** — staff uploads internal-only by default. | +| 7 | Keep client dashboard/claim pages in public docroot (API-gated) or move behind a path? | **Keep in public docroot**, gate by API session. | +| 8 | Deploy model: run FastAPI directly from the git repo clone at `/opt/dre-portal`, or copy `app/` from `/root/projects/dre`? | **Clone repo at `/opt/dre-portal`**; `data/` + `.env` gitignored. | +| 9 | Service user: dedicated `dre-portal` user vs reuse ops-portal user. | **Reuse ops-portal service-user pattern.** | + +**Also flag to conductor (informational, not blocking):** +- TwentyCRM Payment→Claim and CaseNote→Claim relations are still missing (per current state). The + future sync layer will need them; not required for this backend. +- Turnstile secret not yet provisioned — intake ships with captcha check *conditional* on the env var, + so it works with or without it. +- LPOA/DocuSeal, Stripe, LetterStream, AI scoring are all explicitly deferred (fast-follow). + +--- + +## 8. Handoff Notes + +- **GLM-5.2 (backend):** implement §1 schema verbatim, §2 endpoints, §3 auth, §5 deploy, §6 security. + No ORM required — stdlib `sqlite3` with parameterized queries + Pydantic for validation. Keep raw + tokens out of the DB and logs. Reuse `~/.hermes/.env` `DRE_EMAIL_*` creds for SMTP via the + germainebrown.com relay. +- **Sonnet 5 (frontend):** wire the pages per §4. Every data call sends `Authorization: Bearer`; + render all user/staff text via `textContent`. Real `` dropzone (see platform + pitfall). `chmod 644` any new HTML in the webroots. Keep the D|R|E logo, nav, and theme + conventions from the platform skill. +- **Both:** the internal dashboards read from `/api/staff/*` with the `X-DRE-Staff-Key` header + (still behind Cloudflare Access). Status changes via `PATCH /api/staff/claims/{n}` are the + write-back that surfaces in the client portal.