notifications

This commit is contained in:
alma 2025-05-04 11:06:56 +02:00
parent abb3a4ba0e
commit 8f523d65c0
5 changed files with 126 additions and 49 deletions

View File

@ -19,7 +19,10 @@ export async function GET(request: Request) {
const notificationService = NotificationService.getInstance();
const counts = await notificationService.getNotificationCount(userId);
return NextResponse.json(counts);
// Add Cache-Control header to help with client-side caching
const response = NextResponse.json(counts);
response.headers.set('Cache-Control', 'private, max-age=10'); // Cache for 10 seconds on client
return response;
} catch (error: any) {
console.error('Error in notification count API:', error);
return NextResponse.json(

View File

@ -38,12 +38,15 @@ export async function GET(request: Request) {
const notificationService = NotificationService.getInstance();
const notifications = await notificationService.getNotifications(userId, page, limit);
return NextResponse.json({
// Add Cache-Control header to help with client-side caching
const response = NextResponse.json({
notifications,
page,
limit,
total: notifications.length
});
response.headers.set('Cache-Control', 'private, max-age=30'); // Cache for 30 seconds on client
return response;
} catch (error: any) {
console.error('Error in notifications API:', error);
return NextResponse.json(

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { memo } from 'react';
import Link from 'next/link';
import { Bell } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
@ -8,7 +8,8 @@ interface NotificationBadgeProps {
className?: string;
}
export function NotificationBadge({ className }: NotificationBadgeProps) {
// Use React.memo to prevent unnecessary re-renders
export const NotificationBadge = memo(function NotificationBadge({ className }: NotificationBadgeProps) {
const { notificationCount } = useNotifications();
const hasUnread = notificationCount.unread > 0;
@ -32,4 +33,4 @@ export function NotificationBadge({ className }: NotificationBadgeProps) {
</Link>
</div>
);
}
});

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useSession } from 'next-auth/react';
import { Notification, NotificationCount } from '@/lib/types/notification';
@ -9,20 +9,47 @@ const defaultNotificationCount: NotificationCount = {
sources: {}
};
// Debounce function to limit API calls
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return function(...args: Parameters<T>) {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
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 [pollingInterval, setPollingInterval] = useState<NodeJS.Timeout | null>(null);
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const lastFetchTimeRef = useRef<number>(0);
const isMountedRef = useRef<boolean>(false);
const isPollingRef = useRef<boolean>(false);
// Minimum time between fetches (in milliseconds)
const MIN_FETCH_INTERVAL = 5000; // 5 seconds
const POLLING_INTERVAL = 60000; // 1 minute
// Fetch notification count
const fetchNotificationCount = useCallback(async () => {
if (!session?.user) return;
// Fetch notification count with rate limiting
const fetchNotificationCount = useCallback(async (force = false) => {
if (!session?.user || !isMountedRef.current) return;
const now = Date.now();
if (!force && now - lastFetchTimeRef.current < MIN_FETCH_INTERVAL) {
console.log('Skipping notification count fetch - too soon');
return;
}
try {
setError(null);
lastFetchTimeRef.current = now;
const response = await fetch('/api/notifications/count');
@ -34,19 +61,34 @@ export function useNotifications() {
}
const data = await response.json();
setNotificationCount(data);
if (isMountedRef.current) {
setNotificationCount(data);
}
} catch (err) {
console.error('Error fetching notification count:', err);
setError('Failed to fetch notification count');
}
}, [session?.user]);
// Debounced version to prevent rapid successive calls
const debouncedFetchCount = useCallback(
debounce(fetchNotificationCount, 300),
[fetchNotificationCount]
);
// Fetch notifications
const fetchNotifications = useCallback(async (page = 1, limit = 20) => {
if (!session?.user) return;
if (!session?.user || !isMountedRef.current) return;
const now = Date.now();
if (now - lastFetchTimeRef.current < MIN_FETCH_INTERVAL) {
console.log('Skipping notifications fetch - too soon');
return;
}
setLoading(true);
setError(null);
lastFetchTimeRef.current = now;
try {
const response = await fetch(`/api/notifications?page=${page}&limit=${limit}`);
@ -59,18 +101,22 @@ export function useNotifications() {
}
const data = await response.json();
setNotifications(data.notifications);
if (isMountedRef.current) {
setNotifications(data.notifications);
}
} catch (err) {
console.error('Error fetching notifications:', err);
setError('Failed to fetch notifications');
} finally {
setLoading(false);
if (isMountedRef.current) {
setLoading(false);
}
}
}, [session?.user]);
// Mark notification as read
const markAsRead = useCallback(async (notificationId: string) => {
if (!session?.user) return;
if (!session?.user) return false;
try {
const response = await fetch(`/api/notifications/${notificationId}/read`, {
@ -96,18 +142,18 @@ export function useNotifications() {
);
// Refresh notification count
fetchNotificationCount();
debouncedFetchCount(true);
return true;
} catch (err) {
console.error('Error marking notification as read:', err);
return false;
}
}, [session?.user, fetchNotificationCount]);
}, [session?.user, debouncedFetchCount]);
// Mark all notifications as read
const markAllAsRead = useCallback(async () => {
if (!session?.user) return;
if (!session?.user) return false;
try {
const response = await fetch('/api/notifications/read-all', {
@ -129,55 +175,62 @@ export function useNotifications() {
);
// Refresh notification count
fetchNotificationCount();
debouncedFetchCount(true);
return true;
} catch (err) {
console.error('Error marking all notifications as read:', err);
return false;
}
}, [session?.user, fetchNotificationCount]);
}, [session?.user, debouncedFetchCount]);
// Start polling for notification count
const startPolling = useCallback((interval = 30000) => {
if (pollingInterval) {
clearInterval(pollingInterval);
const startPolling = useCallback(() => {
if (isPollingRef.current) return;
isPollingRef.current = true;
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
}
const id = setInterval(() => {
fetchNotificationCount();
}, interval);
// Ensure we don't create multiple intervals
pollingIntervalRef.current = setInterval(() => {
if (isMountedRef.current) {
debouncedFetchCount();
}
}, POLLING_INTERVAL);
setPollingInterval(id);
return () => {
clearInterval(id);
setPollingInterval(null);
};
}, [fetchNotificationCount, pollingInterval]);
return () => stopPolling();
}, [debouncedFetchCount]);
// Stop polling
const stopPolling = useCallback(() => {
if (pollingInterval) {
clearInterval(pollingInterval);
setPollingInterval(null);
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
}, [pollingInterval]);
isPollingRef.current = false;
}, []);
// Initialize fetching on component mount
// Initialize fetching on component mount and cleanup on unmount
useEffect(() => {
isMountedRef.current = true;
if (status === 'authenticated' && session?.user) {
fetchNotificationCount();
// Initial fetches
fetchNotificationCount(true);
fetchNotifications();
// Start polling
const cleanup = startPolling();
return () => {
cleanup();
};
startPolling();
}
}, [status, session?.user, fetchNotificationCount, fetchNotifications, startPolling]);
return () => {
isMountedRef.current = false;
stopPolling();
};
}, [status, session?.user, fetchNotificationCount, fetchNotifications, startPolling, stopPolling]);
return {
notifications,
@ -185,10 +238,8 @@ export function useNotifications() {
loading,
error,
fetchNotifications,
fetchNotificationCount,
fetchNotificationCount: () => debouncedFetchCount(true),
markAsRead,
markAllAsRead,
startPolling,
stopPolling
markAllAsRead
};
}

View File

@ -278,8 +278,27 @@ export class NotificationService {
// Use setTimeout to make this non-blocking
setTimeout(async () => {
try {
// Check if we've refreshed recently (within the last minute)
// to avoid excessive refreshes from multiple tabs/components
const refreshKey = `notifications:last_refresh:${userId}`;
const lastRefresh = await redis.get(refreshKey);
if (lastRefresh) {
const lastRefreshTime = parseInt(lastRefresh, 10);
const now = Date.now();
// If refreshed in the last minute, skip
if (now - lastRefreshTime < 60000) {
console.log(`[NOTIFICATION_SERVICE] Skipping background refresh for user ${userId} - refreshed recently`);
return;
}
}
console.log(`[NOTIFICATION_SERVICE] Background refresh started for user ${userId}`);
// Set last refresh time
await redis.set(refreshKey, Date.now().toString(), 'EX', 120); // 2 minute TTL
// Refresh counts and notifications (for first page)
await this.getNotificationCount(userId);
await this.getNotifications(userId, 1, 20);