"""Presence & status router. Endpoints: - POST /api/presence/heartbeat – record a heartbeat (sets Redis TTL key) - GET /api/presence/presence – query presence for a list of user ids - PATCH /api/presence/status – update the authenticated user's status message """ from datetime import datetime, timezone from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field, field_validator from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database import get_db from app.dependencies import CurrentUser from app.models.user import User from app.services.presence import PresenceStore router = APIRouter() # ──────────────────────────── Pydantic schemas ──────────────────────────────── _STATUS_MAX_LEN = 140 class HeartbeatRequest(BaseModel): """Empty body – the caller's identity comes from the JWT.""" class PresenceUser(BaseModel): user_id: int is_online: bool last_seen_at: datetime | None status_message: str | None class PresenceResponse(BaseModel): users: list[PresenceUser] class StatusUpdate(BaseModel): status_message: str | Field(default=None, max_length=_STATUS_MAX_LEN) @field_validator("status_message") @classmethod def strip_message(cls, v: str | None) -> str | None: if v is not None: v = v.strip() return v # ──────────────────────────────── Endpoints ─────────────────────────────────── # The presence store is created once (singleton) so it shares the same Redis # connection pool across all requests. _presence_store = PresenceStore() @router.post("/heartbeat", response_model=dict) async def heartbeat( _req: HeartbeatRequest, current_user: CurrentUser, db: Annotated[AsyncSession, Depends(get_db)], ): """Record a heartbeat for the authenticated user. Sets a Redis key with a TTL so the server can derive online/offline status without trusting client claims. Also persists last_seen_at to the database. """ # Update Redis TTL (presence heartbeat) await _presence_store.heartbeat(current_user.id) # Persist last_seen_at to the database now = datetime.now(timezone.utc) result = await db.execute( select(User).where(User.id == current_user.id) ) user = result.scalar_one_or_none() if user: user.last_seen_at = now await db.commit() return {"status": "ok", "user_id": current_user.id} @router.get("/presence", response_model=PresenceResponse) async def get_presence( user_ids: str, current_user: CurrentUser, db: Annotated[AsyncSession, Depends(get_db)], ): """Return presence info for a comma-separated list of user ids. Online/offline is derived from the Redis heartbeat TTL. last_seen_at and status_message come from the database. """ ids = [int(uid.strip()) for uid in user_ids.split(",") if uid.strip()] if not ids: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="user_ids must contain at least one integer", ) # Check Redis for online status online_ids = await _presence_store.is_online(ids) # Fetch DB records for last_seen_at and status_message result = await db.execute( select(User).where(User.id.in_(ids)) ) users = {u.id: u for u in result.scalars().all()} presence_users = [] for uid in ids: user = users.get(uid) if user is None: continue presence_users.append( PresenceUser( user_id=uid, is_online=uid in online_ids, last_seen_at=user.last_seen_at, status_message=user.status_message, ) ) return PresenceResponse(users=presence_users) @router.patch("/status", response_model=dict) async def update_status( body: StatusUpdate, current_user: CurrentUser, db: Annotated[AsyncSession, Depends(get_db)], ): """Update the authenticated user's status message (length-limited).""" result = await db.execute( select(User).where(User.id == current_user.id) ) user = result.scalar_one_or_none() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found", ) user.status_message = body.status_message await db.commit() return {"status": "ok", "status_message": body.status_message}