notifications

This commit is contained in:
alma 2026-01-11 22:29:40 +01:00
parent 735b3ce460
commit a1e4e14b30
6 changed files with 672 additions and 9 deletions

View File

@ -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

View File

@ -194,6 +194,7 @@ export async function GET(request: Request) {
userId: currentUser._id,
username: currentUser.username,
totalSubscriptions: userSubscriptions.length,
totalUnreadCount: totalUnreadCount,
});
const messages: any[] = [];

View File

@ -43,7 +43,8 @@ export function Parole() {
const router = useRouter();
const { data: session, status } = useSession();
const { triggerNotificationRefresh } = useTriggerNotification();
const lastUnreadCountRef = useRef<number>(0);
const lastUnreadCountRef = useRef<number>(-1); // Initialize to -1 to detect first load
const isInitializedRef = useRef<boolean>(false);
const fetchMessages = async (isRefresh = false) => {
try {
@ -67,10 +68,22 @@ export function Parole() {
// Utiliser le totalUnreadCount de l'API (plus fiable)
const currentUnreadCount = data.totalUnreadCount || 0;
// 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,
totalUnreadCount: data.totalUnreadCount,
willTrigger: currentUnreadCount > lastUnreadCountRef.current
});
// 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é', {
if (currentUnreadCount > lastUnreadCountRef.current) {
console.log('[Parole] ⚡ Nouveau message non lu détecté, déclenchement notification', {
previous: lastUnreadCountRef.current,
current: currentUnreadCount
});
@ -78,6 +91,7 @@ export function Parole() {
}
lastUnreadCountRef.current = currentUnreadCount;
}
setMessages(data.messages);
} else {
console.warn('Unexpected data format:', data);

View File

@ -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<boolean> {
// 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<Record<string, Record<string, number>>> {
// 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<string, Record<string, number>> = {};
// 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<NotificationCount> {
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<string, Record<string, number>> | 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<Notification[]> {
// 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<boolean> {
// Not implemented yet - Email read status is handled by the email service
return false;
}
async markAllAsRead(userId: string): Promise<boolean> {
// Not implemented yet - Email read status is handled by the email service
return false;
}
}

View File

@ -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());

View File

@ -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<boolean> {
return !!this.baseUrl &&
!!process.env.ROCKET_CHAT_TOKEN &&
!!process.env.ROCKET_CHAT_USER_ID;
}
/**
* Get user's email from session
*/
private async getUserEmail(): Promise<string | null> {
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<string | null> {
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<NotificationCount> {
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<Notification[]> {
// 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<boolean> {
// Not implemented yet - RocketChat handles read status automatically
return false;
}
async markAllAsRead(userId: string): Promise<boolean> {
// Not implemented yet - RocketChat handles read status automatically
return false;
}
}