mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-08 20:25:19 +02:00
refactor: enhance inbox functionality with type guards and API service integration
- Introduced type guards for metadata in InboxSidebar to ensure safe access and improve type safety. - Refactored the useInbox hook to utilize the new notifications API service for fetching notifications, enhancing validation and error handling. - Added new API request/response schemas for notifications, improving structure and clarity. - Updated logic for loading and marking notifications as read, ensuring consistent state management and user experience.
This commit is contained in:
parent
c98cfac49f
commit
00596f991d
4 changed files with 294 additions and 99 deletions
|
|
@ -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 (
|
||||
<Avatar className="h-8 w-8">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={authorName || "User"} />}
|
||||
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
|
||||
{getInitials(authorName, authorEmail)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
// Fallback for invalid metadata
|
||||
return (
|
||||
<Avatar className="h-8 w-8">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={authorName || "User"} />}
|
||||
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
|
||||
{getInitials(authorName, authorEmail)}
|
||||
{getInitials(null, null)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<string, unknown>;
|
||||
const status = typeof metadata?.status === "string" ? metadata.status : undefined;
|
||||
|
||||
switch (status) {
|
||||
case "in_progress":
|
||||
|
|
|
|||
|
|
@ -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<typeof inboxItemTypeEnum>;
|
||||
export type InboxItemStatusEnum = z.infer<typeof inboxItemStatusEnum>;
|
||||
export type DocumentProcessingStageEnum = z.infer<typeof documentProcessingStageEnum>;
|
||||
|
|
@ -146,3 +254,10 @@ export type InboxItem = z.infer<typeof inboxItem>;
|
|||
export type ConnectorIndexingInboxItem = z.infer<typeof connectorIndexingInboxItem>;
|
||||
export type DocumentProcessingInboxItem = z.infer<typeof documentProcessingInboxItem>;
|
||||
export type NewMentionInboxItem = z.infer<typeof newMentionInboxItem>;
|
||||
|
||||
// API Request/Response types
|
||||
export type GetNotificationsRequest = z.infer<typeof getNotificationsRequest>;
|
||||
export type GetNotificationsResponse = z.infer<typeof getNotificationsResponse>;
|
||||
export type MarkNotificationReadRequest = z.infer<typeof markNotificationReadRequest>;
|
||||
export type MarkNotificationReadResponse = z.infer<typeof markNotificationReadResponse>;
|
||||
export type MarkAllNotificationsReadResponse = z.infer<typeof markAllNotificationsReadResponse>;
|
||||
|
|
|
|||
|
|
@ -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<InboxItem>(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
|
||||
|
|
|
|||
94
surfsense_web/lib/apis/notifications-api.service.ts
Normal file
94
surfsense_web/lib/apis/notifications-api.service.ts
Normal file
|
|
@ -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<GetNotificationsResponse> => {
|
||||
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<MarkNotificationReadResponse> => {
|
||||
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<MarkAllNotificationsReadResponse> => {
|
||||
return baseApiService.patch(
|
||||
"/api/v1/notifications/read-all",
|
||||
markAllNotificationsReadResponse
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const notificationsApiService = new NotificationsApiService();
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue