feature/IO-3096-GlobalNotifications - Checkpoint - Notification Center

This commit is contained in:
Dave Richer
2025-02-24 18:04:15 -05:00
parent b395839b37
commit 4f1c0b9996
9 changed files with 275 additions and 48 deletions

View File

@@ -23,6 +23,8 @@ import InstanceRenderMgr from "../utils/instanceRenderMgr";
import ProductFruitsWrapper from "./ProductFruitsWrapper.jsx"; import ProductFruitsWrapper from "./ProductFruitsWrapper.jsx";
import { SocketProvider } from "../contexts/SocketIO/socketContext.jsx"; import { SocketProvider } from "../contexts/SocketIO/socketContext.jsx";
import { NotificationProvider } from "../contexts/Notifications/notificationContext.jsx"; import { NotificationProvider } from "../contexts/Notifications/notificationContext.jsx";
import { useSubscription, useApolloClient, gql } from "@apollo/client";
import { SUBSCRIBE_TO_NOTIFICATIONS } from "../graphql/notifications.queries.js";
const ResetPassword = lazy(() => import("../pages/reset-password/reset-password.component")); const ResetPassword = lazy(() => import("../pages/reset-password/reset-password.component"));
const ManagePage = lazy(() => import("../pages/manage/manage.page.container")); const ManagePage = lazy(() => import("../pages/manage/manage.page.container"));
@@ -46,6 +48,7 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
const client = useSplitClient().client; const client = useSplitClient().client;
const [listenersAdded, setListenersAdded] = useState(false); const [listenersAdded, setListenersAdded] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const apolloClient = useApolloClient();
useEffect(() => { useEffect(() => {
if (!navigator.onLine) { if (!navigator.onLine) {
@@ -104,6 +107,114 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
} }
}, [bodyshop, client, currentUser.authorized]); }, [bodyshop, client, currentUser.authorized]);
// Add subscription for all unread notifications with proper normalization and format
useSubscription(SUBSCRIBE_TO_NOTIFICATIONS, {
onData: ({ data }) => {
if (data.data?.notifications) {
console.log("Subscription data received (all unread):", data.data.notifications);
const newNotifs = data.data.notifications.filter(
(newNotif) =>
!apolloClient.cache
.readQuery({
query: gql`
query GetNotifications {
notifications(order_by: { created_at: desc }) {
id
__typename
}
}
`
})
?.notifications.some((n) => n.id === newNotif.id)
);
if (newNotifs.length === 0) return;
// Use writeQuery to normalize and add new notifications as References, matching cache format
apolloClient.cache.writeQuery({
query: gql`
query GetNotifications {
notifications(order_by: { created_at: desc }) {
__typename
id
jobid
associationid
scenario_text
fcm_text
scenario_meta
created_at
read
}
}
`,
data: {
notifications: [
...newNotifs.map((notif) => ({
...notif,
__typename: "notifications",
scenario_text: JSON.stringify(
typeof notif.scenario_text === "string" ? JSON.parse(notif.scenario_text) : notif.scenario_text || []
),
scenario_meta: JSON.stringify(
typeof notif.scenario_meta === "string" ? JSON.parse(notif.scenario_meta) : notif.scenario_meta || []
)
})),
...(apolloClient.cache.readQuery({
query: gql`
query GetNotifications {
notifications(order_by: { created_at: desc }) {
__typename
id
jobid
associationid
scenario_text
fcm_text
scenario_meta
created_at
read
}
}
`
})?.notifications || [])
].sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) // Sort by created_at desc
}
});
// Update notifications_aggregate for unread count
apolloClient.cache.modify({
id: "ROOT_QUERY",
fields: {
notifications_aggregate(existing = { aggregate: { count: 0 } }) {
const unreadCount = existing.aggregate.count + newNotifs.length;
console.log("Updating unread count from subscription:", unreadCount);
return {
...existing,
aggregate: {
...existing.aggregate,
count: unreadCount
}
};
}
},
optimistic: false
});
}
},
onError: (err) => {
console.error("Subscription error:", err);
// Fallback: Poll for all unread notifications if subscription fails
apolloClient
.query({
query: SUBSCRIBE_TO_NOTIFICATIONS,
variables: { where: { read: { _is_null: true } }, order_by: { created_at: "desc" } },
pollInterval: 30000
})
.catch((pollError) => console.error("Polling error:", pollError));
},
shouldResubscribe: true,
skip: !currentUser.authorized // Skip if user isnt authorized
});
if (currentUser.authorized === null) { if (currentUser.authorized === null) {
return <LoadingSpinner message={t("general.labels.loggingin")} />; return <LoadingSpinner message={t("general.labels.loggingin")} />;
} }

