"""Author RAG — Public API (Author-Facing REST API). Allows Enterprise authors to query their chatbot data programmatically. Used for: custom dashboards, CRM syncs, Zapier integrations. All endpoints require an API key (not a JWT) — keys are long-lived and scoped per author. Routes: GET /api/v1/conversations → list sessions (API key auth) GET /api/v1/analytics → summary stats (API key auth) API Key model is stored as a special ClientAccess grant with role="api_key". """ import structlog from datetime import datetime, timezone from fastapi import APIRouter, Header, HTTPException, Depends, Query from sqlalchemy import select, desc, func from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import get_db from app.models.chat_session import ChatSession, ChatMessage from app.models.analytics import AnalyticsEvent logger = structlog.get_logger(__name__) router = APIRouter(prefix="/v1", tags=["Public API v1"]) # ── API Key Auth Dependency ─────────────────────────────────────────────────── async def get_api_key_author( x_api_key: str = Header(..., alias="X-API-Key"), db: AsyncSession = Depends(get_db), ): """Validate X-API-Key header and return the author user.""" if not x_api_key or len(x_api_key) < 20: raise HTTPException(401, "Invalid API key") # API keys are stored as SHA256 hashes in a dedicated table # For Phase 2: resolve via ClientAccess.notes field lookup from app.models.client_access import ClientAccess import hashlib key_hash = hashlib.sha256(x_api_key.encode()).hexdigest() result = await db.execute( select(ClientAccess).where( ClientAccess.notes == f"api_key:{key_hash}", ClientAccess.is_revoked == False, # noqa: E712 ) ) access = result.scalar_one_or_none() if not access: raise HTTPException(401, "Invalid or revoked API key") from app.models.user import User user = await db.get(User, access.author_id) if not user or not user.is_active: raise HTTPException(401, "Author account not found") # Check API access feature flag on plan from app.models.pricing_plan import PricingPlan plan = await db.get(PricingPlan, user.stripe_plan_id or "") if not plan or not plan.has_api_access: raise HTTPException(403, "API access requires Enterprise plan") return user # ── Endpoints ───────────────────────────────────────────────────────────────── @router.get("/conversations") async def api_list_conversations( limit: int = Query(50, ge=1, le=500), offset: int = Query(0, ge=0), author=Depends(get_api_key_author), db: AsyncSession = Depends(get_db), ): """List recent conversations. Max 500 per request.""" result = await db.execute( select(ChatSession) .where(ChatSession.author_id == author.id) .order_by(desc(ChatSession.created_at)) .offset(offset) .limit(limit) ) sessions = result.scalars().all() return { "data": [ { "session_id": s.session_id, "started_at": s.created_at.isoformat() if s.created_at else None, "book_id": getattr(s, "book_id", None), } for s in sessions ], "limit": limit, "offset": offset, } @router.get("/analytics") async def api_analytics( author=Depends(get_api_key_author), db: AsyncSession = Depends(get_db), ): """Return 30-day analytics summary.""" from datetime import timedelta since = datetime.now(timezone.utc) - timedelta(days=30) sessions_r = await db.execute( select(func.count(ChatSession.session_id)) .where(ChatSession.author_id == author.id, ChatSession.created_at >= since) ) messages_r = await db.execute( select(func.count(ChatMessage.id)) .join(ChatSession, ChatMessage.session_id == ChatSession.session_id) .where(ChatSession.author_id == author.id, ChatSession.created_at >= since) ) return { "period": "last_30_days", "total_sessions": sessions_r.scalar() or 0, "total_messages": messages_r.scalar() or 0, }