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();
+