Compare commits

...

2 Commits

8 changed files with 694 additions and 13 deletions

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 = () => {
<div className="text-blue-100">{t('navigation.loading')}</div>
) : session ? (
<>
{/* Notifications - Admin only for now */}
{session?.user?.role === 'admin' && (
{/* Notifications - Admin and Team Lead */}
{(session?.user?.role === 'admin' || session?.user?.role === 'team_lead') && (
<div className="relative" ref={notificationsRef}>
<button
onClick={() => setIsNotificationsOpen(!isNotificationsOpen)}
@@ -113,21 +219,74 @@ const Navigation = () => {
</svg>
{/* Notification badge */}
<span className="absolute -top-1 -right-1 h-4 w-4 bg-gray-400 dark:bg-gray-500 text-white text-xs rounded-full flex items-center justify-center">
0
{unreadCount > 99 ? '99+' : unreadCount}
</span>
</button>
{/* Notifications Dropdown */}
{isNotificationsOpen && (
<div className="absolute right-0 mt-2 w-80 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50">
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{t('notifications.title')}</h3>
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
{t('notifications.title')}
</h3>
{notifications.length > 0 && (
<button
onClick={() => markAsRead()}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
{t('notifications.markAllRead') || 'Mark all read'}
</button>
)}
</div>
<div className="max-h-96 overflow-y-auto">
{isLoadingNotifications ? (
<div className="p-4 text-center text-gray-500 dark:text-gray-400">
<p className="text-sm">{t('notifications.loading') || 'Loading...'}</p>
</div>
) : notifications.length > 0 ? (
notifications.map((notification) => (
<div
key={notification.id}
onClick={() => 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' : ''
}`}
>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
<div className={`w-2 h-2 rounded-full ${
notification.priority === 'urgent' ? 'bg-red-500' :
notification.priority === 'high' ? 'bg-orange-500' :
notification.priority === 'low' ? 'bg-gray-400' :
'bg-blue-500'
}`}></div>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white">
{notification.title}
</p>
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1">
{notification.message}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{formatTimeAgo(notification.created_at)}
</p>
</div>
{!notification.is_read && (
<div className="flex-shrink-0">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
</div>
)}
</div>
</div>
))
) : (
<div className="p-4 text-center text-gray-500 dark:text-gray-400">
<p className="text-sm">{t('notifications.noNotifications')}</p>
<p className="text-xs mt-1">{t('notifications.placeholder')}</p>
</div>
)}
</div>
</div>
)}
@@ -227,8 +386,8 @@ const Navigation = () => {
</div>
</Link>
<div className="flex items-center space-x-2">
{/* 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') && (
<button
onClick={() => {
setIsNotificationsOpen(!isNotificationsOpen);
@@ -241,7 +400,7 @@ const Navigation = () => {
</svg>
{/* Notification badge */}
<span className="absolute -top-1 -right-1 h-3 w-3 bg-gray-400 dark:bg-gray-500 text-white text-xs rounded-full flex items-center justify-center">
0
{unreadCount > 99 ? '99+' : unreadCount}
</span>
</button>
)}

View File

@@ -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

View File

@@ -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);
`);
}

319
src/lib/notifications.js Normal file
View File

@@ -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<number>} [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;
}
}

View File

@@ -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();

View File

@@ -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();