"""Notification center API — list, count, mark-read.""" import uuid from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, update, or_, and_ from app.database import get_db from app.models.audit_log import AuditLog from app.models.user import User from app.utils.auth import get_current_user router = APIRouter(prefix="/notifications", tags=["notifications"]) class NotificationOut(BaseModel): id: str action: str entity_type: str | None = None entity_id: str | None = None details: dict | None = None timestamp: datetime read_at: datetime | None = None model_config = {"from_attributes": True} class NotificationListResponse(BaseModel): items: list[NotificationOut] unread_count: int total: int class UnreadCountResponse(BaseModel): unread_count: int class MarkReadRequest(BaseModel): notification_ids: list[str] | None = None def _visibility_filter(user: User): """Rows visible to this user: targeted at them, or broadcast (null) if admin/PM.""" targeted = AuditLog.target_user_id == user.id if user.role.value in ("admin", "project_manager"): broadcast = AuditLog.target_user_id.is_(None) return and_(AuditLog.notification == True, or_(targeted, broadcast)) # noqa: E712 return and_(AuditLog.notification == True, targeted) # noqa: E712 # Default channels shown in bell dropdown _BELL_CHANNELS = ("notification", "alert") @router.get("", response_model=NotificationListResponse) async def list_notifications( limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0), unread_only: bool = Query(False), channel: Optional[str] = Query(None, description="Filter by channel: notification, activity, alert. Defaults to notification+alert."), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): vis = _visibility_filter(user) # Channel filter if channel: channel_filter = AuditLog.channel == channel else: channel_filter = AuditLog.channel.in_(_BELL_CHANNELS) # Total count total_q = select(func.count(AuditLog.id)).where(vis, channel_filter) if unread_only: total_q = total_q.where(AuditLog.read_at.is_(None)) total = (await db.execute(total_q)).scalar() or 0 # Unread count (always — only for bell channels, not activity) bell_channel_filter = AuditLog.channel.in_(_BELL_CHANNELS) unread_q = select(func.count(AuditLog.id)).where(vis, bell_channel_filter, AuditLog.read_at.is_(None)) unread_count = (await db.execute(unread_q)).scalar() or 0 # Items items_q = ( select(AuditLog) .where(vis, channel_filter) .order_by(AuditLog.timestamp.desc()) .offset(offset) .limit(limit) ) if unread_only: items_q = items_q.where(AuditLog.read_at.is_(None)) rows = (await db.execute(items_q)).scalars().all() items = [ NotificationOut( id=str(r.id), action=r.action, entity_type=r.entity_type, entity_id=r.entity_id, details=r.details, timestamp=r.timestamp, read_at=r.read_at, ) for r in rows ] return NotificationListResponse(items=items, unread_count=unread_count, total=total) @router.get("/unread-count", response_model=UnreadCountResponse) async def unread_count( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): vis = _visibility_filter(user) bell_channel_filter = AuditLog.channel.in_(_BELL_CHANNELS) q = select(func.count(AuditLog.id)).where(vis, bell_channel_filter, AuditLog.read_at.is_(None)) count = (await db.execute(q)).scalar() or 0 return UnreadCountResponse(unread_count=count) @router.post("/mark-read") async def mark_read( body: MarkReadRequest, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Mark notifications as read. If notification_ids is null, mark all as read.""" vis = _visibility_filter(user) now = datetime.utcnow() if body.notification_ids is None: # Mark all unread stmt = ( update(AuditLog) .where(vis, AuditLog.read_at.is_(None)) .values(read_at=now) ) else: ids = [uuid.UUID(nid) for nid in body.notification_ids] stmt = ( update(AuditLog) .where(vis, AuditLog.id.in_(ids), AuditLog.read_at.is_(None)) .values(read_at=now) ) await db.execute(stmt) await db.commit() return {"ok": True} @router.post("/{notification_id}/mark-read") async def mark_one_read( notification_id: uuid.UUID, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): vis = _visibility_filter(user) now = datetime.utcnow() result = await db.execute( update(AuditLog) .where(vis, AuditLog.id == notification_id, AuditLog.read_at.is_(None)) .values(read_at=now) ) await db.commit() return {"ok": True} # ── Notification Config Endpoints ──────────────────────────────────────────── from app.domains.notifications.schemas import NotificationConfigOut, NotificationConfigUpdate from app.domains.notifications.service import ( get_notification_configs, upsert_notification_config, reset_notification_configs ) @router.get("/config", response_model=list[NotificationConfigOut]) async def get_my_notification_config( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): return await get_notification_configs(db, current_user.id) @router.put("/config/{event_type}/{channel}", response_model=NotificationConfigOut) async def update_my_notification_config( event_type: str, channel: str, body: NotificationConfigUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): if channel not in ("in_app", "email"): raise HTTPException(status_code=400, detail="channel must be 'in_app' or 'email'") if body.frequency is not None and body.frequency not in ("immediate", "daily", "never"): raise HTTPException(status_code=400, detail="frequency must be 'immediate', 'daily', or 'never'") return await upsert_notification_config(db, current_user.id, event_type, channel, body.enabled, body.frequency) @router.post("/config/reset", response_model=list[NotificationConfigOut]) async def reset_my_notification_config( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): return await reset_notification_configs(db, current_user.id)