"""Per-user notification inbox (phase 1): the durable, server-backed center that coexists with the client-side transient bell. Read-only listing plus read/dismiss/clear mutations.""" from fastapi import APIRouter, Depends, Query from sqlalchemy import func, select, update from sqlalchemy.orm import Session from app.auth import current_user from app.db import get_db from app.models import Notification, User from app.notifications import trim_read from app.utils import iso from app.routes._common import owned_or_404 router = APIRouter(prefix="/api/me/notifications", tags=["notifications"]) def _serialize(n: Notification) -> dict: return { "id": n.id, "type": n.type, "title": n.title, "body": n.body, "data": n.data, "read": n.read, "dismissed": n.dismissed, "created_at": iso(n.created_at), } @router.get("") def list_notifications( include_dismissed: bool = False, limit: int = Query(default=100, le=500), offset: int = 0, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: where = [Notification.user_id == user.id] if not include_dismissed: where.append(Notification.dismissed.is_(False)) total = db.scalar(select(func.count()).select_from(Notification).where(*where)) or 0 rows = ( db.execute( select(Notification) .where(*where) .order_by(Notification.created_at.desc()) .offset(offset) .limit(limit) ) .scalars() .all() ) return {"items": [_serialize(n) for n in rows], "total": total} @router.get("/unread_count") def unread_count(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: """Cheap poll target for the nav badge (uses the (user_id, read) index).""" count = db.scalar( select(func.count()) .select_from(Notification) .where( Notification.user_id == user.id, Notification.read.is_(False), Notification.dismissed.is_(False), ) ) return {"count": count or 0} @router.post("/{notification_id}/read") def mark_read( notification_id: int, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: n = owned_or_404(db, Notification, notification_id, user, what="notification") n.read = True db.commit() trim_read(db, user.id) return {"ok": True} @router.post("/read_all") def mark_all_read(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: db.execute( update(Notification) .where(Notification.user_id == user.id, Notification.read.is_(False)) .values(read=True) ) db.commit() trim_read(db, user.id) return {"ok": True} @router.post("/{notification_id}/dismiss") def dismiss( notification_id: int, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: n = owned_or_404(db, Notification, notification_id, user, what="notification") # Dismiss implies read (it no longer counts toward the unread badge). n.dismissed = True n.read = True db.commit() return {"ok": True} @router.post("/clear") def clear_all(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: """Dismiss every (still-visible) notification for this user.""" db.execute( update(Notification) .where(Notification.user_id == user.id, Notification.dismissed.is_(False)) .values(dismissed=True, read=True) ) db.commit() return {"ok": True}