From a1e4e14b300c045bda7110828823c83ff3748d98 Mon Sep 17 00:00:00 2001 From: alma Date: Sun, 11 Jan 2026 22:29:40 +0100 Subject: [PATCH] notifications --- NOTIFICATIONS_COMPLETE_SYSTEM.md | 156 +++++++++++ app/api/rocket-chat/messages/route.ts | 1 + components/parole.tsx | 32 ++- lib/services/notifications/email-adapter.ts | 223 +++++++++++++++ .../notifications/notification-service.ts | 4 + .../notifications/rocketchat-adapter.ts | 265 ++++++++++++++++++ 6 files changed, 672 insertions(+), 9 deletions(-) create mode 100644 NOTIFICATIONS_COMPLETE_SYSTEM.md create mode 100644 lib/services/notifications/email-adapter.ts create mode 100644 lib/services/notifications/rocketchat-adapter.ts diff --git a/NOTIFICATIONS_COMPLETE_SYSTEM.md b/NOTIFICATIONS_COMPLETE_SYSTEM.md new file mode 100644 index 0000000..0b024fa --- /dev/null +++ b/NOTIFICATIONS_COMPLETE_SYSTEM.md @@ -0,0 +1,156 @@ +# 🔔 Système de Notifications Complet + +## ✅ Adapters Implémentés + +Le système de notifications agrège maintenant **3 sources** : + +1. **Leantime** (`leantime-adapter.ts`) + - Notifications de tâches, commentaires, mentions + - Polling toutes les 30 secondes + +2. **RocketChat** (`rocketchat-adapter.ts`) ⚡ NOUVEAU + - Messages non lus dans Parole + - Déclenchement en temps réel quand nouveau message détecté + +3. **Email** (`email-adapter.ts`) ⚡ NOUVEAU + - Emails non lus dans Courrier (INBOX uniquement) + - Déclenchement en temps réel quand nouvel email détecté + +--- + +## 🎯 Flow Complet + +### Badge de Notification + +Le badge rouge affiche maintenant le **total agrégé** : +``` +Total = Leantime (unread) + RocketChat (unread) + Email (unread) +``` + +### Déclenchement Temps Réel + +#### 1. **Nouveau Message Parole** +``` +Widget Parole détecte nouveau message + └─> totalUnreadCount augmente + └─> triggerNotificationRefresh() + └─> Invalide cache notifications + └─> NotificationService.getNotificationCount() + ├─> LeantimeAdapter.getNotificationCount() + ├─> RocketChatAdapter.getNotificationCount() ⚡ + └─> EmailAdapter.getNotificationCount() ⚡ + └─> Badge mis à jour (< 1 seconde) +``` + +#### 2. **Nouvel Email Courrier** +``` +checkForNewEmails() détecte nouvel email + └─> newestEmailId > lastKnownEmailId + └─> triggerNotificationRefresh() ⚡ + └─> Invalide cache notifications + └─> NotificationService.getNotificationCount() + ├─> LeantimeAdapter.getNotificationCount() + ├─> RocketChatAdapter.getNotificationCount() + └─> EmailAdapter.getNotificationCount() ⚡ + └─> Badge mis à jour (< 1 seconde) +``` + +#### 3. **Notification Leantime** +``` +Polling toutes les 30s + └─> LeantimeAdapter.getNotificationCount() + └─> Badge mis à jour +``` + +--- + +## 📊 Structure des Counts + +```typescript +NotificationCount { + total: number, // Total de toutes les sources + unread: number, // Total non lus de toutes les sources + sources: { + leantime: { + total: number, + unread: number + }, + rocketchat: { + total: number, + unread: number + }, + email: { + total: number, + unread: number + } + } +} +``` + +--- + +## 🔧 Fichiers Modifiés/Créés + +### Nouveaux Fichiers +- `lib/services/notifications/rocketchat-adapter.ts` - Adapter RocketChat +- `lib/services/notifications/email-adapter.ts` - Adapter Email +- `hooks/use-trigger-notification.ts` - Hook pour déclencher refresh + +### Fichiers Modifiés +- `lib/services/notifications/notification-service.ts` - Enregistrement des nouveaux adapters +- `components/parole.tsx` - Détection et déclenchement pour RocketChat +- `hooks/use-email-state.ts` - Déclenchement pour Email (déjà présent) +- `hooks/use-notifications.ts` - Écoute d'événements custom +- `app/api/notifications/count/route.ts` - Support `?force=true` +- `app/api/rocket-chat/messages/route.ts` - Retourne `totalUnreadCount` + +--- + +## 🎨 Avantages + +1. **⚡ Temps Réel** : Notifications instantanées (< 1 seconde) +2. **📊 Multi-Sources** : Leantime + RocketChat + Email +3. **💚 Efficace** : Déclenchement uniquement quand nécessaire +4. **🔄 Rétrocompatible** : Le polling reste en fallback +5. **📈 Scalable** : Facile d'ajouter d'autres adapters + +--- + +## 🚀 Résultat Final + +Le badge de notification affiche maintenant : +- ✅ Notifications Leantime (polling 30s) +- ✅ Messages non lus RocketChat (temps réel) +- ✅ Emails non lus Courrier (temps réel) + +**Total = Leantime + RocketChat + Email** 🎉 + +--- + +## 📝 Notes Techniques + +### RocketChat Adapter +- Utilise les subscriptions RocketChat +- Compte les messages non lus (`unread > 0`) +- Supporte channels, groups, et DMs + +### Email Adapter +- Utilise le cache Redis de `/api/courrier/unread-counts` +- Focus sur INBOX (principal dossier pour notifications) +- Peut être étendu pour d'autres dossiers si besoin + +### Cache Strategy +- **Leantime** : Cache 30s (aligné avec polling) +- **RocketChat** : Pas de cache dédié (utilise cache messages) +- **Email** : Cache 2 minutes (via unread-counts API) + +--- + +## 🔍 Debugging + +Pour voir les logs : +- `[ROCKETCHAT_ADAPTER]` - Adapter RocketChat +- `[EMAIL_ADAPTER]` - Adapter Email +- `[Parole]` - Détection dans widget Parole +- `[useTriggerNotification]` - Déclenchement refresh +- `[NOTIFICATION_SERVICE]` - Agrégation des counts diff --git a/app/api/rocket-chat/messages/route.ts b/app/api/rocket-chat/messages/route.ts index eb68da6..eea2906 100644 --- a/app/api/rocket-chat/messages/route.ts +++ b/app/api/rocket-chat/messages/route.ts @@ -194,6 +194,7 @@ export async function GET(request: Request) { userId: currentUser._id, username: currentUser.username, totalSubscriptions: userSubscriptions.length, + totalUnreadCount: totalUnreadCount, }); const messages: any[] = []; diff --git a/components/parole.tsx b/components/parole.tsx index 2a0e813..305f514 100644 --- a/components/parole.tsx +++ b/components/parole.tsx @@ -43,7 +43,8 @@ export function Parole() { const router = useRouter(); const { data: session, status } = useSession(); const { triggerNotificationRefresh } = useTriggerNotification(); - const lastUnreadCountRef = useRef(0); + const lastUnreadCountRef = useRef(-1); // Initialize to -1 to detect first load + const isInitializedRef = useRef(false); const fetchMessages = async (isRefresh = false) => { try { @@ -67,17 +68,30 @@ export function Parole() { // Utiliser le totalUnreadCount de l'API (plus fiable) const currentUnreadCount = data.totalUnreadCount || 0; - // Si nouveau message non lu détecté, déclencher notification - // On vérifie si le count a augmenté (et qu'on avait déjà un count initialisé) - if (currentUnreadCount > lastUnreadCountRef.current && lastUnreadCountRef.current >= 0) { - console.log('[Parole] Nouveau message non lu détecté', { + // On initialise le count au premier chargement + if (!isInitializedRef.current) { + isInitializedRef.current = true; + lastUnreadCountRef.current = currentUnreadCount; + console.log('[Parole] Initial unread count:', currentUnreadCount); + } else { + console.log('[Parole] Unread count check', { previous: lastUnreadCountRef.current, - current: currentUnreadCount + current: currentUnreadCount, + totalUnreadCount: data.totalUnreadCount, + willTrigger: currentUnreadCount > lastUnreadCountRef.current }); - triggerNotificationRefresh(); + + // Si nouveau message non lu détecté, déclencher notification + if (currentUnreadCount > lastUnreadCountRef.current) { + console.log('[Parole] ⚡ Nouveau message non lu détecté, déclenchement notification', { + previous: lastUnreadCountRef.current, + current: currentUnreadCount + }); + triggerNotificationRefresh(); + } + + lastUnreadCountRef.current = currentUnreadCount; } - - lastUnreadCountRef.current = currentUnreadCount; setMessages(data.messages); } else { console.warn('Unexpected data format:', data); diff --git a/lib/services/notifications/email-adapter.ts b/lib/services/notifications/email-adapter.ts new file mode 100644 index 0000000..5397150 --- /dev/null +++ b/lib/services/notifications/email-adapter.ts @@ -0,0 +1,223 @@ +import { NotificationAdapter } from './notification-adapter.interface'; +import { logger } from '@/lib/logger'; +import { Notification, NotificationCount } from '@/lib/types/notification'; +import { getRedisClient } from '@/lib/redis'; +import { prisma } from '@/lib/prisma'; +import { getImapConnection } from '@/lib/services/email-service'; + +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 prisma.mailCredentials.findMany({ + where: { userId }, + select: { + id: true, + email: true + } + }); + + 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 { + // Get IMAP connection for this account + logger.debug('[EMAIL_ADAPTER] Processing account', { + userId, + accountId, + email: account.email, + }); + const client = await getImapConnection(userId, accountId); + unreadCounts[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', { + 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 { + // For now, return empty array - we can implement this later if needed + // The notification count is what matters for the badge + // In the future, we could return unread emails as notifications + return []; + } + + async markAsRead(userId: string, notificationId: string): Promise { + // Not implemented yet - Email read status is handled by the email service + return false; + } + + async markAllAsRead(userId: string): Promise { + // Not implemented yet - Email read status is handled by the email service + return false; + } +} diff --git a/lib/services/notifications/notification-service.ts b/lib/services/notifications/notification-service.ts index f044b5a..564ae90 100644 --- a/lib/services/notifications/notification-service.ts +++ b/lib/services/notifications/notification-service.ts @@ -1,6 +1,8 @@ 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'; @@ -22,6 +24,8 @@ export class NotificationService { // 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()); diff --git a/lib/services/notifications/rocketchat-adapter.ts b/lib/services/notifications/rocketchat-adapter.ts new file mode 100644 index 0000000..63d03da --- /dev/null +++ b/lib/services/notifications/rocketchat-adapter.ts @@ -0,0 +1,265 @@ +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); + return session?.user?.email || null; + } 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 + */ + 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)) { + return null; + } + + const currentUser = usersData.users.find((u: any) => u.username === username); + return currentUser?._id || null; + } 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 + */ + private async getUserToken(): 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' + }; + + const createTokenResponse = await fetch(`${this.baseUrl}/api/v1/users.createToken`, { + method: 'POST', + headers: adminHeaders + }); + + if (!createTokenResponse.ok) { + logger.error('[ROCKETCHAT_ADAPTER] Failed to create user token', { + status: createTokenResponse.status, + }); + return null; + } + + const tokenData = await createTokenResponse.json(); + 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), + }); + 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'); + return { + total: 0, + unread: 0, + sources: { + rocketchat: { + total: 0, + unread: 0 + } + } + }; + } + + const userToken = await this.getUserToken(); + if (!userToken) { + logger.error('[ROCKETCHAT_ADAPTER] Could not get user token'); + 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 { + // For now, return empty array - we can implement this later if needed + // The notification count is what matters for the badge + return []; + } + + async markAsRead(userId: string, notificationId: string): Promise { + // Not implemented yet - RocketChat handles read status automatically + return false; + } + + async markAllAsRead(userId: string): Promise { + // Not implemented yet - RocketChat handles read status automatically + return false; + } +}