Skip to content
2 changes: 1 addition & 1 deletion backend/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" }));

Expand Down
11 changes: 11 additions & 0 deletions backend/src/modules/expenses/expenses.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
Comment on lines +101 to +109

return ok(res, 201, created);
} catch (error) {
await client.query("ROLLBACK");
Expand Down
2 changes: 1 addition & 1 deletion backend/src/modules/expenses/expenses.validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}),
});

Expand Down
7 changes: 7 additions & 0 deletions backend/src/modules/notifications/notifications.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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] });
});
Comment on lines +9 to +14
7 changes: 7 additions & 0 deletions backend/src/modules/notifications/notifications.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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],
);
},
};
6 changes: 5 additions & 1 deletion backend/src/modules/notifications/notifications.routes.js
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions frontend/src/app/router.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ function RoleHomeRedirect() {

if (role === "admin") return <Navigate to="/admin" replace />;
if (role === "manager") return <Navigate to="/manager" replace />;
if (role === "finance") return <Navigate to="/manager" replace />;
if (role === "director") return <Navigate to="/manager" replace />;
if (role === "employee") return <Navigate to="/employee" replace />;
return <Navigate to="/login" replace />;
}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/layout/AppLayout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -153,7 +153,7 @@ export default function AppLayout() {
</div>

{/* Toast Stack */}
<ToastStack items={notifications} />
<ToastStack items={toasts} onDismiss={dismiss} />
</div>
);
}
75 changes: 60 additions & 15 deletions frontend/src/context/NotificationContext.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<NotificationContext.Provider value={value}>
Expand All @@ -33,4 +78,4 @@ export function NotificationProvider({ children }) {

export function useNotifications() {
return useContext(NotificationContext);
}
}
50 changes: 17 additions & 33 deletions frontend/src/features/notifications/components/NotificationBell.jsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 (
<div className="relative" ref={ref}>
<button
Expand All @@ -52,53 +43,46 @@ export default function NotificationBell() {
style={{ boxShadow: "0 8px 32px rgba(26, 77, 46, 0.12)" }}
>
<div className="px-5 py-4 flex items-center justify-between">
<h4 className="font-manrope font-bold text-sm text-forest-900">
Notifications
</h4>
<h4 className="font-manrope font-bold text-sm text-forest-900">Notifications</h4>
<span className="text-xs text-surface-400">{unreadCount} new</span>
</div>

<div className="max-h-72 overflow-y-auto">
{items.map((item) => {
{notifications.length === 0 && (
<p className="px-5 py-6 text-sm text-surface-400 text-center">No notifications yet.</p>
)}
{notifications.map((item) => {
const Icon = ICON_MAP[item.type] || Bell;
return (
<div
key={item.id}
onClick={() => markRead(item.id)}
className={`px-5 py-3 flex items-start gap-3 cursor-pointer transition-colors hover:bg-surface-50 ${
!item.read ? "bg-neon-50/30" : ""
!item.is_read ? "bg-neon-50/30" : ""
}`}
>
<div
className={`p-2 rounded-xl flex-shrink-0 ${
item.type === "SOCKET_UPDATE"
? "bg-neon-50 text-forest-500"
: "bg-surface-100 text-surface-500"
item.type === "SOCKET_UPDATE" ? "bg-neon-50 text-forest-500" : "bg-surface-100 text-surface-500"
}`}
>
<Icon className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-forest-900 leading-snug">
{item.title}
<p className="text-sm text-forest-900 leading-snug">{item.title}</p>
<p className="text-xs text-surface-400 mt-1">
{new Date(item.created_at).toLocaleString()}
</p>
<p className="text-xs text-surface-400 mt-1">{item.time}</p>
</div>
{!item.read && (
{!item.is_read && (
<div className="w-2 h-2 rounded-full bg-neon flex-shrink-0 mt-2" />
)}
</div>
);
})}
</div>

<div className="px-5 py-3 text-center">
<button className="text-xs text-forest-500 font-semibold hover:text-neon-700 transition-colors">
View All Notifications
</button>
</div>
</div>
)}
</div>
);
}
}
9 changes: 6 additions & 3 deletions frontend/src/features/notifications/components/ToastStack.jsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -20,11 +20,14 @@ export default function ToastStack({ items = [] }) {
</p>
<p className="text-sm text-forest-900">{toast.title}</p>
</div>
<button className="text-surface-400 hover:text-forest-600 transition-colors flex-shrink-0">
<button
onClick={() => onDismiss?.(toast.id)}
className="text-surface-400 hover:text-forest-600 transition-colors flex-shrink-0"
>
Comment on lines +23 to +26
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
);
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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;
}
16 changes: 10 additions & 6 deletions frontend/src/pages/LoginPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Comment on lines 16 to +20
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();
Expand All @@ -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(
Expand Down