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:
@@ -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=
|
||||
@@ -0,0 +1 @@
|
||||
# DRE Portal API package
|
||||
+227
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi>=0.110
|
||||
uvicorn[standard]>=0.29
|
||||
python-multipart>=0.0.9
|
||||
email-validator>=2.0
|
||||
pydantic>=2.6
|
||||
@@ -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
|
||||
);
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user