NeahStable/NOTIFICATIONS_SIMPLIFIED_ARCHITECTURE.md
2026-01-16 00:12:15 +01:00

22 KiB

🔔 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

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<Record<string, number>>({});
  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

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<void> {
    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<NotificationCount> {
    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<any[]> {
    // 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

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

// components/email.tsx
import { useWidgetNotification } from '@/hooks/use-widget-notification';

export function Email() {
  const { triggerNotification } = useWidgetNotification();
  const [unreadCount, setUnreadCount] = useState<number>(0);
  const [emails, setEmails] = useState<Email[]>([]);
  const lastUnreadCountRef = useRef<number>(-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

// components/parole.tsx
import { useWidgetNotification } from '@/hooks/use-widget-notification';

export function Parole() {
  const { triggerNotification } = useWidgetNotification();
  const [unreadCount, setUnreadCount] = useState<number>(0);
  const lastUnreadCountRef = useRef<number>(-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

// components/flow.tsx
import { useWidgetNotification } from '@/hooks/use-widget-notification';

export function Duties() {
  const { triggerNotification } = useWidgetNotification();
  const [tasks, setTasks] = useState<Task[]>([]);
  const lastTaskCountRef = useRef<number>(-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

// components/calendar/calendar-widget.tsx
import { useWidgetNotification } from '@/hooks/use-widget-notification';

export function CalendarWidget() {
  const { triggerNotification } = useWidgetNotification();
  const [events, setEvents] = useState<Event[]>([]);
  const lastEventCountRef = useRef<number>(-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

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

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

  1. Modifier Widget Courrier
  2. Modifier Widget Parole
  3. Modifier Widget Devoirs
  4. Modifier Widget Agenda

Phase 3 : Nettoyer l'ancien code

  1. Supprimer LeantimeAdapter
  2. Supprimer RocketChatAdapter
  3. Supprimer EmailAdapter
  4. Simplifier NotificationService (ou le supprimer)

Phase 4 : Tests et validation

  1. Tester chaque widget
  2. Tester le badge
  3. Tester le dropdown
  4. 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.