"""Discovery router - visit tracking and world browsing. docs/planning/discovery.md (tasks 42726e60fe10, 300da1dcddf3, 33706f401ad9). """ from datetime import datetime, timedelta, timezone from typing import Annotated from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status from pydantic import BaseModel from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.dependencies import OptionalUser from app.models.world import World, WorldVisibility from app.models.world_visit import WorldVisit router = APIRouter() DbDep = Annotated[AsyncSession, Depends(get_db)] class VisitRequest(BaseModel): anonymous_token: str | None = None idempotency_key: str | None = None class VisitResponse(BaseModel): play_count: int last_played_at: datetime | None = None model_config = {"from_attributes": True} DEDUP_WINDOW_SECONDS = 60 @router.post("/worlds/{world_id}/visit", response_model=VisitResponse) async def record_world_visit( world_id: int, request: Request, db: DbDep, current_user: OptionalUser = None, visit_data: VisitRequest | None = None, x_idempotency_key: Annotated[str | None, Header(alias="X-Idempotency-Key")] = None, idempotency_key: str | None = Query(None), ): """Record a visit to a world. - Increments worlds.play_count atomically (World.play_count + 1 in UPDATE). - Updates worlds.last_played_at to func.now(). - Records row in world_visits (with world_id, user_id nullable, visited_at). - Checks visibility: 404 if world not found or not PUBLIC and not owned by user. - Idempotent: repeated calls within deduplication window or with same token/key do not duplicate. """ # 1. Fetch world res = await db.execute(select(World).where(World.id == world_id)) world = res.scalar_one_or_none() if not world: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="World not found") # 2. Check visibility: public or owned by requesting user if world.visibility != WorldVisibility.PUBLIC: if not current_user or world.owner_id != current_user.id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="World not found") # Determine token / idempotency key explicit_key = ( (visit_data.idempotency_key if visit_data else None) or (visit_data.anonymous_token if visit_data else None) or x_idempotency_key or idempotency_key ) fallback_host = request.client.host if request.client else "unknown_client" dedup_key = explicit_key or (f"client_{fallback_host}" if not current_user else None) # 3. Deduplication check cutoff = datetime.now(timezone.utc) - timedelta(seconds=DEDUP_WINDOW_SECONDS) is_duplicate = False if current_user: dedup_res = await db.execute( select(WorldVisit).where( WorldVisit.world_id == world_id, WorldVisit.user_id == current_user.id, WorldVisit.visited_at >= cutoff, ).limit(1) ) if dedup_res.scalar_one_or_none(): is_duplicate = True elif dedup_key: dedup_res = await db.execute( select(WorldVisit).where( WorldVisit.world_id == world_id, WorldVisit.anonymous_token == dedup_key, WorldVisit.visited_at >= cutoff, ).limit(1) ) if dedup_res.scalar_one_or_none(): is_duplicate = True if is_duplicate: return VisitResponse( play_count=world.play_count, last_played_at=world.last_played_at, ) # 4. Atomic increment and visit log in single transaction await db.execute( update(World) .where(World.id == world_id) .values( play_count=World.play_count + 1, last_played_at=func.now(), ) ) visit = WorldVisit( world_id=world_id, user_id=current_user.id if current_user else None, anonymous_token=dedup_key if not current_user else None, ) db.add(visit) await db.commit() # Re-fetch or refresh to return updated fields await db.refresh(world) return VisitResponse( play_count=world.play_count, last_played_at=world.last_played_at, )