From 28424707f479ea8976ed00a1f9bceefbe38b3236 Mon Sep 17 00:00:00 2001 From: Aditya Valsangkar Date: Sat, 8 Aug 2026 22:40:23 +0530 Subject: [PATCH 1/8] fix(server): reorder CORS middleware before rate limiter for correct error responses --- backend/src/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/app.js b/backend/src/app.js index 05b0d58..62aa382 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -12,8 +12,8 @@ import { env } from "./config/env.js"; const app = express(); app.use(helmet()); app.use(compression()); -app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100 })); app.use(cors({ origin: env.FRONTEND_URL })); +app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100 })); app.use(morgan("dev")); app.use(express.json({ limit: "5mb" })); From affa6885046abb4937982ce8e8584e9cbb3bfa87 Mon Sep 17 00:00:00 2001 From: Aditya Valsangkar Date: Sat, 8 Aug 2026 22:40:43 +0530 Subject: [PATCH 2/8] feat(expenses): notify first approver when expense is submitted --- backend/src/modules/expenses/expenses.controller.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/backend/src/modules/expenses/expenses.controller.js b/backend/src/modules/expenses/expenses.controller.js index 2f47672..efb7c1e 100644 --- a/backend/src/modules/expenses/expenses.controller.js +++ b/backend/src/modules/expenses/expenses.controller.js @@ -97,6 +97,17 @@ export async function createExpense(req, res) { await client.query("COMMIT"); const created = await expensesModel.getExpenseWithSteps(expense.id); + + const firstApprover = await getCurrentPendingApprover(expense.id, pool); + if (firstApprover) { + const note = await notificationsModel.create({ + userId: firstApprover.id, + title: "New expense pending your approval", + body: `${submitter.name || "An employee"} submitted a new expense for review.`, + }); + notifyUser(firstApprover.id, note.rows[0]); + } + return ok(res, 201, created); } catch (error) { await client.query("ROLLBACK"); From 51c7baaa00d4f3e445b1ed28a85a586e1eb2afb2 Mon Sep 17 00:00:00 2001 From: Aditya Valsangkar Date: Sat, 8 Aug 2026 22:41:02 +0530 Subject: [PATCH 3/8] fix(expenses): allow nullable receipt_url in create expense schema --- backend/src/modules/expenses/expenses.validator.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/modules/expenses/expenses.validator.js b/backend/src/modules/expenses/expenses.validator.js index 2602cc2..a6e4e92 100644 --- a/backend/src/modules/expenses/expenses.validator.js +++ b/backend/src/modules/expenses/expenses.validator.js @@ -9,7 +9,7 @@ export const createExpenseSchema = z.object({ category: z.enum(ALLOWED_CATEGORIES), vendor: z.string().optional(), description: z.string().optional(), - receipt_url: z.string().url().optional(), + receipt_url: z.string().url().nullable().optional(), }), }); From f1e15d783ae6842578d18bd9c34fe172bfad202d Mon Sep 17 00:00:00 2001 From: Aditya Valsangkar Date: Sat, 8 Aug 2026 22:41:38 +0530 Subject: [PATCH 4/8] feat(notifications): add mark-as-read endpoint with company-scoped ownership check --- .../src/modules/notifications/notifications.controller.js | 7 +++++++ backend/src/modules/notifications/notifications.model.js | 7 +++++++ backend/src/modules/notifications/notifications.routes.js | 6 +++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/backend/src/modules/notifications/notifications.controller.js b/backend/src/modules/notifications/notifications.controller.js index f2930e0..9ca7433 100644 --- a/backend/src/modules/notifications/notifications.controller.js +++ b/backend/src/modules/notifications/notifications.controller.js @@ -5,3 +5,10 @@ export const myNotifications = asyncHandler(async (req, res) => { const rows = await notificationsModel.listByUser(req.user.id); res.json({ notifications: rows.rows }); }); + +export const markNotificationRead = asyncHandler(async (req, res) => { + const result = await notificationsModel.markRead(req.params.id, req.user.id); + if (!result.rows[0]) + return res.status(404).json({ message: "Notification not found" }); + res.json({ notification: result.rows[0] }); +}); diff --git a/backend/src/modules/notifications/notifications.model.js b/backend/src/modules/notifications/notifications.model.js index 5471f4a..b68bc7b 100644 --- a/backend/src/modules/notifications/notifications.model.js +++ b/backend/src/modules/notifications/notifications.model.js @@ -14,4 +14,11 @@ export const notificationsModel = { [userId, title, body], ); }, + + markRead(id, userID) { + return query( + "UPDATE notifications SET is_read = true WHERE id = $1 AND user_id = $2 RETURNING id, title, body, is_read, created_at", + [id, userID], + ); + }, }; diff --git a/backend/src/modules/notifications/notifications.routes.js b/backend/src/modules/notifications/notifications.routes.js index 68b6fd9..df11a70 100644 --- a/backend/src/modules/notifications/notifications.routes.js +++ b/backend/src/modules/notifications/notifications.routes.js @@ -1,9 +1,13 @@ import { Router } from "express"; import { authenticate } from "../../middleware/authenticate.js"; -import { myNotifications } from "./notifications.controller.js"; +import { + myNotifications, + markNotificationRead, +} from "./notifications.controller.js"; const router = Router(); router.use(authenticate); router.get("/me", myNotifications); +router.patch("/:id/read", markNotificationRead); export default router; From 774b2376bff74019cff054fdf274d6ed64a3bff7 Mon Sep 17 00:00:00 2001 From: Aditya Valsangkar Date: Sat, 8 Aug 2026 22:42:01 +0530 Subject: [PATCH 5/8] fix(routes): add finance/director role handling to home redirect --- frontend/src/app/router.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/app/router.jsx b/frontend/src/app/router.jsx index bdde82f..8d6f2cd 100644 --- a/frontend/src/app/router.jsx +++ b/frontend/src/app/router.jsx @@ -18,6 +18,8 @@ function RoleHomeRedirect() { if (role === "admin") return ; if (role === "manager") return ; + if (role === "finance") return ; + if (role === "director") return ; if (role === "employee") return ; return ; } From 910672dcfc33cd6f527ae007d569a59fb0bafaf2 Mon Sep 17 00:00:00 2001 From: Aditya Valsangkar Date: Sat, 8 Aug 2026 22:42:19 +0530 Subject: [PATCH 6/8] fix(auth): add finance/director redirect branches on login --- frontend/src/pages/LoginPage.jsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index bb6fabe..e0e3689 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -14,13 +14,15 @@ export default function LoginPage() { const [error, setError] = useState(""); useEffect(() => { - if (!isAuthenticated) return; + if (!isAuthenticated) return; - const role = String(user?.role || "").toLowerCase(); - if (role === "admin") navigate("/admin", { replace: true }); - else if (role === "manager") navigate("/manager", { replace: true }); - else navigate("/employee", { replace: true }); - }, [isAuthenticated, user?.role, navigate]); + const role = String(user?.role || "").toLowerCase(); + if (role === "admin") navigate("/admin", { replace: true }); + else if (role === "manager") navigate("/manager", { replace: true }); + else if (role === "finance") navigate("/manager", { replace: true }); + else if (role === "director") navigate("/manager", { replace: true }); + else navigate("/employee", { replace: true }); +}, [isAuthenticated, user?.role, navigate]); async function onSubmit(e) { e.preventDefault(); @@ -35,6 +37,8 @@ export default function LoginPage() { if (role === "admin") navigate("/admin", { replace: true }); else if (role === "manager") navigate("/manager", { replace: true }); + else if (role === "finance") navigate("/manager", { replace: true }); + else if (role === "director") navigate("/manager", { replace: true }); else navigate("/employee", { replace: true }); } catch (err) { setError( From 2c45312dede84d0c8886220461f95fbec8b40ccf Mon Sep 17 00:00:00 2001 From: Aditya Valsangkar Date: Sat, 8 Aug 2026 22:42:48 +0530 Subject: [PATCH 7/8] feat(notifications): wire real-time socket + REST notifications to bell and toast UI --- frontend/src/context/NotificationContext.jsx | 75 +++++++++++++++---- .../components/NotificationBell.jsx | 50 +++++-------- .../notifications/components/ToastStack.jsx | 9 ++- .../hooks/useRealtimeNotifications.js | 15 ---- .../services/notifications.api.js | 9 ++- 5 files changed, 90 insertions(+), 68 deletions(-) delete mode 100644 frontend/src/features/notifications/hooks/useRealtimeNotifications.js diff --git a/frontend/src/context/NotificationContext.jsx b/frontend/src/context/NotificationContext.jsx index f443d10..65f416d 100644 --- a/frontend/src/context/NotificationContext.jsx +++ b/frontend/src/context/NotificationContext.jsx @@ -1,28 +1,73 @@ -import { createContext, useCallback, useContext, useMemo, useState } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { getSocket } from "../services/websocketClient.js"; +import { myNotificationsApi, markNotificationReadApi } from "../features/notifications/services/notifications.api.js"; +import { useAuth } from "./AuthContext.jsx"; -const NotificationContext = createContext({ push: () => {}, notifications: [], dismiss: () => {} }); +const NotificationContext = createContext({ + notifications: [], + toasts: [], + push: () => {}, + dismiss: () => {}, + markRead: () => {}, +}); export function NotificationProvider({ children }) { + const { user } = useAuth(); const [notifications, setNotifications] = useState([]); + const [toasts, setToasts] = useState([]); - const push = useCallback((notification) => { - const id = Date.now() + Math.random(); + // Toast (auto-dismiss) queue + const pushToast = useCallback((notification) => { + const id = notification.id ?? Date.now() + Math.random(); const item = { id, ...notification, createdAt: Date.now() }; - setNotifications((prev) => [item, ...prev]); - - // Auto-dismiss after 5 seconds - if (notification.autoDismiss !== false) { - setTimeout(() => { - setNotifications((prev) => prev.filter((n) => n.id !== id)); - }, 5000); - } + setToasts((prev) => [item, ...prev]); + setTimeout(() => { + setToasts((prev) => prev.filter((n) => n.id !== id)); + }, 5000); }, []); const dismiss = useCallback((id) => { - setNotifications((prev) => prev.filter((n) => n.id !== id)); + setToasts((prev) => prev.filter((n) => n.id !== id)); + }, []); + + // Initial load from REST + useEffect(() => { + if (!user?.id) return; + myNotificationsApi() + .then((data) => setNotifications(data.notifications || [])) + .catch((err) => console.error("Failed to load notifications:", err)); + }, [user?.id]); + + // Socket subscription + useEffect(() => { + if (!user?.id) return; + const socket = getSocket(); + socket.emit("join:user", user.id); + + function handleNew(payload) { + setNotifications((prev) => [payload, ...prev]); + pushToast(payload); + } + + socket.on("notification:new", handleNew); + return () => socket.off("notification:new", handleNew); + }, [user?.id, pushToast]); + + const markRead = useCallback(async (id) => { + setNotifications((prev) => + prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)), + ); + try { + await markNotificationReadApi(id); + } catch (err) { + console.error("Failed to mark notification read:", err); + } }, []); - const value = useMemo(() => ({ notifications, push, dismiss }), [notifications, push, dismiss]); + const value = useMemo( + () => ({ notifications, toasts, push: pushToast, dismiss, markRead }), + [notifications, toasts, pushToast, dismiss, markRead], + ); return ( @@ -33,4 +78,4 @@ export function NotificationProvider({ children }) { export function useNotifications() { return useContext(NotificationContext); -} +} \ No newline at end of file diff --git a/frontend/src/features/notifications/components/NotificationBell.jsx b/frontend/src/features/notifications/components/NotificationBell.jsx index c54c881..e0878d4 100644 --- a/frontend/src/features/notifications/components/NotificationBell.jsx +++ b/frontend/src/features/notifications/components/NotificationBell.jsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect } from "react"; -import { Bell, Zap, FileText, BarChart3, X } from "lucide-react"; -import { MOCK_NOTIFICATIONS } from "../../../utils/mockData.js"; +import { Bell, Zap, FileText, BarChart3 } from "lucide-react"; +import { useNotifications } from "../../../context/NotificationContext.jsx"; const ICON_MAP = { SOCKET_UPDATE: Zap, @@ -10,28 +10,19 @@ const ICON_MAP = { export default function NotificationBell() { const [open, setOpen] = useState(false); - const [items, setItems] = useState(MOCK_NOTIFICATIONS); + const { notifications, markRead } = useNotifications(); const ref = useRef(null); - const unreadCount = items.filter((n) => !n.read).length; + const unreadCount = notifications.filter((n) => !n.is_read).length; - // Close on outside click useEffect(() => { function handleClick(e) { - if (ref.current && !ref.current.contains(e.target)) { - setOpen(false); - } + if (ref.current && !ref.current.contains(e.target)) setOpen(false); } document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); }, []); - function markRead(id) { - setItems((prev) => - prev.map((n) => (n.id === id ? { ...n, read: true } : n)) - ); - } - return (
-
)} ); -} +} \ No newline at end of file diff --git a/frontend/src/features/notifications/components/ToastStack.jsx b/frontend/src/features/notifications/components/ToastStack.jsx index 6ab6357..966de44 100644 --- a/frontend/src/features/notifications/components/ToastStack.jsx +++ b/frontend/src/features/notifications/components/ToastStack.jsx @@ -1,6 +1,6 @@ import { X, Zap } from "lucide-react"; -export default function ToastStack({ items = [] }) { +export default function ToastStack({ items = [], onDismiss }) { if (!items || items.length === 0) return null; return ( @@ -20,11 +20,14 @@ export default function ToastStack({ items = [] }) {

{toast.title}

- ))} ); -} +} \ No newline at end of file diff --git a/frontend/src/features/notifications/hooks/useRealtimeNotifications.js b/frontend/src/features/notifications/hooks/useRealtimeNotifications.js deleted file mode 100644 index e89b155..0000000 --- a/frontend/src/features/notifications/hooks/useRealtimeNotifications.js +++ /dev/null @@ -1,15 +0,0 @@ -import { useEffect, useState } from "react"; -import { getSocket } from "../../../services/websocketClient.js"; - -export function useRealtimeNotifications() { - const [notifications, setNotifications] = useState([]); - - useEffect(() => { - const socket = getSocket(); - socket.emit("join:user", 1); - socket.on("notification:new", (payload) => setNotifications((prev) => [payload, ...prev])); - return () => socket.off("notification:new"); - }, []); - - return notifications; -} diff --git a/frontend/src/features/notifications/services/notifications.api.js b/frontend/src/features/notifications/services/notifications.api.js index 738f370..1b37c18 100644 --- a/frontend/src/features/notifications/services/notifications.api.js +++ b/frontend/src/features/notifications/services/notifications.api.js @@ -1,6 +1,11 @@ import apiClient from "../../../services/apiClient.js"; export async function myNotificationsApi() { - const { data } = await apiClient.get("/notifications/me"); - return data; + const { data } = await apiClient.get("/notifications/me"); + return data; +} + +export async function markNotificationReadApi(id) { + const { data } = await apiClient.patch(`/notifications/${id}/read`); + return data; } From 3934356f9ec23511f9d4f98088f598cd3c00cdde Mon Sep 17 00:00:00 2001 From: Aditya Valsangkar Date: Sat, 8 Aug 2026 22:43:10 +0530 Subject: [PATCH 8/8] feat(layout): wire toast stack to notification context, update admin nav label --- frontend/src/components/layout/AppLayout.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/layout/AppLayout.jsx b/frontend/src/components/layout/AppLayout.jsx index 9573fb5..184a582 100644 --- a/frontend/src/components/layout/AppLayout.jsx +++ b/frontend/src/components/layout/AppLayout.jsx @@ -34,7 +34,7 @@ const NAV_ITEMS = [ export default function AppLayout() { const { user, logout, switchRole } = useAuthContext(); - const { notifications } = useNotifications(); + const { toasts, dismiss } = useNotifications(); const location = useLocation(); const visibleNav = NAV_ITEMS.filter( @@ -153,7 +153,7 @@ export default function AppLayout() { {/* Toast Stack */} - + ); }