notifications

This commit is contained in:
alma 2025-05-04 11:01:30 +02:00
parent f9ee1abf65
commit abb3a4ba0e
13 changed files with 1214 additions and 16 deletions

View File

@ -0,0 +1,50 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { NotificationService } from '@/lib/services/notifications/notification-service';
// POST /api/notifications/{id}/read
export async function POST(
request: Request,
context: { params: { id: string } }
) {
try {
// Authenticate user
const session = await getServerSession(authOptions);
if (!session || !session.user?.id) {
return NextResponse.json(
{ error: "Not authenticated" },
{ status: 401 }
);
}
// Await params as per Next.js requirements
const params = await context.params;
const id = params?.id;
if (!id) {
return NextResponse.json(
{ error: "Missing notification ID" },
{ status: 400 }
);
}
const userId = session.user.id;
const notificationService = NotificationService.getInstance();
const success = await notificationService.markAsRead(userId, id);
if (!success) {
return NextResponse.json(
{ error: "Failed to mark notification as read" },
{ status: 400 }
);
}
return NextResponse.json({ success: true });
} catch (error: any) {
console.error('Error marking notification as read:', error);
return NextResponse.json(
{ error: "Internal server error", message: error.message },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { NotificationService } from '@/lib/services/notifications/notification-service';
// GET /api/notifications/count
export async function GET(request: Request) {
try {
// Authenticate user
const session = await getServerSession(authOptions);
if (!session || !session.user?.id) {
return NextResponse.json(
{ error: "Not authenticated" },
{ status: 401 }
);
}
const userId = session.user.id;
const notificationService = NotificationService.getInstance();
const counts = await notificationService.getNotificationCount(userId);
return NextResponse.json(counts);
} catch (error: any) {
console.error('Error in notification count API:', error);
return NextResponse.json(
{ error: "Internal server error", message: error.message },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { NotificationService } from '@/lib/services/notifications/notification-service';
// POST /api/notifications/read-all
export async function POST(request: Request) {
try {
// Authenticate user
const session = await getServerSession(authOptions);
if (!session || !session.user?.id) {
return NextResponse.json(
{ error: "Not authenticated" },
{ status: 401 }
);
}
const userId = session.user.id;
const notificationService = NotificationService.getInstance();
const success = await notificationService.markAllAsRead(userId);
if (!success) {
return NextResponse.json(
{ error: "Failed to mark all notifications as read" },
{ status: 400 }
);
}
return NextResponse.json({ success: true });
} catch (error: any) {
console.error('Error marking all notifications as read:', error);
return NextResponse.json(
{ error: "Internal server error", message: error.message },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { NotificationService } from '@/lib/services/notifications/notification-service';
// GET /api/notifications?page=1&limit=20
export async function GET(request: Request) {
try {
// Authenticate user
const session = await getServerSession(authOptions);
if (!session || !session.user?.id) {
return NextResponse.json(
{ error: "Not authenticated" },
{ status: 401 }
);
}
const userId = session.user.id;
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1', 10);
const limit = parseInt(searchParams.get('limit') || '20', 10);
// Validate parameters
if (isNaN(page) || page < 1) {
return NextResponse.json(
{ error: "Invalid page parameter" },
{ status: 400 }
);
}
if (isNaN(limit) || limit < 1 || limit > 100) {
return NextResponse.json(
{ error: "Invalid limit parameter, must be between 1 and 100" },
{ status: 400 }
);
}
const notificationService = NotificationService.getInstance();
const notifications = await notificationService.getNotifications(userId, page, limit);
return NextResponse.json({
notifications,
page,
limit,
total: notifications.length
});
} catch (error: any) {
console.error('Error in notifications API:', error);
return NextResponse.json(
{ error: "Internal server error", message: error.message },
{ status: 500 }
);
}
}

View File

@ -1,13 +1,186 @@
"use client";
import { useEffect, useState } from 'react';
import { useNotifications } from '@/hooks/use-notifications';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Notification } from '@/lib/types/notification';
import { Badge } from '@/components/ui/badge';
import { formatDistanceToNow } from 'date-fns';
import { Bell, Check, CheckCheck, ExternalLink, Trash2 } from 'lucide-react';
import Link from 'next/link';
// Source icon mapping
const SourceIcons: Record<string, React.ReactNode> = {
leantime: <div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center text-blue-600">L</div>,
nextcloud: <div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center text-blue-600">N</div>,
gitea: <div className="w-8 h-8 rounded-full bg-green-100 flex items-center justify-center text-green-600">G</div>,
dolibarr: <div className="w-8 h-8 rounded-full bg-purple-100 flex items-center justify-center text-purple-600">D</div>,
moodle: <div className="w-8 h-8 rounded-full bg-orange-100 flex items-center justify-center text-orange-600">M</div>,
};
export default function NotificationsPage() {
const { notifications, notificationCount, loading, error, fetchNotifications, markAsRead, markAllAsRead } = useNotifications();
const [activeTab, setActiveTab] = useState('all');
// Filter notifications based on active tab
const filteredNotifications = activeTab === 'all'
? notifications
: notifications.filter(notification => notification.source === activeTab);
// Group notifications by source for counts
const sourceCounts = Object.entries(notificationCount.sources).map(([source, count]) => ({
source,
count: count.total,
unread: count.unread
}));
const handleMarkAsRead = async (notification: Notification) => {
if (!notification.isRead) {
await markAsRead(notification.id);
}
};
const handleMarkAllAsRead = async () => {
await markAllAsRead();
};
return (
<div className="w-full h-[calc(100vh-8rem)]">
<iframe
src="https://example.com/notifications"
className="w-full h-full border-none"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
<div className="container mx-auto py-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Notifications</h1>
<p className="text-muted-foreground">
Manage your notifications from all connected services
</p>
</div>
<Button variant="outline" onClick={handleMarkAllAsRead} disabled={notificationCount.unread === 0}>
<CheckCheck className="mr-2 h-4 w-4" />
Mark all as read
</Button>
</div>
<Tabs defaultValue="all" value={activeTab} onValueChange={setActiveTab}>
<div className="flex justify-between items-center mb-4">
<TabsList>
<TabsTrigger value="all" className="relative">
All
{notificationCount.unread > 0 && (
<Badge variant="secondary" className="ml-2">
{notificationCount.unread}
</Badge>
)}
</TabsTrigger>
{sourceCounts.map(({ source, unread }) => (
<TabsTrigger key={source} value={source} className="relative capitalize">
{source}
{unread > 0 && (
<Badge variant="secondary" className="ml-2">
{unread}
</Badge>
)}
</TabsTrigger>
))}
</TabsList>
</div>
<TabsContent value={activeTab} className="space-y-4">
{loading ? (
<Card>
<CardContent className="py-10 text-center">
<div className="flex flex-col items-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mb-2"></div>
<p>Loading notifications...</p>
</div>
</CardContent>
</Card>
) : error ? (
<Card>
<CardContent className="py-10 text-center">
<div className="flex flex-col items-center">
<p className="text-red-500">{error}</p>
<Button
variant="outline"
onClick={() => fetchNotifications()}
className="mt-4"
>
Retry
</Button>
</div>
</CardContent>
</Card>
) : filteredNotifications.length === 0 ? (
<Card>
<CardContent className="py-10 text-center">
<div className="flex flex-col items-center">
<Bell className="h-12 w-12 text-gray-400 mb-4" />
<h3 className="font-medium text-lg">No notifications</h3>
<p className="text-muted-foreground">
You don't have any {activeTab !== 'all' ? `${activeTab} ` : ''}notifications right now.
</p>
</div>
</CardContent>
</Card>
) : (
filteredNotifications.map((notification) => (
<Card
key={notification.id}
className={notification.isRead ? 'bg-white' : 'bg-blue-50 border-blue-200'}
>
<CardContent className="p-4">
<div className="flex">
<div className="mr-4 mt-1">
{SourceIcons[notification.source] || <Bell className="h-8 w-8" />}
</div>
<div className="flex-1">
<div className="flex items-start justify-between">
<div>
<h3 className="font-medium">
{notification.title}
{!notification.isRead && (
<Badge className="ml-2 bg-blue-500">New</Badge>
)}
</h3>
<p className="text-sm text-muted-foreground capitalize">
From {notification.source} {formatDistanceToNow(new Date(notification.timestamp), { addSuffix: true })}
</p>
</div>
<div className="flex space-x-2 ml-4">
{!notification.isRead && (
<Button
size="sm"
variant="ghost"
onClick={() => handleMarkAsRead(notification)}
title="Mark as read"
>
<Check className="h-4 w-4" />
</Button>
)}
{notification.link && (
<Link href={notification.link} target="_blank">
<Button
size="sm"
variant="ghost"
title="Open link"
>
<ExternalLink className="h-4 w-4" />
</Button>
</Link>
)}
</div>
</div>
<p className="mt-2">{notification.message}</p>
</div>
</div>
</CardContent>
</Card>
))
)}
</TabsContent>
</Tabs>
</div>
)
);
}

View File

@ -36,6 +36,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { format } from 'date-fns';
import { fr } from 'date-fns/locale';
import { NotificationBadge } from './notification-badge';
const requestNotificationPermission = async () => {
try {
@ -280,12 +281,7 @@ export function MainNav() {
<span>{formattedTime}</span>
</div>
<Link
href='/notifications'
className='text-white/80 hover:text-white'
>
<Bell className='w-5 h-5' />
</Link>
<NotificationBadge />
{status === "authenticated" && session?.user ? (
<DropdownMenu>

View File

@ -0,0 +1,35 @@
import React from 'react';
import Link from 'next/link';
import { Bell } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { useNotifications } from '@/hooks/use-notifications';
interface NotificationBadgeProps {
className?: string;
}
export function NotificationBadge({ className }: NotificationBadgeProps) {
const { notificationCount } = useNotifications();
const hasUnread = notificationCount.unread > 0;
return (
<div className={`relative ${className || ''}`}>
<Link
href='/notifications'
className='text-white/80 hover:text-white transition-colors relative'
aria-label={`Notifications${hasUnread ? ` (${notificationCount.unread} unread)` : ''}`}
>
<Bell className='w-5 h-5' />
{hasUnread && (
<Badge
variant="notification"
size="notification"
className="absolute -top-2 -right-2"
>
{notificationCount.unread > 99 ? '99+' : notificationCount.unread}
</Badge>
)}
</Link>
</div>
);
}

View File

@ -15,10 +15,25 @@ const badgeVariants = cva(
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
notification:
"border-transparent bg-red-500 text-white hover:bg-red-600 absolute -top-1 -right-1 px-1.5 py-0.5 min-w-[1.25rem] h-5 flex items-center justify-center",
},
shape: {
default: "rounded-full",
pill: "rounded-full",
square: "rounded-md"
},
size: {
default: "text-xs px-2.5 py-0.5",
sm: "text-xs px-2 py-0.25 h-3.5 min-w-[1rem]",
lg: "text-sm px-3 py-1",
notification: "text-xs px-1.5 py-0.5 h-5 min-w-[1.25rem]"
}
},
defaultVariants: {
variant: "default",
shape: "default",
size: "default"
},
}
)
@ -27,9 +42,18 @@ export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
function Badge({
className,
variant,
shape,
size,
...props
}: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
<div
className={cn(badgeVariants({ variant, shape, size }), className)}
{...props}
/>
)
}

194
hooks/use-notifications.ts Normal file
View File

@ -0,0 +1,194 @@
import { useState, useEffect, useCallback } from 'react';
import { useSession } from 'next-auth/react';
import { Notification, NotificationCount } from '@/lib/types/notification';
// Default empty notification count
const defaultNotificationCount: NotificationCount = {
total: 0,
unread: 0,
sources: {}
};
export function useNotifications() {
const { data: session, status } = useSession();
const [notifications, setNotifications] = useState<Notification[]>([]);
const [notificationCount, setNotificationCount] = useState<NotificationCount>(defaultNotificationCount);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pollingInterval, setPollingInterval] = useState<NodeJS.Timeout | null>(null);
// Fetch notification count
const fetchNotificationCount = useCallback(async () => {
if (!session?.user) return;
try {
setError(null);
const response = await fetch('/api/notifications/count');
if (!response.ok) {
const errorData = await response.json();
console.error('Failed to fetch notification count:', errorData);
setError(errorData.error || 'Failed to fetch notification count');
return;
}
const data = await response.json();
setNotificationCount(data);
} catch (err) {
console.error('Error fetching notification count:', err);
setError('Failed to fetch notification count');
}
}, [session?.user]);
// Fetch notifications
const fetchNotifications = useCallback(async (page = 1, limit = 20) => {
if (!session?.user) return;
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/notifications?page=${page}&limit=${limit}`);
if (!response.ok) {
const errorData = await response.json();
console.error('Failed to fetch notifications:', errorData);
setError(errorData.error || 'Failed to fetch notifications');
return;
}
const data = await response.json();
setNotifications(data.notifications);
} catch (err) {
console.error('Error fetching notifications:', err);
setError('Failed to fetch notifications');
} finally {
setLoading(false);
}
}, [session?.user]);
// Mark notification as read
const markAsRead = useCallback(async (notificationId: string) => {
if (!session?.user) return;
try {
const response = await fetch(`/api/notifications/${notificationId}/read`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorData = await response.json();
console.error('Failed to mark notification as read:', errorData);
return false;
}
// Update local state
setNotifications(prev =>
prev.map(notification =>
notification.id === notificationId
? { ...notification, isRead: true }
: notification
)
);
// Refresh notification count
fetchNotificationCount();
return true;
} catch (err) {
console.error('Error marking notification as read:', err);
return false;
}
}, [session?.user, fetchNotificationCount]);
// Mark all notifications as read
const markAllAsRead = useCallback(async () => {
if (!session?.user) return;
try {
const response = await fetch('/api/notifications/read-all', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorData = await response.json();
console.error('Failed to mark all notifications as read:', errorData);
return false;
}
// Update local state
setNotifications(prev =>
prev.map(notification => ({ ...notification, isRead: true }))
);
// Refresh notification count
fetchNotificationCount();
return true;
} catch (err) {
console.error('Error marking all notifications as read:', err);
return false;
}
}, [session?.user, fetchNotificationCount]);
// Start polling for notification count
const startPolling = useCallback((interval = 30000) => {
if (pollingInterval) {
clearInterval(pollingInterval);
}
const id = setInterval(() => {
fetchNotificationCount();
}, interval);
setPollingInterval(id);
return () => {
clearInterval(id);
setPollingInterval(null);
};
}, [fetchNotificationCount, pollingInterval]);
// Stop polling
const stopPolling = useCallback(() => {
if (pollingInterval) {
clearInterval(pollingInterval);
setPollingInterval(null);
}
}, [pollingInterval]);
// Initialize fetching on component mount
useEffect(() => {
if (status === 'authenticated' && session?.user) {
fetchNotificationCount();
fetchNotifications();
// Start polling
const cleanup = startPolling();
return () => {
cleanup();
};
}
}, [status, session?.user, fetchNotificationCount, fetchNotifications, startPolling]);
return {
notifications,
notificationCount,
loading,
error,
fetchNotifications,
fetchNotificationCount,
markAsRead,
markAllAsRead,
startPolling,
stopPolling
};
}

View File

@ -0,0 +1,236 @@
import { Notification, NotificationCount } from '@/lib/types/notification';
import { NotificationAdapter } from './notification-adapter.interface';
import { prisma } from '@/lib/prisma';
// 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 apiKey: string;
constructor() {
// Load from environment or database config
this.apiUrl = process.env.LEANTIME_API_URL || '';
this.apiKey = process.env.LEANTIME_API_KEY || '';
}
private async getLeantimeCredentials(userId: string): Promise<{ url: string, apiKey: string } | null> {
// Get Leantime credentials from the database for this user
const credentials = await prisma.userServiceCredentials.findFirst({
where: {
userId: userId,
service: 'leantime'
}
});
if (!credentials) {
return null;
}
return {
url: credentials.serviceUrl || this.apiUrl,
apiKey: credentials.accessToken || this.apiKey,
};
}
async getNotifications(userId: string, page = 1, limit = 20): Promise<Notification[]> {
const credentials = await this.getLeantimeCredentials(userId);
if (!credentials) {
return [];
}
try {
// Calculate offset for pagination
const offset = (page - 1) * limit;
// Make request to Leantime API
const response = await fetch(
`${credentials.url}/api/notifications?limit=${limit}&offset=${offset}`,
{
headers: {
'Authorization': `Bearer ${credentials.apiKey}`,
'Content-Type': 'application/json'
}
}
);
if (!response.ok) {
console.error(`Failed to fetch Leantime notifications: ${response.status}`);
return [];
}
const data = await response.json();
return this.transformNotifications(data, userId);
} catch (error) {
console.error('Error fetching Leantime notifications:', error);
return [];
}
}
async getNotificationCount(userId: string): Promise<NotificationCount> {
const credentials = await this.getLeantimeCredentials(userId);
if (!credentials) {
return {
total: 0,
unread: 0,
sources: {
leantime: {
total: 0,
unread: 0
}
}
};
}
try {
// Make request to Leantime API
const response = await fetch(
`${credentials.url}/api/notifications/count`,
{
headers: {
'Authorization': `Bearer ${credentials.apiKey}`,
'Content-Type': 'application/json'
}
}
);
if (!response.ok) {
console.error(`Failed to fetch Leantime notification count: ${response.status}`);
return {
total: 0,
unread: 0,
sources: {
leantime: {
total: 0,
unread: 0
}
}
};
}
const data = await response.json();
const unreadCount = data.unread || 0;
const totalCount = data.total || 0;
return {
total: totalCount,
unread: unreadCount,
sources: {
leantime: {
total: totalCount,
unread: unreadCount
}
}
};
} catch (error) {
console.error('Error fetching Leantime notification count:', error);
return {
total: 0,
unread: 0,
sources: {
leantime: {
total: 0,
unread: 0
}
}
};
}
}
async markAsRead(userId: string, notificationId: string): Promise<boolean> {
const credentials = await this.getLeantimeCredentials(userId);
if (!credentials) {
return false;
}
try {
// Extract the source ID from our compound ID
const sourceId = notificationId.replace(`${this.sourceName}-`, '');
// Make request to Leantime API
const response = await fetch(
`${credentials.url}/api/notifications/${sourceId}/read`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${credentials.apiKey}`,
'Content-Type': 'application/json'
}
}
);
return response.ok;
} catch (error) {
console.error('Error marking Leantime notification as read:', error);
return false;
}
}
async markAllAsRead(userId: string): Promise<boolean> {
const credentials = await this.getLeantimeCredentials(userId);
if (!credentials) {
return false;
}
try {
// Make request to Leantime API
const response = await fetch(
`${credentials.url}/api/notifications/read-all`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${credentials.apiKey}`,
'Content-Type': 'application/json'
}
}
);
return response.ok;
} catch (error) {
console.error('Error marking all Leantime notifications as read:', error);
return false;
}
}
async isConfigured(): Promise<boolean> {
return !!(this.apiUrl && this.apiKey);
}
private transformNotifications(data: LeantimeNotification[], userId: string): Notification[] {
if (!Array.isArray(data)) {
return [];
}
return data.map(notification => ({
id: `${this.sourceName}-${notification.id}`,
source: this.sourceName as 'leantime',
sourceId: notification.id.toString(),
type: notification.type,
title: notification.type, // Leantime doesn't provide a separate title
message: notification.message,
link: notification.url,
isRead: notification.read === 1,
timestamp: new Date(notification.date),
priority: 'normal', // Default priority as Leantime doesn't specify
user: {
id: userId,
name: notification.username
},
metadata: {
moduleId: notification.moduleId
}
}));
}
}

View File

@ -0,0 +1,45 @@
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>;
/**
* Mark a specific notification as read
* @param userId The user ID
* @param notificationId The notification ID
* @returns Promise with success status
*/
markAsRead(userId: string, notificationId: string): Promise<boolean>;
/**
* Mark all notifications as read
* @param userId The user ID
* @returns Promise with success status
*/
markAllAsRead(userId: string): Promise<boolean>;
/**
* Check if this adapter is configured and ready to use
* @returns Promise with boolean indicating if adapter is ready
*/
isConfigured(): Promise<boolean>;
}

View File

@ -0,0 +1,296 @@
import { Notification, NotificationCount } from '@/lib/types/notification';
import { NotificationAdapter } from './notification-adapter.interface';
import { LeantimeAdapter } from './leantime-adapter';
import { getRedisClient } from '@/lib/redis';
export class NotificationService {
private adapters: Map<string, NotificationAdapter> = new Map();
private static instance: NotificationService;
// Cache keys and TTLs
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
private static LIST_CACHE_TTL = 300; // 5 minutes
private static REFRESH_LOCK_KEY = (userId: string) => `notifications:refresh:lock:${userId}`;
private static REFRESH_LOCK_TTL = 30; // 30 seconds
constructor() {
// Register adapters
this.registerAdapter(new LeantimeAdapter());
// 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());
}
/**
* Get the singleton instance of the notification service
*/
public static getInstance(): NotificationService {
if (!NotificationService.instance) {
NotificationService.instance = new NotificationService();
}
return NotificationService.instance;
}
/**
* Register a notification adapter
*/
private registerAdapter(adapter: NotificationAdapter): void {
this.adapters.set(adapter.sourceName, adapter);
console.log(`Registered notification adapter: ${adapter.sourceName}`);
}
/**
* Get all notifications for a user from all configured sources
*/
async getNotifications(userId: string, page = 1, limit = 20): Promise<Notification[]> {
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) {
console.log(`[NOTIFICATION_SERVICE] Using cached notifications for user ${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) {
this.scheduleBackgroundRefresh(userId);
}
return JSON.parse(cachedData);
}
} catch (error) {
console.error('Error retrieving notifications from cache:', error);
}
// No cached data, fetch from all adapters
console.log(`[NOTIFICATION_SERVICE] Fetching notifications for user ${userId}`);
const allNotifications: Notification[] = [];
const promises = Array.from(this.adapters.values())
.map(adapter => adapter.isConfigured()
.then(configured => configured ? adapter.getNotifications(userId, page, limit) : [])
.catch(error => {
console.error(`Error fetching notifications from ${adapter.sourceName}:`, error);
return [];
})
);
const results = await Promise.all(promises);
// Combine all notifications
results.forEach(notifications => {
allNotifications.push(...notifications);
});
// Sort by timestamp (newest first)
allNotifications.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
// Store in cache
try {
await redis.set(
cacheKey,
JSON.stringify(allNotifications),
'EX',
NotificationService.LIST_CACHE_TTL
);
} catch (error) {
console.error('Error caching notifications:', error);
}
return allNotifications;
}
/**
* Get notification counts for a user
*/
async getNotificationCount(userId: string): Promise<NotificationCount> {
const redis = getRedisClient();
const cacheKey = NotificationService.NOTIFICATION_COUNT_CACHE_KEY(userId);
// Try to get from cache first
try {
const cachedData = await redis.get(cacheKey);
if (cachedData) {
console.log(`[NOTIFICATION_SERVICE] Using cached notification counts for user ${userId}`);
// Schedule background refresh if TTL is less than half the original value
const ttl = await redis.ttl(cacheKey);
if (ttl < NotificationService.COUNT_CACHE_TTL / 2) {
this.scheduleBackgroundRefresh(userId);
}
return JSON.parse(cachedData);
}
} catch (error) {
console.error('Error retrieving notification counts from cache:', error);
}
// No cached data, fetch counts from all adapters
console.log(`[NOTIFICATION_SERVICE] Fetching notification counts for user ${userId}`);
const aggregatedCount: NotificationCount = {
total: 0,
unread: 0,
sources: {}
};
const promises = Array.from(this.adapters.values())
.map(adapter => adapter.isConfigured()
.then(configured => configured ? adapter.getNotificationCount(userId) : null)
.catch(error => {
console.error(`Error fetching notification count from ${adapter.sourceName}:`, error);
return null;
})
);
const results = await Promise.all(promises);
// Combine all counts
results.forEach(count => {
if (!count) return;
aggregatedCount.total += count.total;
aggregatedCount.unread += count.unread;
// Merge source-specific counts
Object.entries(count.sources).forEach(([source, sourceCount]) => {
aggregatedCount.sources[source] = sourceCount;
});
});
// Store in cache
try {
await redis.set(
cacheKey,
JSON.stringify(aggregatedCount),
'EX',
NotificationService.COUNT_CACHE_TTL
);
} catch (error) {
console.error('Error caching notification counts:', error);
}
return aggregatedCount;
}
/**
* Mark a notification as read
*/
async markAsRead(userId: string, notificationId: string): Promise<boolean> {
// Extract the source from the notification ID (format: "source-id")
const [source, ...idParts] = notificationId.split('-');
const sourceId = idParts.join('-'); // Reconstruct the ID in case it has hyphens
if (!source || !this.adapters.has(source)) {
return false;
}
const adapter = this.adapters.get(source)!;
const success = await adapter.markAsRead(userId, notificationId);
if (success) {
// Invalidate caches
await this.invalidateCache(userId);
}
return success;
}
/**
* Mark all notifications from all sources as read
*/
async markAllAsRead(userId: string): Promise<boolean> {
const promises = Array.from(this.adapters.values())
.map(adapter => adapter.isConfigured()
.then(configured => configured ? adapter.markAllAsRead(userId) : true)
.catch(error => {
console.error(`Error marking all notifications as read for ${adapter.sourceName}:`, error);
return false;
})
);
const results = await Promise.all(promises);
const success = results.every(result => result);
if (success) {
// Invalidate caches
await this.invalidateCache(userId);
}
return success;
}
/**
* Invalidate notification caches for a user
*/
private 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
const listKeys = await redis.keys(listKeysPattern);
if (listKeys.length > 0) {
await redis.del(...listKeys);
}
console.log(`[NOTIFICATION_SERVICE] Invalidated notification caches for user ${userId}`);
} catch (error) {
console.error('Error invalidating notification caches:', 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 {
console.log(`[NOTIFICATION_SERVICE] Background refresh started for user ${userId}`);
// Refresh counts and notifications (for first page)
await this.getNotificationCount(userId);
await this.getNotifications(userId, 1, 20);
console.log(`[NOTIFICATION_SERVICE] Background refresh completed for user ${userId}`);
} catch (error) {
console.error(`[NOTIFICATION_SERVICE] Background refresh failed for user ${userId}:`, error);
} finally {
// Release the lock
await redis.del(lockKey).catch(() => {});
}
}, 0);
}
}

28
lib/types/notification.ts Normal file
View File

@ -0,0 +1,28 @@
export interface Notification {
id: string;
source: 'leantime' | 'nextcloud' | 'gitea' | 'dolibarr' | 'moodle';
sourceId: string; // Original ID from the source system
type: string; // Type of notification (e.g., 'task', 'mention', 'comment')
title: string;
message: string;
link?: string; // Link to view the item in the source system
isRead: boolean;
timestamp: Date;
priority: 'low' | 'normal' | 'high';
user: {
id: string;
name?: string;
};
metadata?: Record<string, any>; // Additional source-specific data
}
export interface NotificationCount {
total: number;
unread: number;
sources: {
[key: string]: {
total: number;
unread: number;
}
};
}