refactor Notifications

This commit is contained in:
alma 2026-01-16 00:21:04 +01:00
parent 988747a466
commit c29ade92f8
12 changed files with 421 additions and 2024 deletions

View File

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

View File

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

View File

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

View File

@ -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<SortOption>('newest');
const [filterBy, setFilterBy] = useState<FilterOption>('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 (
<div className={`relative ${className || ''}`}>
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="text-white/80 hover:text-white relative p-0">
<Bell className='w-5 h-5' />
{hasUnread && (
<Badge
variant="notification"
size="notification"
className="absolute -top-2 -right-2 z-50"
>
{notificationCount.unread > 99 ? '99+' : notificationCount.unread}
</Badge>
)}
<span className="sr-only">Notifications</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-96 max-h-[85vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<h3 className="font-semibold text-base">Notifications</h3>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => fetchNotifications(1, 50, filterBy === 'all' ? undefined : filterBy)}
title="Refresh"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
{/* Filters and Sort */}
<div className="p-3 border-b bg-gray-50/50 space-y-2">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<Select value={filterBy} onValueChange={(v) => setFilterBy(v as FilterOption)}>
<SelectTrigger className="h-8 text-xs flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes les sources</SelectItem>
<SelectItem value="email">Courrier</SelectItem>
<SelectItem value="rocketchat">Parole</SelectItem>
<SelectItem value="leantime">Agilité</SelectItem>
<SelectItem value="calendar">Agenda</SelectItem>
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOption)}>
<SelectTrigger className="h-8 w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="newest">Plus récent</SelectItem>
<SelectItem value="oldest">Plus ancien</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Notifications List */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="py-8 px-4 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">Chargement...</p>
</div>
) : isAuthError ? (
<div className="py-8 px-4 text-center">
<LogIn className="h-8 w-8 text-orange-500 mx-auto mb-2" />
<p className="text-sm text-muted-foreground mb-2">Authentification requise</p>
<Button variant="outline" size="sm" onClick={() => signIn()}>
Se connecter
</Button>
</div>
) : error ? (
<div className="py-8 px-4 text-center">
<AlertCircle className="h-8 w-8 text-red-500 mx-auto mb-2" />
<p className="text-sm text-red-500 mb-2">{error}</p>
<Button variant="outline" size="sm" onClick={() => fetchNotifications(1, 50)}>
Réessayer
</Button>
</div>
) : sortedAndFilteredNotifications.length === 0 ? (
<div className="py-8 px-4 text-center">
<p className="text-sm text-muted-foreground">Aucune notification</p>
</div>
) : (
<>
{sortedAndFilteredNotifications.map((notification) => (
<DropdownMenuItem
key={notification.id}
className="px-4 py-3 cursor-default hover:bg-gray-50"
onSelect={(e) => e.preventDefault()}
>
<div className="w-full">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
{!notification.isRead && notification.source === 'rocketchat' && (
<span className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0"></span>
)}
<span className="text-sm font-medium truncate">
{notification.title}
</span>
{!notification.isRead && notification.source !== 'rocketchat' && (
<Badge variant="secondary" className="bg-blue-500 text-white text-[10px] px-1.5 py-0">New</Badge>
)}
{notification.source === 'leantime' && (
<Badge variant="outline" className="text-[10px] py-0 px-1.5 bg-amber-50 text-amber-700 border-amber-200 flex items-center">
<Kanban className="mr-1 h-2.5 w-2.5" />
Agilité
</Badge>
)}
{notification.source === 'rocketchat' && (
<Badge variant="outline" className="text-[10px] py-0 px-1.5 bg-blue-50 text-blue-700 border-blue-200 flex items-center">
<MessageSquare className="mr-1 h-2.5 w-2.5" />
Parole
</Badge>
)}
{notification.source === 'email' && (
<Badge variant="outline" className="text-[10px] py-0 px-1.5 bg-green-50 text-green-700 border-green-200 flex items-center">
<Mail className="mr-1 h-2.5 w-2.5" />
Courrier
</Badge>
)}
{notification.source === 'calendar' && (
<Badge variant="outline" className="text-[10px] py-0 px-1.5 bg-purple-50 text-purple-700 border-purple-200 flex items-center">
<Calendar className="mr-1 h-2.5 w-2.5" />
Agenda
</Badge>
)}
</div>
<SafeHTML
html={notification.message}
className="text-xs text-muted-foreground mb-1 notification-message line-clamp-2"
/>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(notification.timestamp), { addSuffix: true })}
</span>
</div>
<div className="flex items-start gap-1 flex-shrink-0">
{!notification.isRead && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => handleMarkAsRead(notification.id, e)}
title="Marquer comme lu"
>
<Check className="h-3.5 w-3.5" />
</Button>
)}
{notification.link && (
<Link href={notification.link} onClick={() => setIsOpen(false)}>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" title="Ouvrir">
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</Link>
)}
</div>
</div>
</div>
</DropdownMenuItem>
))}
{hasMore && (
<div className="p-2 border-t text-center">
<Button
variant="ghost"
size="sm"
onClick={handleLoadMore}
className="w-full"
>
Charger plus ({notifications.length - displayLimit} restantes)
</Button>
</div>
)}
</>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
});

View File

@ -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[]) => {

View File

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

View File

@ -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<number>(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 };
}

View File

@ -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<boolean> {
// 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<Record<string, Record<string, number>>> {
// 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<string, Record<string, number>> = {};
// 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<NotificationCount> {
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<string, Record<string, number>> | 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<Notification[]> {
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 [];
}
}
}

View File

@ -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<void> {
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<Notification[]> {
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<NotificationCount> {
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<boolean> {
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<string | null> {
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<number | null> {
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<number | null> => {
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;
}
}
}

View File

@ -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<Notification[]>;
/**
* Get count of notifications for a user
* @param userId The user ID
* @returns Promise with notification count data
*/
getNotificationCount(userId: string): Promise<NotificationCount>;
/**
* Check if this adapter is configured and ready to use
* @returns Promise with boolean indicating if adapter is ready
*/
isConfigured(): Promise<boolean>;
}

View File

@ -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<string, NotificationAdapter> = 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<Notification[]> {
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<NotificationCount> {
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<void> {
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<void> {
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);
}
}

View File

@ -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<boolean> {
return !!this.baseUrl &&
!!process.env.ROCKET_CHAT_TOKEN &&
!!process.env.ROCKET_CHAT_USER_ID;
}
/**
* Get user's email from session
*/
private async getUserEmail(): Promise<string | null> {
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<string | null> {
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<NotificationCount> {
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<Notification[]> {
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 [];
}
}
}