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:
Anish Sarkar 2026-01-22 18:32:25 +05:30
parent c98cfac49f
commit 00596f991d
4 changed files with 294 additions and 99 deletions

View file

@ -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":

View file

@ -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>;

View file

@ -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

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