205 lines
8.1 KiB
Python
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,
|
|
}
|