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 = () => {
{/* 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