290 lines
9.2 KiB
TypeScript
290 lines
9.2 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { useSession } from 'next-auth/react';
|
|
import { Notification, NotificationCount } from '@/lib/types/notification';
|
|
import { useUnifiedRefresh } from './use-unified-refresh';
|
|
import { REFRESH_INTERVALS } from '@/lib/constants/refresh-intervals';
|
|
import { requestDeduplicator } from '@/lib/utils/request-deduplication';
|
|
|
|
// Default empty notification count
|
|
const defaultNotificationCount: NotificationCount = {
|
|
total: 0,
|
|
unread: 0,
|
|
sources: {}
|
|
};
|
|
|
|
export function useNotifications() {
|
|
const { data: session, status } = useSession();
|
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
|
const [notificationCount, setNotificationCount] = useState<NotificationCount>(defaultNotificationCount);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [markingProgress, setMarkingProgress] = useState<{ current: number; total: number } | null>(null);
|
|
const isMountedRef = useRef<boolean>(false);
|
|
|
|
// Fetch notification count with request deduplication
|
|
const fetchNotificationCount = useCallback(async (force = false) => {
|
|
if (!session?.user || !isMountedRef.current) return;
|
|
|
|
try {
|
|
setError(null);
|
|
|
|
console.log('[useNotifications] Fetching notification count', { force });
|
|
|
|
// Use request deduplication to prevent duplicate calls
|
|
const requestKey = `notifications-count-${session.user.id}`;
|
|
const url = force
|
|
? `/api/notifications/count?_t=${Date.now()}`
|
|
: '/api/notifications/count';
|
|
|
|
const data = await requestDeduplicator.execute(
|
|
requestKey,
|
|
async () => {
|
|
const response = await fetch(url, {
|
|
credentials: 'include',
|
|
cache: force ? 'no-store' : 'default',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error('Failed to fetch notification count:', {
|
|
status: response.status,
|
|
body: errorText
|
|
});
|
|
throw new Error(errorText || 'Failed to fetch notification count');
|
|
}
|
|
|
|
return response.json();
|
|
},
|
|
2000 // 2 second deduplication window
|
|
);
|
|
|
|
if (isMountedRef.current) {
|
|
console.log('[useNotifications] Received notification count:', data);
|
|
setNotificationCount(data);
|
|
}
|
|
} catch (err: any) {
|
|
console.error('Error fetching notification count:', err);
|
|
if (isMountedRef.current) {
|
|
setError(err.message || 'Failed to fetch notification count');
|
|
}
|
|
}
|
|
}, [session?.user]);
|
|
|
|
// Fetch notifications with request deduplication
|
|
const fetchNotifications = useCallback(async (page = 1, limit = 20) => {
|
|
if (!session?.user || !isMountedRef.current) return;
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
console.log('[useNotifications] Fetching notifications', { page, limit });
|
|
|
|
// Use request deduplication to prevent duplicate calls
|
|
const requestKey = `notifications-${session.user.id}-${page}-${limit}`;
|
|
|
|
const data = await requestDeduplicator.execute(
|
|
requestKey,
|
|
async () => {
|
|
const response = await fetch(`/api/notifications?page=${page}&limit=${limit}`, {
|
|
credentials: 'include'
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error('Failed to fetch notifications:', {
|
|
status: response.status,
|
|
body: errorText
|
|
});
|
|
throw new Error(errorText || 'Failed to fetch notifications');
|
|
}
|
|
|
|
return response.json();
|
|
},
|
|
2000 // 2 second deduplication window
|
|
);
|
|
|
|
if (isMountedRef.current) {
|
|
setNotifications(data.notifications);
|
|
}
|
|
} catch (err: any) {
|
|
console.error('Error fetching notifications:', err);
|
|
if (isMountedRef.current) {
|
|
setError(err.message || 'Failed to fetch notifications');
|
|
}
|
|
} finally {
|
|
if (isMountedRef.current) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
}, [session?.user]);
|
|
|
|
// Mark notification as read
|
|
const markAsRead = useCallback(async (notificationId: string) => {
|
|
if (!session?.user) return false;
|
|
|
|
try {
|
|
console.log('[useNotifications] Marking notification as read:', notificationId);
|
|
const response = await fetch(`/api/notifications/${notificationId}/read`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
credentials: 'include' // Ensure cookies are sent with the request
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error('Failed to mark notification as read:', {
|
|
status: response.status,
|
|
body: errorText
|
|
});
|
|
return false;
|
|
}
|
|
|
|
// Update local state optimistically (only if we're confident it will succeed)
|
|
setNotifications(prev =>
|
|
prev.map(notification =>
|
|
notification.id === notificationId
|
|
? { ...notification, isRead: true }
|
|
: notification
|
|
)
|
|
);
|
|
|
|
// Update count optimistically (decrement unread count)
|
|
setNotificationCount(prev => ({
|
|
...prev,
|
|
unread: Math.max(0, prev.unread - 1), // Ensure it doesn't go below 0
|
|
total: prev.total, // Keep total the same
|
|
}));
|
|
|
|
// Refresh notification count after a delay to ensure cache is invalidated
|
|
// Poll until count matches expected value or timeout
|
|
let pollCount = 0;
|
|
const maxPolls = 5;
|
|
const pollInterval = 500; // 500ms between polls
|
|
|
|
const pollForCount = async () => {
|
|
if (pollCount >= maxPolls) return;
|
|
pollCount++;
|
|
|
|
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
await fetchNotificationCount(true);
|
|
|
|
// Check if count matches expected (unread should be prev - 1)
|
|
// If not, poll again
|
|
if (pollCount < maxPolls) {
|
|
setTimeout(pollForCount, pollInterval);
|
|
}
|
|
};
|
|
|
|
// Start polling after initial delay
|
|
setTimeout(pollForCount, 300);
|
|
|
|
return true;
|
|
} catch (err) {
|
|
console.error('Error marking notification as read:', err);
|
|
return false;
|
|
}
|
|
}, [session?.user, fetchNotificationCount]);
|
|
|
|
// Mark all notifications as read with progress tracking
|
|
const markAllAsRead = useCallback(async () => {
|
|
if (!session?.user) return false;
|
|
|
|
try {
|
|
console.log('[useNotifications] Marking all notifications as read');
|
|
|
|
// Show loading state instead of optimistic update
|
|
setMarkingProgress({ current: 0, total: notificationCount.unread });
|
|
|
|
const response = await fetch('/api/notifications/read-all', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
credentials: 'include'
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error('Failed to mark all notifications as read:', {
|
|
status: response.status,
|
|
body: errorText
|
|
});
|
|
setMarkingProgress(null);
|
|
return false;
|
|
}
|
|
|
|
// Update local state optimistically after successful response
|
|
setNotifications(prev =>
|
|
prev.map(notification => ({ ...notification, isRead: true }))
|
|
);
|
|
|
|
// Update count optimistically
|
|
setNotificationCount(prev => ({
|
|
...prev,
|
|
unread: 0,
|
|
total: prev.total,
|
|
sources: Object.fromEntries(
|
|
Object.entries(prev.sources).map(([key, value]) => [
|
|
key,
|
|
{ ...value, unread: 0 }
|
|
])
|
|
),
|
|
}));
|
|
|
|
// Clear progress
|
|
setMarkingProgress(null);
|
|
|
|
// Refresh notification count after a delay to ensure cache is invalidated
|
|
setTimeout(() => {
|
|
fetchNotificationCount(true);
|
|
}, 500);
|
|
|
|
return true;
|
|
} catch (err) {
|
|
console.error('Error marking all notifications as read:', err);
|
|
setMarkingProgress(null);
|
|
return false;
|
|
}
|
|
}, [session?.user, fetchNotificationCount, notificationCount.unread]);
|
|
|
|
// Use unified refresh system for notification count
|
|
const { refresh: refreshCount } = useUnifiedRefresh({
|
|
resource: 'notifications-count',
|
|
interval: REFRESH_INTERVALS.NOTIFICATIONS_COUNT,
|
|
enabled: status === 'authenticated',
|
|
onRefresh: async () => {
|
|
await fetchNotificationCount(false);
|
|
},
|
|
priority: 'high',
|
|
});
|
|
|
|
// Initialize fetching on component mount and cleanup on unmount
|
|
useEffect(() => {
|
|
isMountedRef.current = true;
|
|
|
|
if (status === 'authenticated' && session?.user) {
|
|
// Initial fetches
|
|
fetchNotificationCount(true);
|
|
fetchNotifications();
|
|
}
|
|
|
|
return () => {
|
|
isMountedRef.current = false;
|
|
};
|
|
}, [status, session?.user, fetchNotificationCount, fetchNotifications]);
|
|
|
|
return {
|
|
notifications,
|
|
notificationCount,
|
|
loading,
|
|
error,
|
|
markingProgress, // Progress for mark all as read
|
|
fetchNotifications,
|
|
fetchNotificationCount: () => fetchNotificationCount(true),
|
|
markAsRead,
|
|
markAllAsRead
|
|
};
|
|
}
|