notifications
This commit is contained in:
parent
f9ee1abf65
commit
abb3a4ba0e
50
app/api/notifications/[id]/read/route.ts
Normal file
50
app/api/notifications/[id]/read/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
30
app/api/notifications/count/route.ts
Normal file
30
app/api/notifications/count/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
37
app/api/notifications/read-all/route.ts
Normal file
37
app/api/notifications/read-all/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
54
app/api/notifications/route.ts
Normal file
54
app/api/notifications/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
35
components/notification-badge.tsx
Normal file
35
components/notification-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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
194
hooks/use-notifications.ts
Normal 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
|
||||
};
|
||||
}
|
||||
236
lib/services/notifications/leantime-adapter.ts
Normal file
236
lib/services/notifications/leantime-adapter.ts
Normal 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
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
45
lib/services/notifications/notification-adapter.interface.ts
Normal file
45
lib/services/notifications/notification-adapter.interface.ts
Normal 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>;
|
||||
}
|
||||
296
lib/services/notifications/notification-service.ts
Normal file
296
lib/services/notifications/notification-service.ts
Normal 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
28
lib/types/notification.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user