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 lignesRocketChatAdapter: ~540 lignesEmailAdapter: ~410 lignesNotificationService: ~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 lignesuseWidgetNotification: ~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
- ✅ Créer
useWidgetNotificationhook - ✅ Créer
NotificationRegistryservice - ✅ Créer
/api/notifications/updateendpoint - ✅ Simplifier
/api/notifications/count - ✅ Simplifier
/api/notifications
Phase 2 : Intégrer dans les widgets
- ✅ Modifier Widget Courrier
- ✅ Modifier Widget Parole
- ✅ Modifier Widget Devoirs
- ✅ Modifier Widget Agenda
Phase 3 : Nettoyer l'ancien code
- ❌ Supprimer
LeantimeAdapter - ❌ Supprimer
RocketChatAdapter - ❌ Supprimer
EmailAdapter - ❌ Simplifier
NotificationService(ou le supprimer)
Phase 4 : Tests et validation
- ✅ Tester chaque widget
- ✅ Tester le badge
- ✅ Tester le dropdown
- ✅ 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.