DRE customer portal: FastAPI + SQLite backend (18 endpoints)

- Magic-link auth (sha256-only token storage, 15-min single-use, 7-day sessions)
- Staff-key auth via X-DRE-Staff-Key (constant-time compare)
- SQLite WAL, foreign_keys, parameterized queries, atomic DRE/CLT sequence allocation
- Intake validator rejects SSN/PAN patterns (FDCPA/TDCPA compliance)
- Document upload allowlist + magic-byte check, 20MB cap
- Unified error envelope, money as integer cents
- systemd unit (port 8093, User=root, hardening directives)
- Fixes import bug (auth.py relative imports) and audit_log placeholder mismatch
This commit is contained in:
root
2026-08-21 18:43:33 -04:00
parent c573ed1a14
commit be0750d001
14 changed files with 2662 additions and 0 deletions
+23
View File
@@ -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=
+1
View File
@@ -0,0 +1 @@
# DRE Portal API package
+227
View File
@@ -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
+331
View File
@@ -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,
}
+99
View File
@@ -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()
+22
View File
@@ -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
+102
View File
@@ -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"<h2>New claim submitted</h2>"
f"<p><b>Claim:</b> {claim_number}<br>"
f"<b>Client:</b> {client_number}{company_name}<br>"
f"<b>Debtor:</b> {debtor_name}<br>"
f"<b>Amount:</b> ${dollars:,.2f}</p>"
f"<p><a href=\"{base}/\">Review in portal</a></p>"
)
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"<p>Hello,</p>"
f"<p>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.</p>"
f"<p><a href=\"{link}\" style=\"...\">Log In</a></p>"
f"<p>If the button doesn't work, copy this link: {link}</p>"
f"<p>If you did not request this link, you can ignore this email.</p>"
)
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)
+147
View File
@@ -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}},
)
+195
View File
@@ -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,
}
+190
View File
@@ -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)
+5
View File
@@ -0,0 +1,5 @@
fastapi>=0.110
uvicorn[standard]>=0.29
python-multipart>=0.0.9
email-validator>=2.0
pydantic>=2.6
+139
View File
@@ -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
);
+460
View File
@@ -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,
}