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

332 lines
14 KiB
Python

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