156 lines
4.7 KiB
Python
156 lines
4.7 KiB
Python
"""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}
|