Spaces:
Running
Running
| """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 βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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, | |
| } | |
| 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, | |
| } | |