mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 09:00:57 +00:00
feat: setup notification to the portal
This commit is contained in:
@@ -34,6 +34,7 @@
|
||||
"react-phone-number-input": "^3.4.17",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"recharts": "^3.8.1",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.0"
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
Bell,
|
||||
ChevronDown,
|
||||
FileSignature,
|
||||
LogOut,
|
||||
@@ -40,6 +39,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
||||
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
|
||||
|
||||
export interface SidebarItem {
|
||||
label: string;
|
||||
@@ -353,25 +353,8 @@ export function AppLayout({
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* Bell */}
|
||||
<Box style={{ position: "relative" }}>
|
||||
<UnstyledButton style={islandStyle} aria-label="Notifications">
|
||||
<Bell size={17} color={textColor} strokeWidth={1.8} />
|
||||
</UnstyledButton>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 7,
|
||||
right: 7,
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: accentColor,
|
||||
border: "1.5px solid #fff",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{/* Notifications */}
|
||||
<NotificationBellContainer />
|
||||
|
||||
{enableThemeToggle && (
|
||||
<UnstyledButton
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NotificationBell, type NotificationBellItem } from "@edr/ui-common";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
useMarkAllRead,
|
||||
useMarkRead,
|
||||
useNotificationsList,
|
||||
useUnreadCount,
|
||||
} from "./useNotifications";
|
||||
import { useNotificationSocket } from "./useNotificationSocket";
|
||||
|
||||
/**
|
||||
* Wires react-query + the notification WebSocket into the shared presentational
|
||||
* bell. Mount inside an authenticated layout.
|
||||
*/
|
||||
export default function NotificationBellContainer({
|
||||
enabled = true,
|
||||
}: {
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const list = useNotificationsList(enabled);
|
||||
const unread = useUnreadCount(enabled);
|
||||
const markRead = useMarkRead();
|
||||
const markAllRead = useMarkAllRead();
|
||||
useNotificationSocket(enabled);
|
||||
|
||||
const items: NotificationBellItem[] = (list.data?.items ?? []).map((n) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
body: n.body,
|
||||
createdAt: n.createdAt,
|
||||
isRead: n.isRead,
|
||||
link: n.link,
|
||||
}));
|
||||
|
||||
return (
|
||||
<NotificationBell
|
||||
items={items}
|
||||
unreadCount={unread.data ?? list.data?.unreadCount ?? 0}
|
||||
loading={list.isLoading}
|
||||
onOpen={() => {
|
||||
void list.refetch();
|
||||
void unread.refetch();
|
||||
}}
|
||||
onItemClick={(item) => {
|
||||
if (!item.isRead) markRead.mutate(item.id);
|
||||
if (item.link) navigate(item.link);
|
||||
}}
|
||||
onMarkAllRead={() => markAllRead.mutate()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { NotificationListResult } from "@edr/types";
|
||||
|
||||
import { client } from "@/utils/api";
|
||||
|
||||
export interface ListNotificationsParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
isRead?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Portal notification REST calls. The portal axios `client` returns the raw
|
||||
* response, and the API wraps payloads in a `{ success, data }` envelope — so we
|
||||
* unwrap `.data.data` here (same convention as the other portal services).
|
||||
*/
|
||||
export const notificationsApi = {
|
||||
list: async (
|
||||
params: ListNotificationsParams = {},
|
||||
): Promise<NotificationListResult> => {
|
||||
const { data } = await client.get("/notifications", { params });
|
||||
return data.data;
|
||||
},
|
||||
unreadCount: async (): Promise<number> => {
|
||||
const { data } = await client.get("/notifications/unread-count");
|
||||
return data.data.unreadCount;
|
||||
},
|
||||
markRead: async (id: string): Promise<void> => {
|
||||
await client.patch(`/notifications/${id}/read`);
|
||||
},
|
||||
markAllRead: async (): Promise<void> => {
|
||||
await client.post("/notifications/read-all");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
NOTIFICATION_WS_EVENTS,
|
||||
NOTIFICATION_WS_NAMESPACE,
|
||||
type NotificationDto,
|
||||
} from "@edr/types";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { io } from "socket.io-client";
|
||||
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
|
||||
import { NOTIFICATIONS_KEY, UNREAD_KEY } from "./useNotifications";
|
||||
|
||||
function getAuthToken(): string | undefined {
|
||||
return document.cookie
|
||||
.split("; ")
|
||||
.find((row) => row.startsWith("auth-token="))
|
||||
?.split("=")[1];
|
||||
}
|
||||
|
||||
// The socket namespace lives at the server root, not under the `/api` REST
|
||||
// prefix — strip a trailing `/api` if the base URL carries one.
|
||||
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
|
||||
|
||||
/**
|
||||
* Subscribes to live notification pushes for the signed-in user. New items
|
||||
* invalidate the list + toast; unread-count pushes update the badge instantly.
|
||||
*/
|
||||
export function useNotificationSocket(enabled: boolean) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const token = getAuthToken();
|
||||
if (!token) return;
|
||||
|
||||
const socket = io(`${SOCKET_ORIGIN}/${NOTIFICATION_WS_NAMESPACE}`, {
|
||||
auth: { token },
|
||||
transports: ["websocket"],
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
socket.on(NOTIFICATION_WS_EVENTS.NEW, (n: NotificationDto) => {
|
||||
qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY });
|
||||
toast(n.title);
|
||||
});
|
||||
|
||||
socket.on(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, (count: number) => {
|
||||
if (typeof count === "number") qc.setQueryData(UNREAD_KEY, count);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.off();
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [enabled, qc]);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { notificationsApi } from "./notificationsApi";
|
||||
|
||||
export const NOTIFICATIONS_KEY = ["notifications"] as const;
|
||||
export const UNREAD_KEY = ["notifications", "unread"] as const;
|
||||
|
||||
export function useNotificationsList(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: [...NOTIFICATIONS_KEY, "list"],
|
||||
queryFn: () => notificationsApi.list({ page: 1, limit: 20 }),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnreadCount(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: UNREAD_KEY,
|
||||
queryFn: () => notificationsApi.unreadCount(),
|
||||
enabled,
|
||||
// WebSocket keeps this fresh; poll as a fallback if the socket drops.
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkRead() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => notificationsApi.markRead(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkAllRead() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => notificationsApi.markAllRead(),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }),
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import "@mantine/dates/styles.css";
|
||||
import "@edr/ui-common/styles.css";
|
||||
import "../index.css";
|
||||
import "@edr/ui-common/theme.css";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { mantineTheme } from "./theme/mantine";
|
||||
|
||||
import App from "./App";
|
||||
@@ -39,6 +40,7 @@ createRoot(document.getElementById("root")!).render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<Toaster position="top-right" />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</MantineProvider>
|
||||
|
||||
Reference in New Issue
Block a user