refactor Notifications
This commit is contained in:
parent
988747a466
commit
c29ade92f8
@ -1,7 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/app/api/auth/options";
|
||||
import { NotificationService } from '@/lib/services/notifications/notification-service';
|
||||
import { NotificationRegistry } from '@/lib/services/notifications/notification-registry';
|
||||
|
||||
// GET /api/debug/notifications
|
||||
export async function GET(request: Request) {
|
||||
@ -36,20 +36,20 @@ export async function GET(request: Request) {
|
||||
email: session.user.email || 'Unknown'
|
||||
};
|
||||
|
||||
// Test notification service
|
||||
console.log(`[DEBUG] Testing notification service`);
|
||||
const notificationService = NotificationService.getInstance();
|
||||
// Test notification registry
|
||||
console.log(`[DEBUG] Testing notification registry`);
|
||||
const registry = NotificationRegistry.getInstance();
|
||||
|
||||
// Get notification count
|
||||
console.log(`[DEBUG] Getting notification count`);
|
||||
const startTimeCount = Date.now();
|
||||
const notificationCount = await notificationService.getNotificationCount(userId);
|
||||
const notificationCount = await registry.getCount(userId);
|
||||
const timeForCount = Date.now() - startTimeCount;
|
||||
|
||||
// Get notifications
|
||||
console.log(`[DEBUG] Getting notifications`);
|
||||
const startTimeNotifications = Date.now();
|
||||
const notifications = await notificationService.getNotifications(userId, 1, 10);
|
||||
const notifications = await registry.getNotifications(userId, 10);
|
||||
const timeForNotifications = Date.now() - startTimeNotifications;
|
||||
|
||||
return NextResponse.json({
|
||||
@ -57,7 +57,7 @@ export async function GET(request: Request) {
|
||||
timestamp: new Date().toISOString(),
|
||||
userInfo,
|
||||
environmentVariables: envStatus,
|
||||
notificationServiceTest: {
|
||||
notificationRegistryTest: {
|
||||
count: {
|
||||
result: notificationCount,
|
||||
timeMs: timeForCount
|
||||
|
||||
96
app/api/notifications/[id]/read/route.ts
Normal file
96
app/api/notifications/[id]/read/route.ts
Normal file
@ -0,0 +1,96 @@
|
||||
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 { logger } from '@/lib/logger';
|
||||
|
||||
// POST /api/notifications/[id]/read
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
|
||||
const notificationId = params.id;
|
||||
if (!notificationId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Notification ID is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse notification ID to extract source and sourceId
|
||||
// Format: "source-sourceId"
|
||||
const parts = notificationId.split('-');
|
||||
if (parts.length < 2) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid notification ID format" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const source = parts[0];
|
||||
const sourceId = parts.slice(1).join('-'); // In case sourceId contains dashes
|
||||
|
||||
const registry = NotificationRegistry.getInstance();
|
||||
|
||||
// For now, we just remove the item from the cache
|
||||
// In a full implementation, you might want to store read status in a database
|
||||
// For simplicity, we'll just remove it from the items cache
|
||||
try {
|
||||
const redis = await import('@/lib/redis').then(m => m.getRedisClient());
|
||||
const itemsKey = `notifications:items:${session.user.id}:${source}`;
|
||||
|
||||
// Get current items
|
||||
const items = await redis.get(itemsKey);
|
||||
if (items) {
|
||||
const parsed = JSON.parse(items);
|
||||
// Filter out the read item
|
||||
const filtered = parsed.filter((item: any) => item.id !== sourceId);
|
||||
|
||||
// Update cache
|
||||
await redis.set(itemsKey, JSON.stringify(filtered), 'EX', 300);
|
||||
|
||||
// Update count (decrement unread)
|
||||
const count = await registry.getCount(session.user.id);
|
||||
if (count.sources[source] && count.sources[source].unread > 0) {
|
||||
count.sources[source].unread = Math.max(0, count.sources[source].unread - 1);
|
||||
count.unread = Object.values(count.sources).reduce(
|
||||
(sum, s) => sum + s.unread,
|
||||
0
|
||||
);
|
||||
|
||||
const countKey = `notifications:count:${session.user.id}`;
|
||||
await redis.set(countKey, JSON.stringify(count), 'EX', 30);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[NOTIFICATIONS_READ] Error marking as read', {
|
||||
userId: session.user.id,
|
||||
notificationId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug('[NOTIFICATIONS_READ] Notification marked as read', {
|
||||
userId: session.user.id,
|
||||
notificationId,
|
||||
source,
|
||||
sourceId,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error('[NOTIFICATIONS_READ] Error', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error", message: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -37,7 +37,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { NotificationBadge } from "./notification-badge";
|
||||
import { NotificationBadge } from "./notification-badge-enhanced";
|
||||
import { NotesDialog } from "./notes-dialog";
|
||||
import { MainNavTime } from "./main-nav-time";
|
||||
|
||||
|
||||
271
components/notification-badge-enhanced.tsx
Normal file
271
components/notification-badge-enhanced.tsx
Normal file
@ -0,0 +1,271 @@
|
||||
import React, { memo, useState, useEffect, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Bell, ExternalLink, AlertCircle, LogIn, Kanban, MessageSquare, Mail, Calendar,
|
||||
Check, X, Filter, ChevronDown, ChevronUp, RefreshCw
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useNotifications } from '@/hooks/use-notifications';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useSession, signIn } from 'next-auth/react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { SafeHTML } from '@/components/safe-html';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
interface NotificationBadgeProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type SortOption = 'newest' | 'oldest';
|
||||
type FilterOption = 'all' | 'email' | 'rocketchat' | 'leantime' | 'calendar';
|
||||
|
||||
// Use React.memo to prevent unnecessary re-renders
|
||||
export const NotificationBadge = memo(function NotificationBadge({ className }: NotificationBadgeProps) {
|
||||
const { data: session, status } = useSession();
|
||||
const { notifications, notificationCount, fetchNotifications, markAsRead, loading, error } = useNotifications();
|
||||
const hasUnread = notificationCount.unread > 0;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [sortBy, setSortBy] = useState<SortOption>('newest');
|
||||
const [filterBy, setFilterBy] = useState<FilterOption>('all');
|
||||
const [displayLimit, setDisplayLimit] = useState(10);
|
||||
|
||||
// Fetch notifications when dropdown opens
|
||||
useEffect(() => {
|
||||
if (isOpen && status === 'authenticated') {
|
||||
fetchNotifications(1, 50, filterBy === 'all' ? undefined : filterBy);
|
||||
}
|
||||
}, [isOpen, status, filterBy, fetchNotifications]);
|
||||
|
||||
// Sort and filter notifications
|
||||
const sortedAndFilteredNotifications = useMemo(() => {
|
||||
let filtered = notifications;
|
||||
|
||||
// Filter by source
|
||||
if (filterBy !== 'all') {
|
||||
filtered = filtered.filter(n => n.source === filterBy);
|
||||
}
|
||||
|
||||
// Sort
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
const dateA = new Date(a.timestamp).getTime();
|
||||
const dateB = new Date(b.timestamp).getTime();
|
||||
return sortBy === 'newest' ? dateB - dateA : dateA - dateB;
|
||||
});
|
||||
|
||||
return sorted.slice(0, displayLimit);
|
||||
}, [notifications, filterBy, sortBy, displayLimit]);
|
||||
|
||||
const handleMarkAsRead = async (notificationId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
await markAsRead(notificationId);
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
setDisplayLimit(prev => prev + 10);
|
||||
};
|
||||
|
||||
const hasMore = notifications.length > displayLimit;
|
||||
|
||||
// Special case for auth error
|
||||
const isAuthError = error?.includes('Not authenticated') || error?.includes('401');
|
||||
|
||||
return (
|
||||
<div className={`relative ${className || ''}`}>
|
||||
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="text-white/80 hover:text-white relative p-0">
|
||||
<Bell className='w-5 h-5' />
|
||||
{hasUnread && (
|
||||
<Badge
|
||||
variant="notification"
|
||||
size="notification"
|
||||
className="absolute -top-2 -right-2 z-50"
|
||||
>
|
||||
{notificationCount.unread > 99 ? '99+' : notificationCount.unread}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="sr-only">Notifications</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-96 max-h-[85vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h3 className="font-semibold text-base">Notifications</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => fetchNotifications(1, 50, filterBy === 'all' ? undefined : filterBy)}
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters and Sort */}
|
||||
<div className="p-3 border-b bg-gray-50/50 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<Select value={filterBy} onValueChange={(v) => setFilterBy(v as FilterOption)}>
|
||||
<SelectTrigger className="h-8 text-xs flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toutes les sources</SelectItem>
|
||||
<SelectItem value="email">Courrier</SelectItem>
|
||||
<SelectItem value="rocketchat">Parole</SelectItem>
|
||||
<SelectItem value="leantime">Agilité</SelectItem>
|
||||
<SelectItem value="calendar">Agenda</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOption)}>
|
||||
<SelectTrigger className="h-8 w-24 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="newest">Plus récent</SelectItem>
|
||||
<SelectItem value="oldest">Plus ancien</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="py-8 px-4 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-muted-foreground">Chargement...</p>
|
||||
</div>
|
||||
) : isAuthError ? (
|
||||
<div className="py-8 px-4 text-center">
|
||||
<LogIn className="h-8 w-8 text-orange-500 mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground mb-2">Authentification requise</p>
|
||||
<Button variant="outline" size="sm" onClick={() => signIn()}>
|
||||
Se connecter
|
||||
</Button>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-8 px-4 text-center">
|
||||
<AlertCircle className="h-8 w-8 text-red-500 mx-auto mb-2" />
|
||||
<p className="text-sm text-red-500 mb-2">{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => fetchNotifications(1, 50)}>
|
||||
Réessayer
|
||||
</Button>
|
||||
</div>
|
||||
) : sortedAndFilteredNotifications.length === 0 ? (
|
||||
<div className="py-8 px-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">Aucune notification</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{sortedAndFilteredNotifications.map((notification) => (
|
||||
<DropdownMenuItem
|
||||
key={notification.id}
|
||||
className="px-4 py-3 cursor-default hover:bg-gray-50"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
{!notification.isRead && notification.source === 'rocketchat' && (
|
||||
<span className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0"></span>
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">
|
||||
{notification.title}
|
||||
</span>
|
||||
{!notification.isRead && notification.source !== 'rocketchat' && (
|
||||
<Badge variant="secondary" className="bg-blue-500 text-white text-[10px] px-1.5 py-0">New</Badge>
|
||||
)}
|
||||
{notification.source === 'leantime' && (
|
||||
<Badge variant="outline" className="text-[10px] py-0 px-1.5 bg-amber-50 text-amber-700 border-amber-200 flex items-center">
|
||||
<Kanban className="mr-1 h-2.5 w-2.5" />
|
||||
Agilité
|
||||
</Badge>
|
||||
)}
|
||||
{notification.source === 'rocketchat' && (
|
||||
<Badge variant="outline" className="text-[10px] py-0 px-1.5 bg-blue-50 text-blue-700 border-blue-200 flex items-center">
|
||||
<MessageSquare className="mr-1 h-2.5 w-2.5" />
|
||||
Parole
|
||||
</Badge>
|
||||
)}
|
||||
{notification.source === 'email' && (
|
||||
<Badge variant="outline" className="text-[10px] py-0 px-1.5 bg-green-50 text-green-700 border-green-200 flex items-center">
|
||||
<Mail className="mr-1 h-2.5 w-2.5" />
|
||||
Courrier
|
||||
</Badge>
|
||||
)}
|
||||
{notification.source === 'calendar' && (
|
||||
<Badge variant="outline" className="text-[10px] py-0 px-1.5 bg-purple-50 text-purple-700 border-purple-200 flex items-center">
|
||||
<Calendar className="mr-1 h-2.5 w-2.5" />
|
||||
Agenda
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<SafeHTML
|
||||
html={notification.message}
|
||||
className="text-xs text-muted-foreground mb-1 notification-message line-clamp-2"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(notification.timestamp), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-1 flex-shrink-0">
|
||||
{!notification.isRead && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => handleMarkAsRead(notification.id, e)}
|
||||
title="Marquer comme lu"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{notification.link && (
|
||||
<Link href={notification.link} onClick={() => setIsOpen(false)}>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" title="Ouvrir">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className="p-2 border-t text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLoadMore}
|
||||
className="w-full"
|
||||
>
|
||||
Charger plus ({notifications.length - displayLimit} restantes)
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@ -1,7 +1,6 @@
|
||||
import { useReducer, useCallback, useEffect, useRef } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useToast } from './use-toast';
|
||||
import { useTriggerNotification } from './use-trigger-notification';
|
||||
import {
|
||||
emailReducer,
|
||||
initialState,
|
||||
@ -550,8 +549,7 @@ export const useEmailState = () => {
|
||||
if (data.newestEmailId && data.newestEmailId > lastKnownEmailId) {
|
||||
logEmailOp('NEW_EMAILS', `Found new emails, newest ID: ${data.newestEmailId} (current: ${lastKnownEmailId})`);
|
||||
|
||||
// ⚡ Déclencher immédiatement le refresh des notifications
|
||||
triggerNotificationRefresh();
|
||||
// Note: Notifications are now handled by the email widget itself via useWidgetNotification
|
||||
|
||||
// Show a toast notification with the new custom variant
|
||||
toast({
|
||||
@ -574,7 +572,7 @@ export const useEmailState = () => {
|
||||
} catch (error) {
|
||||
console.error('Error checking for new emails:', error);
|
||||
}
|
||||
}, [session?.user?.id, state.currentFolder, state.isLoading, state.emails, state.perPage, toast, loadEmails, logEmailOp, dispatch, triggerNotificationRefresh]);
|
||||
}, [session?.user?.id, state.currentFolder, state.isLoading, state.emails, state.perPage, toast, loadEmails, logEmailOp, dispatch]);
|
||||
|
||||
// Delete emails
|
||||
const deleteEmails = useCallback(async (emailIds: string[]) => {
|
||||
|
||||
@ -32,7 +32,7 @@ export function useNotifications() {
|
||||
// Use request deduplication to prevent duplicate calls
|
||||
const requestKey = `notifications-count-${session.user.id}`;
|
||||
const url = force
|
||||
? `/api/notifications/count?_t=${Date.now()}`
|
||||
? `/api/notifications/count?force=true&_t=${Date.now()}`
|
||||
: '/api/notifications/count';
|
||||
|
||||
const data = await requestDeduplicator.execute(
|
||||
@ -69,24 +69,55 @@ export function useNotifications() {
|
||||
}
|
||||
}, [session?.user]);
|
||||
|
||||
// Mark notification as read
|
||||
const markAsRead = useCallback(async (notificationId: string) => {
|
||||
if (!session?.user || !isMountedRef.current) return false;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/notifications/${notificationId}/read`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to mark notification as read');
|
||||
}
|
||||
|
||||
// Update local state - remove from list and update count
|
||||
setNotifications(prev => prev.filter(n => n.id !== notificationId));
|
||||
setNotificationCount(prev => ({
|
||||
...prev,
|
||||
unread: Math.max(0, prev.unread - 1),
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
console.error('Error marking notification as read:', err);
|
||||
setError(err.message || 'Failed to mark notification as read');
|
||||
return false;
|
||||
}
|
||||
}, [session?.user]);
|
||||
|
||||
// Fetch notifications with request deduplication
|
||||
const fetchNotifications = useCallback(async (page = 1, limit = 20) => {
|
||||
const fetchNotifications = useCallback(async (page = 1, limit = 20, source?: string) => {
|
||||
if (!session?.user || !isMountedRef.current) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
console.log('[useNotifications] Fetching notifications', { page, limit });
|
||||
console.log('[useNotifications] Fetching notifications', { page, limit, source });
|
||||
|
||||
// Use request deduplication to prevent duplicate calls
|
||||
const requestKey = `notifications-${session.user.id}-${page}-${limit}`;
|
||||
const requestKey = `notifications-${session.user.id}-${page}-${limit}-${source || 'all'}`;
|
||||
const url = `/api/notifications?limit=${limit}${source ? `&source=${source}` : ''}`;
|
||||
|
||||
const data = await requestDeduplicator.execute(
|
||||
requestKey,
|
||||
async () => {
|
||||
const response = await fetch(`/api/notifications?page=${page}&limit=${limit}`, {
|
||||
credentials: 'include'
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@ -104,7 +135,12 @@ export function useNotifications() {
|
||||
);
|
||||
|
||||
if (isMountedRef.current) {
|
||||
setNotifications(data.notifications);
|
||||
// Filter by source if specified
|
||||
let filtered = data.notifications || [];
|
||||
if (source) {
|
||||
filtered = filtered.filter((n: Notification) => n.source === source);
|
||||
}
|
||||
setNotifications(filtered);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching notifications:', err);
|
||||
@ -171,5 +207,6 @@ export function useNotifications() {
|
||||
error,
|
||||
fetchNotifications,
|
||||
fetchNotificationCount: () => fetchNotificationCount(true),
|
||||
markAsRead,
|
||||
};
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Hook to trigger immediate notification refresh
|
||||
* Use this when widgets detect new messages/emails
|
||||
*/
|
||||
export function useTriggerNotification() {
|
||||
const { data: session } = useSession();
|
||||
const lastTriggerRef = useRef<number>(0);
|
||||
const TRIGGER_DEBOUNCE_MS = 2000; // 2 seconds debounce
|
||||
|
||||
const triggerNotificationRefresh = useCallback(async () => {
|
||||
if (!session?.user?.id) return;
|
||||
|
||||
// Debounce: prevent multiple triggers within 2 seconds
|
||||
const now = Date.now();
|
||||
if (now - lastTriggerRef.current < TRIGGER_DEBOUNCE_MS) {
|
||||
console.log('[useTriggerNotification] Debouncing trigger (too soon)');
|
||||
return;
|
||||
}
|
||||
lastTriggerRef.current = now;
|
||||
|
||||
try {
|
||||
console.log('[useTriggerNotification] Triggering notification refresh');
|
||||
|
||||
// Dispatch custom event for immediate UI update
|
||||
window.dispatchEvent(new CustomEvent('trigger-notification-refresh'));
|
||||
|
||||
// Force refresh du notification count en invalidant le cache
|
||||
const response = await fetch(`/api/notifications/count?_t=${Date.now()}&force=true`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log('[useTriggerNotification] Notification refresh triggered successfully');
|
||||
} else {
|
||||
console.warn('[useTriggerNotification] Failed to trigger refresh:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[useTriggerNotification] Error triggering notification refresh:', error);
|
||||
}
|
||||
}, [session?.user?.id]);
|
||||
|
||||
return { triggerNotificationRefresh };
|
||||
}
|
||||
@ -1,410 +0,0 @@
|
||||
import { NotificationAdapter } from './notification-adapter.interface';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { Notification, NotificationCount } from '@/lib/types/notification';
|
||||
import { getRedisClient } from '@/lib/redis';
|
||||
import { getImapConnection, shouldUseGraphAPI, getUserEmailAccounts } from '@/lib/services/email-service';
|
||||
import { getGraphUnreadCount, fetchGraphEmails } from '@/lib/services/microsoft-graph-mail';
|
||||
|
||||
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 getUserEmailAccounts(userId);
|
||||
|
||||
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 {
|
||||
logger.debug('[EMAIL_ADAPTER] Processing account', {
|
||||
userId,
|
||||
accountId,
|
||||
email: account.email,
|
||||
});
|
||||
|
||||
// Check if this is a Microsoft account that should use Graph API
|
||||
const graphCheck = await shouldUseGraphAPI(userId, accountId);
|
||||
|
||||
unreadCounts[accountId] = {};
|
||||
|
||||
if (graphCheck.useGraph && graphCheck.mailCredentialId) {
|
||||
// Use Graph API for Microsoft accounts
|
||||
try {
|
||||
const unreadCount = await getGraphUnreadCount(graphCheck.mailCredentialId, 'Inbox');
|
||||
unreadCounts[accountId]['INBOX'] = unreadCount;
|
||||
|
||||
logger.debug('[EMAIL_ADAPTER] Unread count (Graph API)', {
|
||||
userId,
|
||||
accountId,
|
||||
folder: 'INBOX',
|
||||
unread: unreadCount,
|
||||
});
|
||||
} catch (graphError) {
|
||||
logger.error('[EMAIL_ADAPTER] Error getting unread count via Graph API', {
|
||||
userId,
|
||||
accountId,
|
||||
error: graphError instanceof Error ? graphError.message : String(graphError),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Use IMAP for non-Microsoft accounts
|
||||
const client = await getImapConnection(userId, 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 (IMAP)', {
|
||||
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[]> {
|
||||
logger.debug('[EMAIL_ADAPTER] getNotifications called', { userId, page, limit });
|
||||
|
||||
try {
|
||||
// Get all accounts from the database
|
||||
const accounts = await getUserEmailAccounts(userId);
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const notifications: Notification[] = [];
|
||||
|
||||
// For each account, get unread emails from INBOX
|
||||
// Use the same flow as getEmails() but filter for unread only
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
// Check if this is a Microsoft account that should use Graph API
|
||||
const graphCheck = await shouldUseGraphAPI(userId, account.id);
|
||||
|
||||
if (graphCheck.useGraph && graphCheck.mailCredentialId) {
|
||||
// Use Graph API for Microsoft accounts
|
||||
try {
|
||||
const graphResult = await fetchGraphEmails(
|
||||
graphCheck.mailCredentialId,
|
||||
'Inbox',
|
||||
limit * 3, // Get more than limit to have enough after filtering
|
||||
0,
|
||||
'isRead eq false' // Filter for unread only
|
||||
);
|
||||
|
||||
// Convert Graph messages to notifications
|
||||
for (const graphMessage of graphResult.value) {
|
||||
if (graphMessage.isRead) continue; // Double-check unread status
|
||||
|
||||
notifications.push({
|
||||
id: `email-${graphMessage.id}`,
|
||||
source: 'email',
|
||||
title: graphMessage.subject || '(No subject)',
|
||||
message: graphMessage.bodyPreview || '',
|
||||
timestamp: new Date(graphMessage.receivedDateTime),
|
||||
read: false,
|
||||
link: `/courrier/${account.id}?email=${graphMessage.id}`,
|
||||
metadata: {
|
||||
accountId: account.id,
|
||||
accountEmail: account.email,
|
||||
emailId: graphMessage.id,
|
||||
folder: 'INBOX',
|
||||
},
|
||||
});
|
||||
|
||||
if (notifications.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (graphError) {
|
||||
logger.error('[EMAIL_ADAPTER] Error fetching notifications via Graph API', {
|
||||
userId,
|
||||
accountId: account.id,
|
||||
error: graphError instanceof Error ? graphError.message : String(graphError),
|
||||
});
|
||||
}
|
||||
continue; // Skip IMAP processing for Microsoft accounts
|
||||
}
|
||||
|
||||
// Use IMAP for non-Microsoft accounts
|
||||
const client = await getImapConnection(userId, account.id);
|
||||
|
||||
// Use the same approach as getEmails() - open mailbox first
|
||||
const mailboxInfo = await client.mailboxOpen('INBOX');
|
||||
|
||||
if (!mailboxInfo || typeof mailboxInfo === 'boolean') {
|
||||
logger.debug('[EMAIL_ADAPTER] Could not open INBOX', {
|
||||
userId,
|
||||
accountId: account.id,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const totalEmails = mailboxInfo.exists || 0;
|
||||
|
||||
if (totalEmails === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Search for unread emails (same as getNotificationCount logic)
|
||||
const searchResult = await client.search({ seen: false });
|
||||
|
||||
if (!searchResult || searchResult.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Limit the number of results for performance (get more than limit to have enough after filtering)
|
||||
const limitedResults = searchResult.slice(0, limit * 3);
|
||||
|
||||
// Fetch email metadata using the same structure as getEmails()
|
||||
const messages = await client.fetch(limitedResults, {
|
||||
envelope: true,
|
||||
flags: true,
|
||||
uid: true
|
||||
});
|
||||
|
||||
// Convert to notifications (same format as getEmails() processes emails)
|
||||
for await (const message of messages) {
|
||||
// Filter: only process if truly unread (double-check flags)
|
||||
if (message.flags && message.flags.has('\\Seen')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const envelope = message.envelope;
|
||||
const from = envelope.from?.[0];
|
||||
const subject = envelope.subject || '(Sans objet)';
|
||||
const fromName = from?.name || from?.address || 'Expéditeur inconnu';
|
||||
const fromAddress = from?.address || '';
|
||||
|
||||
const notification: Notification = {
|
||||
id: `email-${account.id}-${message.uid}`,
|
||||
source: 'email',
|
||||
sourceId: message.uid.toString(),
|
||||
type: 'email',
|
||||
title: 'Email',
|
||||
message: `${fromName}${fromAddress ? ` <${fromAddress}>` : ''}: ${subject}`,
|
||||
link: `/courrier?accountId=${account.id}&folder=INBOX&emailId=${message.uid}`,
|
||||
isRead: false,
|
||||
timestamp: envelope.date || new Date(),
|
||||
priority: 'normal',
|
||||
user: {
|
||||
id: fromAddress,
|
||||
name: fromName,
|
||||
},
|
||||
metadata: {
|
||||
accountId: account.id,
|
||||
accountEmail: account.email,
|
||||
folder: 'INBOX',
|
||||
emailId: message.uid.toString(),
|
||||
}
|
||||
};
|
||||
|
||||
notifications.push(notification);
|
||||
}
|
||||
|
||||
// Close mailbox (same as getEmails() does implicitly via connection pool)
|
||||
try {
|
||||
await client.mailboxClose();
|
||||
} catch (closeError) {
|
||||
// Non-fatal, connection pool will handle it
|
||||
logger.debug('[EMAIL_ADAPTER] Error closing mailbox (non-fatal)', {
|
||||
error: closeError instanceof Error ? closeError.message : String(closeError),
|
||||
});
|
||||
}
|
||||
} catch (accountError) {
|
||||
logger.error('[EMAIL_ADAPTER] Error processing account for notifications', {
|
||||
userId,
|
||||
accountId: account.id,
|
||||
error: accountError instanceof Error ? accountError.message : String(accountError),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp (newest first) and apply pagination
|
||||
notifications.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
|
||||
const startIndex = (page - 1) * limit;
|
||||
const endIndex = startIndex + limit;
|
||||
const paginatedNotifications = notifications.slice(startIndex, endIndex);
|
||||
|
||||
logger.debug('[EMAIL_ADAPTER] getNotifications result', {
|
||||
total: notifications.length,
|
||||
returned: paginatedNotifications.length,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
|
||||
return paginatedNotifications;
|
||||
} catch (error) {
|
||||
logger.error('[EMAIL_ADAPTER] Error getting notifications', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,551 +0,0 @@
|
||||
import { Notification, NotificationCount } from '@/lib/types/notification';
|
||||
import { NotificationAdapter } from './notification-adapter.interface';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/app/api/auth/options";
|
||||
import { getRedisClient } from '@/lib/redis';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
// Leantime notification type from their API
|
||||
interface LeantimeNotification {
|
||||
id: number;
|
||||
userId: number;
|
||||
username: string;
|
||||
message: string;
|
||||
type: string;
|
||||
moduleId: number;
|
||||
url: string;
|
||||
read: number; // 0 for unread, 1 for read
|
||||
date: string; // ISO format date
|
||||
}
|
||||
|
||||
export class LeantimeAdapter implements NotificationAdapter {
|
||||
readonly sourceName = 'leantime';
|
||||
private apiUrl: string;
|
||||
private apiToken: string;
|
||||
private static readonly USER_ID_CACHE_TTL = 3600; // 1 hour
|
||||
private static readonly USER_ID_CACHE_KEY_PREFIX = 'leantime:userid:';
|
||||
private static readonly USER_EMAIL_CACHE_TTL = 1800; // 30 minutes
|
||||
private static readonly USER_EMAIL_CACHE_KEY_PREFIX = 'leantime:useremail:';
|
||||
|
||||
constructor() {
|
||||
this.apiUrl = process.env.LEANTIME_API_URL || '';
|
||||
this.apiToken = process.env.LEANTIME_TOKEN || '';
|
||||
|
||||
logger.debug('[LEANTIME_ADAPTER] Initialized', {
|
||||
hasApiUrl: !!this.apiUrl,
|
||||
hasApiToken: !!this.apiToken,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached Leantime user ID for an email
|
||||
* Useful when user data changes or for debugging
|
||||
*/
|
||||
static async invalidateUserIdCache(email: string): Promise<void> {
|
||||
try {
|
||||
const redis = getRedisClient();
|
||||
const cacheKey = `${LeantimeAdapter.USER_ID_CACHE_KEY_PREFIX}${email.toLowerCase()}`;
|
||||
await redis.del(cacheKey);
|
||||
logger.info('[LEANTIME_ADAPTER] Invalidated user ID cache', {
|
||||
emailHash: Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[LEANTIME_ADAPTER] Error invalidating user ID cache:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async getNotifications(userId: string, page = 1, limit = 20): Promise<Notification[]> {
|
||||
logger.debug('[LEANTIME_ADAPTER] getNotifications called', {
|
||||
userId,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
|
||||
try {
|
||||
// Get the user's email directly from the session
|
||||
const email = await this.getUserEmail();
|
||||
|
||||
if (!email) {
|
||||
logger.error('[LEANTIME_ADAPTER] Could not get user email from session');
|
||||
return [];
|
||||
}
|
||||
|
||||
const leantimeUserId = await this.getLeantimeUserId(email);
|
||||
|
||||
if (!leantimeUserId) {
|
||||
logger.error('[LEANTIME_ADAPTER] User not found in Leantime', {
|
||||
emailHash: Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
// Calculate pagination limits
|
||||
const limitStart = (page - 1) * limit;
|
||||
const limitEnd = limit;
|
||||
|
||||
// Make request to Leantime API using the correct jsonrpc method
|
||||
const jsonRpcBody = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'leantime.rpc.Notifications.Notifications.getAllNotifications',
|
||||
params: {
|
||||
userId: leantimeUserId,
|
||||
showNewOnly: 0, // Get all notifications, not just unread
|
||||
limitStart: limitStart,
|
||||
limitEnd: limitEnd,
|
||||
filterOptions: [] // No additional filters
|
||||
},
|
||||
id: 1
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.apiUrl}/api/jsonrpc`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': this.apiToken
|
||||
},
|
||||
body: JSON.stringify(jsonRpcBody)
|
||||
});
|
||||
|
||||
logger.debug('[LEANTIME_ADAPTER] getNotifications response status', {
|
||||
status: response.status,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('[LEANTIME_ADAPTER] Failed to fetch Leantime notifications', {
|
||||
status: response.status,
|
||||
bodyPreview: errorText.substring(0, 200),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
const data = JSON.parse(responseText);
|
||||
|
||||
if (!data.result || !Array.isArray(data.result)) {
|
||||
if (data.error) {
|
||||
logger.error('[LEANTIME_ADAPTER] API error in getNotifications', {
|
||||
message: data.error.message,
|
||||
code: data.error.code,
|
||||
});
|
||||
} else {
|
||||
logger.error('[LEANTIME_ADAPTER] Invalid response format from Leantime notifications API');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const notifications = this.transformNotifications(data.result, userId);
|
||||
logger.debug('[LEANTIME_ADAPTER] Transformed notifications count', {
|
||||
count: notifications.length,
|
||||
});
|
||||
return notifications;
|
||||
} catch (error) {
|
||||
logger.error('[LEANTIME_ADAPTER] Error fetching Leantime notifications', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getNotificationCount(userId: string): Promise<NotificationCount> {
|
||||
logger.debug('[LEANTIME_ADAPTER] getNotificationCount called', { userId });
|
||||
|
||||
try {
|
||||
// Fetch notifications directly from API for accurate counting (bypassing cache)
|
||||
// Fetch up to 1000 notifications to get accurate count
|
||||
const email = await this.getUserEmail();
|
||||
if (!email) {
|
||||
logger.error('[LEANTIME_ADAPTER] Could not get user email for count');
|
||||
return {
|
||||
total: 0,
|
||||
unread: 0,
|
||||
sources: {
|
||||
leantime: {
|
||||
total: 0,
|
||||
unread: 0
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const leantimeUserId = await this.getLeantimeUserId(email);
|
||||
if (!leantimeUserId) {
|
||||
logger.error('[LEANTIME_ADAPTER] Could not get Leantime user ID for count');
|
||||
return {
|
||||
total: 0,
|
||||
unread: 0,
|
||||
sources: {
|
||||
leantime: {
|
||||
total: 0,
|
||||
unread: 0
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch directly from API (bypassing cache) to get accurate count
|
||||
const jsonRpcBody = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'leantime.rpc.Notifications.Notifications.getAllNotifications',
|
||||
params: {
|
||||
userId: leantimeUserId,
|
||||
showNewOnly: 0, // Get all notifications
|
||||
limitStart: 0,
|
||||
limitEnd: 1000, // Fetch up to 1000 for accurate counting
|
||||
filterOptions: []
|
||||
},
|
||||
id: 1
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.apiUrl}/api/jsonrpc`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': this.apiToken
|
||||
},
|
||||
body: JSON.stringify(jsonRpcBody)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error('[LEANTIME_ADAPTER] Failed to fetch notifications for count', {
|
||||
status: response.status,
|
||||
});
|
||||
return {
|
||||
total: 0,
|
||||
unread: 0,
|
||||
sources: {
|
||||
leantime: {
|
||||
total: 0,
|
||||
unread: 0
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error || !Array.isArray(data.result)) {
|
||||
logger.error('[LEANTIME_ADAPTER] Error or invalid response for count', {
|
||||
error: data.error,
|
||||
});
|
||||
return {
|
||||
total: 0,
|
||||
unread: 0,
|
||||
sources: {
|
||||
leantime: {
|
||||
total: 0,
|
||||
unread: 0
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const rawNotifications = data.result;
|
||||
|
||||
// Count total and unread from raw data
|
||||
const totalCount = rawNotifications.length;
|
||||
const unreadCount = rawNotifications.filter((n: any) =>
|
||||
n.read === 0 || n.read === false || n.read === '0'
|
||||
).length;
|
||||
|
||||
const hasMore = totalCount === 1000; // If we got exactly 1000, there might be more
|
||||
|
||||
logger.debug('[LEANTIME_ADAPTER] Notification counts', {
|
||||
total: totalCount,
|
||||
unread: unreadCount,
|
||||
hasMore: hasMore,
|
||||
});
|
||||
|
||||
return {
|
||||
total: totalCount,
|
||||
unread: unreadCount,
|
||||
sources: {
|
||||
leantime: {
|
||||
total: totalCount,
|
||||
unread: unreadCount
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[LEANTIME_ADAPTER] Error fetching notification count', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return {
|
||||
total: 0,
|
||||
unread: 0,
|
||||
sources: {
|
||||
leantime: {
|
||||
total: 0,
|
||||
unread: 0
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async isConfigured(): Promise<boolean> {
|
||||
return !!(this.apiUrl && this.apiToken);
|
||||
}
|
||||
|
||||
private transformNotifications(data: any[], userId: string): Notification[] {
|
||||
if (!Array.isArray(data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.map(notification => {
|
||||
// Determine properties from notification object
|
||||
// Adjust these based on actual structure of Leantime notifications
|
||||
const id = notification.id || notification._id || notification.notificationId;
|
||||
const message = notification.message || notification.text || notification.content || '';
|
||||
const type = notification.type || 'notification';
|
||||
const read = notification.read || notification.isRead || 0;
|
||||
const date = notification.date || notification.datetime || notification.createdDate || new Date().toISOString();
|
||||
const url = notification.url || notification.link || '';
|
||||
|
||||
// Convert external Leantime URL to hub.slm-lab.net/agilite
|
||||
let link = url.startsWith('http') ? url : `${this.apiUrl}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
// Replace external Leantime URL with hub.slm-lab.net/agilite
|
||||
if (link.includes('agilite.slm-lab.net') || link.includes(this.apiUrl)) {
|
||||
link = '/agilite';
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${this.sourceName}-${id}`,
|
||||
source: this.sourceName as 'leantime',
|
||||
sourceId: id.toString(),
|
||||
type: type,
|
||||
title: type, // Use type as title if no specific title field
|
||||
message: message,
|
||||
link: link,
|
||||
isRead: read === 1 || read === true,
|
||||
timestamp: new Date(date),
|
||||
priority: 'normal',
|
||||
user: {
|
||||
id: userId,
|
||||
name: notification.username || notification.userName || ''
|
||||
},
|
||||
metadata: {
|
||||
// Include any other useful fields from notification
|
||||
moduleId: notification.moduleId || notification.module || '',
|
||||
projectId: notification.projectId || '',
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to get user's email with caching
|
||||
private async getUserEmail(): Promise<string | null> {
|
||||
try {
|
||||
// Get user ID from session first (for cache key)
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || !session.user?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
const emailCacheKey = `${LeantimeAdapter.USER_EMAIL_CACHE_KEY_PREFIX}${userId}`;
|
||||
|
||||
// Check cache first
|
||||
try {
|
||||
const redis = getRedisClient();
|
||||
const cachedEmail = await redis.get(emailCacheKey);
|
||||
if (cachedEmail) {
|
||||
logger.debug('[LEANTIME_ADAPTER] Found cached email for user', { userId });
|
||||
return cachedEmail;
|
||||
}
|
||||
} catch (cacheError) {
|
||||
logger.warn('[LEANTIME_ADAPTER] Error checking email cache, will fetch from session', {
|
||||
error: cacheError instanceof Error ? cacheError.message : String(cacheError),
|
||||
});
|
||||
}
|
||||
|
||||
// Get from session
|
||||
if (!session.user?.email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const email = session.user.email;
|
||||
|
||||
// Cache the email
|
||||
try {
|
||||
const redis = getRedisClient();
|
||||
await redis.set(emailCacheKey, email, 'EX', LeantimeAdapter.USER_EMAIL_CACHE_TTL);
|
||||
logger.debug('[LEANTIME_ADAPTER] Cached user email', { userId });
|
||||
} catch (cacheError) {
|
||||
logger.warn('[LEANTIME_ADAPTER] Failed to cache email (non-fatal)', {
|
||||
error: cacheError instanceof Error ? cacheError.message : String(cacheError),
|
||||
});
|
||||
}
|
||||
|
||||
return email;
|
||||
} catch (error) {
|
||||
logger.error('[LEANTIME_ADAPTER] Error getting user email from session', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get Leantime user ID by email with caching and retry logic
|
||||
private async getLeantimeUserId(email: string, retryCount = 0): Promise<number | null> {
|
||||
const MAX_RETRIES = 3;
|
||||
const CACHE_KEY = `${LeantimeAdapter.USER_ID_CACHE_KEY_PREFIX}${email.toLowerCase()}`;
|
||||
|
||||
try {
|
||||
if (!this.apiToken) {
|
||||
logger.error('[LEANTIME_ADAPTER] No API token available for getLeantimeUserId');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check Redis cache first
|
||||
try {
|
||||
const redis = getRedisClient();
|
||||
const cachedUserId = await redis.get(CACHE_KEY);
|
||||
if (cachedUserId) {
|
||||
const userId = parseInt(cachedUserId, 10);
|
||||
if (!isNaN(userId)) {
|
||||
logger.debug('[LEANTIME_ADAPTER] Found cached Leantime user ID', {
|
||||
emailHash: Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12),
|
||||
userId,
|
||||
});
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
} catch (cacheError) {
|
||||
logger.warn('[LEANTIME_ADAPTER] Error checking cache for user ID, will fetch from API', {
|
||||
error: cacheError instanceof Error ? cacheError.message : String(cacheError),
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch from API with retry logic
|
||||
const fetchWithRetry = async (attempt: number): Promise<number | null> => {
|
||||
try {
|
||||
logger.debug('[LEANTIME_ADAPTER] Fetching Leantime user ID', {
|
||||
emailHash: Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12),
|
||||
attempt: attempt + 1,
|
||||
maxRetries: MAX_RETRIES,
|
||||
});
|
||||
|
||||
const response = await fetch(`${this.apiUrl}/api/jsonrpc`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': this.apiToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'leantime.rpc.users.getAll',
|
||||
id: 1
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('[LEANTIME_ADAPTER] User lookup API HTTP error', {
|
||||
status: response.status,
|
||||
bodyPreview: errorText.substring(0, 200),
|
||||
});
|
||||
|
||||
// Retry on server errors (5xx) or rate limiting (429)
|
||||
if ((response.status >= 500 || response.status === 429) && attempt < MAX_RETRIES) {
|
||||
const delay = Math.min(1000 * Math.pow(2, attempt), 5000); // Exponential backoff, max 5s
|
||||
logger.debug('[LEANTIME_ADAPTER] Retrying user lookup after HTTP error', {
|
||||
attempt: attempt + 1,
|
||||
delay,
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
return fetchWithRetry(attempt + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
logger.error('[LEANTIME_ADAPTER] User lookup JSON-RPC error', {
|
||||
error: data.error,
|
||||
});
|
||||
// Retry on certain errors
|
||||
if (attempt < MAX_RETRIES && (data.error.code === -32603 || data.error.code === -32000)) {
|
||||
const delay = Math.min(1000 * Math.pow(2, attempt), 5000);
|
||||
logger.debug('[LEANTIME_ADAPTER] Retrying user lookup after JSON-RPC error', {
|
||||
attempt: attempt + 1,
|
||||
delay,
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
return fetchWithRetry(attempt + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!data.result || !Array.isArray(data.result)) {
|
||||
logger.error('[LEANTIME_ADAPTER] Invalid user lookup response format');
|
||||
return null;
|
||||
}
|
||||
|
||||
const users = data.result;
|
||||
|
||||
// Find user with matching email (check in both username and email fields)
|
||||
const user = users.find((u: any) =>
|
||||
u.username === email ||
|
||||
u.email === email ||
|
||||
(typeof u.username === 'string' && u.username.toLowerCase() === email.toLowerCase())
|
||||
);
|
||||
|
||||
if (user && user.id) {
|
||||
const userId = typeof user.id === 'number' ? user.id : parseInt(String(user.id), 10);
|
||||
|
||||
if (!isNaN(userId)) {
|
||||
// Cache the result
|
||||
try {
|
||||
const redis = getRedisClient();
|
||||
await redis.set(CACHE_KEY, userId.toString(), 'EX', LeantimeAdapter.USER_ID_CACHE_TTL);
|
||||
logger.debug('[LEANTIME_ADAPTER] Cached Leantime user ID', {
|
||||
emailHash: Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12),
|
||||
userId,
|
||||
});
|
||||
} catch (cacheError) {
|
||||
logger.warn('[LEANTIME_ADAPTER] Failed to cache user ID (non-fatal)', {
|
||||
error: cacheError instanceof Error ? cacheError.message : String(cacheError),
|
||||
});
|
||||
// Continue even if caching fails
|
||||
}
|
||||
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn('[LEANTIME_ADAPTER] User not found in Leantime', {
|
||||
emailHash: Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12),
|
||||
});
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error('[LEANTIME_ADAPTER] Error fetching user ID', {
|
||||
attempt: attempt + 1,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
// Retry on network errors
|
||||
if (attempt < MAX_RETRIES && error instanceof Error) {
|
||||
const delay = Math.min(1000 * Math.pow(2, attempt), 5000);
|
||||
logger.debug('[LEANTIME_ADAPTER] Retrying user lookup after network error', {
|
||||
attempt: attempt + 1,
|
||||
delay,
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
return fetchWithRetry(attempt + 1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return await fetchWithRetry(retryCount);
|
||||
} catch (error) {
|
||||
logger.error('[LEANTIME_ADAPTER] Fatal error getting Leantime user ID', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
import { Notification, NotificationCount } from '@/lib/types/notification';
|
||||
|
||||
export interface NotificationAdapter {
|
||||
/**
|
||||
* The source name of this notification adapter
|
||||
*/
|
||||
readonly sourceName: string;
|
||||
|
||||
/**
|
||||
* Fetch all notifications for a user
|
||||
* @param userId The user ID
|
||||
* @param page Page number for pagination
|
||||
* @param limit Number of items per page
|
||||
* @returns Promise with notification data
|
||||
*/
|
||||
getNotifications(userId: string, page?: number, limit?: number): Promise<Notification[]>;
|
||||
|
||||
/**
|
||||
* Get count of notifications for a user
|
||||
* @param userId The user ID
|
||||
* @returns Promise with notification count data
|
||||
*/
|
||||
getNotificationCount(userId: string): Promise<NotificationCount>;
|
||||
|
||||
/**
|
||||
* Check if this adapter is configured and ready to use
|
||||
* @returns Promise with boolean indicating if adapter is ready
|
||||
*/
|
||||
isConfigured(): Promise<boolean>;
|
||||
}
|
||||
@ -1,426 +0,0 @@
|
||||
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';
|
||||
|
||||
export class NotificationService {
|
||||
private adapters: Map<string, NotificationAdapter> = new Map();
|
||||
private static instance: NotificationService;
|
||||
|
||||
// Cache keys and TTLs - All aligned to 30 seconds for consistency
|
||||
private static NOTIFICATION_COUNT_CACHE_KEY = (userId: string) => `notifications:count:${userId}`;
|
||||
private static NOTIFICATIONS_LIST_CACHE_KEY = (userId: string, page: number, limit: number) =>
|
||||
`notifications:list:${userId}:${page}:${limit}`;
|
||||
private static COUNT_CACHE_TTL = 30; // 30 seconds (aligned with refresh interval)
|
||||
private static LIST_CACHE_TTL = 30; // 30 seconds (aligned with count cache for consistency)
|
||||
private static REFRESH_LOCK_KEY = (userId: string) => `notifications:refresh:lock:${userId}`;
|
||||
private static REFRESH_LOCK_TTL = 30; // 30 seconds
|
||||
|
||||
constructor() {
|
||||
logger.debug('[NOTIFICATION_SERVICE] Initializing notification service');
|
||||
|
||||
// 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());
|
||||
// this.registerAdapter(new GiteaAdapter());
|
||||
// this.registerAdapter(new DolibarrAdapter());
|
||||
// this.registerAdapter(new MoodleAdapter());
|
||||
|
||||
logger.debug('[NOTIFICATION_SERVICE] Registered adapters', {
|
||||
adapters: Array.from(this.adapters.keys()),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of the notification service
|
||||
*/
|
||||
public static getInstance(): NotificationService {
|
||||
if (!NotificationService.instance) {
|
||||
logger.debug('[NOTIFICATION_SERVICE] Creating new notification service instance');
|
||||
NotificationService.instance = new NotificationService();
|
||||
}
|
||||
return NotificationService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a notification adapter
|
||||
*/
|
||||
private registerAdapter(adapter: NotificationAdapter): void {
|
||||
this.adapters.set(adapter.sourceName, adapter);
|
||||
logger.debug('[NOTIFICATION_SERVICE] Registered notification adapter', {
|
||||
adapter: adapter.sourceName,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all notifications for a user from all configured sources
|
||||
*/
|
||||
async getNotifications(userId: string, page = 1, limit = 20): Promise<Notification[]> {
|
||||
logger.debug('[NOTIFICATION_SERVICE] getNotifications called', {
|
||||
userId,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
const redis = getRedisClient();
|
||||
const cacheKey = NotificationService.NOTIFICATIONS_LIST_CACHE_KEY(userId, page, limit);
|
||||
|
||||
// Try to get from cache first
|
||||
try {
|
||||
const cachedData = await redis.get(cacheKey);
|
||||
if (cachedData) {
|
||||
logger.debug('[NOTIFICATION_SERVICE] Using cached notifications', {
|
||||
userId,
|
||||
});
|
||||
|
||||
// Schedule background refresh if TTL is less than half the original value
|
||||
const ttl = await redis.ttl(cacheKey);
|
||||
if (ttl < NotificationService.LIST_CACHE_TTL / 2) {
|
||||
// Background refresh is now handled by unified refresh system
|
||||
// Only schedule if unified refresh is not active
|
||||
}
|
||||
|
||||
return JSON.parse(cachedData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[NOTIFICATION_SERVICE] Error retrieving notifications from cache:', error);
|
||||
}
|
||||
|
||||
// No cached data, fetch from all adapters
|
||||
logger.debug('[NOTIFICATION_SERVICE] Fetching notifications from adapters', {
|
||||
userId,
|
||||
adapterCount: this.adapters.size,
|
||||
});
|
||||
|
||||
const allNotifications: Notification[] = [];
|
||||
const adapterEntries = Array.from(this.adapters.entries());
|
||||
logger.debug('[NOTIFICATION_SERVICE] Available adapters', {
|
||||
adapters: adapterEntries.map(([name]) => name),
|
||||
});
|
||||
|
||||
const promises = adapterEntries.map(async ([name, adapter]) => {
|
||||
logger.debug('[NOTIFICATION_SERVICE] Checking adapter configuration', {
|
||||
adapter: name,
|
||||
});
|
||||
try {
|
||||
const configured = await adapter.isConfigured();
|
||||
logger.debug('[NOTIFICATION_SERVICE] Adapter configuration result', {
|
||||
adapter: name,
|
||||
configured,
|
||||
});
|
||||
|
||||
if (configured) {
|
||||
logger.debug('[NOTIFICATION_SERVICE] Fetching notifications from adapter', {
|
||||
adapter: name,
|
||||
userId,
|
||||
});
|
||||
const notifications = await adapter.getNotifications(userId, page, limit);
|
||||
logger.debug('[NOTIFICATION_SERVICE] Adapter notifications fetched', {
|
||||
adapter: name,
|
||||
count: notifications.length,
|
||||
});
|
||||
return notifications;
|
||||
} else {
|
||||
logger.debug('[NOTIFICATION_SERVICE] Skipping adapter (not configured)', {
|
||||
adapter: name,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[NOTIFICATION_SERVICE] Error fetching notifications from adapter', {
|
||||
adapter: name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// Combine all notifications
|
||||
results.forEach((notifications, index) => {
|
||||
const adapterName = adapterEntries[index][0];
|
||||
logger.debug('[NOTIFICATION_SERVICE] Adding notifications from adapter', {
|
||||
adapter: adapterName,
|
||||
count: notifications.length,
|
||||
});
|
||||
allNotifications.push(...notifications);
|
||||
});
|
||||
|
||||
// Sort by timestamp (newest first)
|
||||
allNotifications.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
logger.debug('[NOTIFICATION_SERVICE] Notifications sorted', {
|
||||
total: allNotifications.length,
|
||||
});
|
||||
|
||||
// Store in cache
|
||||
try {
|
||||
await redis.set(
|
||||
cacheKey,
|
||||
JSON.stringify(allNotifications),
|
||||
'EX',
|
||||
NotificationService.LIST_CACHE_TTL
|
||||
);
|
||||
logger.debug('[NOTIFICATION_SERVICE] Cached notifications', {
|
||||
userId,
|
||||
count: allNotifications.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[NOTIFICATION_SERVICE] Error caching notifications', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return allNotifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notification counts for a user
|
||||
* @param forceRefresh If true, bypass cache and fetch fresh data
|
||||
*/
|
||||
async getNotificationCount(userId: string, forceRefresh: boolean = false): Promise<NotificationCount> {
|
||||
logger.debug('[NOTIFICATION_SERVICE] getNotificationCount called', { userId, forceRefresh });
|
||||
const redis = getRedisClient();
|
||||
const cacheKey = NotificationService.NOTIFICATION_COUNT_CACHE_KEY(userId);
|
||||
|
||||
// If force refresh, skip cache
|
||||
if (!forceRefresh) {
|
||||
// Try to get from cache first
|
||||
try {
|
||||
const cachedData = await redis.get(cacheKey);
|
||||
if (cachedData) {
|
||||
logger.debug('[NOTIFICATION_SERVICE] Using cached notification counts', { userId });
|
||||
|
||||
// Background refresh is now handled by unified refresh system
|
||||
// Cache TTL is aligned with refresh interval (30s)
|
||||
|
||||
return JSON.parse(cachedData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[NOTIFICATION_SERVICE] Error retrieving notification counts from cache:', error);
|
||||
}
|
||||
} else {
|
||||
logger.debug('[NOTIFICATION_SERVICE] Force refresh requested, bypassing cache', { userId });
|
||||
}
|
||||
|
||||
// No cached data, fetch counts from all adapters
|
||||
logger.debug('[NOTIFICATION_SERVICE] Fetching notification counts from adapters', {
|
||||
userId,
|
||||
adapterCount: this.adapters.size,
|
||||
});
|
||||
|
||||
const aggregatedCount: NotificationCount = {
|
||||
total: 0,
|
||||
unread: 0,
|
||||
sources: {}
|
||||
};
|
||||
|
||||
const adapterEntries = Array.from(this.adapters.entries());
|
||||
logger.debug('[NOTIFICATION_SERVICE] Available adapters for count', {
|
||||
adapters: adapterEntries.map(([name]) => name),
|
||||
});
|
||||
|
||||
const promises = adapterEntries.map(async ([name, adapter]) => {
|
||||
logger.debug('[NOTIFICATION_SERVICE] Checking adapter configuration for count', {
|
||||
adapter: name,
|
||||
});
|
||||
try {
|
||||
const configured = await adapter.isConfigured();
|
||||
logger.debug('[NOTIFICATION_SERVICE] Adapter configuration result for count', {
|
||||
adapter: name,
|
||||
configured,
|
||||
});
|
||||
|
||||
if (configured) {
|
||||
logger.debug('[NOTIFICATION_SERVICE] Fetching notification count from adapter', {
|
||||
adapter: name,
|
||||
userId,
|
||||
});
|
||||
const count = await adapter.getNotificationCount(userId);
|
||||
logger.debug('[NOTIFICATION_SERVICE] Adapter count fetched', {
|
||||
adapter: name,
|
||||
total: count.total,
|
||||
unread: count.unread,
|
||||
});
|
||||
return count;
|
||||
} else {
|
||||
logger.debug('[NOTIFICATION_SERVICE] Skipping adapter for count (not configured)', {
|
||||
adapter: name,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[NOTIFICATION_SERVICE] Error fetching notification count from adapter', {
|
||||
adapter: name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// Combine all counts
|
||||
results.forEach((count, index) => {
|
||||
if (!count) return;
|
||||
|
||||
const adapterName = adapterEntries[index][0];
|
||||
logger.debug('[NOTIFICATION_SERVICE] Adding counts from adapter', {
|
||||
adapter: adapterName,
|
||||
total: count.total,
|
||||
unread: count.unread,
|
||||
});
|
||||
|
||||
aggregatedCount.total += count.total;
|
||||
aggregatedCount.unread += count.unread;
|
||||
|
||||
// Merge source-specific counts
|
||||
Object.entries(count.sources).forEach(([source, sourceCount]) => {
|
||||
aggregatedCount.sources[source] = sourceCount;
|
||||
});
|
||||
});
|
||||
|
||||
logger.debug('[NOTIFICATION_SERVICE] Aggregated counts', {
|
||||
userId,
|
||||
total: aggregatedCount.total,
|
||||
unread: aggregatedCount.unread,
|
||||
});
|
||||
|
||||
// Store in cache
|
||||
try {
|
||||
await redis.set(
|
||||
cacheKey,
|
||||
JSON.stringify(aggregatedCount),
|
||||
'EX',
|
||||
NotificationService.COUNT_CACHE_TTL
|
||||
);
|
||||
logger.debug('[NOTIFICATION_SERVICE] Cached notification counts', {
|
||||
userId,
|
||||
total: aggregatedCount.total,
|
||||
unread: aggregatedCount.unread,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[NOTIFICATION_SERVICE] Error caching notification counts', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return aggregatedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate notification caches for a user
|
||||
* Made public so it can be called from API routes for force refresh
|
||||
*/
|
||||
public async invalidateCache(userId: string): Promise<void> {
|
||||
try {
|
||||
const redis = getRedisClient();
|
||||
|
||||
// Get all cache keys for this user
|
||||
const countKey = NotificationService.NOTIFICATION_COUNT_CACHE_KEY(userId);
|
||||
const listKeysPattern = `notifications:list:${userId}:*`;
|
||||
|
||||
// Delete count cache
|
||||
await redis.del(countKey);
|
||||
|
||||
// Find and delete list caches using SCAN to avoid blocking Redis
|
||||
let cursor = "0";
|
||||
do {
|
||||
const [nextCursor, keys] = await redis.scan(
|
||||
cursor,
|
||||
"MATCH",
|
||||
listKeysPattern,
|
||||
"COUNT",
|
||||
100
|
||||
);
|
||||
cursor = nextCursor;
|
||||
|
||||
if (keys.length > 0) {
|
||||
await redis.del(...keys);
|
||||
}
|
||||
} while (cursor !== "0");
|
||||
|
||||
logger.debug('[NOTIFICATION_SERVICE] Invalidated notification caches', {
|
||||
userId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[NOTIFICATION_SERVICE] Error invalidating notification caches', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a background refresh of notification data
|
||||
*/
|
||||
private async scheduleBackgroundRefresh(userId: string): Promise<void> {
|
||||
const redis = getRedisClient();
|
||||
const lockKey = NotificationService.REFRESH_LOCK_KEY(userId);
|
||||
|
||||
// Try to acquire a lock to prevent multiple refreshes
|
||||
const lockAcquired = await redis.set(
|
||||
lockKey,
|
||||
Date.now().toString(),
|
||||
'EX',
|
||||
NotificationService.REFRESH_LOCK_TTL,
|
||||
'NX' // Set only if the key doesn't exist
|
||||
);
|
||||
|
||||
if (!lockAcquired) {
|
||||
// Another process is already refreshing
|
||||
return;
|
||||
}
|
||||
|
||||
// Use setTimeout to make this non-blocking
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// Check if we've refreshed recently (within the last minute)
|
||||
// to avoid excessive refreshes from multiple tabs/components
|
||||
const refreshKey = `notifications:last_refresh:${userId}`;
|
||||
const lastRefresh = await redis.get(refreshKey);
|
||||
|
||||
if (lastRefresh) {
|
||||
const lastRefreshTime = parseInt(lastRefresh, 10);
|
||||
const now = Date.now();
|
||||
|
||||
// If refreshed in the last minute, skip
|
||||
if (now - lastRefreshTime < 60000) {
|
||||
logger.debug('[NOTIFICATION_SERVICE] Skipping background refresh (recently refreshed)', {
|
||||
userId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('[NOTIFICATION_SERVICE] Background refresh started', {
|
||||
userId,
|
||||
});
|
||||
|
||||
// Set last refresh time
|
||||
await redis.set(refreshKey, Date.now().toString(), 'EX', 120); // 2 minute TTL
|
||||
|
||||
// Refresh counts and notifications (for first page)
|
||||
await this.getNotificationCount(userId);
|
||||
await this.getNotifications(userId, 1, 20);
|
||||
|
||||
logger.debug('[NOTIFICATION_SERVICE] Background refresh completed', {
|
||||
userId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[NOTIFICATION_SERVICE] Background refresh failed', {
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
// Release the lock
|
||||
await redis.del(lockKey).catch(() => {});
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
@ -1,537 +0,0 @@
|
||||
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);
|
||||
const email = session?.user?.email || null;
|
||||
logger.debug('[ROCKETCHAT_ADAPTER] getUserEmail', {
|
||||
hasSession: !!session,
|
||||
hasEmail: !!email,
|
||||
emailHash: email ? Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12) : null,
|
||||
});
|
||||
return email;
|
||||
} 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
|
||||
* Uses the same logic as the messages API: search by username OR 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)) {
|
||||
logger.error('[ROCKETCHAT_ADAPTER] Invalid users list response', {
|
||||
success: usersData.success,
|
||||
hasUsers: Array.isArray(usersData.users),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug('[ROCKETCHAT_ADAPTER] Searching for user', {
|
||||
username,
|
||||
email,
|
||||
totalUsers: usersData.users.length,
|
||||
});
|
||||
|
||||
// Use the exact same logic as the messages API
|
||||
const currentUser = usersData.users.find((user: any) =>
|
||||
user.username === username || user.emails?.some((e: any) => e.address === email)
|
||||
);
|
||||
|
||||
if (!currentUser) {
|
||||
logger.warn('[ROCKETCHAT_ADAPTER] User not found in RocketChat', {
|
||||
username,
|
||||
email,
|
||||
searchedIn: usersData.users.length,
|
||||
availableUsernames: usersData.users.slice(0, 5).map((u: any) => u.username),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug('[ROCKETCHAT_ADAPTER] Found user', {
|
||||
username,
|
||||
email,
|
||||
rocketChatUsername: currentUser.username,
|
||||
userId: currentUser._id,
|
||||
});
|
||||
|
||||
return currentUser._id;
|
||||
} 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
|
||||
* Creates a token for the specified RocketChat user ID
|
||||
*/
|
||||
private async getUserToken(rocketChatUserId: string): 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'
|
||||
};
|
||||
|
||||
// Create token for the specific user
|
||||
// RocketChat 8.0.2+ requires a 'secret' parameter
|
||||
const secret = process.env.ROCKET_CHAT_CREATE_TOKEN_SECRET;
|
||||
if (!secret) {
|
||||
logger.error('[ROCKETCHAT_ADAPTER] ROCKET_CHAT_CREATE_TOKEN_SECRET is not configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
const createTokenResponse = await fetch(`${this.baseUrl}/api/v1/users.createToken`, {
|
||||
method: 'POST',
|
||||
headers: adminHeaders,
|
||||
body: JSON.stringify({
|
||||
userId: rocketChatUserId,
|
||||
secret: secret
|
||||
})
|
||||
});
|
||||
|
||||
if (!createTokenResponse.ok) {
|
||||
logger.error('[ROCKETCHAT_ADAPTER] Failed to create user token', {
|
||||
status: createTokenResponse.status,
|
||||
rocketChatUserId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenData = await createTokenResponse.json();
|
||||
|
||||
if (!tokenData.success || !tokenData.data) {
|
||||
logger.error('[ROCKETCHAT_ADAPTER] Invalid token response', {
|
||||
response: tokenData,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug('[ROCKETCHAT_ADAPTER] User token created', {
|
||||
rocketChatUserId,
|
||||
tokenUserId: tokenData.data.userId,
|
||||
});
|
||||
|
||||
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),
|
||||
rocketChatUserId,
|
||||
});
|
||||
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', {
|
||||
emailHash: email ? Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12) : null,
|
||||
});
|
||||
return {
|
||||
total: 0,
|
||||
unread: 0,
|
||||
sources: {
|
||||
rocketchat: {
|
||||
total: 0,
|
||||
unread: 0
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug('[ROCKETCHAT_ADAPTER] Found RocketChat user', {
|
||||
rocketChatUserId,
|
||||
emailHash: Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12),
|
||||
});
|
||||
|
||||
const userToken = await this.getUserToken(rocketChatUserId);
|
||||
if (!userToken) {
|
||||
logger.error('[ROCKETCHAT_ADAPTER] Could not get user token', {
|
||||
rocketChatUserId,
|
||||
});
|
||||
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[]> {
|
||||
logger.debug('[ROCKETCHAT_ADAPTER] getNotifications called', { userId, page, limit });
|
||||
|
||||
try {
|
||||
const email = await this.getUserEmail();
|
||||
if (!email) {
|
||||
logger.error('[ROCKETCHAT_ADAPTER] Could not get user email');
|
||||
return [];
|
||||
}
|
||||
|
||||
const rocketChatUserId = await this.getRocketChatUserId(email);
|
||||
if (!rocketChatUserId) {
|
||||
logger.debug('[ROCKETCHAT_ADAPTER] User not found in RocketChat');
|
||||
return [];
|
||||
}
|
||||
|
||||
const userToken = await this.getUserToken(rocketChatUserId);
|
||||
if (!userToken) {
|
||||
logger.error('[ROCKETCHAT_ADAPTER] Could not get user token');
|
||||
return [];
|
||||
}
|
||||
|
||||
const userHeaders = {
|
||||
'X-Auth-Token': userToken.authToken,
|
||||
'X-User-Id': userToken.userId,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
// Get user's subscriptions with unread messages
|
||||
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 [];
|
||||
}
|
||||
|
||||
const subscriptionsData = await subscriptionsResponse.json();
|
||||
if (!subscriptionsData.success || !Array.isArray(subscriptionsData.update)) {
|
||||
logger.error('[ROCKETCHAT_ADAPTER] Invalid subscriptions response');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Filter subscriptions with unread messages
|
||||
const userSubscriptions = subscriptionsData.update.filter((sub: any) => {
|
||||
return (sub.unread > 0 || sub.alert) && ['d', 'c', 'p'].includes(sub.t);
|
||||
});
|
||||
|
||||
// Get user info for comparison
|
||||
const usersResponse = await fetch(`${this.baseUrl}/api/v1/users.list`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Auth-Token': process.env.ROCKET_CHAT_TOKEN!,
|
||||
'X-User-Id': process.env.ROCKET_CHAT_USER_ID!,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
let currentUser: any = null;
|
||||
if (usersResponse.ok) {
|
||||
const usersData = await usersResponse.json();
|
||||
if (usersData.success && Array.isArray(usersData.users)) {
|
||||
const username = email.split('@')[0];
|
||||
currentUser = usersData.users.find((u: any) =>
|
||||
u.username === username || u.emails?.some((e: any) => e.address === email)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const notifications: Notification[] = [];
|
||||
|
||||
// Fetch messages for each subscription with unread messages
|
||||
for (const subscription of userSubscriptions) {
|
||||
try {
|
||||
// Determine the correct endpoint based on room type
|
||||
let endpoint;
|
||||
switch (subscription.t) {
|
||||
case 'c':
|
||||
endpoint = 'channels.messages';
|
||||
break;
|
||||
case 'p':
|
||||
endpoint = 'groups.messages';
|
||||
break;
|
||||
case 'd':
|
||||
endpoint = 'im.messages';
|
||||
break;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
roomId: subscription.rid,
|
||||
count: String(Math.max(subscription.unread, 1)) // Fetch at least 1 message
|
||||
});
|
||||
|
||||
const messagesResponse = await fetch(
|
||||
`${this.baseUrl}/api/v1/${endpoint}?${queryParams}`, {
|
||||
method: 'GET',
|
||||
headers: userHeaders
|
||||
}
|
||||
);
|
||||
|
||||
if (!messagesResponse.ok) {
|
||||
logger.error('[ROCKETCHAT_ADAPTER] Failed to get messages for room', {
|
||||
roomName: subscription.name,
|
||||
status: messagesResponse.status,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const messageData = await messagesResponse.json();
|
||||
|
||||
if (messageData.success && messageData.messages?.length > 0) {
|
||||
// Get the latest unread message (skip own messages)
|
||||
const unreadMessages = messageData.messages.filter((msg: any) => {
|
||||
// Skip messages sent by current user
|
||||
if (currentUser && msg.u?._id === currentUser._id) {
|
||||
return false;
|
||||
}
|
||||
// Skip system messages
|
||||
if (msg.t || !msg.msg) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (unreadMessages.length > 0) {
|
||||
const latestMessage = unreadMessages[0];
|
||||
const messageUser = latestMessage.u || {};
|
||||
const roomName = subscription.fname || subscription.name || subscription.name;
|
||||
|
||||
// Determine room type for link
|
||||
let roomTypePath = 'channel';
|
||||
if (subscription.t === 'd') roomTypePath = 'direct';
|
||||
else if (subscription.t === 'p') roomTypePath = 'group';
|
||||
|
||||
// Format title similar to Leantime (simple and consistent)
|
||||
const title = subscription.t === 'd'
|
||||
? 'Message'
|
||||
: subscription.t === 'p'
|
||||
? 'Message de groupe'
|
||||
: 'Message de canal';
|
||||
|
||||
// Format message with sender and room info
|
||||
const senderName = messageUser.name || messageUser.username || 'Utilisateur';
|
||||
const formattedMessage = subscription.t === 'd'
|
||||
? `${senderName}: ${latestMessage.msg || ''}`
|
||||
: `${senderName} dans ${roomName}: ${latestMessage.msg || ''}`;
|
||||
|
||||
// Link to hub.slm-lab.net/parole instead of external RocketChat URL
|
||||
const notification: Notification = {
|
||||
id: `rocketchat-${latestMessage._id}`,
|
||||
source: 'rocketchat',
|
||||
sourceId: latestMessage._id,
|
||||
type: 'message',
|
||||
title: title,
|
||||
message: formattedMessage,
|
||||
link: `/parole`,
|
||||
isRead: false, // All messages here are unread
|
||||
timestamp: new Date(latestMessage.ts),
|
||||
priority: subscription.alert ? 'high' : 'normal',
|
||||
user: {
|
||||
id: messageUser._id || '',
|
||||
name: senderName,
|
||||
},
|
||||
metadata: {
|
||||
roomId: subscription.rid,
|
||||
roomName: roomName,
|
||||
roomType: subscription.t,
|
||||
unreadCount: subscription.unread,
|
||||
}
|
||||
};
|
||||
|
||||
notifications.push(notification);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[ROCKETCHAT_ADAPTER] Error fetching messages for room', {
|
||||
roomName: subscription.name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp (newest first) and apply pagination
|
||||
notifications.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
|
||||
const startIndex = (page - 1) * limit;
|
||||
const endIndex = startIndex + limit;
|
||||
const paginatedNotifications = notifications.slice(startIndex, endIndex);
|
||||
|
||||
logger.debug('[ROCKETCHAT_ADAPTER] getNotifications result', {
|
||||
total: notifications.length,
|
||||
returned: paginatedNotifications.length,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
|
||||
return paginatedNotifications;
|
||||
} catch (error) {
|
||||
logger.error('[ROCKETCHAT_ADAPTER] Error getting notifications', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user