Compare commits
2 Commits
6ac5ac9dda
...
99853bb755
| Author | SHA1 | Date | |
|---|---|---|---|
| 99853bb755 | |||
| 9b84c6b9e8 |
73
src/app/api/notifications/route.js
Normal file
73
src/app/api/notifications/route.js
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/app/api/notifications/unread-count/route.js
Normal file
23
src/app/api/notifications/unread-count/route.js
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ const Navigation = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
const [isNotificationsOpen, setIsNotificationsOpen] = 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);
|
const notificationsRef = useRef(null);
|
||||||
|
|
||||||
// Close notifications dropdown when clicking outside
|
// 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) => {
|
const isActive = (path) => {
|
||||||
if (path === "/") return pathname === "/";
|
if (path === "/") return pathname === "/";
|
||||||
if (pathname === path) return true;
|
if (pathname === path) return true;
|
||||||
@@ -101,8 +207,8 @@ const Navigation = () => {
|
|||||||
<div className="text-blue-100">{t('navigation.loading')}</div>
|
<div className="text-blue-100">{t('navigation.loading')}</div>
|
||||||
) : session ? (
|
) : session ? (
|
||||||
<>
|
<>
|
||||||
{/* Notifications - Admin only for now */}
|
{/* Notifications - Admin and Team Lead */}
|
||||||
{session?.user?.role === 'admin' && (
|
{(session?.user?.role === 'admin' || session?.user?.role === 'team_lead') && (
|
||||||
<div className="relative" ref={notificationsRef}>
|
<div className="relative" ref={notificationsRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsNotificationsOpen(!isNotificationsOpen)}
|
onClick={() => setIsNotificationsOpen(!isNotificationsOpen)}
|
||||||
@@ -113,21 +219,74 @@ const Navigation = () => {
|
|||||||
</svg>
|
</svg>
|
||||||
{/* Notification badge */}
|
{/* 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">
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Notifications Dropdown */}
|
{/* Notifications Dropdown */}
|
||||||
{isNotificationsOpen && (
|
{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="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">
|
<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>
|
<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>
|
||||||
<div className="max-h-96 overflow-y-auto">
|
<div className="max-h-96 overflow-y-auto">
|
||||||
<div className="p-4 text-center text-gray-500 dark:text-gray-400">
|
{isLoadingNotifications ? (
|
||||||
<p className="text-sm">{t('notifications.noNotifications')}</p>
|
<div className="p-4 text-center text-gray-500 dark:text-gray-400">
|
||||||
<p className="text-xs mt-1">{t('notifications.placeholder')}</p>
|
<p className="text-sm">{t('notifications.loading') || 'Loading...'}</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -227,8 +386,8 @@ const Navigation = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
{/* Mobile Notifications - Admin only for now */}
|
{/* Mobile Notifications - Admin and Team Lead */}
|
||||||
{session?.user?.role === 'admin' && (
|
{(session?.user?.role === 'admin' || session?.user?.role === 'team_lead') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsNotificationsOpen(!isNotificationsOpen);
|
setIsNotificationsOpen(!isNotificationsOpen);
|
||||||
@@ -241,7 +400,7 @@ const Navigation = () => {
|
|||||||
</svg>
|
</svg>
|
||||||
{/* Notification badge */}
|
{/* 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">
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ const translations = {
|
|||||||
notifications: {
|
notifications: {
|
||||||
title: "Powiadomienia",
|
title: "Powiadomienia",
|
||||||
noNotifications: "Brak powiadomień",
|
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
|
// Common UI elements
|
||||||
|
|||||||
@@ -477,5 +477,28 @@ export default function initializeDatabase() {
|
|||||||
-- Create index for password reset tokens
|
-- 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_token ON password_reset_tokens(token);
|
||||||
CREATE INDEX IF NOT EXISTS idx_password_reset_user ON password_reset_tokens(user_id);
|
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
319
src/lib/notifications.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
test-notifications-api.mjs
Normal file
35
test-notifications-api.mjs
Normal 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();
|
||||||
46
test-notifications-working.mjs
Normal file
46
test-notifications-working.mjs
Normal 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();
|
||||||
Reference in New Issue
Block a user