diff --git a/client/src/App.jsx b/client/src/App.jsx index 22e7061..a9a2ce1 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -27,6 +27,7 @@ import MeetingDetails from "./pages/MeetingDetails.jsx"; import TeamMembers from "./pages/TeamMembers.jsx"; import Profile from "./pages/Profile.jsx"; import Calendar from "./pages/Calendar.jsx"; +import Notifications from "./pages/Notifications.jsx"; // --- Components --- import ProtectedRoute from "./components/ProtectedRoute.jsx"; @@ -250,6 +251,15 @@ const App = () => { } /> + + + + } + /> + {/* ✅ Fallback route — send unknown routes to Home */} } /> diff --git a/client/src/components/Navbar.jsx b/client/src/components/Navbar.jsx index 18ae87c..0088405 100644 --- a/client/src/components/Navbar.jsx +++ b/client/src/components/Navbar.jsx @@ -31,7 +31,8 @@ const NAV_LINKS = [ const Navbar = () => { const navigate = useNavigate(); const location = useLocation(); - const { backendUrl, userData, setUserData, setIsLoggedin } = useContext(AppContent); + const { backendUrl, userData, setUserData, setIsLoggedin } = + useContext(AppContent); const [menuOpen, setMenuOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false); @@ -44,39 +45,63 @@ const Navbar = () => { setImgFailed(false); }, [userData?.profilePic]); - // Unread notifications mock state - const [unreadCount, setUnreadCount] = useState(3); - const [notifications, setNotifications] = useState([ - { - id: 1, - title: "Minutes of Meeting Ready", - description: "AI summary for 'Q3 Sprint Review' is now compiled.", - time: "10m ago", - unread: true, - }, - { - id: 2, - title: "New Team Member Joined", - description: "Sarah Jenkins has linked to the organization.", - time: "2h ago", - unread: true, - }, - { - id: 3, - title: "Policy Update Alert", - description: "The 'Privacy & Security Policy' has been updated.", - time: "1d ago", - unread: true, - }, - { - id: 4, - title: "Welcome to MeetOnMemory", - description: - "Start recording or uploading meetings to generate summaries.", - time: "3d ago", - unread: false, - }, - ]); + // Fetch unread count from backend + const [unreadCount, setUnreadCount] = useState(0); + const [notifications, setNotifications] = useState([]); + + const formatTimeAgo = (dateString) => { + const date = new Date(dateString); + const now = new Date(); + const seconds = Math.floor((now - date) / 1000); + + if (seconds < 60) return "Just now"; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`; + return date.toLocaleDateString(); + }; + + useEffect(() => { + if (userData && backendUrl) { + const fetchUnreadCount = async () => { + try { + const { data } = await axios.get( + `${backendUrl}/api/notifications/unread-count`, + { withCredentials: true }, + ); + if (data.success) { + setUnreadCount(data.unreadCount); + } + } catch (err) { + console.error("Error fetching unread count:", err); + } + }; + + const fetchRecentNotifications = async () => { + try { + const { data } = await axios.get(`${backendUrl}/api/notifications`, { + withCredentials: true, + }); + if (data.success) { + setNotifications( + data.notifications.slice(0, 5).map((n) => ({ + id: n.id, + title: n.title, + description: n.description, + time: formatTimeAgo(n.createdAt), + unread: !n.isRead, + })), + ); + } + } catch (err) { + console.error("Error fetching notifications:", err); + } + }; + + fetchUnreadCount(); + fetchRecentNotifications(); + } + }, [userData, backendUrl]); const menuRef = useRef(); const mobileMenuRef = useRef(); @@ -204,11 +229,6 @@ const Navbar = () => { return currentPath === tabPath; }; - const markAllAsRead = () => { - setNotifications((prev) => prev.map((n) => ({ ...n, unread: false }))); - setUnreadCount(0); - }; - const appLinks = [ { label: "Dashboard", href: "/dashboard", icon: LayoutDashboard }, { label: "Meetings", href: "/meetings", icon: Calendar }, @@ -356,14 +376,15 @@ const Navbar = () => { Notifications - {unreadCount > 0 && ( - - )} +
{notifications.length > 0 ? ( @@ -621,7 +642,10 @@ const Navbar = () => { {/* Mobile Notifications Section Toggle */} - {/* Mobile Notifications Expandable List */} - {mobileNotifOpen && ( -
- {notifications.map((notif) => ( -
-
-

- {notif.unread && ( - - )} - {notif.title} -

- - {notif.time} - -
-

- {notif.description} -

-
- ))} - {unreadCount > 0 && ( - - )} -
- )} - + )} +
+ + + {/* Filters */} +
+
+ + + + +
+ +
+ +
+
+ + {/* Notifications List */} + {loading ? ( +
+
+
+ ) : error ? ( +
+ +

{error}

+ +
+ ) : filteredNotifications.length === 0 ? ( +
+
+ +
+

+ No notifications +

+

+ {filter === "unread" || categoryFilter !== "all" + ? "No notifications match your current filters." + : "You're all caught up! We'll notify you when something new happens."} +

+
+ ) : ( +
+ {filteredNotifications.map((notification) => { + const Icon = CATEGORY_ICONS[notification.category] || AlertCircle; + const categoryColor = + CATEGORY_COLORS[notification.category] || + CATEGORY_COLORS.system; + + return ( +
+
+ {/* Icon */} +
+ +
+ + {/* Content */} +
+
+
+ {!notification.isRead && ( + + )} +

+ {notification.title} +

+ + {CATEGORY_LABELS[notification.category] || "System"} + +
+ + {formatTimeAgo(notification.createdAt)} + +
+

+ {notification.description} +

+ + {/* Actions */} +
+ {notification.actionUrl && ( + + )} + + {!notification.isRead && ( + + )} + + +
+
+
+
+ ); + })} +
+ )} + + + ); +}; + +export default Notifications; diff --git a/server/controllers/notificationController.js b/server/controllers/notificationController.js new file mode 100644 index 0000000..21ab550 --- /dev/null +++ b/server/controllers/notificationController.js @@ -0,0 +1,195 @@ +// server/controllers/notificationController.js +import notificationModel from "../models/notificationModel.js"; + +// Helper to format notification response +const formatNotificationResponse = (notification) => { + return { + id: notification._id, + title: notification.title, + description: notification.description, + category: notification.category, + isRead: notification.isRead, + actionUrl: notification.actionUrl, + actionLabel: notification.actionLabel, + metadata: notification.metadata, + createdAt: notification.createdAt, + updatedAt: notification.updatedAt, + }; +}; + +// @desc Get all notifications for a user +// @route GET /api/notifications +// @access Private +export const getNotifications = async (req, res) => { + try { + if (!req.user || !req.user.id) { + return res.status(401).json({ + success: false, + message: "Authentication error, user ID not found.", + }); + } + + const { category, status } = req.query; + + let query = notificationModel.find({ user: req.user.id }); + + if (category === "meetings") { + query = query.where("category").equals("meetings"); + } else if (category === "ai_processing") { + query = query.where("category").equals("ai_processing"); + } else if (category === "organizations") { + query = query.where("category").equals("organizations"); + } else if (category === "policies") { + query = query.where("category").equals("policies"); + } else if (category === "reports") { + query = query.where("category").equals("reports"); + } else if (category === "system") { + query = query.where("category").equals("system"); + } + + if (status === "unread") { + query = query.where("isRead").equals(false); + } else if (status === "read") { + query = query.where("isRead").equals(true); + } + + const notifications = await query.sort({ createdAt: -1 }).limit(100); + + const unreadCount = await notificationModel.countDocuments({ + user: req.user.id, + isRead: false, + }); + + res.status(200).json({ + success: true, + notifications: notifications.map(formatNotificationResponse), + unreadCount, + }); + } catch (error) { + console.error("Error in getNotifications:", error); + res.status(500).json({ success: false, message: "Server error" }); + } +}; + +// @desc Mark notification as read +// @route PATCH /api/notifications/:id/read +// @access Private +export const markAsRead = async (req, res) => { + try { + if (!req.user || !req.user.id) { + return res.status(401).json({ + success: false, + message: "Authentication error, user ID not found.", + }); + } + + const notification = await notificationModel.findOneAndUpdate( + { _id: req.params.id, user: req.user.id }, + { isRead: true }, + { new: true }, + ); + + if (!notification) { + return res + .status(404) + .json({ success: false, message: "Notification not found" }); + } + + res.status(200).json({ + success: true, + message: "Notification marked as read", + notification: formatNotificationResponse(notification), + }); + } catch (error) { + console.error("Error in markAsRead:", error); + res.status(500).json({ success: false, message: "Server error" }); + } +}; + +// @desc Mark all notifications as read +// @route PATCH /api/notifications/mark-all-read +// @access Private +export const markAllAsRead = async (req, res) => { + try { + if (!req.user || !req.user.id) { + return res.status(401).json({ + success: false, + message: "Authentication error, user ID not found.", + }); + } + + const result = await notificationModel.updateMany( + { user: req.user.id, isRead: false }, + { isRead: true }, + ); + + res.status(200).json({ + success: true, + message: "All notifications marked as read", + modifiedCount: result.modifiedCount, + }); + } catch (error) { + console.error("Error in markAllAsRead:", error); + res.status(500).json({ success: false, message: "Server error" }); + } +}; + +// @desc Delete notification +// @route DELETE /api/notifications/:id +// @access Private +export const deleteNotification = async (req, res) => { + try { + if (!req.user || !req.user.id) { + return res.status(401).json({ + success: false, + message: "Authentication error, user ID not found.", + }); + } + + const notification = await notificationModel.findOneAndDelete({ + _id: req.params.id, + user: req.user.id, + }); + + if (!notification) { + return res + .status(404) + .json({ success: false, message: "Notification not found" }); + } + + res.status(200).json({ + success: true, + message: "Notification deleted", + }); + } catch (error) { + console.error("Error in deleteNotification:", error); + res.status(500).json({ success: false, message: "Server error" }); + } +}; + +// @desc Get unread count +// @route GET /api/notifications/unread-count +// @access Private +export const getUnreadCount = async (req, res) => { + try { + if (!req.user || !req.user.id) { + return res.status(401).json({ + success: false, + message: "Authentication error, user ID not found.", + }); + } + + const unreadCount = await notificationModel.countDocuments({ + user: req.user.id, + isRead: false, + }); + + res.status(200).json({ + success: true, + unreadCount, + }); + } catch (error) { + console.error("Error in getUnreadCount:", error); + res.status(500).json({ success: false, message: "Server error" }); + } +}; diff --git a/server/models/notificationModel.js b/server/models/notificationModel.js new file mode 100644 index 0000000..1d77b3a --- /dev/null +++ b/server/models/notificationModel.js @@ -0,0 +1,57 @@ +// server/models/notificationModel.js +import mongoose from "mongoose"; + +const notificationSchema = new mongoose.Schema( + { + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "user", + required: true, + index: true, + }, + title: { + type: String, + required: true, + }, + description: { + type: String, + required: true, + }, + category: { + type: String, + enum: [ + "meetings", + "ai_processing", + "organizations", + "policies", + "reports", + "system", + ], + default: "system", + }, + isRead: { + type: Boolean, + default: false, + index: true, + }, + actionUrl: { + type: String, + default: "", + }, + actionLabel: { + type: String, + default: "", + }, + metadata: { + type: mongoose.Schema.Types.Mixed, + default: {}, + }, + }, + { timestamps: true }, +); + +const notificationModel = + mongoose.models.notification || + mongoose.model("notification", notificationSchema); + +export default notificationModel; diff --git a/server/routes/notificationRoutes.js b/server/routes/notificationRoutes.js new file mode 100644 index 0000000..01256fe --- /dev/null +++ b/server/routes/notificationRoutes.js @@ -0,0 +1,23 @@ +// server/routes/notificationRoutes.js +import express from "express"; +import userAuth from "../middleware/userAuth.js"; +import { apiLimiter, writeLimiter } from "../middleware/rateLimiter.js"; +import { + getNotifications, + markAsRead, + markAllAsRead, + deleteNotification, + getUnreadCount, +} from "../controllers/notificationController.js"; + +const notificationRouter = express.Router(); + +notificationRouter.use(userAuth, apiLimiter); + +notificationRouter.get("/", getNotifications); +notificationRouter.get("/unread-count", getUnreadCount); +notificationRouter.patch("/:id/read", writeLimiter, markAsRead); +notificationRouter.patch("/mark-all-read", writeLimiter, markAllAsRead); +notificationRouter.delete("/:id", writeLimiter, deleteNotification); + +export default notificationRouter; diff --git a/server/server.js b/server/server.js index fa84843..e193286 100644 --- a/server/server.js +++ b/server/server.js @@ -16,6 +16,7 @@ import policyRoutes from "./routes/policyRoutes.js"; import analyticsRoutes from "./routes/analyticsRoutes.js"; import geminiRoutes from "./routes/geminiRoutes.js"; import userRoutes from "./routes/userRoutes.js"; +import notificationRoutes from "./routes/notificationRoutes.js"; import { initVectorStore } from "./utils/embeddingUtils.js"; import meetingSocket from "./socket/meetingSocket.js"; @@ -130,6 +131,7 @@ app.use("/api/policies", policyRoutes); app.use("/api/analytics", analyticsRoutes); app.use("/api/gemini", geminiRoutes); app.use("/api/user", userRoutes); +app.use("/api/notifications", notificationRoutes); // ================================ // VECTOR STORE INIT