Files
dre/backend/main.py
T

205 lines
8.1 KiB
Python

"""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", "https://my.debtrecoveryexperts.com", "http://127.0.0.1:8093"],
allow_methods=["GET", "POST", "PATCH"],
allow_headers=["*"],
)
# Include routers (intake, claims, staff, packet)
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
from .packet import router as packet_router # noqa: E402
from .letters import router as letters_router # noqa: E402
from .chatbot import router as chatbot_router # noqa: E402
app.include_router(intake_router)
app.include_router(claims_router)
app.include_router(staff_router)
app.include_router(packet_router)
app.include_router(letters_router)
app.include_router(chatbot_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()
# Ensure the welcome-packet overrides table exists on existing DBs.
from . import packet
packet.ensure_packet_table()
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,
}