From 9b84c6b9e89b0f3e4126e06445f722bdbb3f8fd5 Mon Sep 17 00:00:00 2001 From: RKWojs Date: Tue, 2 Dec 2025 11:06:31 +0100 Subject: [PATCH] feat: implement notifications system with API endpoints for fetching and marking notifications as read --- src/app/api/notifications/route.js | 73 ++++ .../api/notifications/unread-count/route.js | 23 ++ src/components/ui/Navigation.js | 183 +++++++++- src/lib/i18n.js | 5 +- src/lib/init-db.js | 23 ++ src/lib/notifications.js | 319 ++++++++++++++++++ test-notifications-api.mjs | 35 ++ test-notifications-working.mjs | 46 +++ 8 files changed, 694 insertions(+), 13 deletions(-) create mode 100644 src/app/api/notifications/route.js create mode 100644 src/app/api/notifications/unread-count/route.js create mode 100644 src/lib/notifications.js create mode 100644 test-notifications-api.mjs create mode 100644 test-notifications-working.mjs diff --git a/src/app/api/notifications/route.js b/src/app/api/notifications/route.js new file mode 100644 index 0000000..9049c2b --- /dev/null +++ b/src/app/api/notifications/route.js @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { + getUserNotifications, + markNotificationsAsRead, + getUnreadNotificationCount, +} from "@/lib/notifications"; + +export async function GET(request) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const includeRead = searchParams.get("includeRead") === "true"; + const limit = parseInt(searchParams.get("limit") || "50"); + const offset = parseInt(searchParams.get("offset") || "0"); + + const notifications = await getUserNotifications(session.user.id, { + includeRead, + limit, + offset, + }); + + const unreadCount = await getUnreadNotificationCount(session.user.id); + + return NextResponse.json({ + notifications, + unreadCount, + hasMore: notifications.length === limit, + }); + } catch (error) { + console.error("Error fetching notifications:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function PATCH(request) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { action, notificationIds } = body; + + if (action === "markAsRead") { + await markNotificationsAsRead(session.user.id, notificationIds); + const unreadCount = await getUnreadNotificationCount(session.user.id); + + return NextResponse.json({ + success: true, + unreadCount, + }); + } + + return NextResponse.json({ error: "Invalid action" }, { status: 400 }); + } catch (error) { + console.error("Error updating notifications:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/notifications/unread-count/route.js b/src/app/api/notifications/unread-count/route.js new file mode 100644 index 0000000..6af6f14 --- /dev/null +++ b/src/app/api/notifications/unread-count/route.js @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { getUnreadNotificationCount } from "@/lib/notifications"; + +export async function GET(request) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const unreadCount = await getUnreadNotificationCount(session.user.id); + + return NextResponse.json({ unreadCount }); + } catch (error) { + console.error("Error fetching unread notification count:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/components/ui/Navigation.js b/src/components/ui/Navigation.js index 470f1ad..65de26b 100644 --- a/src/components/ui/Navigation.js +++ b/src/components/ui/Navigation.js @@ -12,6 +12,9 @@ const Navigation = () => { const { t } = useTranslation(); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isNotificationsOpen, setIsNotificationsOpen] = useState(false); + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [isLoadingNotifications, setIsLoadingNotifications] = useState(false); const notificationsRef = useRef(null); // Close notifications dropdown when clicking outside @@ -28,6 +31,109 @@ const Navigation = () => { }; }, []); + // Fetch notifications when component mounts or session changes + useEffect(() => { + if (session?.user?.id) { + fetchUnreadCount(); + } + }, [session]); + + // Fetch notifications when dropdown opens + useEffect(() => { + if (isNotificationsOpen && session?.user?.id) { + fetchNotifications(); + } + }, [isNotificationsOpen, session]); + + const fetchUnreadCount = async () => { + try { + const response = await fetch('/api/notifications/unread-count'); + if (response.ok) { + const data = await response.json(); + setUnreadCount(data.unreadCount); + } + } catch (error) { + console.error('Failed to fetch unread count:', error); + } + }; + + const fetchNotifications = async () => { + setIsLoadingNotifications(true); + try { + const response = await fetch('/api/notifications?limit=10'); + if (response.ok) { + const data = await response.json(); + setNotifications(data.notifications); + } + } catch (error) { + console.error('Failed to fetch notifications:', error); + } finally { + setIsLoadingNotifications(false); + } + }; + + const markAsRead = async (notificationIds = null) => { + try { + const response = await fetch('/api/notifications', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + action: 'markAsRead', + notificationIds, + }), + }); + + if (response.ok) { + const data = await response.json(); + setUnreadCount(data.unreadCount); + + // Update local notifications state + if (notificationIds) { + setNotifications(prev => + prev.map(notif => + notificationIds.includes(notif.id) + ? { ...notif, is_read: 1 } + : notif + ) + ); + } else { + setNotifications(prev => + prev.map(notif => ({ ...notif, is_read: 1 })) + ); + } + } + } catch (error) { + console.error('Failed to mark notifications as read:', error); + } + }; + + const handleNotificationClick = (notification) => { + // Mark as read if not already read + if (!notification.is_read) { + markAsRead([notification.id]); + } + + // Navigate to action URL if available + if (notification.action_url) { + window.location.href = notification.action_url; + } + + setIsNotificationsOpen(false); + }; + + const formatTimeAgo = (timestamp) => { + const now = new Date(); + const notificationTime = new Date(timestamp); + const diffInMinutes = Math.floor((now - notificationTime) / (1000 * 60)); + + if (diffInMinutes < 1) return t('notifications.justNow') || 'Just now'; + if (diffInMinutes < 60) return `${diffInMinutes}m ago`; + if (diffInMinutes < 1440) return `${Math.floor(diffInMinutes / 60)}h ago`; + return `${Math.floor(diffInMinutes / 1440)}d ago`; + }; + const isActive = (path) => { if (path === "/") return pathname === "/"; if (pathname === path) return true; @@ -101,8 +207,8 @@ const Navigation = () => {
{t('navigation.loading')}
) : session ? ( <> - {/* Notifications - Admin only for now */} - {session?.user?.role === 'admin' && ( + {/* Notifications - Admin and Team Lead */} + {(session?.user?.role === 'admin' || session?.user?.role === 'team_lead') && (
{/* Notifications Dropdown */} {isNotificationsOpen && (
-
-

{t('notifications.title')}

+
+

+ {t('notifications.title')} +

+ {notifications.length > 0 && ( + + )}
-
-

{t('notifications.noNotifications')}

-

{t('notifications.placeholder')}

-
+ {isLoadingNotifications ? ( +
+

{t('notifications.loading') || 'Loading...'}

+
+ ) : notifications.length > 0 ? ( + notifications.map((notification) => ( +
handleNotificationClick(notification)} + className={`p-4 border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer ${ + !notification.is_read ? 'bg-blue-50 dark:bg-blue-900/20' : '' + }`} + > +
+
+
+
+
+

+ {notification.title} +

+

+ {notification.message} +

+

+ {formatTimeAgo(notification.created_at)} +

+
+ {!notification.is_read && ( +
+
+
+ )} +
+
+ )) + ) : ( +
+

{t('notifications.noNotifications')}

+

{t('notifications.placeholder')}

+
+ )}
)} @@ -227,8 +386,8 @@ const Navigation = () => {
- {/* Mobile Notifications - Admin only for now */} - {session?.user?.role === 'admin' && ( + {/* Mobile Notifications - Admin and Team Lead */} + {(session?.user?.role === 'admin' || session?.user?.role === 'team_lead') && ( )} diff --git a/src/lib/i18n.js b/src/lib/i18n.js index 2603e15..1f139d9 100644 --- a/src/lib/i18n.js +++ b/src/lib/i18n.js @@ -28,7 +28,10 @@ const translations = { notifications: { title: "Powiadomienia", noNotifications: "Brak powiadomień", - placeholder: "To jest miejsce na przyszłe powiadomienia" + placeholder: "To jest miejsce na przyszłe powiadomienia", + markAllRead: "Oznacz wszystkie jako przeczytane", + loading: "Ładowanie...", + justNow: "Przed chwilą" }, // Common UI elements diff --git a/src/lib/init-db.js b/src/lib/init-db.js index b9ab45d..8d6a539 100644 --- a/src/lib/init-db.js +++ b/src/lib/init-db.js @@ -477,5 +477,28 @@ export default function initializeDatabase() { -- Create index for password reset tokens CREATE INDEX IF NOT EXISTS idx_password_reset_token ON password_reset_tokens(token); CREATE INDEX IF NOT EXISTS idx_password_reset_user ON password_reset_tokens(user_id); + + -- Notifications table + CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('task_assigned', 'task_status_changed', 'project_updated', 'due_date_reminder', 'system_announcement', 'mention')), + title TEXT NOT NULL, + message TEXT NOT NULL, + resource_type TEXT, + resource_id TEXT, + is_read INTEGER DEFAULT 0, + priority TEXT DEFAULT 'normal' CHECK(priority IN ('low', 'normal', 'high', 'urgent')), + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT, + action_url TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + -- Create indexes for notifications + CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id); + CREATE INDEX IF NOT EXISTS idx_notifications_user_read ON notifications(user_id, is_read); + CREATE INDEX IF NOT EXISTS idx_notifications_created ON notifications(created_at); + CREATE INDEX IF NOT EXISTS idx_notifications_type ON notifications(type); `); } diff --git a/src/lib/notifications.js b/src/lib/notifications.js new file mode 100644 index 0000000..6155364 --- /dev/null +++ b/src/lib/notifications.js @@ -0,0 +1,319 @@ +/** + * Notification types - standardized notification types + */ +export const NOTIFICATION_TYPES = { + // Task notifications + TASK_ASSIGNED: "task_assigned", + TASK_STATUS_CHANGED: "task_status_changed", + + // Project notifications + PROJECT_UPDATED: "project_updated", + + // System notifications + DUE_DATE_REMINDER: "due_date_reminder", + SYSTEM_ANNOUNCEMENT: "system_announcement", + MENTION: "mention", +}; + +/** + * Notification priorities + */ +export const NOTIFICATION_PRIORITIES = { + LOW: "low", + NORMAL: "normal", + HIGH: "high", + URGENT: "urgent", +}; + +/** + * Create a notification + * @param {Object} params - Notification parameters + * @param {string} params.userId - User to receive the notification + * @param {string} params.type - Notification type (use NOTIFICATION_TYPES constants) + * @param {string} params.title - Notification title + * @param {string} params.message - Notification message + * @param {string} [params.resourceType] - Type of related resource + * @param {string} [params.resourceId] - ID of the related resource + * @param {string} [params.priority] - Priority level (default: normal) + * @param {string} [params.actionUrl] - URL to navigate to when clicked + * @param {string} [params.expiresAt] - When the notification expires + */ +export async function createNotification({ + userId, + type, + title, + message, + resourceType = null, + resourceId = null, + priority = NOTIFICATION_PRIORITIES.NORMAL, + actionUrl = null, + expiresAt = null, +}) { + try { + // Check if we're in Edge Runtime - if so, skip database operations + if ( + typeof EdgeRuntime !== "undefined" || + process.env.NEXT_RUNTIME === "edge" + ) { + console.log( + `[Notification - Edge Runtime] ${type} notification for user ${userId}: ${title}` + ); + return; + } + + // Dynamic import to avoid Edge Runtime issues + const { default: db } = await import("./db.js"); + + const stmt = db.prepare(` + INSERT INTO notifications ( + user_id, type, title, message, resource_type, resource_id, + priority, action_url, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + const result = stmt.run( + userId, + type, + title, + message, + resourceType, + resourceId, + priority, + actionUrl, + expiresAt + ); + + console.log( + `Notification created: ${type} for user ${userId} - ${title}` + ); + + return result.lastInsertRowid; + } catch (error) { + console.error("Failed to create notification:", error); + // Don't throw error to avoid breaking the main application flow + } +} + +/** + * Get notifications for a user + * @param {string} userId - User ID + * @param {Object} options - Query options + * @param {boolean} [options.includeRead] - Include read notifications (default: false) + * @param {number} [options.limit] - Maximum number of notifications to return + * @param {number} [options.offset] - Number of notifications to skip + * @returns {Array} Array of notifications + */ +export async function getUserNotifications( + userId, + { includeRead = false, limit = 50, offset = 0 } = {} +) { + try { + // Check if we're in Edge Runtime - if so, return empty array + if ( + typeof EdgeRuntime !== "undefined" || + process.env.NEXT_RUNTIME === "edge" + ) { + console.log( + "[Notification - Edge Runtime] Cannot query notifications in Edge Runtime" + ); + return []; + } + + // Dynamic import to avoid Edge Runtime issues + const { default: db } = await import("./db.js"); + + let query = ` + SELECT * FROM notifications + WHERE user_id = ? + `; + + const params = [userId]; + + if (!includeRead) { + query += " AND is_read = 0"; + } + + query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"; + params.push(limit, offset); + + const stmt = db.prepare(query); + const notifications = stmt.all(...params); + + return notifications; + } catch (error) { + console.error("Failed to get user notifications:", error); + return []; + } +} + +/** + * Mark notifications as read + * @param {string} userId - User ID + * @param {Array} [notificationIds] - Specific notification IDs to mark as read (if not provided, marks all as read) + */ +export async function markNotificationsAsRead(userId, notificationIds = null) { + try { + // Check if we're in Edge Runtime - if so, skip database operations + if ( + typeof EdgeRuntime !== "undefined" || + process.env.NEXT_RUNTIME === "edge" + ) { + console.log( + `[Notification - Edge Runtime] Cannot mark notifications as read in Edge Runtime` + ); + return; + } + + // Dynamic import to avoid Edge Runtime issues + const { default: db } = await import("./db.js"); + + let query; + let params; + + if (notificationIds && notificationIds.length > 0) { + // Mark specific notifications as read + const placeholders = notificationIds.map(() => "?").join(","); + query = ` + UPDATE notifications + SET is_read = 1 + WHERE user_id = ? AND id IN (${placeholders}) + `; + params = [userId, ...notificationIds]; + } else { + // Mark all notifications as read + query = ` + UPDATE notifications + SET is_read = 1 + WHERE user_id = ? + `; + params = [userId]; + } + + const stmt = db.prepare(query); + stmt.run(...params); + + console.log(`Marked notifications as read for user ${userId}`); + } catch (error) { + console.error("Failed to mark notifications as read:", error); + } +} + +/** + * Get unread notification count for a user + * @param {string} userId - User ID + * @returns {number} Number of unread notifications + */ +export async function getUnreadNotificationCount(userId) { + try { + // Check if we're in Edge Runtime - if so, return 0 + if ( + typeof EdgeRuntime !== "undefined" || + process.env.NEXT_RUNTIME === "edge" + ) { + return 0; + } + + // Dynamic import to avoid Edge Runtime issues + const { default: db } = await import("./db.js"); + + const stmt = db.prepare(` + SELECT COUNT(*) as count + FROM notifications + WHERE user_id = ? AND is_read = 0 + `); + + const result = stmt.get(userId); + return result.count || 0; + } catch (error) { + console.error("Failed to get unread notification count:", error); + return 0; + } +} + +/** + * Delete old notifications (cleanup function) + * @param {number} daysOld - Delete notifications older than this many days + */ +export async function cleanupOldNotifications(daysOld = 30) { + try { + // Check if we're in Edge Runtime - if so, skip database operations + if ( + typeof EdgeRuntime !== "undefined" || + process.env.NEXT_RUNTIME === "edge" + ) { + console.log( + `[Notification - Edge Runtime] Cannot cleanup notifications in Edge Runtime` + ); + return; + } + + // Dynamic import to avoid Edge Runtime issues + const { default: db } = await import("./db.js"); + + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysOld); + const cutoffIso = cutoffDate.toISOString(); + + const stmt = db.prepare(` + DELETE FROM notifications + WHERE created_at < ? AND is_read = 1 + `); + + const result = stmt.run(cutoffIso); + console.log(`Cleaned up ${result.changes} old notifications`); + } catch (error) { + console.error("Failed to cleanup old notifications:", error); + } +} + +/** + * Create notification from audit event (helper function) + * @param {Object} auditEvent - Audit event data + * @param {string} targetUserId - User to notify + * @param {string} notificationType - Type of notification + * @param {string} title - Notification title + * @param {string} message - Notification message + */ +export async function createNotificationFromAuditEvent( + auditEvent, + targetUserId, + notificationType, + title, + message +) { + // Don't notify the user who performed the action + if (auditEvent.userId === targetUserId) { + return; + } + + await createNotification({ + userId: targetUserId, + type: notificationType, + title, + message, + resourceType: auditEvent.resourceType, + resourceId: auditEvent.resourceId, + actionUrl: getActionUrl(auditEvent.resourceType, auditEvent.resourceId), + }); +} + +/** + * Generate action URL for notification + * @param {string} resourceType - Type of resource + * @param {string} resourceId - Resource ID + * @returns {string} Action URL + */ +function getActionUrl(resourceType, resourceId) { + switch (resourceType) { + case "project": + return `/projects/${resourceId}`; + case "project_task": + return `/project-tasks/${resourceId}`; + case "task": + return `/tasks/${resourceId}`; + case "contract": + return `/contracts/${resourceId}`; + default: + return null; + } +} \ No newline at end of file diff --git a/test-notifications-api.mjs b/test-notifications-api.mjs new file mode 100644 index 0000000..6622a11 --- /dev/null +++ b/test-notifications-api.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node + +/** + * Test script to verify notifications API + */ + +async function testNotificationsAPI() { + try { + console.log("Testing notifications API..."); + + // Test unread count endpoint + const unreadResponse = await fetch('http://localhost:3001/api/notifications/unread-count'); + if (unreadResponse.ok) { + const unreadData = await unreadResponse.json(); + console.log("✅ Unread count:", unreadData.unreadCount); + } else { + console.log("❌ Unread count endpoint failed:", unreadResponse.status); + } + + // Test notifications list endpoint + const notificationsResponse = await fetch('http://localhost:3001/api/notifications'); + if (notificationsResponse.ok) { + const notificationsData = await notificationsResponse.json(); + console.log("✅ Notifications fetched:", notificationsData.notifications.length); + console.log("Sample notification:", notificationsData.notifications[0]); + } else { + console.log("❌ Notifications endpoint failed:", notificationsResponse.status); + } + + } catch (error) { + console.error("Error testing API:", error); + } +} + +testNotificationsAPI(); \ No newline at end of file diff --git a/test-notifications-working.mjs b/test-notifications-working.mjs new file mode 100644 index 0000000..61afb1a --- /dev/null +++ b/test-notifications-working.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +/** + * Test script to verify notifications are working + */ + +async function testNotifications() { + try { + console.log("Testing notifications system..."); + + // Test unread count endpoint (this should work without auth for now) + console.log("1. Testing unread count endpoint..."); + const unreadResponse = await fetch('http://localhost:3001/api/notifications/unread-count'); + + if (unreadResponse.status === 401) { + console.log("✅ Unread count endpoint requires auth (expected)"); + } else if (unreadResponse.ok) { + const data = await unreadResponse.json(); + console.log("✅ Unread count:", data.unreadCount); + } else { + console.log("❌ Unread count endpoint failed:", unreadResponse.status); + } + + // Test notifications endpoint + console.log("2. Testing notifications endpoint..."); + const notificationsResponse = await fetch('http://localhost:3001/api/notifications'); + + if (notificationsResponse.status === 401) { + console.log("✅ Notifications endpoint requires auth (expected)"); + } else if (notificationsResponse.ok) { + const data = await notificationsResponse.json(); + console.log("✅ Notifications fetched:", data.notifications?.length || 0); + } else { + console.log("❌ Notifications endpoint failed:", notificationsResponse.status); + } + + console.log("\n🎉 Notification system test completed!"); + console.log("Note: API endpoints require authentication, so 401 responses are expected."); + console.log("Test the UI by logging into the application and checking the notification dropdown."); + + } catch (error) { + console.error("❌ Error testing notifications:", error); + } +} + +testNotifications(); \ No newline at end of file