"""Notification routes — list, mark-as-read, device registration, preferences. All endpoints require JWT authentication (Bearer token). Design decisions consistent with the existing iot.py and auth.py patterns: • Dependencies use the shared ``get_current_user`` from app.auth.jwt. • The dependency returns a JWT payload dict (``{"sub": user_id, "role": ...}``). • UUID path/query parameters are validated inline. • Responses follow the same plain-dict style as iot.py. """ from __future__ import annotations from datetime import datetime, timezone from typing import Optional from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.auth.jwt import get_current_user from app.database import get_session from app.models.models import Notification, NotificationDevice, NotificationPreference # --------------------------------------------------------------------------- # Router # --------------------------------------------------------------------------- router = APIRouter(prefix="/notifications", tags=["Notifications"]) # --------------------------------------------------------------------------- # Request / Response models (inline — no Pydantic schema file to keep it simple) # --------------------------------------------------------------------------- class RegisterDeviceRequest(BaseModel): platform: str # "android" | "ios" | "web" push_token: str class UpdatePreferencesRequest(BaseModel): push_enabled: Optional[bool] = None email_enabled: Optional[bool] = None alert_notifications: Optional[bool] = None maintenance_notifications: Optional[bool] = None event_notifications: Optional[bool] = None general_notifications: Optional[bool] = None class NotificationResponse(BaseModel): id: str user_id: str title: str body: str category: str is_read: bool data: Optional[str] created_at: str read_at: Optional[str] class NotificationPreferenceResponse(BaseModel): push_enabled: bool email_enabled: bool alert_notifications: bool maintenance_notifications: bool event_notifications: bool general_notifications: bool updated_at: str # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _user_id_from_payload(payload: dict) -> UUID: """Extract and validate the user UUID from a JWT payload dict.""" raw = payload.get("sub") if raw is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload: missing 'sub'", ) try: return UUID(raw) except (ValueError, AttributeError): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Invalid user id in token: '{raw}'", ) def _notification_to_dict(n: Notification) -> dict: return { "id": str(n.id), "user_id": str(n.user_id), "title": n.title, "body": n.body, "category": n.category, "is_read": n.is_read, "data": n.data, "created_at": n.created_at.isoformat() if n.created_at else None, "read_at": n.read_at.isoformat() if n.read_at else None, } def _device_to_dict(d: NotificationDevice) -> dict: return { "id": str(d.id), "platform": d.platform, "is_active": d.is_active, "created_at": d.created_at.isoformat() if d.created_at else None, } # =========================================================================== # GET /notifications — paginated list for the authenticated user # =========================================================================== @router.get( "", summary="List user notifications (paginated)", description="Return paginated notifications for the authenticated user, newest first.", ) async def list_notifications( page: int = Query(1, ge=1, description="Page number (1-indexed)"), page_size: int = Query(20, ge=1, le=100, description="Items per page"), is_read: Optional[bool] = Query(None, description="Filter by read status (true/false)"), category: Optional[str] = Query( None, description="Filter by category (general, alert, maintenance, event)", ), session: AsyncSession = Depends(get_session), current_user: dict = Depends(get_current_user), ) -> dict: uid = _user_id_from_payload(current_user) base_stmt = select(Notification).where(Notification.user_id == uid) total_stmt = select(func.count(Notification.id)).where(Notification.user_id == uid) # Optional filters if is_read is not None: base_stmt = base_stmt.where(Notification.is_read == is_read) total_stmt = total_stmt.where(Notification.is_read == is_read) if category is not None: base_stmt = base_stmt.where(Notification.category == category) total_stmt = total_stmt.where(Notification.category == category) # Count total_result = await session.execute(total_stmt) total: int = total_result.scalar_one() # Fetch page offset = (page - 1) * page_size stmt = ( base_stmt .order_by(Notification.created_at.desc()) .offset(offset) .limit(page_size) ) result = await session.execute(stmt) notifications = result.scalars().all() pages = max(1, -(-total // page_size)) # ceiling division return { "items": [_notification_to_dict(n) for n in notifications], "total": total, "page": page, "page_size": page_size, "pages": pages, } # =========================================================================== # PUT /notifications/{notification_id}/read — mark as read # =========================================================================== @router.put( "/{notification_id}/read", summary="Mark a notification as read", description="Mark a single notification as read for the authenticated user.", ) async def mark_notification_read( notification_id: str, session: AsyncSession = Depends(get_session), current_user: dict = Depends(get_current_user), ) -> dict: uid = _user_id_from_payload(current_user) # Validate path param try: nid = UUID(notification_id) except (ValueError, AttributeError): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"notification_id must be a valid UUID, got '{notification_id}'", ) # Fetch notification belonging to the user stmt = select(Notification).where( Notification.id == nid, Notification.user_id == uid, ) result = await session.execute(stmt) notification = result.scalar_one_or_none() if notification is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Notification {notification_id} not found", ) if not notification.is_read: notification.is_read = True notification.read_at = datetime.now(timezone.utc) session.add(notification) await session.commit() await session.refresh(notification) return _notification_to_dict(notification) # =========================================================================== # POST /notifications/register-device — register an FCM/APNs device token # =========================================================================== @router.post( "/register-device", summary="Register a push device token (FCM / APNs)", description=( "Register or update a device token for push notifications. " "If the push_token already exists, its platform and user association are updated." ), status_code=status.HTTP_201_CREATED, ) async def register_device( payload: RegisterDeviceRequest, session: AsyncSession = Depends(get_session), current_user: dict = Depends(get_current_user), ) -> dict: uid = _user_id_from_payload(current_user) platform = payload.platform.strip().lower() if platform not in ("android", "ios", "web"): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="platform must be one of: android, ios, web", ) # Upsert logic: if the push_token already exists, update the user + platform existing = await session.execute( select(NotificationDevice).where( NotificationDevice.push_token == payload.push_token ) ) device = existing.scalar_one_or_none() if device is not None: # Re-associate and ensure active device.user_id = uid device.platform = platform device.is_active = True session.add(device) await session.commit() await session.refresh(device) else: device = NotificationDevice( user_id=uid, platform=platform, push_token=payload.push_token, is_active=True, ) session.add(device) await session.commit() await session.refresh(device) return _device_to_dict(device) # =========================================================================== # GET /notifications/preferences — fetch user preferences # =========================================================================== @router.get( "/preferences", summary="Get notification preferences", description="Return the authenticated user's notification preferences.", ) async def get_preferences( session: AsyncSession = Depends(get_session), current_user: dict = Depends(get_current_user), ) -> dict: uid = _user_id_from_payload(current_user) stmt = select(NotificationPreference).where( NotificationPreference.user_id == uid ) result = await session.execute(stmt) pref = result.scalar_one_or_none() if pref is None: # Auto-create default preferences pref = NotificationPreference(user_id=uid) session.add(pref) await session.commit() await session.refresh(pref) return { "push_enabled": pref.push_enabled, "email_enabled": pref.email_enabled, "alert_notifications": pref.alert_notifications, "maintenance_notifications": pref.maintenance_notifications, "event_notifications": pref.event_notifications, "general_notifications": pref.general_notifications, "updated_at": pref.updated_at.isoformat() if pref.updated_at else None, } # =========================================================================== # PUT /notifications/preferences — update user preferences # =========================================================================== @router.put( "/preferences", summary="Update notification preferences", description="Partially update the authenticated user's notification preferences.", ) async def update_preferences( payload: UpdatePreferencesRequest, session: AsyncSession = Depends(get_session), current_user: dict = Depends(get_current_user), ) -> dict: uid = _user_id_from_payload(current_user) # Fetch or create stmt = select(NotificationPreference).where( NotificationPreference.user_id == uid ) result = await session.execute(stmt) pref = result.scalar_one_or_none() if pref is None: # Create defaults first, then patch pref = NotificationPreference(user_id=uid) session.add(pref) await session.commit() await session.refresh(pref) # Apply only provided fields (PATCH-like semantics) update_data = payload.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(pref, field, value) pref.updated_at = datetime.now(timezone.utc) session.add(pref) await session.commit() await session.refresh(pref) return { "push_enabled": pref.push_enabled, "email_enabled": pref.email_enabled, "alert_notifications": pref.alert_notifications, "maintenance_notifications": pref.maintenance_notifications, "event_notifications": pref.event_notifications, "general_notifications": pref.general_notifications, "updated_at": pref.updated_at.isoformat() if pref.updated_at else None, }