diff --git a/app/api/debug/notifications/route.ts b/app/api/debug/notifications/route.ts index dbc6a61..09f1d6c 100644 --- a/app/api/debug/notifications/route.ts +++ b/app/api/debug/notifications/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from "@/app/api/auth/options"; -import { NotificationService } from '@/lib/services/notifications/notification-service'; +import { NotificationRegistry } from '@/lib/services/notifications/notification-registry'; // GET /api/debug/notifications export async function GET(request: Request) { @@ -36,20 +36,20 @@ export async function GET(request: Request) { email: session.user.email || 'Unknown' }; - // Test notification service - console.log(`[DEBUG] Testing notification service`); - const notificationService = NotificationService.getInstance(); + // Test notification registry + console.log(`[DEBUG] Testing notification registry`); + const registry = NotificationRegistry.getInstance(); // Get notification count console.log(`[DEBUG] Getting notification count`); const startTimeCount = Date.now(); - const notificationCount = await notificationService.getNotificationCount(userId); + const notificationCount = await registry.getCount(userId); const timeForCount = Date.now() - startTimeCount; // Get notifications console.log(`[DEBUG] Getting notifications`); const startTimeNotifications = Date.now(); - const notifications = await notificationService.getNotifications(userId, 1, 10); + const notifications = await registry.getNotifications(userId, 10); const timeForNotifications = Date.now() - startTimeNotifications; return NextResponse.json({ @@ -57,7 +57,7 @@ export async function GET(request: Request) { timestamp: new Date().toISOString(), userInfo, environmentVariables: envStatus, - notificationServiceTest: { + notificationRegistryTest: { count: { result: notificationCount, timeMs: timeForCount diff --git a/app/api/notifications/[id]/read/route.ts b/app/api/notifications/[id]/read/route.ts new file mode 100644 index 0000000..5f8d90e --- /dev/null +++ b/app/api/notifications/[id]/read/route.ts @@ -0,0 +1,96 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from "@/app/api/auth/options"; +import { NotificationRegistry } from '@/lib/services/notifications/notification-registry'; +import { logger } from '@/lib/logger'; + +// POST /api/notifications/[id]/read +export async function POST( + request: Request, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const notificationId = params.id; + if (!notificationId) { + return NextResponse.json( + { error: "Notification ID is required" }, + { status: 400 } + ); + } + + // Parse notification ID to extract source and sourceId + // Format: "source-sourceId" + const parts = notificationId.split('-'); + if (parts.length < 2) { + return NextResponse.json( + { error: "Invalid notification ID format" }, + { status: 400 } + ); + } + + const source = parts[0]; + const sourceId = parts.slice(1).join('-'); // In case sourceId contains dashes + + const registry = NotificationRegistry.getInstance(); + + // For now, we just remove the item from the cache + // In a full implementation, you might want to store read status in a database + // For simplicity, we'll just remove it from the items cache + try { + const redis = await import('@/lib/redis').then(m => m.getRedisClient()); + const itemsKey = `notifications:items:${session.user.id}:${source}`; + + // Get current items + const items = await redis.get(itemsKey); + if (items) { + const parsed = JSON.parse(items); + // Filter out the read item + const filtered = parsed.filter((item: any) => item.id !== sourceId); + + // Update cache + await redis.set(itemsKey, JSON.stringify(filtered), 'EX', 300); + + // Update count (decrement unread) + const count = await registry.getCount(session.user.id); + if (count.sources[source] && count.sources[source].unread > 0) { + count.sources[source].unread = Math.max(0, count.sources[source].unread - 1); + count.unread = Object.values(count.sources).reduce( + (sum, s) => sum + s.unread, + 0 + ); + + const countKey = `notifications:count:${session.user.id}`; + await redis.set(countKey, JSON.stringify(count), 'EX', 30); + } + } + } catch (error) { + logger.error('[NOTIFICATIONS_READ] Error marking as read', { + userId: session.user.id, + notificationId, + error: error instanceof Error ? error.message : String(error), + }); + } + + logger.debug('[NOTIFICATIONS_READ] Notification marked as read', { + userId: session.user.id, + notificationId, + source, + sourceId, + }); + + return NextResponse.json({ success: true }); + } catch (error: any) { + logger.error('[NOTIFICATIONS_READ] Error', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: "Internal server error", message: error.message }, + { status: 500 } + ); + } +} diff --git a/components/main-nav.tsx b/components/main-nav.tsx index 9b9343b..460daf1 100644 --- a/components/main-nav.tsx +++ b/components/main-nav.tsx @@ -37,7 +37,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { NotificationBadge } from "./notification-badge"; +import { NotificationBadge } from "./notification-badge-enhanced"; import { NotesDialog } from "./notes-dialog"; import { MainNavTime } from "./main-nav-time"; diff --git a/components/notification-badge-enhanced.tsx b/components/notification-badge-enhanced.tsx new file mode 100644 index 0000000..d00834a --- /dev/null +++ b/components/notification-badge-enhanced.tsx @@ -0,0 +1,271 @@ +import React, { memo, useState, useEffect, useMemo } from 'react'; +import Link from 'next/link'; +import { + Bell, ExternalLink, AlertCircle, LogIn, Kanban, MessageSquare, Mail, Calendar, + Check, X, Filter, ChevronDown, ChevronUp, RefreshCw +} from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { useNotifications } from '@/hooks/use-notifications'; +import { Button } from '@/components/ui/button'; +import { useSession, signIn } from 'next-auth/react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, + DropdownMenuLabel, +} from '@/components/ui/dropdown-menu'; +import { formatDistanceToNow } from 'date-fns'; +import { SafeHTML } from '@/components/safe-html'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; + +interface NotificationBadgeProps { + className?: string; +} + +type SortOption = 'newest' | 'oldest'; +type FilterOption = 'all' | 'email' | 'rocketchat' | 'leantime' | 'calendar'; + +// Use React.memo to prevent unnecessary re-renders +export const NotificationBadge = memo(function NotificationBadge({ className }: NotificationBadgeProps) { + const { data: session, status } = useSession(); + const { notifications, notificationCount, fetchNotifications, markAsRead, loading, error } = useNotifications(); + const hasUnread = notificationCount.unread > 0; + const [isOpen, setIsOpen] = useState(false); + const [sortBy, setSortBy] = useState('newest'); + const [filterBy, setFilterBy] = useState('all'); + const [displayLimit, setDisplayLimit] = useState(10); + + // Fetch notifications when dropdown opens + useEffect(() => { + if (isOpen && status === 'authenticated') { + fetchNotifications(1, 50, filterBy === 'all' ? undefined : filterBy); + } + }, [isOpen, status, filterBy, fetchNotifications]); + + // Sort and filter notifications + const sortedAndFilteredNotifications = useMemo(() => { + let filtered = notifications; + + // Filter by source + if (filterBy !== 'all') { + filtered = filtered.filter(n => n.source === filterBy); + } + + // Sort + const sorted = [...filtered].sort((a, b) => { + const dateA = new Date(a.timestamp).getTime(); + const dateB = new Date(b.timestamp).getTime(); + return sortBy === 'newest' ? dateB - dateA : dateA - dateB; + }); + + return sorted.slice(0, displayLimit); + }, [notifications, filterBy, sortBy, displayLimit]); + + const handleMarkAsRead = async (notificationId: string, e: React.MouseEvent) => { + e.stopPropagation(); + await markAsRead(notificationId); + }; + + const handleOpenChange = (open: boolean) => { + setIsOpen(open); + }; + + const handleLoadMore = () => { + setDisplayLimit(prev => prev + 10); + }; + + const hasMore = notifications.length > displayLimit; + + // Special case for auth error + const isAuthError = error?.includes('Not authenticated') || error?.includes('401'); + + return ( +
+ + + + + + {/* Header */} +
+

Notifications

