diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index 4b6df350a..84ce86451 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -25,6 +25,7 @@ from .luma_add_connector_route import router as luma_add_connector_router from .new_chat_routes import router as new_chat_router from .new_llm_config_routes import router as new_llm_config_router from .notes_routes import router as notes_router +from .notifications_routes import router as notifications_router from .notion_add_connector_route import router as notion_add_connector_router from .podcasts_routes import router as podcasts_router from .rbac_routes import router as rbac_router @@ -61,3 +62,4 @@ router.include_router(new_llm_config_router) # LLM configs with prompt configur router.include_router(logs_router) router.include_router(circleback_webhook_router) # Circleback meeting webhooks router.include_router(surfsense_docs_router) # Surfsense documentation for citations +router.include_router(notifications_router) # Notifications with Electric SQL sync diff --git a/surfsense_backend/app/routes/notifications_routes.py b/surfsense_backend/app/routes/notifications_routes.py new file mode 100644 index 000000000..deee748d8 --- /dev/null +++ b/surfsense_backend/app/routes/notifications_routes.py @@ -0,0 +1,102 @@ +""" +Notifications API routes. +These endpoints allow marking notifications as read. +Electric SQL automatically syncs the changes to all connected clients. +""" + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import Notification, User, get_async_session +from app.users import current_active_user + +router = APIRouter(prefix="/notifications", tags=["notifications"]) + + +class MarkReadResponse(BaseModel): + """Response for mark as read operations.""" + + success: bool + message: str + + +class MarkAllReadResponse(BaseModel): + """Response for mark all as read operation.""" + + success: bool + message: str + updated_count: int + + +@router.patch("/{notification_id}/read", response_model=MarkReadResponse) +async def mark_notification_as_read( + notification_id: int, + user: User = Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), +) -> MarkReadResponse: + """ + Mark a single notification as read. + + Electric SQL will automatically sync this change to all connected clients. + """ + # Verify the notification belongs to the user + result = await session.execute( + select(Notification).where( + Notification.id == notification_id, + Notification.user_id == user.id, + ) + ) + notification = result.scalar_one_or_none() + + if not notification: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Notification not found", + ) + + if notification.read: + return MarkReadResponse( + success=True, + message="Notification already marked as read", + ) + + # Update the notification + notification.read = True + await session.commit() + + return MarkReadResponse( + success=True, + message="Notification marked as read", + ) + + +@router.patch("/read-all", response_model=MarkAllReadResponse) +async def mark_all_notifications_as_read( + user: User = Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), +) -> MarkAllReadResponse: + """ + Mark all notifications as read for the current user. + + Electric SQL will automatically sync these changes to all connected clients. + """ + # Update all unread notifications for the user + result = await session.execute( + update(Notification) + .where( + Notification.user_id == user.id, + Notification.read == False, # noqa: E712 + ) + .values(read=True) + ) + await session.commit() + + updated_count = result.rowcount + + return MarkAllReadResponse( + success=True, + message=f"Marked {updated_count} notification(s) as read", + updated_count=updated_count, + ) diff --git a/surfsense_web/hooks/use-notifications.ts b/surfsense_web/hooks/use-notifications.ts index 7e95b32ef..7906fc500 100644 --- a/surfsense_web/hooks/use-notifications.ts +++ b/surfsense_web/hooks/use-notifications.ts @@ -1,13 +1,9 @@ "use client"; import { useEffect, useState, useCallback, useRef } from "react"; -import { - initElectric, - isElectricInitialized, - type ElectricClient, - type SyncHandle, -} from "@/lib/electric/client"; +import { initElectric, type ElectricClient, type SyncHandle } from "@/lib/electric/client"; import type { Notification } from "@/contracts/types/notification.types"; +import { authenticatedFetch } from "@/lib/auth-utils"; export type { Notification } from "@/contracts/types/notification.types"; @@ -215,56 +211,53 @@ export function useNotifications(userId: string | null) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [userId]); - // Mark notification as read (local only - needs backend sync) - const markAsRead = useCallback( - async (notificationId: number) => { - if (!electric || !isElectricInitialized()) { - console.warn("Electric SQL not initialized"); - return false; + // Mark notification as read via backend API + // Electric SQL will automatically sync the change to all clients + const markAsRead = useCallback(async (notificationId: number) => { + try { + // Call backend API - Electric SQL will sync the change automatically + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${notificationId}/read`, + { method: "PATCH" } + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: "Failed to mark as read" })); + throw new Error(error.detail || "Failed to mark notification as read"); } - try { - // Update locally in PGlite - await electric.db.query( - `UPDATE notifications SET read = true, updated_at = NOW() WHERE id = $1`, - [notificationId] - ); - - // Update local state - setNotifications((prev) => - prev.map((n) => (n.id === notificationId ? { ...n, read: true } : n)) - ); - - // TODO: Also send to backend to persist the change - // This could be done via a REST API call - - return true; - } catch (err) { - console.error("Failed to mark notification as read:", err); - return false; - } - }, - [electric] - ); - - // Mark all notifications as read - const markAllAsRead = useCallback(async () => { - if (!electric || !isElectricInitialized()) { - console.warn("Electric SQL not initialized"); + // Electric SQL will sync the change from PostgreSQL to PGlite automatically + // The live query subscription will update the UI + return true; + } catch (err) { + console.error("Failed to mark notification as read:", err); return false; } + }, []); + // Mark all notifications as read via backend API + // Electric SQL will automatically sync the changes to all clients + const markAllAsRead = useCallback(async () => { try { - const unread = notifications.filter((n) => !n.read); - for (const notification of unread) { - await markAsRead(notification.id); + // Call backend API - Electric SQL will sync all changes automatically + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/read-all`, + { method: "PATCH" } + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: "Failed to mark all as read" })); + throw new Error(error.detail || "Failed to mark all notifications as read"); } + + // Electric SQL will sync the changes from PostgreSQL to PGlite automatically + // The live query subscription will update the UI return true; } catch (err) { console.error("Failed to mark all notifications as read:", err); return false; } - }, [electric, notifications, markAsRead]); + }, []); // Get unread count const unreadCount = notifications.filter((n) => !n.read).length;