"""Author RAG — Public Chat API (Vanity URL Support). Serves data needed by the Next.js vanity chat pages: chat.yourdomain.com/jane-smith → full-page chatbot for author "jane-smith" Routes (no auth — fully public): GET /api/public/{author_slug}/profile → name, avatar, bio for OG tags GET /api/public/{author_slug}/widget-config → bot name, colors, welcome message GET /api/public/{author_slug}/books → public book list for selector UI """ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import get_db from app.models.user import User from app.models.book import Book from app.models.client_access import ClientAccess from datetime import datetime, timezone router = APIRouter(prefix="/public", tags=["Public Chat"]) async def _get_active_author(slug: str, db: AsyncSession) -> User: """Fetch author by slug (ID), verify they have active access.""" user = await db.get(User, slug) if not user or not user.is_active or user.role != "author": raise HTTPException(404, "Author not found") # Check active subscription result = await db.execute( select(ClientAccess).where( ClientAccess.author_id == slug, ClientAccess.is_revoked == False, # noqa: E712 ClientAccess.expires_at > datetime.now(timezone.utc), ) ) access = result.scalar_one_or_none() if not access: raise HTTPException(404, "Author not found") # Don't reveal subscription status return user @router.get("/{author_slug}/profile") async def get_public_profile( author_slug: str, db: AsyncSession = Depends(get_db), ): """Public author profile — used for Open Graph meta tags on vanity chat pages. Powers the rich link previews when authors share their chat links on social media: Title: "Chat with Jane Smith about her books" Description: Author's bio Image: Author's avatar """ user = await _get_active_author(author_slug, db) # Count published books for the description books_result = await db.execute( select(Book).where(Book.author_id == author_slug, Book.is_active == True) # noqa: E712 ) books = books_result.scalars().all() return { "slug": author_slug, "full_name": user.full_name or "Author", "bio": user.bio if user.bio and not user.bio.startswith("__meta__") else None, "avatar_url": user.bot_avatar_path, "website_url": user.website_url, "book_count": len(books), "og_title": f"Chat with {user.full_name or 'this author'} about their books", "og_description": ( f"{user.full_name or 'An author'} uses AI to answer your questions about " f"their {len(books)} book{'s' if len(books) != 1 else ''}. Ask anything!" ), } @router.get("/{author_slug}/widget-config") async def get_widget_config( author_slug: str, db: AsyncSession = Depends(get_db), ): """Return widget configuration for the vanity chat page. The Next.js page uses this to initialize the chatbot with the correct bot name, colors, welcome message, and styling. """ user = await _get_active_author(author_slug, db) return { "slug": author_slug, "bot_name": user.bot_name, "welcome_message": user.welcome_message, "widget_theme": user.widget_theme, "widget_position": user.widget_position, "chatbot_is_active": user.chatbot_is_active, "remove_branding": user.remove_branding, "custom_widget_css": user.custom_widget_css, "response_style": user.response_style, } @router.get("/{author_slug}/books") async def get_public_books( author_slug: str, db: AsyncSession = Depends(get_db), ): """Return public book list for the chat page's book selector UI.""" await _get_active_author(author_slug, db) result = await db.execute( select(Book).where( Book.author_id == author_slug, Book.is_active == True, # noqa: E712 ).order_by(Book.created_at.desc()) ) books = result.scalars().all() return { "books": [ { "id": b.id, "title": b.title, "cover_image_path": b.cover_image_path if hasattr(b, "cover_image_path") else None, "buy_link": b.buy_link if hasattr(b, "buy_link") else None, "genre": b.genre if hasattr(b, "genre") else None, } for b in books ] }