NeahNew/hooks/use-notifications.ts
2026-01-06 19:59:37 +01:00

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