+
+ +
+
+ + {/* Filters and Sort */} +
+
+ + + +
+
+ + {/* Notifications List */} +
+ {loading ? ( +
+
+

Chargement...

+
+ ) : isAuthError ? ( +
+ +

Authentification requise

+ +
+ ) : error ? ( +
+ +

{error}

+ +
+ ) : sortedAndFilteredNotifications.length === 0 ? ( +
+

Aucune notification

+
+ ) : ( + <> + {sortedAndFilteredNotifications.map((notification) => ( + e.preventDefault()} + > +
+
+
+
+ {!notification.isRead && notification.source === 'rocketchat' && ( + + )} + + {notification.title} + + {!notification.isRead && notification.source !== 'rocketchat' && ( + New + )} + {notification.source === 'leantime' && ( + + + Agilité + + )} + {notification.source === 'rocketchat' && ( + + + Parole + + )} + {notification.source === 'email' && ( + + + Courrier + + )} + {notification.source === 'calendar' && ( + + + Agenda + + )} +
+ + + {formatDistanceToNow(new Date(notification.timestamp), { addSuffix: true })} + +
+
+ {!notification.isRead && ( + + )} + {notification.link && ( + setIsOpen(false)}> + + + )} +
+
+
+
+ ))} + {hasMore && ( +
+ +
+ )} + + )} +
+
+
+
+ ); +}); diff --git a/hooks/use-email-state.ts b/hooks/use-email-state.ts index e9abc3e..d89ce9e 100644 --- a/hooks/use-email-state.ts +++ b/hooks/use-email-state.ts @@ -1,7 +1,6 @@ import { useReducer, useCallback, useEffect, useRef } from 'react'; import { useSession } from 'next-auth/react'; import { useToast } from './use-toast'; -import { useTriggerNotification } from './use-trigger-notification'; import { emailReducer, initialState, @@ -550,8 +549,7 @@ export const useEmailState = () => { if (data.newestEmailId && data.newestEmailId > lastKnownEmailId) { logEmailOp('NEW_EMAILS', `Found new emails, newest ID: ${data.newestEmailId} (current: ${lastKnownEmailId})`); - // ⚡ Déclencher immédiatement le refresh des notifications - triggerNotificationRefresh(); + // Note: Notifications are now handled by the email widget itself via useWidgetNotification // Show a toast notification with the new custom variant toast({ @@ -574,7 +572,7 @@ export const useEmailState = () => { } catch (error) { console.error('Error checking for new emails:', error); } - }, [session?.user?.id, state.currentFolder, state.isLoading, state.emails, state.perPage, toast, loadEmails, logEmailOp, dispatch, triggerNotificationRefresh]); + }, [session?.user?.id, state.currentFolder, state.isLoading, state.emails, state.perPage, toast, loadEmails, logEmailOp, dispatch]); // Delete emails const deleteEmails = useCallback(async (emailIds: string[]) => { diff --git a/hooks/use-notifications.ts b/hooks/use-notifications.ts index a19bb39..7f14473 100644 --- a/hooks/use-notifications.ts +++ b/hooks/use-notifications.ts @@ -32,7 +32,7 @@ export function useNotifications() { // 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?force=true&_t=${Date.now()}` : '/api/notifications/count'; const data = await requestDeduplicator.execute( @@ -69,24 +69,55 @@ export function useNotifications() { } }, [session?.user]); + // Mark notification as read + const markAsRead = useCallback(async (notificationId: string) => { + if (!session?.user || !isMountedRef.current) return false; + + try { + const response = await fetch(`/api/notifications/${notificationId}/read`, { + method: 'POST', + credentials: 'include', + }); + + if (!response.ok) { + throw new Error('Failed to mark notification as read'); + } + + // Update local state - remove from list and update count + setNotifications(prev => prev.filter(n => n.id !== notificationId)); + setNotificationCount(prev => ({ + ...prev, + unread: Math.max(0, prev.unread - 1), + })); + + return true; + } catch (err: any) { + console.error('Error marking notification as read:', err); + setError(err.message || 'Failed to mark notification as read'); + return false; + } + }, [session?.user]); + // Fetch notifications with request deduplication - const fetchNotifications = useCallback(async (page = 1, limit = 20) => { + const fetchNotifications = useCallback(async (page = 1, limit = 20, source?: string) => { if (!session?.user || !isMountedRef.current) return; setLoading(true); setError(null); try { - console.log('[useNotifications] Fetching notifications', { page, limit }); + console.log('[useNotifications] Fetching notifications', { page, limit, source }); // Use request deduplication to prevent duplicate calls - const requestKey = `notifications-${session.user.id}-${page}-${limit}`; + const requestKey = `notifications-${session.user.id}-${page}-${limit}-${source || 'all'}`; + const url = `/api/notifications?limit=${limit}${source ? `&source=${source}` : ''}`; const data = await requestDeduplicator.execute( requestKey, async () => { - const response = await fetch(`/api/notifications?page=${page}&limit=${limit}`, { - credentials: 'include' + const response = await fetch(url, { + credentials: 'include', + cache: 'no-store', }); if (!response.ok) { @@ -104,7 +135,12 @@ export function useNotifications() { ); if (isMountedRef.current) { - setNotifications(data.notifications); + // Filter by source if specified + let filtered = data.notifications || []; + if (source) { + filtered = filtered.filter((n: Notification) => n.source === source); + } + setNotifications(filtered); } } catch (err: any) { console.error('Error fetching notifications:', err); @@ -171,5 +207,6 @@ export function useNotifications() { error, fetchNotifications, fetchNotificationCount: () => fetchNotificationCount(true), + markAsRead, }; } \ No newline at end of file diff --git a/hooks/use-trigger-notification.ts b/hooks/use-trigger-notification.ts deleted file mode 100644 index 6478d49..0000000 --- a/hooks/use-trigger-notification.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { useSession } from 'next-auth/react'; -import { useCallback, useRef } from 'react'; - -/** - * Hook to trigger immediate notification refresh - * Use this when widgets detect new messages/emails - */ -export function useTriggerNotification() { - const { data: session } = useSession(); - const lastTriggerRef = useRef(0); - const TRIGGER_DEBOUNCE_MS = 2000; // 2 seconds debounce - - const triggerNotificationRefresh = useCallback(async () => { - if (!session?.user?.id) return; - - // Debounce: prevent multiple triggers within 2 seconds - const now = Date.now(); - if (now - lastTriggerRef.current < TRIGGER_DEBOUNCE_MS) { - console.log('[useTriggerNotification] Debouncing trigger (too soon)'); - return; - } - lastTriggerRef.current = now; - - try { - console.log('[useTriggerNotification] Triggering notification refresh'); - - // Dispatch custom event for immediate UI update - window.dispatchEvent(new CustomEvent('trigger-notification-refresh')); - - // Force refresh du notification count en invalidant le cache - const response = await fetch(`/api/notifications/count?_t=${Date.now()}&force=true`, { - method: 'GET', - credentials: 'include', - cache: 'no-store', - headers: { - 'Cache-Control': 'no-cache', - } - }); - - if (response.ok) { - console.log('[useTriggerNotification] Notification refresh triggered successfully'); - } else { - console.warn('[useTriggerNotification] Failed to trigger refresh:', response.status); - } - } catch (error) { - console.error('[useTriggerNotification] Error triggering notification refresh:', error); - } - }, [session?.user?.id]); - - return { triggerNotificationRefresh }; -} diff --git a/lib/services/notifications/email-adapter.ts b/lib/services/notifications/email-adapter.ts deleted file mode 100644 index 30a450e..0000000 --- a/lib/services/notifications/email-adapter.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { NotificationAdapter } from './notification-adapter.interface'; -import { logger } from '@/lib/logger'; -import { Notification, NotificationCount } from '@/lib/types/notification'; -import { getRedisClient } from '@/lib/redis'; -import { getImapConnection, shouldUseGraphAPI, getUserEmailAccounts } from '@/lib/services/email-service'; -import { getGraphUnreadCount, fetchGraphEmails } from '@/lib/services/microsoft-graph-mail'; - -export class EmailAdapter implements NotificationAdapter { - readonly sourceName = 'email'; - private static readonly UNREAD_COUNTS_CACHE_KEY = (userId: string) => `email:unread:${userId}`; - private static readonly CACHE_TTL = 120; // 2 minutes (aligned with unread-counts API) - - async isConfigured(): Promise { - // Email service is always configured if user has email accounts - return true; - } - - /** - * Fetch unread counts from IMAP (same logic as unread-counts API) - */ - private async fetchUnreadCounts(userId: string): Promise>> { - // Get all accounts from the database - const accounts = await getUserEmailAccounts(userId); - - logger.debug('[EMAIL_ADAPTER] Found accounts', { - userId, - accountCount: accounts.length, - }); - - if (accounts.length === 0) { - return { default: {} }; - } - - // Mapping to hold the unread counts - const unreadCounts: Record> = {}; - - // For each account, get the unread counts for standard folders - for (const account of accounts) { - const accountId = account.id; - try { - logger.debug('[EMAIL_ADAPTER] Processing account', { - userId, - accountId, - email: account.email, - }); - - // Check if this is a Microsoft account that should use Graph API - const graphCheck = await shouldUseGraphAPI(userId, accountId); - - unreadCounts[accountId] = {}; - - if (graphCheck.useGraph && graphCheck.mailCredentialId) { - // Use Graph API for Microsoft accounts - try { - const unreadCount = await getGraphUnreadCount(graphCheck.mailCredentialId, 'Inbox'); - unreadCounts[accountId]['INBOX'] = unreadCount; - - logger.debug('[EMAIL_ADAPTER] Unread count (Graph API)', { - userId, - accountId, - folder: 'INBOX', - unread: unreadCount, - }); - } catch (graphError) { - logger.error('[EMAIL_ADAPTER] Error getting unread count via Graph API', { - userId, - accountId, - error: graphError instanceof Error ? graphError.message : String(graphError), - }); - } - } else { - // Use IMAP for non-Microsoft accounts - const client = await getImapConnection(userId, accountId); - - // Standard folders to check (focus on INBOX for notifications) - const standardFolders = ['INBOX']; - - // Get mailboxes for this account to check if folders exist - const mailboxes = await client.list(); - const availableFolders = mailboxes.map(mb => mb.path); - - // Check each standard folder if it exists - for (const folder of standardFolders) { - // Skip if folder doesn't exist in this account - if (!availableFolders.includes(folder) && - !availableFolders.some(f => f.toLowerCase() === folder.toLowerCase())) { - continue; - } - - try { - // Check folder status without opening it (more efficient) - const status = await client.status(folder, { unseen: true }); - - if (status && typeof status.unseen === 'number') { - // Store the unread count - unreadCounts[accountId][folder] = status.unseen; - - logger.debug('[EMAIL_ADAPTER] Unread count (IMAP)', { - userId, - accountId, - folder, - unread: status.unseen, - }); - } - } catch (folderError) { - logger.error('[EMAIL_ADAPTER] Error getting unread count for folder', { - userId, - accountId, - folder, - error: folderError instanceof Error ? folderError.message : String(folderError), - }); - // Continue to next folder even if this one fails - } - } - } - } catch (accountError) { - logger.error('[EMAIL_ADAPTER] Error processing account', { - userId, - accountId, - error: accountError instanceof Error ? accountError.message : String(accountError), - }); - } - } - - return unreadCounts; - } - - /** - * Get user's email accounts and calculate total unread count - */ - async getNotificationCount(userId: string): Promise { - logger.debug('[EMAIL_ADAPTER] getNotificationCount called', { userId }); - - try { - // Try to get from cache first (same cache as unread-counts API) - const redis = getRedisClient(); - const cacheKey = EmailAdapter.UNREAD_COUNTS_CACHE_KEY(userId); - - let unreadCounts: Record> | null = null; - - try { - const cachedData = await redis.get(cacheKey); - if (cachedData) { - unreadCounts = JSON.parse(cachedData); - logger.debug('[EMAIL_ADAPTER] Using cached unread counts', { userId }); - } - } catch (error) { - logger.debug('[EMAIL_ADAPTER] Cache miss or error', { - userId, - error: error instanceof Error ? error.message : String(error), - }); - } - - // If no cache, fetch directly (but don't cache here - let the API route handle caching) - if (!unreadCounts) { - try { - // Fetch unread counts directly - unreadCounts = await this.fetchUnreadCounts(userId); - logger.debug('[EMAIL_ADAPTER] Fetched unread counts directly', { userId }); - } catch (error) { - logger.error('[EMAIL_ADAPTER] Error fetching unread counts', { - userId, - error: error instanceof Error ? error.message : String(error), - }); - return { - total: 0, - unread: 0, - sources: { - email: { - total: 0, - unread: 0 - } - } - }; - } - } - - // Calculate total unread count across all accounts and folders - // Focus on INBOX for notifications - let totalUnread = 0; - let foldersWithUnread = 0; - - for (const accountId in unreadCounts) { - const accountFolders = unreadCounts[accountId]; - // Focus on INBOX folder for notifications - const inboxCount = accountFolders['INBOX'] || accountFolders[`${accountId}:INBOX`] || 0; - if (inboxCount > 0) { - totalUnread += inboxCount; - foldersWithUnread++; - } - } - - logger.debug('[EMAIL_ADAPTER] Notification counts', { - userId, - total: foldersWithUnread, - unread: totalUnread, - }); - - return { - total: foldersWithUnread, - unread: totalUnread, - sources: { - email: { - total: foldersWithUnread, - unread: totalUnread - } - } - }; - } catch (error) { - logger.error('[EMAIL_ADAPTER] Error getting notification count', { - userId, - error: error instanceof Error ? error.message : String(error), - }); - return { - total: 0, - unread: 0, - sources: { - email: { - total: 0, - unread: 0 - } - } - }; - } - } - - async getNotifications(userId: string, page = 1, limit = 20): Promise { - logger.debug('[EMAIL_ADAPTER] getNotifications called', { userId, page, limit }); - - try { - // Get all accounts from the database - const accounts = await getUserEmailAccounts(userId); - - if (accounts.length === 0) { - return []; - } - - const notifications: Notification[] = []; - - // For each account, get unread emails from INBOX - // Use the same flow as getEmails() but filter for unread only - for (const account of accounts) { - try { - // Check if this is a Microsoft account that should use Graph API - const graphCheck = await shouldUseGraphAPI(userId, account.id); - - if (graphCheck.useGraph && graphCheck.mailCredentialId) { - // Use Graph API for Microsoft accounts - try { - const graphResult = await fetchGraphEmails( - graphCheck.mailCredentialId, - 'Inbox', - limit * 3, // Get more than limit to have enough after filtering - 0, - 'isRead eq false' // Filter for unread only - ); - - // Convert Graph messages to notifications - for (const graphMessage of graphResult.value) { - if (graphMessage.isRead) continue; // Double-check unread status - - notifications.push({ - id: `email-${graphMessage.id}`, - source: 'email', - title: graphMessage.subject || '(No subject)', - message: graphMessage.bodyPreview || '', - timestamp: new Date(graphMessage.receivedDateTime), - read: false, - link: `/courrier/${account.id}?email=${graphMessage.id}`, - metadata: { - accountId: account.id, - accountEmail: account.email, - emailId: graphMessage.id, - folder: 'INBOX', - }, - }); - - if (notifications.length >= limit) { - break; - } - } - } catch (graphError) { - logger.error('[EMAIL_ADAPTER] Error fetching notifications via Graph API', { - userId, - accountId: account.id, - error: graphError instanceof Error ? graphError.message : String(graphError), - }); - } - continue; // Skip IMAP processing for Microsoft accounts - } - - // Use IMAP for non-Microsoft accounts - const client = await getImapConnection(userId, account.id); - - // Use the same approach as getEmails() - open mailbox first - const mailboxInfo = await client.mailboxOpen('INBOX'); - - if (!mailboxInfo || typeof mailboxInfo === 'boolean') { - logger.debug('[EMAIL_ADAPTER] Could not open INBOX', { - userId, - accountId: account.id, - }); - continue; - } - - const totalEmails = mailboxInfo.exists || 0; - - if (totalEmails === 0) { - continue; - } - - // Search for unread emails (same as getNotificationCount logic) - const searchResult = await client.search({ seen: false }); - - if (!searchResult || searchResult.length === 0) { - continue; - } - - // Limit the number of results for performance (get more than limit to have enough after filtering) - const limitedResults = searchResult.slice(0, limit * 3); - - // Fetch email metadata using the same structure as getEmails() - const messages = await client.fetch(limitedResults, { - envelope: true, - flags: true, - uid: true - }); - - // Convert to notifications (same format as getEmails() processes emails) - for await (const message of messages) { - // Filter: only process if truly unread (double-check flags) - if (message.flags && message.flags.has('\\Seen')) { - continue; - } - - const envelope = message.envelope; - const from = envelope.from?.[0]; - const subject = envelope.subject || '(Sans objet)'; - const fromName = from?.name || from?.address || 'Expéditeur inconnu'; - const fromAddress = from?.address || ''; - - const notification: Notification = { - id: `email-${account.id}-${message.uid}`, - source: 'email', - sourceId: message.uid.toString(), - type: 'email', - title: 'Email', - message: `${fromName}${fromAddress ? ` <${fromAddress}>` : ''}: ${subject}`, - link: `/courrier?accountId=${account.id}&folder=INBOX&emailId=${message.uid}`, - isRead: false, - timestamp: envelope.date || new Date(), - priority: 'normal', - user: { - id: fromAddress, - name: fromName, - }, - metadata: { - accountId: account.id, - accountEmail: account.email, - folder: 'INBOX', - emailId: message.uid.toString(), - } - }; - - notifications.push(notification); - } - - // Close mailbox (same as getEmails() does implicitly via connection pool) - try { - await client.mailboxClose(); - } catch (closeError) { - // Non-fatal, connection pool will handle it - logger.debug('[EMAIL_ADAPTER] Error closing mailbox (non-fatal)', { - error: closeError instanceof Error ? closeError.message : String(closeError), - }); - } - } catch (accountError) { - logger.error('[EMAIL_ADAPTER] Error processing account for notifications', { - userId, - accountId: account.id, - error: accountError instanceof Error ? accountError.message : String(accountError), - }); - continue; - } - } - - // Sort by timestamp (newest first) and apply pagination - notifications.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); - - const startIndex = (page - 1) * limit; - const endIndex = startIndex + limit; - const paginatedNotifications = notifications.slice(startIndex, endIndex); - - logger.debug('[EMAIL_ADAPTER] getNotifications result', { - total: notifications.length, - returned: paginatedNotifications.length, - page, - limit, - }); - - return paginatedNotifications; - } catch (error) { - logger.error('[EMAIL_ADAPTER] Error getting notifications', { - error: error instanceof Error ? error.message : String(error), - }); - return []; - } - } - -} diff --git a/lib/services/notifications/leantime-adapter.ts b/lib/services/notifications/leantime-adapter.ts deleted file mode 100644 index ba3c994..0000000 --- a/lib/services/notifications/leantime-adapter.ts +++ /dev/null @@ -1,551 +0,0 @@ -import { Notification, NotificationCount } from '@/lib/types/notification'; -import { NotificationAdapter } from './notification-adapter.interface'; -import { getServerSession } from 'next-auth'; -import { authOptions } from "@/app/api/auth/options"; -import { getRedisClient } from '@/lib/redis'; -import { logger } from '@/lib/logger'; - -// Leantime notification type from their API -interface LeantimeNotification { - id: number; - userId: number; - username: string; - message: string; - type: string; - moduleId: number; - url: string; - read: number; // 0 for unread, 1 for read - date: string; // ISO format date -} - -export class LeantimeAdapter implements NotificationAdapter { - readonly sourceName = 'leantime'; - private apiUrl: string; - private apiToken: string; - private static readonly USER_ID_CACHE_TTL = 3600; // 1 hour - private static readonly USER_ID_CACHE_KEY_PREFIX = 'leantime:userid:'; - private static readonly USER_EMAIL_CACHE_TTL = 1800; // 30 minutes - private static readonly USER_EMAIL_CACHE_KEY_PREFIX = 'leantime:useremail:'; - - constructor() { - this.apiUrl = process.env.LEANTIME_API_URL || ''; - this.apiToken = process.env.LEANTIME_TOKEN || ''; - - logger.debug('[LEANTIME_ADAPTER] Initialized', { - hasApiUrl: !!this.apiUrl, - hasApiToken: !!this.apiToken, - }); - } - - /** - * Invalidate cached Leantime user ID for an email - * Useful when user data changes or for debugging - */ - static async invalidateUserIdCache(email: string): Promise { - try { - const redis = getRedisClient(); - const cacheKey = `${LeantimeAdapter.USER_ID_CACHE_KEY_PREFIX}${email.toLowerCase()}`; - await redis.del(cacheKey); - logger.info('[LEANTIME_ADAPTER] Invalidated user ID cache', { - emailHash: Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12), - }); - } catch (error) { - console.error(`[LEANTIME_ADAPTER] Error invalidating user ID cache:`, error); - } - } - - async getNotifications(userId: string, page = 1, limit = 20): Promise { - logger.debug('[LEANTIME_ADAPTER] getNotifications called', { - userId, - page, - limit, - }); - - try { - // Get the user's email directly from the session - const email = await this.getUserEmail(); - - if (!email) { - logger.error('[LEANTIME_ADAPTER] Could not get user email from session'); - return []; - } - - const leantimeUserId = await this.getLeantimeUserId(email); - - if (!leantimeUserId) { - logger.error('[LEANTIME_ADAPTER] User not found in Leantime', { - emailHash: Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12), - }); - return []; - } - - // Calculate pagination limits - const limitStart = (page - 1) * limit; - const limitEnd = limit; - - // Make request to Leantime API using the correct jsonrpc method - const jsonRpcBody = { - jsonrpc: '2.0', - method: 'leantime.rpc.Notifications.Notifications.getAllNotifications', - params: { - userId: leantimeUserId, - showNewOnly: 0, // Get all notifications, not just unread - limitStart: limitStart, - limitEnd: limitEnd, - filterOptions: [] // No additional filters - }, - id: 1 - }; - - const response = await fetch(`${this.apiUrl}/api/jsonrpc`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-API-Key': this.apiToken - }, - body: JSON.stringify(jsonRpcBody) - }); - - logger.debug('[LEANTIME_ADAPTER] getNotifications response status', { - status: response.status, - }); - - if (!response.ok) { - const errorText = await response.text(); - logger.error('[LEANTIME_ADAPTER] Failed to fetch Leantime notifications', { - status: response.status, - bodyPreview: errorText.substring(0, 200), - }); - return []; - } - - const responseText = await response.text(); - const data = JSON.parse(responseText); - - if (!data.result || !Array.isArray(data.result)) { - if (data.error) { - logger.error('[LEANTIME_ADAPTER] API error in getNotifications', { - message: data.error.message, - code: data.error.code, - }); - } else { - logger.error('[LEANTIME_ADAPTER] Invalid response format from Leantime notifications API'); - } - return []; - } - - const notifications = this.transformNotifications(data.result, userId); - logger.debug('[LEANTIME_ADAPTER] Transformed notifications count', { - count: notifications.length, - }); - return notifications; - } catch (error) { - logger.error('[LEANTIME_ADAPTER] Error fetching Leantime notifications', { - error: error instanceof Error ? error.message : String(error), - }); - return []; - } - } - - async getNotificationCount(userId: string): Promise { - logger.debug('[LEANTIME_ADAPTER] getNotificationCount called', { userId }); - - try { - // Fetch notifications directly from API for accurate counting (bypassing cache) - // Fetch up to 1000 notifications to get accurate count - const email = await this.getUserEmail(); - if (!email) { - logger.error('[LEANTIME_ADAPTER] Could not get user email for count'); - return { - total: 0, - unread: 0, - sources: { - leantime: { - total: 0, - unread: 0 - } - } - }; - } - - const leantimeUserId = await this.getLeantimeUserId(email); - if (!leantimeUserId) { - logger.error('[LEANTIME_ADAPTER] Could not get Leantime user ID for count'); - return { - total: 0, - unread: 0, - sources: { - leantime: { - total: 0, - unread: 0 - } - } - }; - } - - // Fetch directly from API (bypassing cache) to get accurate count - const jsonRpcBody = { - jsonrpc: '2.0', - method: 'leantime.rpc.Notifications.Notifications.getAllNotifications', - params: { - userId: leantimeUserId, - showNewOnly: 0, // Get all notifications - limitStart: 0, - limitEnd: 1000, // Fetch up to 1000 for accurate counting - filterOptions: [] - }, - id: 1 - }; - - const response = await fetch(`${this.apiUrl}/api/jsonrpc`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-API-Key': this.apiToken - }, - body: JSON.stringify(jsonRpcBody) - }); - - if (!response.ok) { - logger.error('[LEANTIME_ADAPTER] Failed to fetch notifications for count', { - status: response.status, - }); - return { - total: 0, - unread: 0, - sources: { - leantime: { - total: 0, - unread: 0 - } - } - }; - } - - const data = await response.json(); - - if (data.error || !Array.isArray(data.result)) { - logger.error('[LEANTIME_ADAPTER] Error or invalid response for count', { - error: data.error, - }); - return { - total: 0, - unread: 0, - sources: { - leantime: { - total: 0, - unread: 0 - } - } - }; - } - - const rawNotifications = data.result; - - // Count total and unread from raw data - const totalCount = rawNotifications.length; - const unreadCount = rawNotifications.filter((n: any) => - n.read === 0 || n.read === false || n.read === '0' - ).length; - - const hasMore = totalCount === 1000; // If we got exactly 1000, there might be more - - logger.debug('[LEANTIME_ADAPTER] Notification counts', { - total: totalCount, - unread: unreadCount, - hasMore: hasMore, - }); - - return { - total: totalCount, - unread: unreadCount, - sources: { - leantime: { - total: totalCount, - unread: unreadCount - } - } - }; - } catch (error) { - logger.error('[LEANTIME_ADAPTER] Error fetching notification count', { - error: error instanceof Error ? error.message : String(error), - }); - return { - total: 0, - unread: 0, - sources: { - leantime: { - total: 0, - unread: 0 - } - } - }; - } - } - - async isConfigured(): Promise { - return !!(this.apiUrl && this.apiToken); - } - - private transformNotifications(data: any[], userId: string): Notification[] { - if (!Array.isArray(data)) { - return []; - } - - return data.map(notification => { - // Determine properties from notification object - // Adjust these based on actual structure of Leantime notifications - const id = notification.id || notification._id || notification.notificationId; - const message = notification.message || notification.text || notification.content || ''; - const type = notification.type || 'notification'; - const read = notification.read || notification.isRead || 0; - const date = notification.date || notification.datetime || notification.createdDate || new Date().toISOString(); - const url = notification.url || notification.link || ''; - - // Convert external Leantime URL to hub.slm-lab.net/agilite - let link = url.startsWith('http') ? url : `${this.apiUrl}${url.startsWith('/') ? '' : '/'}${url}`; - // Replace external Leantime URL with hub.slm-lab.net/agilite - if (link.includes('agilite.slm-lab.net') || link.includes(this.apiUrl)) { - link = '/agilite'; - } - - return { - id: `${this.sourceName}-${id}`, - source: this.sourceName as 'leantime', - sourceId: id.toString(), - type: type, - title: type, // Use type as title if no specific title field - message: message, - link: link, - isRead: read === 1 || read === true, - timestamp: new Date(date), - priority: 'normal', - user: { - id: userId, - name: notification.username || notification.userName || '' - }, - metadata: { - // Include any other useful fields from notification - moduleId: notification.moduleId || notification.module || '', - projectId: notification.projectId || '', - } - }; - }); - } - - // Helper function to get user's email with caching - private async getUserEmail(): Promise { - try { - // Get user ID from session first (for cache key) - const session = await getServerSession(authOptions); - if (!session || !session.user?.id) { - return null; - } - - const userId = session.user.id; - const emailCacheKey = `${LeantimeAdapter.USER_EMAIL_CACHE_KEY_PREFIX}${userId}`; - - // Check cache first - try { - const redis = getRedisClient(); - const cachedEmail = await redis.get(emailCacheKey); - if (cachedEmail) { - logger.debug('[LEANTIME_ADAPTER] Found cached email for user', { userId }); - return cachedEmail; - } - } catch (cacheError) { - logger.warn('[LEANTIME_ADAPTER] Error checking email cache, will fetch from session', { - error: cacheError instanceof Error ? cacheError.message : String(cacheError), - }); - } - - // Get from session - if (!session.user?.email) { - return null; - } - - const email = session.user.email; - - // Cache the email - try { - const redis = getRedisClient(); - await redis.set(emailCacheKey, email, 'EX', LeantimeAdapter.USER_EMAIL_CACHE_TTL); - logger.debug('[LEANTIME_ADAPTER] Cached user email', { userId }); - } catch (cacheError) { - logger.warn('[LEANTIME_ADAPTER] Failed to cache email (non-fatal)', { - error: cacheError instanceof Error ? cacheError.message : String(cacheError), - }); - } - - return email; - } catch (error) { - logger.error('[LEANTIME_ADAPTER] Error getting user email from session', { - error: error instanceof Error ? error.message : String(error), - }); - return null; - } - } - - // Helper function to get Leantime user ID by email with caching and retry logic - private async getLeantimeUserId(email: string, retryCount = 0): Promise { - const MAX_RETRIES = 3; - const CACHE_KEY = `${LeantimeAdapter.USER_ID_CACHE_KEY_PREFIX}${email.toLowerCase()}`; - - try { - if (!this.apiToken) { - logger.error('[LEANTIME_ADAPTER] No API token available for getLeantimeUserId'); - return null; - } - - // Check Redis cache first - try { - const redis = getRedisClient(); - const cachedUserId = await redis.get(CACHE_KEY); - if (cachedUserId) { - const userId = parseInt(cachedUserId, 10); - if (!isNaN(userId)) { - logger.debug('[LEANTIME_ADAPTER] Found cached Leantime user ID', { - emailHash: Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12), - userId, - }); - return userId; - } - } - } catch (cacheError) { - logger.warn('[LEANTIME_ADAPTER] Error checking cache for user ID, will fetch from API', { - error: cacheError instanceof Error ? cacheError.message : String(cacheError), - }); - } - - // Fetch from API with retry logic - const fetchWithRetry = async (attempt: number): Promise => { - try { - logger.debug('[LEANTIME_ADAPTER] Fetching Leantime user ID', { - emailHash: Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12), - attempt: attempt + 1, - maxRetries: MAX_RETRIES, - }); - - const response = await fetch(`${this.apiUrl}/api/jsonrpc`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-API-Key': this.apiToken - }, - body: JSON.stringify({ - jsonrpc: '2.0', - method: 'leantime.rpc.users.getAll', - id: 1 - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - logger.error('[LEANTIME_ADAPTER] User lookup API HTTP error', { - status: response.status, - bodyPreview: errorText.substring(0, 200), - }); - - // Retry on server errors (5xx) or rate limiting (429) - if ((response.status >= 500 || response.status === 429) && attempt < MAX_RETRIES) { - const delay = Math.min(1000 * Math.pow(2, attempt), 5000); // Exponential backoff, max 5s - logger.debug('[LEANTIME_ADAPTER] Retrying user lookup after HTTP error', { - attempt: attempt + 1, - delay, - }); - await new Promise(resolve => setTimeout(resolve, delay)); - return fetchWithRetry(attempt + 1); - } - return null; - } - - const data = await response.json(); - - if (data.error) { - logger.error('[LEANTIME_ADAPTER] User lookup JSON-RPC error', { - error: data.error, - }); - // Retry on certain errors - if (attempt < MAX_RETRIES && (data.error.code === -32603 || data.error.code === -32000)) { - const delay = Math.min(1000 * Math.pow(2, attempt), 5000); - logger.debug('[LEANTIME_ADAPTER] Retrying user lookup after JSON-RPC error', { - attempt: attempt + 1, - delay, - }); - await new Promise(resolve => setTimeout(resolve, delay)); - return fetchWithRetry(attempt + 1); - } - return null; - } - - if (!data.result || !Array.isArray(data.result)) { - logger.error('[LEANTIME_ADAPTER] Invalid user lookup response format'); - return null; - } - - const users = data.result; - - // Find user with matching email (check in both username and email fields) - const user = users.find((u: any) => - u.username === email || - u.email === email || - (typeof u.username === 'string' && u.username.toLowerCase() === email.toLowerCase()) - ); - - if (user && user.id) { - const userId = typeof user.id === 'number' ? user.id : parseInt(String(user.id), 10); - - if (!isNaN(userId)) { - // Cache the result - try { - const redis = getRedisClient(); - await redis.set(CACHE_KEY, userId.toString(), 'EX', LeantimeAdapter.USER_ID_CACHE_TTL); - logger.debug('[LEANTIME_ADAPTER] Cached Leantime user ID', { - emailHash: Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12), - userId, - }); - } catch (cacheError) { - logger.warn('[LEANTIME_ADAPTER] Failed to cache user ID (non-fatal)', { - error: cacheError instanceof Error ? cacheError.message : String(cacheError), - }); - // Continue even if caching fails - } - - return userId; - } - } - - logger.warn('[LEANTIME_ADAPTER] User not found in Leantime', { - emailHash: Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12), - }); - return null; - } catch (error) { - logger.error('[LEANTIME_ADAPTER] Error fetching user ID', { - attempt: attempt + 1, - error: error instanceof Error ? error.message : String(error), - }); - - // Retry on network errors - if (attempt < MAX_RETRIES && error instanceof Error) { - const delay = Math.min(1000 * Math.pow(2, attempt), 5000); - logger.debug('[LEANTIME_ADAPTER] Retrying user lookup after network error', { - attempt: attempt + 1, - delay, - }); - await new Promise(resolve => setTimeout(resolve, delay)); - return fetchWithRetry(attempt + 1); - } - - return null; - } - }; - - return await fetchWithRetry(retryCount); - } catch (error) { - logger.error('[LEANTIME_ADAPTER] Fatal error getting Leantime user ID', { - error: error instanceof Error ? error.message : String(error), - }); - return null; - } - } -} \ No newline at end of file diff --git a/lib/services/notifications/notification-adapter.interface.ts b/lib/services/notifications/notification-adapter.interface.ts deleted file mode 100644 index 8f3e33e..0000000 --- a/lib/services/notifications/notification-adapter.interface.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Notification, NotificationCount } from '@/lib/types/notification'; - -export interface NotificationAdapter { - /** - * The source name of this notification adapter - */ - readonly sourceName: string; - - /** - * Fetch all notifications for a user - * @param userId The user ID - * @param page Page number for pagination - * @param limit Number of items per page - * @returns Promise with notification data - */ - getNotifications(userId: string, page?: number, limit?: number): Promise; - - /** - * Get count of notifications for a user - * @param userId The user ID - * @returns Promise with notification count data - */ - getNotificationCount(userId: string): Promise; - - /** - * Check if this adapter is configured and ready to use - * @returns Promise with boolean indicating if adapter is ready - */ - isConfigured(): Promise; -} \ No newline at end of file diff --git a/lib/services/notifications/notification-service.ts b/lib/services/notifications/notification-service.ts deleted file mode 100644 index 35a4bc8..0000000 --- a/lib/services/notifications/notification-service.ts +++ /dev/null @@ -1,426 +0,0 @@ -import { Notification, NotificationCount } from '@/lib/types/notification'; -import { NotificationAdapter } from './notification-adapter.interface'; -import { LeantimeAdapter } from './leantime-adapter'; -import { RocketChatAdapter } from './rocketchat-adapter'; -import { EmailAdapter } from './email-adapter'; -import { getRedisClient } from '@/lib/redis'; -import { logger } from '@/lib/logger'; - -export class NotificationService { - private adapters: Map = new Map(); - private static instance: NotificationService; - - // Cache keys and TTLs - All aligned to 30 seconds for consistency - private static NOTIFICATION_COUNT_CACHE_KEY = (userId: string) => `notifications:count:${userId}`; - private static NOTIFICATIONS_LIST_CACHE_KEY = (userId: string, page: number, limit: number) => - `notifications:list:${userId}:${page}:${limit}`; - private static COUNT_CACHE_TTL = 30; // 30 seconds (aligned with refresh interval) - private static LIST_CACHE_TTL = 30; // 30 seconds (aligned with count cache for consistency) - private static REFRESH_LOCK_KEY = (userId: string) => `notifications:refresh:lock:${userId}`; - private static REFRESH_LOCK_TTL = 30; // 30 seconds - - constructor() { - logger.debug('[NOTIFICATION_SERVICE] Initializing notification service'); - - // Register adapters - this.registerAdapter(new LeantimeAdapter()); - this.registerAdapter(new RocketChatAdapter()); - this.registerAdapter(new EmailAdapter()); - - // More adapters will be added as they are implemented - // this.registerAdapter(new NextcloudAdapter()); - // this.registerAdapter(new GiteaAdapter()); - // this.registerAdapter(new DolibarrAdapter()); - // this.registerAdapter(new MoodleAdapter()); - - logger.debug('[NOTIFICATION_SERVICE] Registered adapters', { - adapters: Array.from(this.adapters.keys()), - }); - } - - /** - * Get the singleton instance of the notification service - */ - public static getInstance(): NotificationService { - if (!NotificationService.instance) { - logger.debug('[NOTIFICATION_SERVICE] Creating new notification service instance'); - NotificationService.instance = new NotificationService(); - } - return NotificationService.instance; - } - - /** - * Register a notification adapter - */ - private registerAdapter(adapter: NotificationAdapter): void { - this.adapters.set(adapter.sourceName, adapter); - logger.debug('[NOTIFICATION_SERVICE] Registered notification adapter', { - adapter: adapter.sourceName, - }); - } - - /** - * Get all notifications for a user from all configured sources - */ - async getNotifications(userId: string, page = 1, limit = 20): Promise { - logger.debug('[NOTIFICATION_SERVICE] getNotifications called', { - userId, - page, - limit, - }); - const redis = getRedisClient(); - const cacheKey = NotificationService.NOTIFICATIONS_LIST_CACHE_KEY(userId, page, limit); - - // Try to get from cache first - try { - const cachedData = await redis.get(cacheKey); - if (cachedData) { - logger.debug('[NOTIFICATION_SERVICE] Using cached notifications', { - userId, - }); - - // Schedule background refresh if TTL is less than half the original value - const ttl = await redis.ttl(cacheKey); - if (ttl < NotificationService.LIST_CACHE_TTL / 2) { - // Background refresh is now handled by unified refresh system - // Only schedule if unified refresh is not active - } - - return JSON.parse(cachedData); - } - } catch (error) { - console.error('[NOTIFICATION_SERVICE] Error retrieving notifications from cache:', error); - } - - // No cached data, fetch from all adapters - logger.debug('[NOTIFICATION_SERVICE] Fetching notifications from adapters', { - userId, - adapterCount: this.adapters.size, - }); - - const allNotifications: Notification[] = []; - const adapterEntries = Array.from(this.adapters.entries()); - logger.debug('[NOTIFICATION_SERVICE] Available adapters', { - adapters: adapterEntries.map(([name]) => name), - }); - - const promises = adapterEntries.map(async ([name, adapter]) => { - logger.debug('[NOTIFICATION_SERVICE] Checking adapter configuration', { - adapter: name, - }); - try { - const configured = await adapter.isConfigured(); - logger.debug('[NOTIFICATION_SERVICE] Adapter configuration result', { - adapter: name, - configured, - }); - - if (configured) { - logger.debug('[NOTIFICATION_SERVICE] Fetching notifications from adapter', { - adapter: name, - userId, - }); - const notifications = await adapter.getNotifications(userId, page, limit); - logger.debug('[NOTIFICATION_SERVICE] Adapter notifications fetched', { - adapter: name, - count: notifications.length, - }); - return notifications; - } else { - logger.debug('[NOTIFICATION_SERVICE] Skipping adapter (not configured)', { - adapter: name, - }); - return []; - } - } catch (error) { - logger.error('[NOTIFICATION_SERVICE] Error fetching notifications from adapter', { - adapter: name, - error: error instanceof Error ? error.message : String(error), - }); - return []; - } - }); - - const results = await Promise.all(promises); - - // Combine all notifications - results.forEach((notifications, index) => { - const adapterName = adapterEntries[index][0]; - logger.debug('[NOTIFICATION_SERVICE] Adding notifications from adapter', { - adapter: adapterName, - count: notifications.length, - }); - allNotifications.push(...notifications); - }); - - // Sort by timestamp (newest first) - allNotifications.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); - logger.debug('[NOTIFICATION_SERVICE] Notifications sorted', { - total: allNotifications.length, - }); - - // Store in cache - try { - await redis.set( - cacheKey, - JSON.stringify(allNotifications), - 'EX', - NotificationService.LIST_CACHE_TTL - ); - logger.debug('[NOTIFICATION_SERVICE] Cached notifications', { - userId, - count: allNotifications.length, - }); - } catch (error) { - logger.error('[NOTIFICATION_SERVICE] Error caching notifications', { - error: error instanceof Error ? error.message : String(error), - }); - } - - return allNotifications; - } - - /** - * Get notification counts for a user - * @param forceRefresh If true, bypass cache and fetch fresh data - */ - async getNotificationCount(userId: string, forceRefresh: boolean = false): Promise { - logger.debug('[NOTIFICATION_SERVICE] getNotificationCount called', { userId, forceRefresh }); - const redis = getRedisClient(); - const cacheKey = NotificationService.NOTIFICATION_COUNT_CACHE_KEY(userId); - - // If force refresh, skip cache - if (!forceRefresh) { - // Try to get from cache first - try { - const cachedData = await redis.get(cacheKey); - if (cachedData) { - logger.debug('[NOTIFICATION_SERVICE] Using cached notification counts', { userId }); - - // Background refresh is now handled by unified refresh system - // Cache TTL is aligned with refresh interval (30s) - - return JSON.parse(cachedData); - } - } catch (error) { - console.error('[NOTIFICATION_SERVICE] Error retrieving notification counts from cache:', error); - } - } else { - logger.debug('[NOTIFICATION_SERVICE] Force refresh requested, bypassing cache', { userId }); - } - - // No cached data, fetch counts from all adapters - logger.debug('[NOTIFICATION_SERVICE] Fetching notification counts from adapters', { - userId, - adapterCount: this.adapters.size, - }); - - const aggregatedCount: NotificationCount = { - total: 0, - unread: 0, - sources: {} - }; - - const adapterEntries = Array.from(this.adapters.entries()); - logger.debug('[NOTIFICATION_SERVICE] Available adapters for count', { - adapters: adapterEntries.map(([name]) => name), - }); - - const promises = adapterEntries.map(async ([name, adapter]) => { - logger.debug('[NOTIFICATION_SERVICE] Checking adapter configuration for count', { - adapter: name, - }); - try { - const configured = await adapter.isConfigured(); - logger.debug('[NOTIFICATION_SERVICE] Adapter configuration result for count', { - adapter: name, - configured, - }); - - if (configured) { - logger.debug('[NOTIFICATION_SERVICE] Fetching notification count from adapter', { - adapter: name, - userId, - }); - const count = await adapter.getNotificationCount(userId); - logger.debug('[NOTIFICATION_SERVICE] Adapter count fetched', { - adapter: name, - total: count.total, - unread: count.unread, - }); - return count; - } else { - logger.debug('[NOTIFICATION_SERVICE] Skipping adapter for count (not configured)', { - adapter: name, - }); - return null; - } - } catch (error) { - logger.error('[NOTIFICATION_SERVICE] Error fetching notification count from adapter', { - adapter: name, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } - }); - - const results = await Promise.all(promises); - - // Combine all counts - results.forEach((count, index) => { - if (!count) return; - - const adapterName = adapterEntries[index][0]; - logger.debug('[NOTIFICATION_SERVICE] Adding counts from adapter', { - adapter: adapterName, - total: count.total, - unread: count.unread, - }); - - aggregatedCount.total += count.total; - aggregatedCount.unread += count.unread; - - // Merge source-specific counts - Object.entries(count.sources).forEach(([source, sourceCount]) => { - aggregatedCount.sources[source] = sourceCount; - }); - }); - - logger.debug('[NOTIFICATION_SERVICE] Aggregated counts', { - userId, - total: aggregatedCount.total, - unread: aggregatedCount.unread, - }); - - // Store in cache - try { - await redis.set( - cacheKey, - JSON.stringify(aggregatedCount), - 'EX', - NotificationService.COUNT_CACHE_TTL - ); - logger.debug('[NOTIFICATION_SERVICE] Cached notification counts', { - userId, - total: aggregatedCount.total, - unread: aggregatedCount.unread, - }); - } catch (error) { - logger.error('[NOTIFICATION_SERVICE] Error caching notification counts', { - error: error instanceof Error ? error.message : String(error), - }); - } - - return aggregatedCount; - } - - /** - * Invalidate notification caches for a user - * Made public so it can be called from API routes for force refresh - */ - public async invalidateCache(userId: string): Promise { - try { - const redis = getRedisClient(); - - // Get all cache keys for this user - const countKey = NotificationService.NOTIFICATION_COUNT_CACHE_KEY(userId); - const listKeysPattern = `notifications:list:${userId}:*`; - - // Delete count cache - await redis.del(countKey); - - // Find and delete list caches using SCAN to avoid blocking Redis - let cursor = "0"; - do { - const [nextCursor, keys] = await redis.scan( - cursor, - "MATCH", - listKeysPattern, - "COUNT", - 100 - ); - cursor = nextCursor; - - if (keys.length > 0) { - await redis.del(...keys); - } - } while (cursor !== "0"); - - logger.debug('[NOTIFICATION_SERVICE] Invalidated notification caches', { - userId, - }); - } catch (error) { - logger.error('[NOTIFICATION_SERVICE] Error invalidating notification caches', { - error: error instanceof Error ? error.message : String(error), - }); - } - } - - /** - * Schedule a background refresh of notification data - */ - private async scheduleBackgroundRefresh(userId: string): Promise { - const redis = getRedisClient(); - const lockKey = NotificationService.REFRESH_LOCK_KEY(userId); - - // Try to acquire a lock to prevent multiple refreshes - const lockAcquired = await redis.set( - lockKey, - Date.now().toString(), - 'EX', - NotificationService.REFRESH_LOCK_TTL, - 'NX' // Set only if the key doesn't exist - ); - - if (!lockAcquired) { - // Another process is already refreshing - return; - } - - // 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) { - logger.debug('[NOTIFICATION_SERVICE] Skipping background refresh (recently refreshed)', { - userId, - }); - return; - } - } - - logger.debug('[NOTIFICATION_SERVICE] Background refresh started', { - 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); - - logger.debug('[NOTIFICATION_SERVICE] Background refresh completed', { - userId, - }); - } catch (error) { - logger.error('[NOTIFICATION_SERVICE] Background refresh failed', { - userId, - error: error instanceof Error ? error.message : String(error), - }); - } finally { - // Release the lock - await redis.del(lockKey).catch(() => {}); - } - }, 0); - } -} \ No newline at end of file diff --git a/lib/services/notifications/rocketchat-adapter.ts b/lib/services/notifications/rocketchat-adapter.ts deleted file mode 100644 index 4447c03..0000000 --- a/lib/services/notifications/rocketchat-adapter.ts +++ /dev/null @@ -1,537 +0,0 @@ -import { NotificationAdapter } from './notification-adapter.interface'; -import { getServerSession } from 'next-auth'; -import { authOptions } from "@/app/api/auth/options"; -import { logger } from '@/lib/logger'; -import { Notification, NotificationCount } from '@/lib/types/notification'; - -export class RocketChatAdapter implements NotificationAdapter { - readonly sourceName = 'rocketchat'; - private baseUrl: string; - - constructor() { - this.baseUrl = process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL?.split('/channel')[0] || ''; - - logger.debug('[ROCKETCHAT_ADAPTER] Initialized', { - hasBaseUrl: !!this.baseUrl, - }); - } - - async isConfigured(): Promise { - return !!this.baseUrl && - !!process.env.ROCKET_CHAT_TOKEN && - !!process.env.ROCKET_CHAT_USER_ID; - } - - /** - * Get user's email from session - */ - private async getUserEmail(): Promise { - try { - const session = await getServerSession(authOptions); - const email = session?.user?.email || null; - logger.debug('[ROCKETCHAT_ADAPTER] getUserEmail', { - hasSession: !!session, - hasEmail: !!email, - emailHash: email ? Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12) : null, - }); - return email; - } catch (error) { - logger.error('[ROCKETCHAT_ADAPTER] Error getting user email', { - error: error instanceof Error ? error.message : String(error), - }); - return null; - } - } - - /** - * Get RocketChat user ID from email - * Uses the same logic as the messages API: search by username OR email - */ - private async getRocketChatUserId(email: string): Promise { - try { - const username = email.split('@')[0]; - if (!username) return null; - - const adminHeaders = { - 'X-Auth-Token': process.env.ROCKET_CHAT_TOKEN!, - 'X-User-Id': process.env.ROCKET_CHAT_USER_ID!, - 'Content-Type': 'application/json' - }; - - const usersResponse = await fetch(`${this.baseUrl}/api/v1/users.list`, { - method: 'GET', - headers: adminHeaders - }); - - if (!usersResponse.ok) { - logger.error('[ROCKETCHAT_ADAPTER] Failed to get users list', { - status: usersResponse.status, - }); - return null; - } - - const usersData = await usersResponse.json(); - if (!usersData.success || !Array.isArray(usersData.users)) { - logger.error('[ROCKETCHAT_ADAPTER] Invalid users list response', { - success: usersData.success, - hasUsers: Array.isArray(usersData.users), - }); - return null; - } - - logger.debug('[ROCKETCHAT_ADAPTER] Searching for user', { - username, - email, - totalUsers: usersData.users.length, - }); - - // Use the exact same logic as the messages API - const currentUser = usersData.users.find((user: any) => - user.username === username || user.emails?.some((e: any) => e.address === email) - ); - - if (!currentUser) { - logger.warn('[ROCKETCHAT_ADAPTER] User not found in RocketChat', { - username, - email, - searchedIn: usersData.users.length, - availableUsernames: usersData.users.slice(0, 5).map((u: any) => u.username), - }); - return null; - } - - logger.debug('[ROCKETCHAT_ADAPTER] Found user', { - username, - email, - rocketChatUsername: currentUser.username, - userId: currentUser._id, - }); - - return currentUser._id; - } catch (error) { - logger.error('[ROCKETCHAT_ADAPTER] Error getting RocketChat user ID', { - error: error instanceof Error ? error.message : String(error), - }); - return null; - } - } - - /** - * Get user token for RocketChat API - * Creates a token for the specified RocketChat user ID - */ - private async getUserToken(rocketChatUserId: string): Promise<{ authToken: string; userId: string } | null> { - try { - const adminHeaders = { - 'X-Auth-Token': process.env.ROCKET_CHAT_TOKEN!, - 'X-User-Id': process.env.ROCKET_CHAT_USER_ID!, - 'Content-Type': 'application/json' - }; - - // Create token for the specific user - // RocketChat 8.0.2+ requires a 'secret' parameter - const secret = process.env.ROCKET_CHAT_CREATE_TOKEN_SECRET; - if (!secret) { - logger.error('[ROCKETCHAT_ADAPTER] ROCKET_CHAT_CREATE_TOKEN_SECRET is not configured'); - return null; - } - - const createTokenResponse = await fetch(`${this.baseUrl}/api/v1/users.createToken`, { - method: 'POST', - headers: adminHeaders, - body: JSON.stringify({ - userId: rocketChatUserId, - secret: secret - }) - }); - - if (!createTokenResponse.ok) { - logger.error('[ROCKETCHAT_ADAPTER] Failed to create user token', { - status: createTokenResponse.status, - rocketChatUserId, - }); - return null; - } - - const tokenData = await createTokenResponse.json(); - - if (!tokenData.success || !tokenData.data) { - logger.error('[ROCKETCHAT_ADAPTER] Invalid token response', { - response: tokenData, - }); - return null; - } - - logger.debug('[ROCKETCHAT_ADAPTER] User token created', { - rocketChatUserId, - tokenUserId: tokenData.data.userId, - }); - - return { - authToken: tokenData.data.authToken, - userId: tokenData.data.userId - }; - } catch (error) { - logger.error('[ROCKETCHAT_ADAPTER] Error getting user token', { - error: error instanceof Error ? error.message : String(error), - rocketChatUserId, - }); - return null; - } - } - - async getNotificationCount(userId: string): Promise { - logger.debug('[ROCKETCHAT_ADAPTER] getNotificationCount called', { userId }); - - try { - const email = await this.getUserEmail(); - if (!email) { - logger.error('[ROCKETCHAT_ADAPTER] Could not get user email'); - return { - total: 0, - unread: 0, - sources: { - rocketchat: { - total: 0, - unread: 0 - } - } - }; - } - - const rocketChatUserId = await this.getRocketChatUserId(email); - if (!rocketChatUserId) { - logger.debug('[ROCKETCHAT_ADAPTER] User not found in RocketChat', { - emailHash: email ? Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12) : null, - }); - return { - total: 0, - unread: 0, - sources: { - rocketchat: { - total: 0, - unread: 0 - } - } - }; - } - - logger.debug('[ROCKETCHAT_ADAPTER] Found RocketChat user', { - rocketChatUserId, - emailHash: Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12), - }); - - const userToken = await this.getUserToken(rocketChatUserId); - if (!userToken) { - logger.error('[ROCKETCHAT_ADAPTER] Could not get user token', { - rocketChatUserId, - }); - return { - total: 0, - unread: 0, - sources: { - rocketchat: { - total: 0, - unread: 0 - } - } - }; - } - - const userHeaders = { - 'X-Auth-Token': userToken.authToken, - 'X-User-Id': userToken.userId, - 'Content-Type': 'application/json' - }; - - // Get user's subscriptions - const subscriptionsResponse = await fetch(`${this.baseUrl}/api/v1/subscriptions.get`, { - method: 'GET', - headers: userHeaders - }); - - if (!subscriptionsResponse.ok) { - logger.error('[ROCKETCHAT_ADAPTER] Failed to get subscriptions', { - status: subscriptionsResponse.status, - }); - return { - total: 0, - unread: 0, - sources: { - rocketchat: { - total: 0, - unread: 0 - } - } - }; - } - - const subscriptionsData = await subscriptionsResponse.json(); - if (!subscriptionsData.success || !Array.isArray(subscriptionsData.update)) { - logger.error('[ROCKETCHAT_ADAPTER] Invalid subscriptions response'); - return { - total: 0, - unread: 0, - sources: { - rocketchat: { - total: 0, - unread: 0 - } - } - }; - } - - // Filter subscriptions with unread messages - const userSubscriptions = subscriptionsData.update.filter((sub: any) => { - return (sub.unread > 0 || sub.alert) && ['d', 'c', 'p'].includes(sub.t); - }); - - // Calculate total unread count - const totalUnreadCount = userSubscriptions.reduce((sum: number, sub: any) => - sum + (sub.unread || 0), 0); - - logger.debug('[ROCKETCHAT_ADAPTER] Notification counts', { - total: userSubscriptions.length, - unread: totalUnreadCount, - }); - - return { - total: userSubscriptions.length, - unread: totalUnreadCount, - sources: { - rocketchat: { - total: userSubscriptions.length, - unread: totalUnreadCount - } - } - }; - } catch (error) { - logger.error('[ROCKETCHAT_ADAPTER] Error getting notification count', { - error: error instanceof Error ? error.message : String(error), - }); - return { - total: 0, - unread: 0, - sources: { - rocketchat: { - total: 0, - unread: 0 - } - } - }; - } - } - - async getNotifications(userId: string, page = 1, limit = 20): Promise { - logger.debug('[ROCKETCHAT_ADAPTER] getNotifications called', { userId, page, limit }); - - try { - const email = await this.getUserEmail(); - if (!email) { - logger.error('[ROCKETCHAT_ADAPTER] Could not get user email'); - return []; - } - - const rocketChatUserId = await this.getRocketChatUserId(email); - if (!rocketChatUserId) { - logger.debug('[ROCKETCHAT_ADAPTER] User not found in RocketChat'); - return []; - } - - const userToken = await this.getUserToken(rocketChatUserId); - if (!userToken) { - logger.error('[ROCKETCHAT_ADAPTER] Could not get user token'); - return []; - } - - const userHeaders = { - 'X-Auth-Token': userToken.authToken, - 'X-User-Id': userToken.userId, - 'Content-Type': 'application/json' - }; - - // Get user's subscriptions with unread messages - const subscriptionsResponse = await fetch(`${this.baseUrl}/api/v1/subscriptions.get`, { - method: 'GET', - headers: userHeaders - }); - - if (!subscriptionsResponse.ok) { - logger.error('[ROCKETCHAT_ADAPTER] Failed to get subscriptions', { - status: subscriptionsResponse.status, - }); - return []; - } - - const subscriptionsData = await subscriptionsResponse.json(); - if (!subscriptionsData.success || !Array.isArray(subscriptionsData.update)) { - logger.error('[ROCKETCHAT_ADAPTER] Invalid subscriptions response'); - return []; - } - - // Filter subscriptions with unread messages - const userSubscriptions = subscriptionsData.update.filter((sub: any) => { - return (sub.unread > 0 || sub.alert) && ['d', 'c', 'p'].includes(sub.t); - }); - - // Get user info for comparison - const usersResponse = await fetch(`${this.baseUrl}/api/v1/users.list`, { - method: 'GET', - headers: { - 'X-Auth-Token': process.env.ROCKET_CHAT_TOKEN!, - 'X-User-Id': process.env.ROCKET_CHAT_USER_ID!, - 'Content-Type': 'application/json' - } - }); - - let currentUser: any = null; - if (usersResponse.ok) { - const usersData = await usersResponse.json(); - if (usersData.success && Array.isArray(usersData.users)) { - const username = email.split('@')[0]; - currentUser = usersData.users.find((u: any) => - u.username === username || u.emails?.some((e: any) => e.address === email) - ); - } - } - - const notifications: Notification[] = []; - - // Fetch messages for each subscription with unread messages - for (const subscription of userSubscriptions) { - try { - // Determine the correct endpoint based on room type - let endpoint; - switch (subscription.t) { - case 'c': - endpoint = 'channels.messages'; - break; - case 'p': - endpoint = 'groups.messages'; - break; - case 'd': - endpoint = 'im.messages'; - break; - default: - continue; - } - - const queryParams = new URLSearchParams({ - roomId: subscription.rid, - count: String(Math.max(subscription.unread, 1)) // Fetch at least 1 message - }); - - const messagesResponse = await fetch( - `${this.baseUrl}/api/v1/${endpoint}?${queryParams}`, { - method: 'GET', - headers: userHeaders - } - ); - - if (!messagesResponse.ok) { - logger.error('[ROCKETCHAT_ADAPTER] Failed to get messages for room', { - roomName: subscription.name, - status: messagesResponse.status, - }); - continue; - } - - const messageData = await messagesResponse.json(); - - if (messageData.success && messageData.messages?.length > 0) { - // Get the latest unread message (skip own messages) - const unreadMessages = messageData.messages.filter((msg: any) => { - // Skip messages sent by current user - if (currentUser && msg.u?._id === currentUser._id) { - return false; - } - // Skip system messages - if (msg.t || !msg.msg) { - return false; - } - return true; - }); - - if (unreadMessages.length > 0) { - const latestMessage = unreadMessages[0]; - const messageUser = latestMessage.u || {}; - const roomName = subscription.fname || subscription.name || subscription.name; - - // Determine room type for link - let roomTypePath = 'channel'; - if (subscription.t === 'd') roomTypePath = 'direct'; - else if (subscription.t === 'p') roomTypePath = 'group'; - - // Format title similar to Leantime (simple and consistent) - const title = subscription.t === 'd' - ? 'Message' - : subscription.t === 'p' - ? 'Message de groupe' - : 'Message de canal'; - - // Format message with sender and room info - const senderName = messageUser.name || messageUser.username || 'Utilisateur'; - const formattedMessage = subscription.t === 'd' - ? `${senderName}: ${latestMessage.msg || ''}` - : `${senderName} dans ${roomName}: ${latestMessage.msg || ''}`; - - // Link to hub.slm-lab.net/parole instead of external RocketChat URL - const notification: Notification = { - id: `rocketchat-${latestMessage._id}`, - source: 'rocketchat', - sourceId: latestMessage._id, - type: 'message', - title: title, - message: formattedMessage, - link: `/parole`, - isRead: false, // All messages here are unread - timestamp: new Date(latestMessage.ts), - priority: subscription.alert ? 'high' : 'normal', - user: { - id: messageUser._id || '', - name: senderName, - }, - metadata: { - roomId: subscription.rid, - roomName: roomName, - roomType: subscription.t, - unreadCount: subscription.unread, - } - }; - - notifications.push(notification); - } - } - } catch (error) { - logger.error('[ROCKETCHAT_ADAPTER] Error fetching messages for room', { - roomName: subscription.name, - error: error instanceof Error ? error.message : String(error), - }); - continue; - } - } - - // Sort by timestamp (newest first) and apply pagination - notifications.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); - - const startIndex = (page - 1) * limit; - const endIndex = startIndex + limit; - const paginatedNotifications = notifications.slice(startIndex, endIndex); - - logger.debug('[ROCKETCHAT_ADAPTER] getNotifications result', { - total: notifications.length, - returned: paginatedNotifications.length, - page, - limit, - }); - - return paginatedNotifications; - } catch (error) { - logger.error('[ROCKETCHAT_ADAPTER] Error getting notifications', { - error: error instanceof Error ? error.message : String(error), - }); - return []; - } - } - -}