diff --git a/surfsense_backend/app/routes/notifications_routes.py b/surfsense_backend/app/routes/notifications_routes.py index 72b6da9a6..0eaa50250 100644 --- a/surfsense_backend/app/routes/notifications_routes.py +++ b/surfsense_backend/app/routes/notifications_routes.py @@ -228,6 +228,10 @@ async def list_notifications( None, description="Filter by source type, e.g. 'connector:GITHUB_CONNECTOR' or 'doctype:FILE'", ), + filter: str | None = Query( + None, + description="Filter preset: 'unread' for unread only, 'errors' for failed/error items only", + ), before_date: str | None = Query( None, description="Get notifications before this ISO date (for pagination)" ), @@ -294,6 +298,21 @@ async def list_notifications( query = query.where(source_filter) count_query = count_query.where(source_filter) + # Filter by preset: 'unread' or 'errors' + if filter == "unread": + unread_filter = Notification.read == False # noqa: E712 + query = query.where(unread_filter) + count_query = count_query.where(unread_filter) + elif filter == "errors": + error_filter = ( + (Notification.type == "page_limit_exceeded") + | ( + Notification.notification_metadata["status"].astext == "failed" + ) + ) + query = query.where(error_filter) + count_query = count_query.where(error_filter) + # Filter by date (for efficient pagination of older items) if before_date: try: diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index 81967152d..432cf0e6f 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -317,6 +317,24 @@ export function InboxSidebar({ // Get items for current tab const displayItems = activeTab === "comments" ? commentsItems : statusItems; + // When a non-default filter (unread/errors) is active on the status tab, + // fetch matching items from the API so older items beyond the Electric + // sync window are included. + const isActiveFilterMode = activeTab === "status" && (activeFilter === "unread" || activeFilter === "errors"); + const { data: activeFilterResponse, isLoading: isActiveFilterLoading } = useQuery({ + queryKey: cacheKeys.notifications.byFilter(searchSpaceId, activeFilter), + queryFn: () => + notificationsApiService.getNotifications({ + queryParams: { + search_space_id: searchSpaceId ?? undefined, + filter: activeFilter as "unread" | "errors", + limit: 100, + }, + }), + staleTime: 30 * 1000, + enabled: isActiveFilterMode && open && !isSearchMode, + }); + // When a source filter is active, fetch matching items from the API so // older items (outside the Electric sync window) are included. const isSourceFilterMode = activeTab === "status" && !!selectedSource; @@ -359,11 +377,26 @@ export function InboxSidebar({ [selectedSource] ); + // Client-side matcher: checks if an item matches the active filter (unread/errors) + const matchesActiveFilter = useCallback( + (item: InboxItem): boolean => { + if (activeFilter === "unread") return !item.read; + if (activeFilter === "errors") { + if (item.type === "page_limit_exceeded") return true; + const meta = item.metadata as Record | undefined; + return typeof meta?.status === "string" && meta.status === "failed"; + } + return true; + }, + [activeFilter] + ); + // Filter items based on filter type, connector filter, and search mode - // Three data paths: - // 1. Search mode → server-side search results - // 2. Source filter mode → API results merged with real-time Electric items - // 3. Default → Electric real-time items (fast, local) + // Four data paths: + // 1. Search mode → server-side search results (client-side filter applied after) + // 2. Active filter mode (unread/errors) → API results merged with real-time Electric items + // 3. Source filter mode → API results merged with real-time Electric items + // 4. Default → Electric real-time items (fast, local) const filteredItems = useMemo(() => { let items: InboxItem[]; @@ -378,9 +411,25 @@ export function InboxSidebar({ item.type === "connector_deletion" ); } + if (activeFilter === "unread") { + items = items.filter((item) => !item.read); + } else if (activeFilter === "errors") { + items = items.filter(matchesActiveFilter); + } + } else if (isActiveFilterMode) { + const apiItems = activeFilterResponse?.items ?? []; + const realtimeMatching = statusItems.filter(matchesActiveFilter); + const seen = new Set(apiItems.map((i) => i.id)); + const merged = [...apiItems]; + for (const item of realtimeMatching) { + if (!seen.has(item.id)) { + merged.push(item); + } + } + items = merged.sort( + (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); } else if (isSourceFilterMode) { - // Merge API results (covers older items) with Electric real-time items - // that match the filter (covers brand-new items arriving in real-time). const apiItems = sourceFilterResponse?.items ?? []; const realtimeMatching = statusItems.filter(matchesSourceFilter); const seen = new Set(apiItems.map((i) => i.id)); @@ -397,25 +446,18 @@ export function InboxSidebar({ items = displayItems; } - if (activeFilter === "unread") { - items = items.filter((item) => !item.read); - } else if (activeFilter === "errors") { - items = items.filter((item) => { - if (item.type === "page_limit_exceeded") return true; - const meta = item.metadata as Record | undefined; - return typeof meta?.status === "string" && meta.status === "failed"; - }); - } - return items; }, [ displayItems, statusItems, searchResponse, sourceFilterResponse, + activeFilterResponse, isSearchMode, + isActiveFilterMode, isSourceFilterMode, matchesSourceFilter, + matchesActiveFilter, activeFilter, activeTab, ]); @@ -1026,7 +1068,7 @@ export function InboxSidebar({
- {(isSearchMode ? isSearchLoading : isSourceFilterMode ? isSourceFilterLoading : loading) ? ( + {(isSearchMode ? isSearchLoading : isActiveFilterMode ? isActiveFilterLoading : isSourceFilterMode ? isSourceFilterLoading : loading) ? (
{activeTab === "comments" ? /* Comments skeleton: avatar + two-line text + time */ diff --git a/surfsense_web/contracts/types/inbox.types.ts b/surfsense_web/contracts/types/inbox.types.ts index dc0bfb6a1..7f292da78 100644 --- a/surfsense_web/contracts/types/inbox.types.ts +++ b/surfsense_web/contracts/types/inbox.types.ts @@ -205,6 +205,7 @@ export const getNotificationsRequest = z.object({ search_space_id: z.number().optional(), type: inboxItemTypeEnum.optional(), source_type: z.string().optional(), + filter: z.enum(["unread", "errors"]).optional(), before_date: z.string().optional(), search: z.string().optional(), limit: z.number().min(1).max(100).optional(), diff --git a/surfsense_web/lib/apis/notifications-api.service.ts b/surfsense_web/lib/apis/notifications-api.service.ts index 7c865f343..00593c395 100644 --- a/surfsense_web/lib/apis/notifications-api.service.ts +++ b/surfsense_web/lib/apis/notifications-api.service.ts @@ -47,6 +47,9 @@ class NotificationsApiService { if (queryParams.source_type) { params.append("source_type", queryParams.source_type); } + if (queryParams.filter) { + params.append("filter", queryParams.filter); + } if (queryParams.before_date) { params.append("before_date", queryParams.before_date); } diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index 717633206..53cb49aaf 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -100,5 +100,7 @@ export const cacheKeys = { ["notifications", "source-types", searchSpaceId] as const, bySourceType: (searchSpaceId: number | null, sourceType: string) => ["notifications", "by-source-type", searchSpaceId, sourceType] as const, + byFilter: (searchSpaceId: number | null, filter: string) => + ["notifications", "by-filter", searchSpaceId, filter] as const, }, };