feat: Add notifications API routes and integrate with frontend

- Introduced new notifications API routes for marking notifications as read and marking all as read, with automatic syncing via Electric SQL.
- Updated the frontend hook to utilize the new API for marking notifications as read, enhancing the user experience with real-time updates.
- Included necessary response models for API interactions.
This commit is contained in:
Anish Sarkar 2026-01-14 02:59:58 +05:30
parent fede7413fb
commit 69f46ff3f4
3 changed files with 141 additions and 44 deletions

View file

@ -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

View file

@ -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,
)

View file

@ -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;