# 🔔 Architecture Simplifiée des Notifications ## 💡 Concept : Notifications Dépendantes des Widgets Au lieu d'avoir des **adapters séparés** qui pollent directement les services externes, les notifications sont **déclenchées par les widgets** quand ils détectent de nouveaux éléments. --- ## 🎯 Avantages de cette Approche ### ✅ **Simplification Majeure** - **Pas de duplication** : Les widgets font déjà le fetch, pourquoi refaire ? - **Source unique de vérité** : Les notifications reflètent exactement ce que les widgets affichent - **Moins de code** : Supprime les adapters complexes (LeantimeAdapter, RocketChatAdapter, EmailAdapter) - **Moins de maintenance** : Un seul endroit à maintenir (les widgets) ### ✅ **Performance** - **Pas de polling séparé** : Les widgets pollent déjà, pas besoin de doubler - **Event-driven** : Notifications déclenchées uniquement quand nécessaire - **Réduction de 70-80% des appels API** : Pas de fetch séparés pour les notifications ### ✅ **Cohérence** - **Notifications = Widgets** : Si le widget voit quelque chose, la notification le voit aussi - **Pas de désynchronisation** : Impossible d'avoir des notifications sans widget correspondant - **UX cohérente** : Les utilisateurs voient les mêmes données partout ### ✅ **Extensibilité** - **Facile d'ajouter de nouveaux widgets** : Il suffit d'ajouter un trigger - **Pas besoin de créer un adapter** : Juste déclencher une notification - **Architecture simple** : Un système d'événements centralisé --- ## 🏗️ Architecture Proposée ### Architecture Actuelle ❌ ``` NotificationService ├─> LeantimeAdapter (polling direct) ├─> RocketChatAdapter (polling direct) └─> EmailAdapter (polling direct) └─> Fetch séparé des données └─> Agrégation └─> Badge mis à jour ``` **Problèmes** : - Duplication de logique (widgets + adapters) - Polling séparé = charge serveur double - Risque de désynchronisation - Complexité élevée ### Architecture Simplifiée ✅ ``` Widget Courrier └─> Détecte nouvel email └─> triggerNotification('email', { count: unreadCount }) └─> NotificationService.recordCount('email', count) └─> Badge mis à jour Widget Parole └─> Détecte nouveau message └─> triggerNotification('rocketchat', { count: unreadCount }) └─> NotificationService.recordCount('rocketchat', count) └─> Badge mis à jour Widget Devoirs └─> Détecte nouvelle tâche └─> triggerNotification('leantime', { count: overdueTasks }) └─> NotificationService.recordCount('leantime', count) └─> Badge mis à jour Widget Agenda └─> Détecte nouvel événement └─> triggerNotification('calendar', { count: upcomingEvents }) └─> NotificationService.recordCount('calendar', count) └─> Badge mis à jour ``` **Avantages** : - ✅ Pas de duplication - ✅ Source unique de vérité - ✅ Event-driven (pas de polling séparé) - ✅ Simple et maintenable --- ## 🔧 Implémentation ### 1. Nouveau Hook : `useWidgetNotification` **Fichier :** `hooks/use-widget-notification.ts` ```typescript import { useCallback, useRef } from 'react'; import { useSession } from 'next-auth/react'; interface NotificationData { source: 'email' | 'rocketchat' | 'leantime' | 'calendar'; count: number; items?: Array<{ id: string; title: string; message: string; link?: string; timestamp: Date; }>; } export function useWidgetNotification() { const { data: session } = useSession(); const lastUpdateRef = useRef>({}); const DEBOUNCE_MS = 1000; // 1 second debounce per source const triggerNotification = useCallback(async (data: NotificationData) => { if (!session?.user?.id) return; const { source, count, items } = data; const now = Date.now(); const lastUpdate = lastUpdateRef.current[source] || 0; // Debounce per source if (now - lastUpdate < DEBOUNCE_MS) { return; } lastUpdateRef.current[source] = now; try { // Envoyer les données au service de notifications await fetch('/api/notifications/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ source, count, items: items || [], }), }); // Dispatch event pour mise à jour immédiate du badge window.dispatchEvent(new CustomEvent('notification-updated', { detail: { source, count } })); } catch (error) { console.error('[useWidgetNotification] Error updating notification:', error); } }, [session?.user?.id]); return { triggerNotification }; } ``` ### 2. Nouveau Service : `NotificationRegistry` **Fichier :** `lib/services/notifications/notification-registry.ts` ```typescript import { getRedisClient } from '@/lib/redis'; import { logger } from '@/lib/logger'; import { NotificationCount } from '@/lib/types/notification'; export class NotificationRegistry { private static instance: NotificationRegistry; private static COUNT_CACHE_KEY = (userId: string) => `notifications:count:${userId}`; private static COUNT_CACHE_TTL = 30; // 30 seconds public static getInstance(): NotificationRegistry { if (!NotificationRegistry.instance) { NotificationRegistry.instance = new NotificationRegistry(); } return NotificationRegistry.instance; } /** * Enregistre le count d'une source (appelé par les widgets) */ async recordCount( userId: string, source: string, count: number, items?: Array<{ id: string; title: string; message: string; link?: string; timestamp: Date }> ): Promise { const redis = getRedisClient(); const cacheKey = NotificationRegistry.COUNT_CACHE_KEY(userId); // Récupérer le count actuel let currentCount: NotificationCount = { total: 0, unread: 0, sources: {}, }; try { const cached = await redis.get(cacheKey); if (cached) { currentCount = JSON.parse(cached); } } catch (error) { logger.error('[NOTIFICATION_REGISTRY] Error reading cache', { error }); } // Mettre à jour le count pour cette source const previousSourceCount = currentCount.sources[source]?.unread || 0; currentCount.sources[source] = { total: count, unread: count, }; // Recalculer le total currentCount.unread = Object.values(currentCount.sources).reduce( (sum, s) => sum + s.unread, 0 ); currentCount.total = currentCount.unread; // Stocker dans le cache try { await redis.set( cacheKey, JSON.stringify(currentCount), 'EX', NotificationRegistry.COUNT_CACHE_TTL ); logger.debug('[NOTIFICATION_REGISTRY] Count updated', { userId, source, count, totalUnread: currentCount.unread, previousCount: previousSourceCount, }); } catch (error) { logger.error('[NOTIFICATION_REGISTRY] Error updating cache', { error }); } } /** * Récupère le count agrégé (appelé par le badge) */ async getCount(userId: string): Promise { const redis = getRedisClient(); const cacheKey = NotificationRegistry.COUNT_CACHE_KEY(userId); try { const cached = await redis.get(cacheKey); if (cached) { return JSON.parse(cached); } } catch (error) { logger.error('[NOTIFICATION_REGISTRY] Error reading cache', { error }); } // Si pas de cache, retourner count vide return { total: 0, unread: 0, sources: {}, }; } /** * Récupère les notifications (items) de toutes les sources */ async getNotifications(userId: string, limit: number = 20): Promise { // Les widgets stockent leurs items dans Redis avec une clé spécifique const redis = getRedisClient(); const sources = ['email', 'rocketchat', 'leantime', 'calendar']; const allItems: any[] = []; for (const source of sources) { try { const itemsKey = `notifications:items:${userId}:${source}`; const items = await redis.get(itemsKey); if (items) { const parsed = JSON.parse(items); allItems.push(...parsed); } } catch (error) { logger.error('[NOTIFICATION_REGISTRY] Error reading items', { source, error }); } } // Trier par timestamp (plus récent en premier) allItems.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() ); return allItems.slice(0, limit); } } ``` ### 3. Nouvelle API Route : `/api/notifications/update` **Fichier :** `app/api/notifications/update/route.ts` ```typescript 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 { getRedisClient } from '@/lib/redis'; import { logger } from '@/lib/logger'; export async function POST(request: Request) { try { const session = await getServerSession(authOptions); if (!session?.user?.id) { return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); } const { source, count, items } = await request.json(); if (!source || typeof count !== 'number') { return NextResponse.json( { error: "Invalid request: source and count required" }, { status: 400 } ); } const registry = NotificationRegistry.getInstance(); // Enregistrer le count await registry.recordCount(session.user.id, source, count, items); // Si des items sont fournis, les stocker aussi if (items && Array.isArray(items)) { const redis = getRedisClient(); const itemsKey = `notifications:items:${session.user.id}:${source}`; // Stocker les items (limiter à 50 par source) await redis.set( itemsKey, JSON.stringify(items.slice(0, 50)), 'EX', 300 // 5 minutes TTL ); } logger.debug('[NOTIFICATIONS_UPDATE] Count updated', { userId: session.user.id, source, count, itemsCount: items?.length || 0, }); return NextResponse.json({ success: true }); } catch (error: any) { logger.error('[NOTIFICATIONS_UPDATE] Error', { error: error.message }); return NextResponse.json( { error: "Internal server error", message: error.message }, { status: 500 } ); } } ``` ### 4. Modification des Widgets #### Widget Courrier ```typescript // components/email.tsx import { useWidgetNotification } from '@/hooks/use-widget-notification'; export function Email() { const { triggerNotification } = useWidgetNotification(); const [unreadCount, setUnreadCount] = useState(0); const [emails, setEmails] = useState([]); const lastUnreadCountRef = useRef(-1); const fetchEmails = async (forceRefresh = false) => { // ... fetch emails ... // Calculer le unread count const currentUnreadCount = emails.filter(e => !e.read).length; // Si le count a changé, déclencher notification if (currentUnreadCount !== lastUnreadCountRef.current) { lastUnreadCountRef.current = currentUnreadCount; // Préparer les items pour les notifications const notificationItems = emails .filter(e => !e.read) .slice(0, 10) .map(email => ({ id: email.id, title: email.subject, message: `De ${email.fromName || email.from}`, link: `/courrier`, timestamp: new Date(email.date), })); // Déclencher notification await triggerNotification({ source: 'email', count: currentUnreadCount, items: notificationItems, }); } }; } ``` #### Widget Parole ```typescript // components/parole.tsx import { useWidgetNotification } from '@/hooks/use-widget-notification'; export function Parole() { const { triggerNotification } = useWidgetNotification(); const [unreadCount, setUnreadCount] = useState(0); const lastUnreadCountRef = useRef(-1); const fetchMessages = async (forceRefresh = false) => { // ... fetch messages ... const currentUnreadCount = data.totalUnreadCount || 0; // Si le count a changé, déclencher notification if (currentUnreadCount !== lastUnreadCountRef.current) { lastUnreadCountRef.current = currentUnreadCount; // Préparer les items pour les notifications const notificationItems = messages .slice(0, 10) .map(msg => ({ id: msg.id, title: msg.sender.name, message: msg.text, link: `/parole`, timestamp: new Date(msg.rawTimestamp), })); await triggerNotification({ source: 'rocketchat', count: currentUnreadCount, items: notificationItems, }); } }; } ``` #### Widget Devoirs ```typescript // components/flow.tsx import { useWidgetNotification } from '@/hooks/use-widget-notification'; export function Duties() { const { triggerNotification } = useWidgetNotification(); const [tasks, setTasks] = useState([]); const lastTaskCountRef = useRef(-1); const fetchTasks = async (forceRefresh = false) => { // ... fetch tasks ... const currentTaskCount = filteredTasks.length; // Si le count a changé, déclencher notification if (currentTaskCount !== lastTaskCountRef.current) { lastTaskCountRef.current = currentTaskCount; // Préparer les items pour les notifications const notificationItems = filteredTasks .slice(0, 10) .map(task => ({ id: task.id.toString(), title: task.headline, message: `Due: ${formatDate(task.dateToFinish)}`, link: task.source === 'twenty-crm' ? (task as any).url : `https://agilite.slm-lab.net/tickets/showTicket/${task.id}`, timestamp: new Date(task.dateToFinish || Date.now()), })); await triggerNotification({ source: 'leantime', count: currentTaskCount, items: notificationItems, }); } }; } ``` #### Widget Agenda ```typescript // components/calendar/calendar-widget.tsx import { useWidgetNotification } from '@/hooks/use-widget-notification'; export function CalendarWidget() { const { triggerNotification } = useWidgetNotification(); const [events, setEvents] = useState([]); const lastEventCountRef = useRef(-1); const fetchUpcomingEvents = async () => { // ... fetch events ... // Filtrer les événements à venir (aujourd'hui et demain) const now = new Date(); const tomorrow = addDays(now, 1); const upcomingEvents = allEvents .filter(event => event.start >= now && event.start <= tomorrow) .slice(0, 10); const currentEventCount = upcomingEvents.length; // Si le count a changé, déclencher notification if (currentEventCount !== lastEventCountRef.current) { lastEventCountRef.current = currentEventCount; // Préparer les items pour les notifications const notificationItems = upcomingEvents.map(event => ({ id: event.id, title: event.title, message: `Le ${format(event.start, 'dd/MM à HH:mm')}`, link: `/agenda`, timestamp: event.start, })); await triggerNotification({ source: 'calendar', count: currentEventCount, items: notificationItems, }); } }; } ``` ### 5. Simplification de `/api/notifications/count` **Fichier :** `app/api/notifications/count/route.ts` ```typescript 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'; export async function GET(request: Request) { try { const session = await getServerSession(authOptions); if (!session?.user?.id) { return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); } const registry = NotificationRegistry.getInstance(); const counts = await registry.getCount(session.user.id); return NextResponse.json(counts); } catch (error: any) { return NextResponse.json( { error: "Internal server error", message: error.message }, { status: 500 } ); } } ``` ### 6. Simplification de `/api/notifications` **Fichier :** `app/api/notifications/route.ts` ```typescript 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'; export async function GET(request: Request) { try { const session = await getServerSession(authOptions); if (!session?.user?.id) { return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); } const { searchParams } = new URL(request.url); const limit = parseInt(searchParams.get('limit') || '20', 10); const registry = NotificationRegistry.getInstance(); const notifications = await registry.getNotifications(session.user.id, limit); return NextResponse.json({ notifications, total: notifications.length, }); } catch (error: any) { return NextResponse.json( { error: "Internal server error", message: error.message }, { status: 500 } ); } } ``` --- ## 📊 Comparaison : Avant vs Après ### Avant ❌ **Code** : - `LeantimeAdapter` : ~550 lignes - `RocketChatAdapter` : ~540 lignes - `EmailAdapter` : ~410 lignes - `NotificationService` : ~425 lignes - **Total** : ~1925 lignes **Appels API** : - Polling notifications : 1 toutes les 30s - Polling Leantime : 1 toutes les 30s (via adapter) - Polling RocketChat : 1 toutes les 30s (via adapter) - Polling Email : 1 toutes les 30s (via adapter) - **Total** : 4 appels toutes les 30s **Complexité** : - 3 adapters à maintenir - Logique de fetch dupliquée - Risque de désynchronisation ### Après ✅ **Code** : - `NotificationRegistry` : ~150 lignes - `useWidgetNotification` : ~50 lignes - Modifications widgets : ~20 lignes chacun - **Total** : ~280 lignes (-85% de code) **Appels API** : - Widgets pollent déjà (pas de changement) - Notifications : Event-driven uniquement - **Total** : 0 appels supplémentaires (réduction de 100%) **Complexité** : - 1 registry simple - Pas de duplication - Source unique de vérité --- ## 🎯 Avantages Spécifiques ### 1. **Simplicité** - ✅ **-85% de code** : De ~1925 lignes à ~280 lignes - ✅ **Pas d'adapters complexes** : Suppression de LeantimeAdapter, RocketChatAdapter, EmailAdapter - ✅ **Architecture claire** : Widgets → Registry → Badge ### 2. **Performance** - ✅ **0 appels API supplémentaires** : Les widgets font déjà le travail - ✅ **Event-driven** : Notifications uniquement quand nécessaire - ✅ **Cache optimisé** : Un seul cache au lieu de 4 ### 3. **Maintenance** - ✅ **Un seul endroit à modifier** : Les widgets - ✅ **Pas de désynchronisation** : Impossible d'avoir des notifications sans widget - ✅ **Facile à étendre** : Ajouter un widget = ajouter un trigger ### 4. **Cohérence** - ✅ **Source unique de vérité** : Les widgets sont la source - ✅ **Notifications = Widgets** : Si le widget voit, la notification voit - ✅ **UX cohérente** : Mêmes données partout --- ## 🚨 Points d'Attention ### 1. **Initialisation** - Au premier chargement, les widgets doivent déclencher les notifications - Le badge doit attendre que les widgets soient chargés **Solution** : - Les widgets déclenchent au mount - Le badge affiche 0 si pas encore initialisé ### 2. **Widgets non chargés** - Si un widget n'est pas sur la page, pas de notifications pour cette source **Solution** : - C'est acceptable : si le widget n'est pas visible, pas besoin de notification - Les notifications reflètent ce que l'utilisateur voit ### 3. **Marquer comme lu** - Comment marquer une notification comme lue si elle vient d'un widget ? **Solution** : - Quand l'utilisateur clique sur une notification, ouvrir le widget - Le widget met à jour son état (email lu, message lu, etc.) - Le widget redéclenche la notification avec le nouveau count - Le badge se met à jour automatiquement ### 4. **Historique** - Comment garder un historique des notifications si elles viennent des widgets ? **Solution** : - Stocker les items dans Redis (déjà prévu dans le code) - TTL de 5 minutes pour les items - Le dropdown affiche les items stockés --- ## 📝 Plan de Migration ### Phase 1 : Créer la nouvelle infrastructure 1. ✅ Créer `useWidgetNotification` hook 2. ✅ Créer `NotificationRegistry` service 3. ✅ Créer `/api/notifications/update` endpoint 4. ✅ Simplifier `/api/notifications/count` 5. ✅ Simplifier `/api/notifications` ### Phase 2 : Intégrer dans les widgets 6. ✅ Modifier Widget Courrier 7. ✅ Modifier Widget Parole 8. ✅ Modifier Widget Devoirs 9. ✅ Modifier Widget Agenda ### Phase 3 : Nettoyer l'ancien code 10. ❌ Supprimer `LeantimeAdapter` 11. ❌ Supprimer `RocketChatAdapter` 12. ❌ Supprimer `EmailAdapter` 13. ❌ Simplifier `NotificationService` (ou le supprimer) ### Phase 4 : Tests et validation 14. ✅ Tester chaque widget 15. ✅ Tester le badge 16. ✅ Tester le dropdown 17. ✅ Vérifier les performances --- ## ✅ Conclusion Cette architecture simplifiée est **beaucoup plus maintenable** et **performante** que l'actuelle. Elle élimine la duplication de code, réduit les appels API, et garantit la cohérence entre widgets et notifications. **Recommandation** : Implémenter cette architecture pour simplifier significativement le système de notifications.