diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index 48553cc85..166d77eca 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -43,7 +43,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { InboxItem } from "@/hooks/use-inbox"; import { useMediaQuery } from "@/hooks/use-media-query"; -import type { ConnectorIndexingMetadata } from "@/contracts/types/inbox.types"; +import { + type ConnectorIndexingMetadata, + type NewMentionMetadata, + isConnectorIndexingMetadata, + isNewMentionMetadata, +} from "@/contracts/types/inbox.types"; import { cn } from "@/lib/utils"; /** @@ -206,9 +211,9 @@ export function InboxSidebar({ statusItems .filter((item) => item.type === "connector_indexing") .forEach((item) => { - const metadata = item.metadata as ConnectorIndexingMetadata; - if (metadata?.connector_type) { - connectorTypes.add(metadata.connector_type); + // Use type guard for safe metadata access + if (isConnectorIndexingMetadata(item.metadata)) { + connectorTypes.add(item.metadata.connector_type); } }); @@ -234,8 +239,11 @@ export function InboxSidebar({ if (activeTab === "status" && selectedConnector) { items = items.filter((item) => { if (item.type === "connector_indexing") { - const metadata = item.metadata as ConnectorIndexingMetadata; - return metadata?.connector_type === selectedConnector; + // Use type guard for safe metadata access + if (isConnectorIndexingMetadata(item.metadata)) { + return item.metadata.connector_type === selectedConnector; + } + return false; } return false; // Hide document_processing when a specific connector is selected }); @@ -297,21 +305,20 @@ export function InboxSidebar({ } if (item.type === "new_mention") { - const metadata = item.metadata as { - thread_id?: number; - comment_id?: number; - }; - const searchSpaceId = item.search_space_id; - const threadId = metadata?.thread_id; - const commentId = metadata?.comment_id; + // Use type guard for safe metadata access + if (isNewMentionMetadata(item.metadata)) { + const searchSpaceId = item.search_space_id; + const threadId = item.metadata.thread_id; + const commentId = item.metadata.comment_id; - if (searchSpaceId && threadId) { - const url = commentId - ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}` - : `/dashboard/${searchSpaceId}/new-chat/${threadId}`; - onOpenChange(false); - onCloseMobileSidebar?.(); - router.push(url); + if (searchSpaceId && threadId) { + const url = commentId + ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}` + : `/dashboard/${searchSpaceId}/new-chat/${threadId}`; + onOpenChange(false); + onCloseMobileSidebar?.(); + router.push(url); + } } } }, @@ -348,27 +355,35 @@ export function InboxSidebar({ const getStatusIcon = (item: InboxItem) => { // For mentions, show the author's avatar with initials fallback if (item.type === "new_mention") { - const metadata = item.metadata as { - author_name?: string; - author_avatar_url?: string | null; - author_email?: string; - }; - const authorName = metadata?.author_name; - const avatarUrl = metadata?.author_avatar_url; - const authorEmail = metadata?.author_email; + // Use type guard for safe metadata access + if (isNewMentionMetadata(item.metadata)) { + const authorName = item.metadata.author_name; + const avatarUrl = item.metadata.author_avatar_url; + const authorEmail = item.metadata.author_email; + return ( + + {avatarUrl && } + + {getInitials(authorName, authorEmail)} + + + ); + } + // Fallback for invalid metadata return ( - {avatarUrl && } - {getInitials(authorName, authorEmail)} + {getInitials(null, null)} ); } // For status items (connector/document), show status icons - const status = item.metadata?.status as string | undefined; + // Safely access status from metadata + const metadata = item.metadata as Record; + const status = typeof metadata?.status === "string" ? metadata.status : undefined; switch (status) { case "in_progress": diff --git a/surfsense_web/contracts/types/inbox.types.ts b/surfsense_web/contracts/types/inbox.types.ts index c1627ebee..12ebfe1e9 100644 --- a/surfsense_web/contracts/types/inbox.types.ts +++ b/surfsense_web/contracts/types/inbox.types.ts @@ -133,7 +133,115 @@ export const newMentionInboxItem = inboxItem.extend({ metadata: newMentionMetadata, }); +// ============================================================================= +// API Request/Response Schemas +// ============================================================================= + +/** + * Request schema for getting notifications + */ +export const getNotificationsRequest = z.object({ + queryParams: z.object({ + search_space_id: z.number().optional(), + type: inboxItemTypeEnum.optional(), + before_date: z.string().optional(), + limit: z.number().min(1).max(100).optional(), + offset: z.number().min(0).optional(), + }), +}); + +/** + * Response schema for listing notifications + */ +export const getNotificationsResponse = z.object({ + items: z.array(inboxItem), + total: z.number(), + has_more: z.boolean(), + next_offset: z.number().nullable(), +}); + +/** + * Request schema for marking a single notification as read + */ +export const markNotificationReadRequest = z.object({ + notificationId: z.number(), +}); + +/** + * Response schema for mark as read operations + */ +export const markNotificationReadResponse = z.object({ + success: z.boolean(), + message: z.string(), +}); + +/** + * Response schema for mark all as read operation + */ +export const markAllNotificationsReadResponse = z.object({ + success: z.boolean(), + message: z.string(), + updated_count: z.number(), +}); + +// ============================================================================= +// Type Guards for Metadata +// ============================================================================= + +/** + * Type guard for ConnectorIndexingMetadata + */ +export function isConnectorIndexingMetadata( + metadata: unknown +): metadata is ConnectorIndexingMetadata { + return connectorIndexingMetadata.safeParse(metadata).success; +} + +/** + * Type guard for DocumentProcessingMetadata + */ +export function isDocumentProcessingMetadata( + metadata: unknown +): metadata is DocumentProcessingMetadata { + return documentProcessingMetadata.safeParse(metadata).success; +} + +/** + * Type guard for NewMentionMetadata + */ +export function isNewMentionMetadata(metadata: unknown): metadata is NewMentionMetadata { + return newMentionMetadata.safeParse(metadata).success; +} + +/** + * Safe metadata parser - returns typed metadata or null + */ +export function parseInboxItemMetadata( + type: InboxItemTypeEnum, + metadata: unknown +): ConnectorIndexingMetadata | DocumentProcessingMetadata | NewMentionMetadata | null { + switch (type) { + case "connector_indexing": { + const result = connectorIndexingMetadata.safeParse(metadata); + return result.success ? result.data : null; + } + case "document_processing": { + const result = documentProcessingMetadata.safeParse(metadata); + return result.success ? result.data : null; + } + case "new_mention": { + const result = newMentionMetadata.safeParse(metadata); + return result.success ? result.data : null; + } + default: + return null; + } +} + +// ============================================================================= // Inferred types +// ============================================================================= + export type InboxItemTypeEnum = z.infer; export type InboxItemStatusEnum = z.infer; export type DocumentProcessingStageEnum = z.infer; @@ -146,3 +254,10 @@ export type InboxItem = z.infer; export type ConnectorIndexingInboxItem = z.infer; export type DocumentProcessingInboxItem = z.infer; export type NewMentionInboxItem = z.infer; + +// API Request/Response types +export type GetNotificationsRequest = z.infer; +export type GetNotificationsResponse = z.infer; +export type MarkNotificationReadRequest = z.infer; +export type MarkNotificationReadResponse = z.infer; +export type MarkAllNotificationsReadResponse = z.infer; diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts index 7c421c341..7f0bd59ef 100644 --- a/surfsense_web/hooks/use-inbox.ts +++ b/surfsense_web/hooks/use-inbox.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types"; -import { authenticatedFetch } from "@/lib/auth-utils"; +import { notificationsApiService } from "@/lib/apis/notifications-api.service"; import type { SyncHandle } from "@/lib/electric/client"; import { useElectricClient } from "@/lib/electric/context"; @@ -198,7 +198,7 @@ export function useInbox( const db = client.db as any; - // Initial fetch from PGLite + // Initial fetch from PGLite - no validation needed, schema is enforced by Electric SQL sync const result = await client.db.query(query, params); if (mounted && result.rows) { @@ -213,30 +213,20 @@ export function useInbox( "[useInbox] Electric returned 0 items, checking API for older notifications" ); try { - const apiParams = new URLSearchParams(); - if (searchSpaceId !== null) { - apiParams.append("search_space_id", String(searchSpaceId)); - } - if (typeFilter) { - apiParams.append("type", typeFilter); - } - apiParams.append("limit", String(PAGE_SIZE)); + // Use the API service with proper Zod validation for API responses + const data = await notificationsApiService.getNotifications({ + queryParams: { + search_space_id: searchSpaceId ?? undefined, + type: typeFilter ?? undefined, + limit: PAGE_SIZE, + }, + }); - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications?${apiParams.toString()}` - ); - - if (response.ok && mounted) { - const data = await response.json(); - const apiItems: InboxItem[] = data.items.map((item: any) => ({ - ...item, - metadata: item.metadata || {}, - })); - - if (apiItems.length > 0) { - setInboxItems(apiItems); + if (mounted) { + if (data.items.length > 0) { + setInboxItems(data.items); } - setHasMore(data.has_more ?? apiItems.length === PAGE_SIZE); + setHasMore(data.has_more); } } catch (err) { console.error("[useInbox] API fallback failed:", err); @@ -254,10 +244,12 @@ export function useInbox( } if (liveQuery.subscribe) { + // Live query data comes from PGlite - no validation needed liveQuery.subscribe((result: { rows: InboxItem[] }) => { if (mounted && result.rows) { + const liveItems = result.rows; + setInboxItems((prev) => { - const liveItems = result.rows; const liveItemIds = new Set(liveItems.map((item) => item.id)); // FIXED: Keep ALL items not in live result (not just slice) @@ -305,43 +297,26 @@ export function useInbox( const oldestItem = inboxItems.length > 0 ? inboxItems[inboxItems.length - 1] : null; const beforeDate = oldestItem ? toISOString(oldestItem.created_at) : null; - const params = new URLSearchParams(); - if (searchSpaceId !== null) { - params.append("search_space_id", String(searchSpaceId)); - } - if (typeFilter) { - params.append("type", typeFilter); - } - // Only add before_date if we have a cursor - // Without before_date, API returns newest items first - if (beforeDate) { - params.append("before_date", beforeDate); - } - params.append("limit", String(PAGE_SIZE)); - console.log("[useInbox] Loading more, before:", beforeDate ?? "none (initial)"); - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications?${params.toString()}` - ); + // Use the API service with proper Zod validation + const data = await notificationsApiService.getNotifications({ + queryParams: { + search_space_id: searchSpaceId ?? undefined, + type: typeFilter ?? undefined, + before_date: beforeDate ?? undefined, + limit: PAGE_SIZE, + }, + }); - if (!response.ok) { - throw new Error("Failed to fetch notifications"); - } - - const data = await response.json(); - const apiItems: InboxItem[] = data.items.map((item: any) => ({ - ...item, - metadata: item.metadata || {}, - })); - - if (apiItems.length > 0) { + if (data.items.length > 0) { // Functional update ensures we always merge with latest state - setInboxItems((prev) => deduplicateAndSort([...prev, ...apiItems])); + // Items are already validated by the API service + setInboxItems((prev) => deduplicateAndSort([...prev, ...data.items])); } - // Use API's has_more flag if available, otherwise check count - setHasMore(data.has_more ?? apiItems.length === PAGE_SIZE); + // Use API's has_more flag + setHasMore(data.has_more); } catch (err) { console.error("[useInbox] Load more failed:", err); } finally { @@ -357,12 +332,10 @@ export function useInbox( ); try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${itemId}/read`, - { method: "PATCH" } - ); + // Use the API service with proper Zod validation + const result = await notificationsApiService.markAsRead({ notificationId: itemId }); - if (!response.ok) { + if (!result.success) { // Rollback on error setInboxItems((prev) => prev.map((item) => (item.id === itemId ? { ...item, read: false } : item)) @@ -370,7 +343,7 @@ export function useInbox( } // If successful, Electric SQL will sync the change and live query will update // This ensures eventual consistency even if optimistic update was wrong - return response.ok; + return result.success; } catch (err) { console.error("Failed to mark as read:", err); // Rollback on error @@ -387,17 +360,15 @@ export function useInbox( setInboxItems((prev) => prev.map((item) => ({ ...item, read: true }))); try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/read-all`, - { method: "PATCH" } - ); + // Use the API service with proper Zod validation + const result = await notificationsApiService.markAllAsRead(); - if (!response.ok) { + if (!result.success) { console.error("Failed to mark all as read"); // On error, let Electric SQL sync correct the state } // Electric SQL will sync and live query will ensure consistency - return response.ok; + return result.success; } catch (err) { console.error("Failed to mark all as read:", err); // On error, let Electric SQL sync correct the state diff --git a/surfsense_web/lib/apis/notifications-api.service.ts b/surfsense_web/lib/apis/notifications-api.service.ts new file mode 100644 index 000000000..a2489cdee --- /dev/null +++ b/surfsense_web/lib/apis/notifications-api.service.ts @@ -0,0 +1,94 @@ +import { + type GetNotificationsRequest, + type GetNotificationsResponse, + type MarkAllNotificationsReadResponse, + type MarkNotificationReadRequest, + type MarkNotificationReadResponse, + getNotificationsRequest, + getNotificationsResponse, + markAllNotificationsReadResponse, + markNotificationReadRequest, + markNotificationReadResponse, +} from "@/contracts/types/inbox.types"; +import { ValidationError } from "../error"; +import { baseApiService } from "./base-api.service"; + +class NotificationsApiService { + /** + * Get notifications with pagination + */ + getNotifications = async ( + request: GetNotificationsRequest + ): Promise => { + const parsedRequest = getNotificationsRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + const { queryParams } = parsedRequest.data; + + // Build query string from params + const params = new URLSearchParams(); + + if (queryParams.search_space_id !== undefined) { + params.append("search_space_id", String(queryParams.search_space_id)); + } + if (queryParams.type) { + params.append("type", queryParams.type); + } + if (queryParams.before_date) { + params.append("before_date", queryParams.before_date); + } + if (queryParams.limit !== undefined) { + params.append("limit", String(queryParams.limit)); + } + if (queryParams.offset !== undefined) { + params.append("offset", String(queryParams.offset)); + } + + const queryString = params.toString(); + + return baseApiService.get( + `/api/v1/notifications${queryString ? `?${queryString}` : ""}`, + getNotificationsResponse + ); + }; + + /** + * Mark a single notification as read + */ + markAsRead = async ( + request: MarkNotificationReadRequest + ): Promise => { + const parsedRequest = markNotificationReadRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + const { notificationId } = parsedRequest.data; + + return baseApiService.patch( + `/api/v1/notifications/${notificationId}/read`, + markNotificationReadResponse + ); + }; + + /** + * Mark all notifications as read + */ + markAllAsRead = async (): Promise => { + return baseApiService.patch( + "/api/v1/notifications/read-all", + markAllNotificationsReadResponse + ); + }; +} + +export const notificationsApiService = new NotificationsApiService(); +