mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-11 08:42:39 +02:00
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:
parent
fede7413fb
commit
69f46ff3f4
3 changed files with 141 additions and 44 deletions
|
|
@ -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_chat_routes import router as new_chat_router
|
||||||
from .new_llm_config_routes import router as new_llm_config_router
|
from .new_llm_config_routes import router as new_llm_config_router
|
||||||
from .notes_routes import router as notes_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 .notion_add_connector_route import router as notion_add_connector_router
|
||||||
from .podcasts_routes import router as podcasts_router
|
from .podcasts_routes import router as podcasts_router
|
||||||
from .rbac_routes import router as rbac_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(logs_router)
|
||||||
router.include_router(circleback_webhook_router) # Circleback meeting webhooks
|
router.include_router(circleback_webhook_router) # Circleback meeting webhooks
|
||||||
router.include_router(surfsense_docs_router) # Surfsense documentation for citations
|
router.include_router(surfsense_docs_router) # Surfsense documentation for citations
|
||||||
|
router.include_router(notifications_router) # Notifications with Electric SQL sync
|
||||||
|
|
|
||||||
102
surfsense_backend/app/routes/notifications_routes.py
Normal file
102
surfsense_backend/app/routes/notifications_routes.py
Normal 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,
|
||||||
|
)
|
||||||
|
|
@ -1,13 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import {
|
import { initElectric, type ElectricClient, type SyncHandle } from "@/lib/electric/client";
|
||||||
initElectric,
|
|
||||||
isElectricInitialized,
|
|
||||||
type ElectricClient,
|
|
||||||
type SyncHandle,
|
|
||||||
} from "@/lib/electric/client";
|
|
||||||
import type { Notification } from "@/contracts/types/notification.types";
|
import type { Notification } from "@/contracts/types/notification.types";
|
||||||
|
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||||
|
|
||||||
export type { Notification } from "@/contracts/types/notification.types";
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
// Mark notification as read (local only - needs backend sync)
|
// Mark notification as read via backend API
|
||||||
const markAsRead = useCallback(
|
// Electric SQL will automatically sync the change to all clients
|
||||||
async (notificationId: number) => {
|
const markAsRead = useCallback(async (notificationId: number) => {
|
||||||
if (!electric || !isElectricInitialized()) {
|
try {
|
||||||
console.warn("Electric SQL not initialized");
|
// Call backend API - Electric SQL will sync the change automatically
|
||||||
return false;
|
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 {
|
// Electric SQL will sync the change from PostgreSQL to PGlite automatically
|
||||||
// Update locally in PGlite
|
// The live query subscription will update the UI
|
||||||
await electric.db.query(
|
return true;
|
||||||
`UPDATE notifications SET read = true, updated_at = NOW() WHERE id = $1`,
|
} catch (err) {
|
||||||
[notificationId]
|
console.error("Failed to mark notification as read:", err);
|
||||||
);
|
|
||||||
|
|
||||||
// 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");
|
|
||||||
return false;
|
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 {
|
try {
|
||||||
const unread = notifications.filter((n) => !n.read);
|
// Call backend API - Electric SQL will sync all changes automatically
|
||||||
for (const notification of unread) {
|
const response = await authenticatedFetch(
|
||||||
await markAsRead(notification.id);
|
`${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;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to mark all notifications as read:", err);
|
console.error("Failed to mark all notifications as read:", err);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [electric, notifications, markAsRead]);
|
}, []);
|
||||||
|
|
||||||
// Get unread count
|
// Get unread count
|
||||||
const unreadCount = notifications.filter((n) => !n.read).length;
|
const unreadCount = notifications.filter((n) => !n.read).length;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue