""" app/main.py ─────────── FastAPI application bootstrap: · CORS middleware (origin whitelist from env) · Lifespan handler: DDL table creation + admin seed · Auth and cron routers (no auth dependency on their own paths) · Protected API router for all CRUD + config endpoints """ from __future__ import annotations import logging import os from contextlib import asynccontextmanager from typing import List from fastapi import APIRouter, Depends, FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from sqlalchemy.orm import Session from app.auth import get_current_user, router as auth_router, seed_admin from app.config import settings from app.cron import router as cron_router from app.crud import ( create_client, create_invoice, create_template, delete_client, delete_invoice, delete_template, get_clients, get_invoices, get_notification_logs, get_templates, update_client, update_invoice, update_template, ) from app.database import Base, engine, get_db from app.models import ( ClientCreate, ClientRead, ClientUpdate, ConfigEntry, InvoiceCreate, InvoiceRead, InvoiceUpdate, NotificationLogRead, TemplateCreate, TemplateRead, TemplateUpdate, ) logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)-8s %(name)s %(message)s", ) logger = logging.getLogger(__name__) # ───────────────────────────────────────────────────────────────────────────── # Config endpoint — sensitive-value masking # ───────────────────────────────────────────────────────────────────────────── _SENSITIVE_TOKENS = {"PASSWORD", "SECRET", "TOKEN", "KEY", "DATABASE_URL", "DSN", "PWD", "PASS"} def _is_sensitive(key: str) -> bool: ku = key.upper() return any(tok in ku for tok in _SENSITIVE_TOKENS) # ───────────────────────────────────────────────────────────────────────────── # Lifespan # ───────────────────────────────────────────────────────────────────────────── @asynccontextmanager async def lifespan(app: FastAPI): # DDL: create all tables if they don't exist yet Base.metadata.create_all(bind=engine) # Seed the default admin account on first boot db = next(get_db()) try: seed_admin(db) finally: db.close() logger.info("✅ PMDI backend ready.") yield logger.info("🛑 PMDI backend shutting down.") # ───────────────────────────────────────────────────────────────────────────── # Application # ───────────────────────────────────────────────────────────────────────────── app = FastAPI( title="PMDI – Pay My Damn Invoice", description="Internal invoice notification dashboard API.", version="1.0.0", docs_url="/api/docs", redoc_url="/api/redoc", openapi_url="/api/openapi.json", lifespan=lifespan, ) # ───────────────────────────────────────────────────────────────────────────── # CORS middleware — strict origin whitelist # ───────────────────────────────────────────────────────────────────────────── _origins = [o.strip() for o in settings.ALLOWED_ORIGINS.split(",") if o.strip()] app.add_middleware( CORSMiddleware, allow_origins=_origins, allow_credentials=True, allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_headers=["Authorization", "Content-Type"], expose_headers=["Content-Length"], ) # ───────────────────────────────────────────────────────────────────────────── # Unauthenticated routers # ───────────────────────────────────────────────────────────────────────────── app.include_router(auth_router) # /api/auth/* — handles its own auth internally app.include_router(cron_router) # /api/cron/* — guarded by CRON_SECRET_TOKEN # ───────────────────────────────────────────────────────────────────────────── # Protected API router (requires valid JWT for every sub-route) # ───────────────────────────────────────────────────────────────────────────── protected = APIRouter( prefix="/api", dependencies=[Depends(get_current_user)], ) # ── Health ──────────────────────────────────────────────────────────────────── @app.get("/api/health", tags=["system"]) def health(): return {"status": "ok", "service": "pmdi-backend", "version": "1.0.0"} # ── Config ──────────────────────────────────────────────────────────────────── @protected.get("/config", response_model=List[ConfigEntry], tags=["config"]) def get_config(): """ Return all container environment variables. Values whose key names contain PASSWORD / SECRET / TOKEN / KEY / DATABASE_URL are automatically replaced with '••••••••'. """ entries = [] for key, value in sorted(os.environ.items()): masked = _is_sensitive(key) entries.append(ConfigEntry( key=key, value="••••••••" if masked else value, masked=masked, )) return entries # ══════════════════════════════════════════════════════════════════════════════ # Clients # ══════════════════════════════════════════════════════════════════════════════ @protected.get("/clients", response_model=List[ClientRead], tags=["clients"]) def list_clients(db: Session = Depends(get_db)): return get_clients(db) @protected.post("/clients", response_model=ClientRead, status_code=201, tags=["clients"]) def add_client(data: ClientCreate, db: Session = Depends(get_db)): return create_client(db, data) @protected.put("/clients/{client_id}", response_model=ClientRead, tags=["clients"]) def edit_client(client_id: int, data: ClientUpdate, db: Session = Depends(get_db)): obj = update_client(db, client_id, data) if not obj: raise HTTPException(404, "Client not found.") return obj @protected.delete("/clients/{client_id}", status_code=204, tags=["clients"]) def remove_client(client_id: int, db: Session = Depends(get_db)): if not delete_client(db, client_id): raise HTTPException(404, "Client not found.") # ══════════════════════════════════════════════════════════════════════════════ # Email Templates # ══════════════════════════════════════════════════════════════════════════════ @protected.get("/templates", response_model=List[TemplateRead], tags=["templates"]) def list_templates(db: Session = Depends(get_db)): return get_templates(db) @protected.post("/templates", response_model=TemplateRead, status_code=201, tags=["templates"]) def add_template(data: TemplateCreate, db: Session = Depends(get_db)): return create_template(db, data) @protected.put("/templates/{template_id}", response_model=TemplateRead, tags=["templates"]) def edit_template(template_id: int, data: TemplateUpdate, db: Session = Depends(get_db)): obj = update_template(db, template_id, data) if not obj: raise HTTPException(404, "Template not found.") return obj @protected.delete("/templates/{template_id}", status_code=204, tags=["templates"]) def remove_template(template_id: int, db: Session = Depends(get_db)): if not delete_template(db, template_id): raise HTTPException(404, "Template not found.") # ══════════════════════════════════════════════════════════════════════════════ # Invoices # ══════════════════════════════════════════════════════════════════════════════ @protected.get("/invoices", response_model=List[InvoiceRead], tags=["invoices"]) def list_invoices(db: Session = Depends(get_db)): return get_invoices(db) @protected.post("/invoices", response_model=InvoiceRead, status_code=201, tags=["invoices"]) def add_invoice(data: InvoiceCreate, db: Session = Depends(get_db)): return create_invoice(db, data) @protected.put("/invoices/{invoice_id}", response_model=InvoiceRead, tags=["invoices"]) def edit_invoice(invoice_id: int, data: InvoiceUpdate, db: Session = Depends(get_db)): obj = update_invoice(db, invoice_id, data) if not obj: raise HTTPException(404, "Invoice not found.") return obj @protected.delete("/invoices/{invoice_id}", status_code=204, tags=["invoices"]) def remove_invoice(invoice_id: int, db: Session = Depends(get_db)): if not delete_invoice(db, invoice_id): raise HTTPException(404, "Invoice not found.") # ══════════════════════════════════════════════════════════════════════════════ # Notification Logs (read-only) # ══════════════════════════════════════════════════════════════════════════════ @protected.get("/notifications", response_model=List[NotificationLogRead], tags=["notifications"]) def list_notifications(db: Session = Depends(get_db)): return get_notification_logs(db) # Mount the protected router app.include_router(protected)