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}
-
+ onDismiss?.(toast.id)}
+ className="text-surface-400 hover:text-forest-600 transition-colors flex-shrink-0"
+ >
))}
);
-}
+}
\ 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 */}
-
+
);
}