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

743 lines
22 KiB
Markdown

# 🔔 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<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`
```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<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`
```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<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
```typescript
// 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
```typescript
// 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
```typescript
// 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`
```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.