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