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