From 4c9f4bdbbfedc5d6461207bda52767e94262cc1b Mon Sep 17 00:00:00 2001 From: Mrunmayee Kokitkar Date: Wed, 8 Jul 2026 12:03:16 +0530 Subject: [PATCH 1/5] feat: Build Notifications Center & Activity Feed (#78) - Created notification model with user, title, description, category, read status, action URL, and metadata - Implemented notification controller with CRUD operations (get, mark as read, mark all as read, delete, unread count) - Added notification routes with proper authentication and rate limiting - Integrated notification routes into server.js - Created dedicated Notifications page with filtering by status and category - Added Notifications route to App.jsx with ProtectedRoute - Updated Navbar to link notification popover and mobile menu to Notifications page - Implemented read/unread visual distinction with blue indicators - Added mark as read, mark all as read, and delete functionality - Included empty state, loading state, and error handling - Designed responsive layout for desktop, tablet, and mobile - Followed MeetOnMemory design language with consistent styling --- client/src/App.jsx | 10 + client/src/components/Navbar.jsx | 57 +-- client/src/pages/Notifications.jsx | 382 +++++++++++++++++++ server/controllers/notificationController.js | 187 +++++++++ server/models/notificationModel.js | 57 +++ server/routes/notificationRoutes.js | 26 ++ server/server.js | 2 + 7 files changed, 679 insertions(+), 42 deletions(-) create mode 100644 client/src/pages/Notifications.jsx create mode 100644 server/controllers/notificationController.js create mode 100644 server/models/notificationModel.js create mode 100644 server/routes/notificationRoutes.js 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..68480c8 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); @@ -356,14 +357,15 @@ const Navbar = () => { Notifications - {unreadCount > 0 && ( - - )} +
{notifications.length > 0 ? ( @@ -621,7 +623,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..710a548 --- /dev/null +++ b/server/controllers/notificationController.js @@ -0,0 +1,187 @@ +// 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; + const filter = { user: req.user.id }; + + if (category && category !== "all") { + filter.category = category; + } + + if (status === "unread") { + filter.isRead = false; + } else if (status === "read") { + filter.isRead = true; + } + + const notifications = await notificationModel + .find(filter) + .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..fa24e24 --- /dev/null +++ b/server/routes/notificationRoutes.js @@ -0,0 +1,26 @@ +// 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.get("/", userAuth, apiLimiter, getNotifications); +notificationRouter.get("/unread-count", userAuth, apiLimiter, getUnreadCount); +notificationRouter.patch("/:id/read", userAuth, writeLimiter, markAsRead); +notificationRouter.patch( + "/mark-all-read", + userAuth, + writeLimiter, + markAllAsRead, +); +notificationRouter.delete("/:id", userAuth, 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 From 62f2b69b355ccf2f3eaa950c5406d135970ccc8e Mon Sep 17 00:00:00 2001 From: Mrunmayee Kokitkar Date: Wed, 8 Jul 2026 12:12:02 +0530 Subject: [PATCH 2/5] fix: Resolve ESLint and CodeQL security alerts - Remove unused markAllAsRead function from Navbar.jsx - Add input validation to notificationController category filter (whitelist allowed categories) - Rate limiting already present in all notification routes (apiLimiter/writeLimiter) --- client/src/components/Navbar.jsx | 5 ----- server/controllers/notificationController.js | 14 +++++++++++++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/client/src/components/Navbar.jsx b/client/src/components/Navbar.jsx index 68480c8..0342b3c 100644 --- a/client/src/components/Navbar.jsx +++ b/client/src/components/Navbar.jsx @@ -205,11 +205,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 }, diff --git a/server/controllers/notificationController.js b/server/controllers/notificationController.js index 710a548..20b1693 100644 --- a/server/controllers/notificationController.js +++ b/server/controllers/notificationController.js @@ -32,7 +32,19 @@ export const getNotifications = async (req, res) => { const { category, status } = req.query; const filter = { user: req.user.id }; - if (category && category !== "all") { + const allowedCategories = [ + "meetings", + "ai_processing", + "organizations", + "policies", + "reports", + "system", + ]; + if ( + category && + category !== "all" && + allowedCategories.includes(category) + ) { filter.category = category; } From 08f61592d4fbd90580018ab9c5d6ffb82a5ebe55 Mon Sep 17 00:00:00 2001 From: Mrunmayee Kokitkar Date: Wed, 8 Jul 2026 12:18:05 +0530 Subject: [PATCH 3/5] fix: Remove unused setUnreadCount and setNotifications from Navbar.jsx --- client/src/components/Navbar.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/components/Navbar.jsx b/client/src/components/Navbar.jsx index 0342b3c..85e4b01 100644 --- a/client/src/components/Navbar.jsx +++ b/client/src/components/Navbar.jsx @@ -46,8 +46,8 @@ const Navbar = () => { }, [userData?.profilePic]); // Unread notifications mock state - const [unreadCount, setUnreadCount] = useState(3); - const [notifications, setNotifications] = useState([ + const [unreadCount] = useState(3); + const [notifications] = useState([ { id: 1, title: "Minutes of Meeting Ready", From 8de8c75d242da9636da2467ad8dcc4d8c49859e4 Mon Sep 17 00:00:00 2001 From: Mrunmayee Kokitkar Date: Wed, 8 Jul 2026 12:27:28 +0530 Subject: [PATCH 4/5] fix: Resolve CodeQL security alerts - Refactor notificationController to use Mongoose query builder instead of user-controlled filter object - Move userAuth middleware to router level in notificationRoutes for clearer rate limiting application --- server/controllers/notificationController.js | 40 +++++++++----------- server/routes/notificationRoutes.js | 17 ++++----- 2 files changed, 25 insertions(+), 32 deletions(-) diff --git a/server/controllers/notificationController.js b/server/controllers/notificationController.js index 20b1693..21ab550 100644 --- a/server/controllers/notificationController.js +++ b/server/controllers/notificationController.js @@ -30,34 +30,30 @@ export const getNotifications = async (req, res) => { } const { category, status } = req.query; - const filter = { user: req.user.id }; - - const allowedCategories = [ - "meetings", - "ai_processing", - "organizations", - "policies", - "reports", - "system", - ]; - if ( - category && - category !== "all" && - allowedCategories.includes(category) - ) { - filter.category = category; + + 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") { - filter.isRead = false; + query = query.where("isRead").equals(false); } else if (status === "read") { - filter.isRead = true; + query = query.where("isRead").equals(true); } - const notifications = await notificationModel - .find(filter) - .sort({ createdAt: -1 }) - .limit(100); + const notifications = await query.sort({ createdAt: -1 }).limit(100); const unreadCount = await notificationModel.countDocuments({ user: req.user.id, diff --git a/server/routes/notificationRoutes.js b/server/routes/notificationRoutes.js index fa24e24..5b6cca8 100644 --- a/server/routes/notificationRoutes.js +++ b/server/routes/notificationRoutes.js @@ -12,15 +12,12 @@ import { const notificationRouter = express.Router(); -notificationRouter.get("/", userAuth, apiLimiter, getNotifications); -notificationRouter.get("/unread-count", userAuth, apiLimiter, getUnreadCount); -notificationRouter.patch("/:id/read", userAuth, writeLimiter, markAsRead); -notificationRouter.patch( - "/mark-all-read", - userAuth, - writeLimiter, - markAllAsRead, -); -notificationRouter.delete("/:id", userAuth, writeLimiter, deleteNotification); +notificationRouter.use(userAuth); + +notificationRouter.get("/", apiLimiter, getNotifications); +notificationRouter.get("/unread-count", apiLimiter, getUnreadCount); +notificationRouter.patch("/:id/read", writeLimiter, markAsRead); +notificationRouter.patch("/mark-all-read", writeLimiter, markAllAsRead); +notificationRouter.delete("/:id", writeLimiter, deleteNotification); export default notificationRouter; From 9aae9bf0528c675d13edbb2dc15fe2554293ef20 Mon Sep 17 00:00:00 2001 From: Mrunmayee Kokitkar Date: Wed, 8 Jul 2026 12:35:58 +0530 Subject: [PATCH 5/5] fix: Resolve CodeQL rate limiting alerts and sync Navbar with backend - Apply apiLimiter at router level in notificationRoutes for CodeQL detection - Update Navbar to fetch unread count and recent notifications from backend - Remove hardcoded mock notification data from Navbar - Add formatTimeAgo helper function for consistent timestamp display --- client/src/components/Navbar.jsx | 90 ++++++++++++++++++----------- server/routes/notificationRoutes.js | 6 +- 2 files changed, 60 insertions(+), 36 deletions(-) diff --git a/client/src/components/Navbar.jsx b/client/src/components/Navbar.jsx index 85e4b01..0088405 100644 --- a/client/src/components/Navbar.jsx +++ b/client/src/components/Navbar.jsx @@ -45,39 +45,63 @@ const Navbar = () => { setImgFailed(false); }, [userData?.profilePic]); - // Unread notifications mock state - const [unreadCount] = useState(3); - const [notifications] = 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(); diff --git a/server/routes/notificationRoutes.js b/server/routes/notificationRoutes.js index 5b6cca8..01256fe 100644 --- a/server/routes/notificationRoutes.js +++ b/server/routes/notificationRoutes.js @@ -12,10 +12,10 @@ import { const notificationRouter = express.Router(); -notificationRouter.use(userAuth); +notificationRouter.use(userAuth, apiLimiter); -notificationRouter.get("/", apiLimiter, getNotifications); -notificationRouter.get("/unread-count", apiLimiter, getUnreadCount); +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);