- 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
332 lines
14 KiB
Python
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,
|
|
}
|