"""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. All endpoints use `current_user` (not `require_human`): notifications and their read/dismiss state are personal UI state with no quota or YouTube involvement, so the shared demo account manages its own inbox normally.""" from fastapi import APIRouter, Depends, HTTPException, 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 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": n.created_at.isoformat() if n.created_at else None, } @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} def _owned(db: Session, notification_id: int, user: User) -> Notification: n = db.get(Notification, notification_id) if n is None or n.user_id != user.id: raise HTTPException(status_code=404, detail="Notification not found") return n @router.post("/{notification_id}/read") def mark_read( notification_id: int, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: n = _owned(db, notification_id, user) 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(db, notification_id, user) # 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}