notifications big attention
This commit is contained in:
parent
0fc5c75217
commit
313c2a874f
@ -1,79 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/app/api/auth/options";
|
||||
import { NotificationService } from '@/lib/services/notifications/notification-service';
|
||||
|
||||
// POST /api/notifications/{id}/read
|
||||
export async function POST(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
console.log('[NOTIFICATION_API] Mark as read endpoint called');
|
||||
|
||||
// Authenticate user
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || !session.user?.id) {
|
||||
console.log('[NOTIFICATION_API] Mark as read - Authentication failed');
|
||||
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) {
|
||||
console.log('[NOTIFICATION_API] Mark as read - Missing notification ID');
|
||||
return NextResponse.json(
|
||||
{ error: "Missing notification ID" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
console.log('[NOTIFICATION_API] Mark as read - Processing', {
|
||||
userId,
|
||||
notificationId: id,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
const notificationService = NotificationService.getInstance();
|
||||
const success = await notificationService.markAsRead(userId, id);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (!success) {
|
||||
console.log('[NOTIFICATION_API] Mark as read - Failed', {
|
||||
userId,
|
||||
notificationId: id,
|
||||
duration: `${duration}ms`
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to mark notification as read" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[NOTIFICATION_API] Mark as read - Success', {
|
||||
userId,
|
||||
notificationId: id,
|
||||
duration: `${duration}ms`
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: any) {
|
||||
const duration = Date.now() - startTime;
|
||||
console.error('[NOTIFICATION_API] Mark as read - Error', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
duration: `${duration}ms`
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error", message: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/app/api/auth/options";
|
||||
import { NotificationService } from '@/lib/services/notifications/notification-service';
|
||||
|
||||
// POST /api/notifications/read-all
|
||||
export async function POST(request: Request) {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
console.log('[NOTIFICATION_API] Mark all as read endpoint called');
|
||||
|
||||
// Authenticate user
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || !session.user?.id) {
|
||||
console.log('[NOTIFICATION_API] Mark all as read - Authentication failed');
|
||||
return NextResponse.json(
|
||||
{ error: "Not authenticated" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
console.log('[NOTIFICATION_API] Mark all as read - Processing', {
|
||||
userId,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
const notificationService = NotificationService.getInstance();
|
||||
const success = await notificationService.markAllAsRead(userId);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (!success) {
|
||||
console.log('[NOTIFICATION_API] Mark all as read - Failed', {
|
||||
userId,
|
||||
duration: `${duration}ms`
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to mark all notifications as read" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[NOTIFICATION_API] Mark all as read - Success', {
|
||||
userId,
|
||||
duration: `${duration}ms`
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: any) {
|
||||
const duration = Date.now() - startTime;
|
||||
console.error('[NOTIFICATION_API] Mark all as read - Error', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
duration: `${duration}ms`
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error", message: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { memo, useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Bell, Check, ExternalLink, AlertCircle, LogIn, Kanban, MessageSquare, Mail } from 'lucide-react';
|
||||
import { Bell, ExternalLink, AlertCircle, LogIn, Kanban, MessageSquare, Mail } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useNotifications } from '@/hooks/use-notifications';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -22,7 +22,7 @@ interface NotificationBadgeProps {
|
||||
// Use React.memo to prevent unnecessary re-renders
|
||||
export const NotificationBadge = memo(function NotificationBadge({ className }: NotificationBadgeProps) {
|
||||
const { data: session, status } = useSession();
|
||||
const { notifications, notificationCount, markAsRead, markAllAsRead, fetchNotifications, loading, error, markingProgress } = useNotifications();
|
||||
const { notifications, notificationCount, fetchNotifications, loading, error } = useNotifications();
|
||||
const hasUnread = notificationCount.unread > 0;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [manualFetchAttempted, setManualFetchAttempted] = useState(false);
|
||||
@ -69,19 +69,6 @@ export const NotificationBadge = memo(function NotificationBadge({ className }:
|
||||
}
|
||||
}, [isOpen, status]);
|
||||
|
||||
const handleMarkAsRead = async (notificationId: string) => {
|
||||
await markAsRead(notificationId);
|
||||
};
|
||||
|
||||
const handleMarkAllAsRead = async () => {
|
||||
// Don't close dropdown immediately - show progress
|
||||
await markAllAsRead();
|
||||
// Close dropdown after a short delay to show completion
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// Force fetch when component mounts
|
||||
useEffect(() => {
|
||||
if (status === 'authenticated') {
|
||||
@ -126,40 +113,10 @@ export const NotificationBadge = memo(function NotificationBadge({ className }:
|
||||
<DropdownMenuContent align="end" className="w-80 max-h-[80vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<h3 className="font-medium">Notifications</h3>
|
||||
{notificationCount.unread > 0 && !markingProgress && (
|
||||
<Button variant="ghost" size="sm" onClick={handleMarkAllAsRead}>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
{markingProgress && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900"></div>
|
||||
<span>Marking {markingProgress.current} of {markingProgress.total}...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{markingProgress ? (
|
||||
<div className="py-8 px-4 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Marking notifications as read...
|
||||
</p>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mb-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${(markingProgress.current / markingProgress.total) * 100}%`
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{markingProgress.current} of {markingProgress.total} completed
|
||||
</p>
|
||||
</div>
|
||||
) : loading ? (
|
||||
{loading ? (
|
||||
<div className="py-8 px-4 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-muted-foreground">Loading notifications...</p>
|
||||
@ -229,17 +186,6 @@ export const NotificationBadge = memo(function NotificationBadge({ className }:
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-1 ml-2">
|
||||
{!notification.isRead && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => handleMarkAsRead(notification.id)}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Mark as read</span>
|
||||
</Button>
|
||||
)}
|
||||
{notification.link && (
|
||||
<Link href={notification.link}>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
||||
|
||||
@ -18,7 +18,6 @@ export function useNotifications() {
|
||||
const [notificationCount, setNotificationCount] = useState<NotificationCount>(defaultNotificationCount);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [markingProgress, setMarkingProgress] = useState<{ current: number; total: number } | null>(null);
|
||||
const isMountedRef = useRef<boolean>(false);
|
||||
|
||||
// Fetch notification count with request deduplication
|
||||
@ -119,137 +118,6 @@ export function useNotifications() {
|
||||
}
|
||||
}, [session?.user]);
|
||||
|
||||
// Mark notification as read
|
||||
const markAsRead = useCallback(async (notificationId: string) => {
|
||||
if (!session?.user) return false;
|
||||
|
||||
try {
|
||||
console.log('[useNotifications] Marking notification as read:', notificationId);
|
||||
const response = await fetch(`/api/notifications/${notificationId}/read`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include' // Ensure cookies are sent with the request
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Failed to mark notification as read:', {
|
||||
status: response.status,
|
||||
body: errorText
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update local state optimistically (only if we're confident it will succeed)
|
||||
setNotifications(prev =>
|
||||
prev.map(notification =>
|
||||
notification.id === notificationId
|
||||
? { ...notification, isRead: true }
|
||||
: notification
|
||||
)
|
||||
);
|
||||
|
||||
// Update count optimistically (decrement unread count)
|
||||
setNotificationCount(prev => ({
|
||||
...prev,
|
||||
unread: Math.max(0, prev.unread - 1), // Ensure it doesn't go below 0
|
||||
total: prev.total, // Keep total the same
|
||||
}));
|
||||
|
||||
// Refresh notification count after a delay to ensure cache is invalidated
|
||||
// Poll until count matches expected value or timeout
|
||||
let pollCount = 0;
|
||||
const maxPolls = 5;
|
||||
const pollInterval = 500; // 500ms between polls
|
||||
|
||||
const pollForCount = async () => {
|
||||
if (pollCount >= maxPolls) return;
|
||||
pollCount++;
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||||
await fetchNotificationCount(true);
|
||||
|
||||
// Check if count matches expected (unread should be prev - 1)
|
||||
// If not, poll again
|
||||
if (pollCount < maxPolls) {
|
||||
setTimeout(pollForCount, pollInterval);
|
||||
}
|
||||
};
|
||||
|
||||
// Start polling after initial delay
|
||||
setTimeout(pollForCount, 300);
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error marking notification as read:', err);
|
||||
return false;
|
||||
}
|
||||
}, [session?.user, fetchNotificationCount]);
|
||||
|
||||
// Mark all notifications as read with progress tracking
|
||||
const markAllAsRead = useCallback(async () => {
|
||||
if (!session?.user) return false;
|
||||
|
||||
try {
|
||||
console.log('[useNotifications] Marking all notifications as read');
|
||||
|
||||
// Show loading state instead of optimistic update
|
||||
setMarkingProgress({ current: 0, total: notificationCount.unread });
|
||||
|
||||
const response = await fetch('/api/notifications/read-all', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Failed to mark all notifications as read:', {
|
||||
status: response.status,
|
||||
body: errorText
|
||||
});
|
||||
setMarkingProgress(null);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update local state optimistically after successful response
|
||||
setNotifications(prev =>
|
||||
prev.map(notification => ({ ...notification, isRead: true }))
|
||||
);
|
||||
|
||||
// Update count optimistically
|
||||
setNotificationCount(prev => ({
|
||||
...prev,
|
||||
unread: 0,
|
||||
total: prev.total,
|
||||
sources: Object.fromEntries(
|
||||
Object.entries(prev.sources).map(([key, value]) => [
|
||||
key,
|
||||
{ ...value, unread: 0 }
|
||||
])
|
||||
),
|
||||
}));
|
||||
|
||||
// Clear progress
|
||||
setMarkingProgress(null);
|
||||
|
||||
// Refresh notification count after a delay to ensure cache is invalidated
|
||||
setTimeout(() => {
|
||||
fetchNotificationCount(true);
|
||||
}, 500);
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error marking all notifications as read:', err);
|
||||
setMarkingProgress(null);
|
||||
return false;
|
||||
}
|
||||
}, [session?.user, fetchNotificationCount, notificationCount.unread]);
|
||||
|
||||
// Use unified refresh system for notification count
|
||||
const { refresh: refreshCount } = useUnifiedRefresh({
|
||||
resource: 'notifications-count',
|
||||
@ -298,10 +166,7 @@ export function useNotifications() {
|
||||
notificationCount,
|
||||
loading,
|
||||
error,
|
||||
markingProgress, // Progress for mark all as read
|
||||
fetchNotifications,
|
||||
fetchNotificationCount: () => fetchNotificationCount(true),
|
||||
markAsRead,
|
||||
markAllAsRead
|
||||
};
|
||||
}
|
||||
@ -344,23 +344,4 @@ export class EmailAdapter implements NotificationAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async markAsRead(userId: string, notificationId: string): Promise<boolean> {
|
||||
// Email read status is handled by the email service when emails are viewed
|
||||
// We return true to acknowledge the UI action, but the actual read status
|
||||
// will be updated when the user views the email in the email service
|
||||
logger.debug('[EMAIL_ADAPTER] markAsRead called (read status handled by email service)', {
|
||||
userId,
|
||||
notificationId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async markAllAsRead(userId: string): Promise<boolean> {
|
||||
// Email read status is handled by the email service when emails are viewed
|
||||
// We return true to acknowledge the UI action
|
||||
logger.debug('[EMAIL_ADAPTER] markAllAsRead called (read status handled by email service)', {
|
||||
userId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -283,365 +283,6 @@ export class LeantimeAdapter implements NotificationAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async markAsRead(userId: string, notificationId: string): Promise<boolean> {
|
||||
logger.debug('[LEANTIME_ADAPTER] markAsRead called', {
|
||||
userId,
|
||||
notificationId,
|
||||
});
|
||||
|
||||
try {
|
||||
// Extract the source ID from our compound ID
|
||||
const sourceId = notificationId.replace(`${this.sourceName}-`, '');
|
||||
|
||||
// Get user email and ID
|
||||
const email = await this.getUserEmail();
|
||||
if (!email) {
|
||||
logger.error('[LEANTIME_ADAPTER] Could not get user email from session');
|
||||
return false;
|
||||
}
|
||||
|
||||
const leantimeUserId = await this.getLeantimeUserId(email);
|
||||
if (!leantimeUserId) {
|
||||
logger.error('[LEANTIME_ADAPTER] User not found in Leantime', {
|
||||
emailHash: Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Make request to Leantime API to mark notification as read
|
||||
// According to Leantime docs: method is markNotificationRead, params are id and userId
|
||||
const jsonRpcBody = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'leantime.rpc.Notifications.Notifications.markNotificationRead',
|
||||
params: {
|
||||
id: parseInt(sourceId),
|
||||
userId: leantimeUserId
|
||||
},
|
||||
id: 1
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.apiUrl}/api/jsonrpc`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': this.apiToken
|
||||
},
|
||||
body: JSON.stringify(jsonRpcBody)
|
||||
});
|
||||
|
||||
logger.debug('[LEANTIME_ADAPTER] markAsRead response status', {
|
||||
status: response.status,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('[LEANTIME_ADAPTER] markAsRead HTTP error', {
|
||||
status: response.status,
|
||||
bodyPreview: errorText.substring(0, 200),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(responseText);
|
||||
} catch (parseError) {
|
||||
logger.error('[LEANTIME_ADAPTER] markAsRead failed to parse response', {
|
||||
error: parseError instanceof Error ? parseError.message : String(parseError),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
logger.error('[LEANTIME_ADAPTER] markAsRead API error', {
|
||||
error: data.error,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const success = data.result === true || data.result === "true" || !!data.result;
|
||||
logger.debug('[LEANTIME_ADAPTER] markAsRead success', { success });
|
||||
return success;
|
||||
} catch (error) {
|
||||
logger.error('[LEANTIME_ADAPTER] Error marking notification as read', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async markAllAsRead(userId: string): Promise<boolean> {
|
||||
// CRITICAL: This should ALWAYS appear if method is called
|
||||
// Using multiple logging methods to ensure visibility
|
||||
logger.info('[LEANTIME_ADAPTER] markAllAsRead START', {
|
||||
userId,
|
||||
hasApiUrl: !!this.apiUrl,
|
||||
hasApiToken: !!this.apiToken,
|
||||
});
|
||||
|
||||
try {
|
||||
// Get user email and ID
|
||||
const email = await this.getUserEmail();
|
||||
if (!email) {
|
||||
logger.error('[LEANTIME_ADAPTER] markAllAsRead could not get user email from session');
|
||||
return false;
|
||||
}
|
||||
|
||||
const leantimeUserId = await this.getLeantimeUserId(email);
|
||||
if (!leantimeUserId) {
|
||||
logger.error('[LEANTIME_ADAPTER] markAllAsRead user not found in Leantime', {
|
||||
emailHash: Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Leantime doesn't have a "mark all as read" method, so we need to:
|
||||
// 1. Fetch all unread notifications directly from API (bypassing any cache)
|
||||
// 2. Mark each one individually using markNotificationRead
|
||||
|
||||
// Fetch all notifications directly from API (up to 1000) to get fresh data (not cached)
|
||||
const jsonRpcBody = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'leantime.rpc.Notifications.Notifications.getAllNotifications',
|
||||
params: {
|
||||
userId: leantimeUserId,
|
||||
showNewOnly: 0, // Get all, not just unread
|
||||
limitStart: 0,
|
||||
limitEnd: 1000,
|
||||
filterOptions: []
|
||||
},
|
||||
id: 1
|
||||
};
|
||||
|
||||
const fetchResponse = await fetch(`${this.apiUrl}/api/jsonrpc`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': this.apiToken
|
||||
},
|
||||
body: JSON.stringify(jsonRpcBody)
|
||||
});
|
||||
|
||||
if (!fetchResponse.ok) {
|
||||
logger.error('[LEANTIME_ADAPTER] markAllAsRead failed to fetch notifications', {
|
||||
status: fetchResponse.status,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const fetchData = await fetchResponse.json();
|
||||
if (fetchData.error) {
|
||||
logger.error('[LEANTIME_ADAPTER] markAllAsRead error fetching notifications', {
|
||||
error: fetchData.error,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Transform the raw Leantime notifications to our format
|
||||
const rawNotifications = Array.isArray(fetchData.result) ? fetchData.result : [];
|
||||
const unreadNotifications = rawNotifications
|
||||
.filter((n: any) => n.read === 0 || n.read === false || n.read === '0')
|
||||
.map((n: any) => ({ id: n.id, sourceId: String(n.id) }));
|
||||
|
||||
logger.info('[LEANTIME_ADAPTER] markAllAsRead unread notifications', {
|
||||
unreadCount: unreadNotifications.length,
|
||||
total: rawNotifications.length,
|
||||
});
|
||||
|
||||
if (unreadNotifications.length === 0) {
|
||||
logger.info('[LEANTIME_ADAPTER] markAllAsRead no unread notifications');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mark notifications in batches to prevent API overload and connection resets
|
||||
const BATCH_SIZE = 15; // Process 15 notifications at a time
|
||||
const BATCH_DELAY = 200; // 200ms delay between batches
|
||||
const MAX_RETRIES = 2; // Retry failed notifications up to 2 times
|
||||
|
||||
let successCount = 0;
|
||||
let failureCount = 0;
|
||||
const failedNotifications: number[] = [];
|
||||
|
||||
// Helper function to mark a single notification
|
||||
const markSingleNotification = async (notificationId: number, retryCount = 0): Promise<boolean> => {
|
||||
try {
|
||||
const jsonRpcBody = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'leantime.rpc.Notifications.Notifications.markNotificationRead',
|
||||
params: {
|
||||
id: notificationId,
|
||||
userId: leantimeUserId
|
||||
},
|
||||
id: 1
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.apiUrl}/api/jsonrpc`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': this.apiToken
|
||||
},
|
||||
body: JSON.stringify(jsonRpcBody)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error('[LEANTIME_ADAPTER] markAllAsRead failed to mark notification', {
|
||||
notificationId,
|
||||
status: response.status,
|
||||
});
|
||||
|
||||
// Retry on server errors (5xx) or rate limiting (429)
|
||||
if ((response.status >= 500 || response.status === 429) && retryCount < MAX_RETRIES) {
|
||||
const delay = Math.min(1000 * Math.pow(2, retryCount), 2000); // Exponential backoff, max 2s
|
||||
logger.debug('[LEANTIME_ADAPTER] Retrying notification after HTTP error', {
|
||||
notificationId,
|
||||
delay,
|
||||
attempt: retryCount + 1,
|
||||
maxRetries: MAX_RETRIES,
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
return markSingleNotification(notificationId, retryCount + 1);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
logger.error('[LEANTIME_ADAPTER] markAllAsRead JSON-RPC error marking notification', {
|
||||
notificationId,
|
||||
error: data.error,
|
||||
});
|
||||
|
||||
// Retry on certain JSON-RPC errors
|
||||
if (retryCount < MAX_RETRIES && (data.error.code === -32603 || data.error.code === -32000)) {
|
||||
const delay = Math.min(1000 * Math.pow(2, retryCount), 2000);
|
||||
logger.debug('[LEANTIME_ADAPTER] Retrying notification after JSON-RPC error', {
|
||||
notificationId,
|
||||
delay,
|
||||
attempt: retryCount + 1,
|
||||
maxRetries: MAX_RETRIES,
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
return markSingleNotification(notificationId, retryCount + 1);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return data.result === true || data.result === "true" || !!data.result;
|
||||
} catch (error) {
|
||||
logger.error('[LEANTIME_ADAPTER] markAllAsRead exception marking notification', {
|
||||
notificationId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
// Retry on network errors
|
||||
if (retryCount < MAX_RETRIES && error instanceof Error) {
|
||||
const delay = Math.min(1000 * Math.pow(2, retryCount), 2000);
|
||||
logger.debug('[LEANTIME_ADAPTER] Retrying notification after network error', {
|
||||
notificationId,
|
||||
delay,
|
||||
attempt: retryCount + 1,
|
||||
maxRetries: MAX_RETRIES,
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
return markSingleNotification(notificationId, retryCount + 1);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Process notifications in batches
|
||||
const notificationIds: number[] = unreadNotifications
|
||||
.map((n: { id: number | string; sourceId: string }): number | null => {
|
||||
const id = typeof n.id === 'number' ? n.id : parseInt(String(n.id || n.sourceId));
|
||||
return isNaN(id) ? null : id;
|
||||
})
|
||||
.filter((id: number | null): id is number => id !== null);
|
||||
|
||||
logger.info('[LEANTIME_ADAPTER] markAllAsRead processing notifications', {
|
||||
count: notificationIds.length,
|
||||
batchSize: BATCH_SIZE,
|
||||
});
|
||||
|
||||
// Split into batches
|
||||
for (let i = 0; i < notificationIds.length; i += BATCH_SIZE) {
|
||||
const batch = notificationIds.slice(i, i + BATCH_SIZE);
|
||||
const batchNumber = Math.floor(i / BATCH_SIZE) + 1;
|
||||
const totalBatches = Math.ceil(notificationIds.length / BATCH_SIZE);
|
||||
|
||||
logger.debug('[LEANTIME_ADAPTER] markAllAsRead processing batch', {
|
||||
batchNumber,
|
||||
totalBatches,
|
||||
batchSize: batch.length,
|
||||
});
|
||||
|
||||
// Process batch in parallel
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (notificationId) => {
|
||||
const result = await markSingleNotification(notificationId);
|
||||
if (result) {
|
||||
successCount++;
|
||||
} else {
|
||||
failureCount++;
|
||||
failedNotifications.push(notificationId);
|
||||
}
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
// Add delay between batches (except for the last batch)
|
||||
if (i + BATCH_SIZE < notificationIds.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, BATCH_DELAY));
|
||||
}
|
||||
}
|
||||
|
||||
// Retry failed notifications once more
|
||||
if (failedNotifications.length > 0 && failedNotifications.length < notificationIds.length) {
|
||||
logger.info('[LEANTIME_ADAPTER] markAllAsRead retrying failed notifications', {
|
||||
failedCount: failedNotifications.length,
|
||||
});
|
||||
|
||||
const retryResults = await Promise.all(
|
||||
failedNotifications.map(async (notificationId) => {
|
||||
const result = await markSingleNotification(notificationId, 0);
|
||||
if (result) {
|
||||
successCount++;
|
||||
failureCount--;
|
||||
}
|
||||
return result;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('[LEANTIME_ADAPTER] markAllAsRead final results', {
|
||||
successCount,
|
||||
failureCount,
|
||||
total: notificationIds.length,
|
||||
});
|
||||
|
||||
// Consider it successful if majority were marked (at least 80% success rate)
|
||||
const successRate = notificationIds.length > 0 ? successCount / notificationIds.length : 0;
|
||||
const success = successRate >= 0.8;
|
||||
|
||||
logger.info('[LEANTIME_ADAPTER] markAllAsRead END', {
|
||||
successRate,
|
||||
success,
|
||||
});
|
||||
return success;
|
||||
} catch (error) {
|
||||
logger.error('[LEANTIME_ADAPTER] markAllAsRead exception', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async isConfigured(): Promise<boolean> {
|
||||
return !!(this.apiUrl && this.apiToken);
|
||||
}
|
||||
|
||||
@ -22,21 +22,6 @@ export interface NotificationAdapter {
|
||||
*/
|
||||
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
|
||||
|
||||
@ -314,121 +314,6 @@ export class NotificationService {
|
||||
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)) {
|
||||
logger.warn('[NOTIFICATION_SERVICE] markAsRead invalid source or adapter not found', {
|
||||
source,
|
||||
});
|
||||
// Still invalidate cache to ensure fresh data
|
||||
await this.invalidateCache(userId);
|
||||
return false;
|
||||
}
|
||||
|
||||
const adapter = this.adapters.get(source)!;
|
||||
let success = false;
|
||||
|
||||
try {
|
||||
success = await adapter.markAsRead(userId, notificationId);
|
||||
logger.debug('[NOTIFICATION_SERVICE] markAsRead result', {
|
||||
userId,
|
||||
notificationId,
|
||||
success,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[NOTIFICATION_SERVICE] markAsRead error', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
success = false;
|
||||
}
|
||||
|
||||
// Always invalidate cache after marking attempt (even on failure)
|
||||
// This ensures fresh data on next fetch, even if the operation partially failed
|
||||
logger.debug('[NOTIFICATION_SERVICE] markAsRead invalidating cache', {
|
||||
userId,
|
||||
success,
|
||||
});
|
||||
await this.invalidateCache(userId);
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications from all sources as read
|
||||
*/
|
||||
async markAllAsRead(userId: string): Promise<boolean> {
|
||||
logger.debug('[NOTIFICATION_SERVICE] markAllAsRead called', {
|
||||
userId,
|
||||
adapters: Array.from(this.adapters.keys()),
|
||||
});
|
||||
|
||||
const promises = Array.from(this.adapters.values())
|
||||
.map(async (adapter) => {
|
||||
const adapterName = adapter.sourceName;
|
||||
logger.debug('[NOTIFICATION_SERVICE] markAllAsRead processing adapter', {
|
||||
adapter: adapterName,
|
||||
});
|
||||
|
||||
try {
|
||||
const configured = await adapter.isConfigured();
|
||||
logger.debug('[NOTIFICATION_SERVICE] markAllAsRead adapter configuration', {
|
||||
adapter: adapterName,
|
||||
configured,
|
||||
});
|
||||
|
||||
if (!configured) {
|
||||
logger.debug('[NOTIFICATION_SERVICE] markAllAsRead skipping adapter (not configured)', {
|
||||
adapter: adapterName,
|
||||
});
|
||||
return true; // Not configured, so nothing to mark (treat as success)
|
||||
}
|
||||
|
||||
logger.debug('[NOTIFICATION_SERVICE] Calling markAllAsRead on adapter', {
|
||||
adapter: adapterName,
|
||||
});
|
||||
const result = await adapter.markAllAsRead(userId);
|
||||
logger.debug('[NOTIFICATION_SERVICE] Adapter markAllAsRead result', {
|
||||
adapter: adapterName,
|
||||
result,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('[NOTIFICATION_SERVICE] Error marking all notifications as read for adapter', {
|
||||
adapter: adapterName,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const success = results.every(result => result);
|
||||
const anySuccess = results.some(result => result);
|
||||
logger.debug('[NOTIFICATION_SERVICE] markAllAsRead results', {
|
||||
results,
|
||||
success,
|
||||
anySuccess,
|
||||
});
|
||||
|
||||
// Always invalidate cache after marking attempt (even on failure)
|
||||
// This ensures fresh data on next fetch, even if the operation failed
|
||||
// The user might have marked some notifications manually, or the operation might have partially succeeded
|
||||
logger.debug('[NOTIFICATION_SERVICE] markAllAsRead invalidating caches', {
|
||||
userId,
|
||||
anySuccess,
|
||||
success,
|
||||
});
|
||||
await this.invalidateCache(userId);
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate notification caches for a user
|
||||
* Made public so it can be called from API routes for force refresh
|
||||
|
||||
@ -526,23 +526,4 @@ export class RocketChatAdapter implements NotificationAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async markAsRead(userId: string, notificationId: string): Promise<boolean> {
|
||||
// RocketChat handles read status automatically when messages are viewed
|
||||
// We return true to acknowledge the UI action, but the actual read status
|
||||
// will be updated when the user views the message in RocketChat
|
||||
logger.debug('[ROCKETCHAT_ADAPTER] markAsRead called (read status handled by RocketChat)', {
|
||||
userId,
|
||||
notificationId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async markAllAsRead(userId: string): Promise<boolean> {
|
||||
// RocketChat handles read status automatically when messages are viewed
|
||||
// We return true to acknowledge the UI action
|
||||
logger.debug('[ROCKETCHAT_ADAPTER] markAllAsRead called (read status handled by RocketChat)', {
|
||||
userId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user