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() {
|
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 (
|
return (
|
||||||
<div className="w-full h-[calc(100vh-8rem)]">
|
<div className="container mx-auto py-6">
|
||||||
<iframe
|
<div className="flex items-center justify-between mb-6">
|
||||||
src="https://example.com/notifications"
|
<div>
|
||||||
className="w-full h-full border-none"
|
<h1 className="text-2xl font-bold tracking-tight">Notifications</h1>
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
<p className="text-muted-foreground">
|
||||||
allowFullScreen
|
Manage your notifications from all connected services
|
||||||
/>
|
</p>
|
||||||
</div>
|
</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";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { fr } from 'date-fns/locale';
|
import { fr } from 'date-fns/locale';
|
||||||
|
import { NotificationBadge } from './notification-badge';
|
||||||
|
|
||||||
const requestNotificationPermission = async () => {
|
const requestNotificationPermission = async () => {
|
||||||
try {
|
try {
|
||||||
@ -280,12 +281,7 @@ export function MainNav() {
|
|||||||
<span>{formattedTime}</span>
|
<span>{formattedTime}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<NotificationBadge />
|
||||||
href='/notifications'
|
|
||||||
className='text-white/80 hover:text-white'
|
|
||||||
>
|
|
||||||
<Bell className='w-5 h-5' />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{status === "authenticated" && session?.user ? (
|
{status === "authenticated" && session?.user ? (
|
||||||
<DropdownMenu>
|
<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:
|
destructive:
|
||||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
outline: "text-foreground",
|
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: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
|
shape: "default",
|
||||||
|
size: "default"
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -27,9 +42,18 @@ export interface BadgeProps
|
|||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
VariantProps<typeof badgeVariants> {}
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
shape,
|
||||||
|
size,
|
||||||
|
...props
|
||||||
|
}: BadgeProps) {
|
||||||
return (
|
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