View File

@@ -137,12 +137,32 @@ function Header({
const [notificationVisible, setNotificationVisible] = useState(false); const [notificationVisible, setNotificationVisible] = useState(false);
const { data: unreadData } = useQuery(GET_UNREAD_COUNT, { const {
fetchPolicy: "cache-and-network" data: unreadData,
error: unreadError,
refetch: refetchUnread,
loading: unreadLoading
} = useQuery(GET_UNREAD_COUNT, {
fetchPolicy: "network-only", // Force network request for fresh data
pollInterval: 30000, // Poll every 30 seconds to ensure updates
onError: (err) => {
console.error("Error fetching unread count:", err);
console.log("Unread data state:", unreadData, "Error details:", err);
}
}); });
const unreadCount = unreadData?.notifications_aggregate?.aggregate?.count || 0; const unreadCount = unreadData?.notifications_aggregate?.aggregate?.count || 0;
// Refetch unread count when the component mounts, updates, or on specific events
useEffect(() => {
refetchUnread();
}, [refetchUnread, bodyshop, currentUser]); // Add dependencies to trigger refetch on user or shop changes
// Log unread count for debugging
useEffect(() => {
console.log("Unread count updated:", unreadCount, "Loading:", unreadLoading, "Error:", unreadError);
}, [unreadCount, unreadLoading, unreadError]);
const handleNotificationClick = (e) => { const handleNotificationClick = (e) => {
setNotificationVisible(!notificationVisible); setNotificationVisible(!notificationVisible);
if (handleMenuClick) handleMenuClick(e); if (handleMenuClick) handleMenuClick(e);
@@ -669,7 +689,7 @@ function Header({
{ {
key: "notifications", key: "notifications",
icon: ( icon: (
<Badge count={unreadCount} offset={[10, 0]}> <Badge count={unreadCount}>
<BellFilled /> <BellFilled />
</Badge> </Badge>
), ),

View File

@@ -22,7 +22,9 @@ const NotificationCenterComponent = ({
console.log(`Rendering notification ${index}:`, { console.log(`Rendering notification ${index}:`, {
id: notification.id, id: notification.id,
scenarioTextLength: notification.scenarioText.length, scenarioTextLength: notification.scenarioText.length,
key: `${notification.id}-${index}` read: notification.read,
created_at: notification.created_at,
associationid: notification.associationid // Log associationid for debugging
}); });
return ( return (
<List.Item <List.Item
@@ -45,11 +47,16 @@ const NotificationCenterComponent = ({
); );
}; };
console.log("Rendering NotificationCenter with notifications:", { console.log(
count: notifications.length, "Rendering NotificationCenter with notifications:",
ids: notifications.map((n) => n.id), notifications.length,
totalCount: notifications.length notifications.map((n) => ({
}); id: n.id,
read: n.read,
created_at: n.created_at,
associationid: n.associationid
}))
);
return ( return (
<div className={`notification-center ${visible ? "visible" : ""}`}> <div className={`notification-center ${visible ? "visible" : ""}`}>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { useQuery, useMutation } from "@apollo/client"; import { useQuery, useMutation } from "@apollo/client";
import { connect } from "react-redux"; import { connect } from "react-redux";
import NotificationCenterComponent from "./notification-center.component"; import NotificationCenterComponent from "./notification-center.component";
@@ -19,9 +19,9 @@ export function NotificationCenterContainer({ visible, onClose }) {
variables: { variables: {
limit: 20, limit: 20,
offset: 0, offset: 0,
where: showUnreadOnly ? { read: { _is_null: true } } : {} where: showUnreadOnly ? { read: { _is_null: true } } : {} // Default to all notifications
}, },
fetchPolicy: "cache-and-network", fetchPolicy: "cache-and-network", // Ensure reactivity to cache updates
notifyOnNetworkStatusChange: true, notifyOnNetworkStatusChange: true,
onError: (err) => { onError: (err) => {
setError(err.message); setError(err.message);
@@ -48,39 +48,69 @@ export function NotificationCenterContainer({ visible, onClose }) {
onError: (err) => setError(err.message) onError: (err) => setError(err.message)
}); });
// Remove refetchNotifications function and useEffect/context logic
useEffect(() => { useEffect(() => {
if (data?.notifications) { if (data?.notifications) {
const processedNotifications = data.notifications.map((notif) => { const processedNotifications = data.notifications.map((notif) => {
let scenarioText; let scenarioText;
let scenarioMeta;
try { try {
scenarioText = scenarioText =
typeof notif.scenario_text === "string" ? JSON.parse(notif.scenario_text) : notif.scenario_text || []; typeof notif.scenario_text === "string" ? JSON.parse(notif.scenario_text) : notif.scenario_text || [];
scenarioMeta =
typeof notif.scenario_meta === "string" ? JSON.parse(notif.scenario_meta) : notif.scenario_meta || [];
} catch (e) { } catch (e) {
console.error("Error parsing JSON for notification:", notif.id, e); console.error("Error parsing JSON for notification:", notif.id, e);
scenarioText = [notif.fcm_text || "Invalid notification data"]; scenarioText = [notif.fcm_text || "Invalid notification data"];
scenarioMeta = [];
} }
if (!Array.isArray(scenarioText)) scenarioText = [scenarioText]; if (!Array.isArray(scenarioText)) scenarioText = [scenarioText];
if (!Array.isArray(scenarioMeta)) scenarioMeta = [scenarioMeta];
console.log("Processed notification:", {
id: notif.id,
scenarioText,
scenarioMeta,
read: notif.read,
created_at: notif.created_at,
raw: notif // Log raw data for debugging
});
return { return {
id: notif.id, id: notif.id,
jobid: notif.jobid, jobid: notif.jobid,
associationid: notif.associationid, associationid: notif.associationid,
scenarioText, scenarioText,
scenarioMeta, // Add scenarioMeta for completeness (optional for rendering)
created_at: notif.created_at, created_at: notif.created_at,
read: notif.read, read: notif.read,
__typename: notif.__typename __typename: notif.__typename
}; };
}); });
console.log("Processed Notifications:", processedNotifications); console.log(
"Notifications data updated:",
data?.notifications?.map((n) => ({
id: n.id,
read: n.read,
created_at: n.created_at
}))
);
console.log(
"Processed Notifications:",
processedNotifications.map((n) => ({
id: n.id,
read: n.read,
created_at: n.created_at
}))
);
console.log("Number of notifications to render:", processedNotifications.length); console.log("Number of notifications to render:", processedNotifications.length);
setNotifications(processedNotifications); setNotifications(processedNotifications);
setError(null); setError(null);
} else { } else {
console.log("No data yet or error in data:", data, queryError); console.log("No data yet or error in data:", data, queryError);
} }
}, [data]); }, [data, queryError]);
useEffect(() => { useEffect(() => {
if (queryError || mutationError) { if (queryError || mutationError) {
@@ -88,7 +118,7 @@ export function NotificationCenterContainer({ visible, onClose }) {
} }
}, [queryError, mutationError]); }, [queryError, mutationError]);
const loadMore = () => { const loadMore = useCallback(() => {
if (!loading && data?.notifications.length) { if (!loading && data?.notifications.length) {
fetchMore({ fetchMore({
variables: { offset: data.notifications.length }, variables: { offset: data.notifications.length },
@@ -100,7 +130,7 @@ export function NotificationCenterContainer({ visible, onClose }) {
} }
}).catch((err) => setError(err.message)); }).catch((err) => setError(err.message));
} }
}; }, [data?.notifications?.length, fetchMore, loading]);
const handleToggleUnreadOnly = (value) => { const handleToggleUnreadOnly = (value) => {
setShowUnreadOnly(value); setShowUnreadOnly(value);
@@ -122,11 +152,20 @@ export function NotificationCenterContainer({ visible, onClose }) {
window.addEventListener("scroll", handleScroll); window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll); return () => window.removeEventListener("scroll", handleScroll);
} }
}, [visible, loading, data]); }, [visible, loading, data, loadMore]);
console.log("Rendering NotificationCenter with notifications:", notifications.length, notifications); console.log(
"Rendering NotificationCenter with notifications:",
notifications.length,
notifications.map((n) => ({
id: n.id,
read: n.read,
created_at: n.created_at
}))
);
return ( return (
// Remove NotificationContext.Provider
<NotificationCenterComponent <NotificationCenterComponent
visible={visible} visible={visible}
onClose={onClose} onClose={onClose}

View File

@@ -4,7 +4,8 @@ import { auth } from "../../firebase/firebase.utils";
import { store } from "../../redux/store"; import { store } from "../../redux/store";
import { addAlerts, setWssStatus } from "../../redux/application/application.actions"; import { addAlerts, setWssStatus } from "../../redux/application/application.actions";
import client from "../../utils/GraphQLClient"; import client from "../../utils/GraphQLClient";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../Notifications/notificationContext.jsx";
import { gql } from "@apollo/client";
const useSocket = (bodyshop) => { const useSocket = (bodyshop) => {
const socketRef = useRef(null); const socketRef = useRef(null);
@@ -79,39 +80,88 @@ const useSocket = (bodyshop) => {
}; };
const handleNotification = (data) => { const handleNotification = (data) => {
const { jobId, bodyShopId, notifications, notificationId } = data; const { jobId, bodyShopId, notificationId, associationId, notifications } = data; // Changed to associationId (capital I)
console.log("handleNotification - Received", { jobId, bodyShopId, notificationId, notifications }); console.log("handleNotification - Received", {
jobId,
bodyShopId,
notificationId,
associationId,
notifications
});
// Construct the notification object to match the cache/database format
const newNotification = { const newNotification = {
__typename: "notifications",
id: notificationId, id: notificationId,
jobid: jobId, jobid: jobId,
associationid: null, associationid: associationId || null, // Use associationId from socket, default to null if missing
scenario_text: notifications.map((notif) => notif.body), scenario_text: JSON.stringify(notifications.map((notif) => notif.body)), // Stringify as in the cache
fcm_text: notifications.map((notif) => notif.body).join(". ") + ".", fcm_text: notifications.map((notif) => notif.body).join(". ") + ".",
scenario_meta: notifications.map((notif) => notif.variables || {}), scenario_meta: JSON.stringify(notifications.map((notif) => notif.variables || {})), // Stringify as in the cache
created_at: new Date(notifications[0].timestamp).toISOString(), created_at: new Date(notifications[0].timestamp).toISOString(),
read: null, read: null // Assume unread unless specified otherwise
__typename: "notifications"
}; };
try { try {
// Use writeQuery to add the new notification to the cache, ensuring normalization and broadcasting
client.cache.writeQuery({
query: gql`
query GetNotifications {
notifications(order_by: { created_at: desc }) {
__typename
id
jobid
associationid
scenario_text
fcm_text
scenario_meta
created_at
read
}
}
`,
data: {
notifications: [
newNotification,
...(
client.cache.readQuery({
query: gql`
query GetNotifications {
notifications(order_by: { created_at: desc }) {
__typename
id
jobid
associationid
scenario_text
fcm_text
scenario_meta
created_at
read
}
}
`
})?.notifications || []
).filter((n) => n.id !== newNotification.id)
].sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) // Sort by created_at desc
},
broadcast: true // Notify dependent queries of the cache update
});
console.log("Cache updated with new notification:", newNotification);
// Update notifications_aggregate for unread count
client.cache.modify({ client.cache.modify({
id: "ROOT_QUERY", id: "ROOT_QUERY",
fields: { fields: {
notifications(existing = []) {
if (existing.some((n) => n.id === newNotification.id)) return existing;
console.log("Adding to cache", newNotification);
return [newNotification, ...existing];
},
notifications_aggregate(existing = { aggregate: { count: 0 } }) { notifications_aggregate(existing = { aggregate: { count: 0 } }) {
// Fallback if aggregate isnt in schema yet const isUnread = newNotification.read === null;
if (!existing) return existing; const countChange = isUnread ? 1 : 0;
console.log("Updating unread count", existing.aggregate.count + 1); console.log("Updating unread count from socket:", existing.aggregate.count + countChange);
return { return {
...existing, ...existing,
aggregate: { aggregate: {
...existing.aggregate, ...existing.aggregate,
count: existing.aggregate.count + 1 count: existing.aggregate.count + countChange
} }
}; };
} }
@@ -183,7 +233,7 @@ const useSocket = (bodyshop) => {
socketRef.current = null; socketRef.current = null;
} }
}; };
}, [bodyshop, client.cache]); }, [bodyshop, notification]);
return { socket: socketRef.current, clientId }; return { socket: socketRef.current, clientId };
}; };

View File

@@ -35,7 +35,7 @@ export const MARK_ALL_NOTIFICATIONS_READ = gql`
export const SUBSCRIBE_TO_NOTIFICATIONS = gql` export const SUBSCRIBE_TO_NOTIFICATIONS = gql`
subscription SubscribeToNotifications { subscription SubscribeToNotifications {
notifications(order_by: { created_at: desc }, limit: 1) { notifications(where: { read: { _is_null: true } }, order_by: { created_at: desc }) {
id id
jobid jobid
associationid associationid

View File

@@ -3776,7 +3776,11 @@
"add-watchers": "Add Watchers", "add-watchers": "Add Watchers",
"employee-search": "Search for an Employee", "employee-search": "Search for an Employee",
"teams-search": "Search for a Team", "teams-search": "Search for a Team",
"add-watchers-team": "Add Team Members" "add-watchers-team": "Add Team Members",
"new-notification-title": "New Notification:",
"show-unread-only": "Show Unread",
"mark-all-read": "Mark Read",
"loading": "Loading Notifications..."
}, },
"actions": { "actions": {
"remove": "remove" "remove": "remove"

View File

@@ -4940,6 +4940,7 @@
_eq: X-Hasura-User-Id _eq: X-Hasura-User-Id
- active: - active:
_eq: true _eq: true
allow_aggregations: true
comment: "" comment: ""
update_permissions: update_permissions:
- role: user - role: user

View File

@@ -200,25 +200,20 @@ const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
// Emit notifications to users via Socket.io with notification ID // Emit notifications to users via Socket.io with notification ID
for (const [user, bodyShopData] of Object.entries(allNotifications)) { for (const [user, bodyShopData] of Object.entries(allNotifications)) {
const userMapping = await redisHelpers.getUserSocketMapping(user); const userMapping = await redisHelpers.getUserSocketMapping(user);
logger.logger.debug(`User socket mapping for ${user}: ${JSON.stringify(userMapping)}`); // Get all recipients for the user and extract the associationId (employeeId)
const userRecipients = recipients.filter((r) => r.user === user);
const associationId = userRecipients[0]?.employeeId;
for (const [bodyShopId, notifications] of Object.entries(bodyShopData)) { for (const [bodyShopId, notifications] of Object.entries(bodyShopData)) {
const notificationId = notificationIdMap.get(`${user}:${bodyShopId}`); const notificationId = notificationIdMap.get(`${user}:${bodyShopId}`);
if (userMapping && userMapping[bodyShopId]?.socketIds) { if (userMapping && userMapping[bodyShopId]?.socketIds) {
userMapping[bodyShopId].socketIds.forEach((socketId) => { userMapping[bodyShopId].socketIds.forEach((socketId) => {
logger.logger.debug(
`Emitting to socket ${socketId}: ${JSON.stringify({
jobId,
bodyShopId,
notifications,
notificationId
})}`
);
ioRedis.to(socketId).emit("notification", { ioRedis.to(socketId).emit("notification", {
jobId, jobId,
bodyShopId, bodyShopId,
notifications, notifications,
notificationId notificationId,
associationId // now included in the emit payload
}); });
}); });
logger.logger.info( logger.logger.info(