"""Public Codex endpoints — the browsable registry of every spirit ever contacted, shared across all users (spec §4).""" import uuid from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import desc, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.db import get_db from app.models.contact_session import ContactSession from app.models.entity import Entity from app.models.entity_sighting import EntitySighting from app.models.event import Event from app.models.user import User router = APIRouter(prefix="/api", tags=["codex"]) # How many distinct hunters the dossier names. Everyone else is still # counted in `total_encounters` — the roster is a window, not the truth. ROSTER_LIMIT = 20 def _user_column(name: str): """The profile columns land in `users` via a parallel workstream (the hunter-profiles spec §"Data model"). Until that migration exists this module must not reference them in SQL at all, or every Codex request 500s on an unknown column. Resolve them by name and fall back to a literal default, so the same code path works before and after. """ return getattr(User, name, None) def _hunter_entry( username: str, display_name: str | None, avatar_form: str | None, avatar_hue: int | None, profile_public: bool | None, times: int, last_seen, ) -> dict: # A hidden profile is still a hunter who was there: counted, named # nowhere. `public` is what the UI keys the link off. public = True if profile_public is None else bool(profile_public) return { "username": username, "display_name": display_name or username, "avatar_form": avatar_form, "avatar_hue": avatar_hue, "public": public, "times_contacted": times, "last_seen": last_seen.isoformat() if last_seen else None, } async def _encounters(db: AsyncSession, entity_id: uuid.UUID) -> tuple[list[dict], int]: """The roster of hunters who have contacted this spirit, newest-first. ONE aggregate query — group the sightings by hunter and carry the count and latest contact out of the same scan. Never a lookup per hunter. """ display_name = _user_column("display_name") avatar_form = _user_column("avatar_form") avatar_hue = _user_column("avatar_hue") profile_public = _user_column("profile_public") last_seen = func.max(EntitySighting.seen_at).label("last_seen") times = func.count(EntitySighting.id).label("times") columns = [User.username, times, last_seen] optional = [display_name, avatar_form, avatar_hue, profile_public] columns.extend(col for col in optional if col is not None) rows = ( await db.execute( select(*columns) .join(User, User.id == EntitySighting.user_id) .where(EntitySighting.entity_id == entity_id) .group_by(User.id) .order_by(desc(last_seen)) .limit(ROSTER_LIMIT) ) ).all() roster = [ _hunter_entry( row.username, getattr(row, "display_name", None) if display_name is not None else None, getattr(row, "avatar_form", None) if avatar_form is not None else None, getattr(row, "avatar_hue", None) if avatar_hue is not None else None, getattr(row, "profile_public", None) if profile_public is not None else None, row.times, row.last_seen, ) for row in rows ] # Distinct hunters, not sighting rows (`sightings` already reports those) # — so the UI can honestly say "and N more" past the roster window. total = ( await db.scalar( select(func.count(func.distinct(EntitySighting.user_id))).where( EntitySighting.entity_id == entity_id ) ) or 0 ) return roster, total def _entity_card(entity: Entity, discoverer: str | None) -> dict: return { "id": str(entity.id), "name": entity.name, "epithet": entity.epithet, "rarity": entity.rarity_tier, "visual": entity.visual_profile, "quotes": entity.sample_quotes, "contact_count": entity.contact_count, "discovered_at": entity.discovered_at.isoformat(), "discovered_by": discoverer, } @router.get("/codex") async def list_codex( rarity: str | None = None, sort: str = "recent", limit: int = 60, db: AsyncSession = Depends(get_db), ): query = select(Entity) if rarity: query = query.where(Entity.rarity_tier == rarity) if sort == "contacted": query = query.order_by(desc(Entity.contact_count)) else: query = query.order_by(desc(Entity.discovered_at)) query = query.limit(min(limit, 200)) entities = (await db.execute(query)).scalars().all() discoverers = { user.id: user.username for user in ( await db.execute( select(User).where( User.id.in_({e.discovered_by for e in entities if e.discovered_by}) ) ) ).scalars() } return { "entities": [ _entity_card(e, discoverers.get(e.discovered_by)) for e in entities ] } @router.get("/codex/{entity_id}") async def get_entity(entity_id: uuid.UUID, db: AsyncSession = Depends(get_db)): entity = await db.get(Entity, entity_id) if entity is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "no such spirit in the codex") discoverer = None discoverer_entry = None if entity.discovered_by: user = await db.get(User, entity.discovered_by) if user is not None: discoverer = user.username discoverer_entry = _hunter_entry( user.username, getattr(user, "display_name", None), getattr(user, "avatar_form", None), getattr(user, "avatar_hue", None), getattr(user, "profile_public", None), 0, None, ) sightings = await db.scalar( select(func.count(EntitySighting.id)).where(EntitySighting.entity_id == entity.id) ) roster, total_encounters = await _encounters(db, entity.id) card = _entity_card(entity, discoverer) card["persona"] = entity.persona card["voice"] = entity.voice_profile card["sightings"] = sightings or 0 # The summoner, with enough to render a glyph and decide on a link. # `times_contacted`/`last_seen` come from their roster row when they # have one — discovery alone doesn't imply a surviving sighting row. if discoverer_entry is not None: for hunter in roster: if hunter["username"] == discoverer_entry["username"]: discoverer_entry["times_contacted"] = hunter["times_contacted"] discoverer_entry["last_seen"] = hunter["last_seen"] break card["discoverer"] = discoverer_entry card["discoverer_public"] = ( discoverer_entry["public"] if discoverer_entry else False ) card["encounters"] = roster card["total_encounters"] = total_encounters return card @router.get("/stats") async def veil_stats(db: AsyncSession = Depends(get_db)): """Live counters for the landing page's 'veil activity' ticker.""" return { "entities": await db.scalar(select(func.count(Entity.id))) or 0, "sessions": await db.scalar(select(func.count(ContactSession.id))) or 0, "utterances": await db.scalar( select(func.count(Event.id)).where(Event.kind == "utterance") ) or 0, "anomalies": await db.scalar( select(func.count(Event.id)).where(Event.kind == "anomaly") ) or 0, }