feat: implement notifications system with API endpoints for fetching and marking notifications as read
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user