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 { SocketProvider } from "../contexts/SocketIO/socketContext.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 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 [listenersAdded, setListenersAdded] = useState(false);
const { t } = useTranslation();
const apolloClient = useApolloClient();
useEffect(() => {
if (!navigator.onLine) {
@@ -104,6 +107,114 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
}
}, [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) {
return <LoadingSpinner message={t("general.labels.loggingin")} />;
}

View File

@@ -137,12 +137,32 @@ function Header({
const [notificationVisible, setNotificationVisible] = useState(false);
const { data: unreadData } = useQuery(GET_UNREAD_COUNT, {
fetchPolicy: "cache-and-network"
const {
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;
// 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) => {
setNotificationVisible(!notificationVisible);
if (handleMenuClick) handleMenuClick(e);
@@ -669,7 +689,7 @@ function Header({
{
key: "notifications",
icon: (
<Badge count={unreadCount} offset={[10, 0]}>
<Badge count={unreadCount}>
<BellFilled />
</Badge>
),

View File

@@ -22,7 +22,9 @@ const NotificationCenterComponent = ({
console.log(`Rendering notification ${index}:`, {
id: notification.id,
scenarioTextLength: notification.scenarioText.length,
key: `${notification.id}-${index}`
read: notification.read,
created_at: notification.created_at,
associationid: notification.associationid // Log associationid for debugging
});
return (
<List.Item
@@ -45,11 +47,16 @@ const NotificationCenterComponent = ({
);
};
console.log("Rendering NotificationCenter with notifications:", {
count: notifications.length,
ids: notifications.map((n) => n.id),
totalCount: notifications.length
});
console.log(
"Rendering NotificationCenter with notifications:",
notifications.length,
notifications.map((n) => ({
id: n.id,
read: n.read,
created_at: n.created_at,
associationid: n.associationid
}))
);
return (
<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 { connect } from "react-redux";
import NotificationCenterComponent from "./notification-center.component";
@@ -19,9 +19,9 @@ export function NotificationCenterContainer({ visible, onClose }) {
variables: {
limit: 20,
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,
onError: (err) => {
setError(err.message);
@@ -48,39 +48,69 @@ export function NotificationCenterContainer({ visible, onClose }) {
onError: (err) => setError(err.message)
});
// Remove refetchNotifications function and useEffect/context logic
useEffect(() => {
if (data?.notifications) {
const processedNotifications = data.notifications.map((notif) => {
let scenarioText;
let scenarioMeta;
try {
scenarioText =
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) {
console.error("Error parsing JSON for notification:", notif.id, e);
scenarioText = [notif.fcm_text || "Invalid notification data"];
scenarioMeta = [];
}
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 {
id: notif.id,
jobid: notif.jobid,
associationid: notif.associationid,
scenarioText,
scenarioMeta, // Add scenarioMeta for completeness (optional for rendering)
created_at: notif.created_at,
read: notif.read,
__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);
setNotifications(processedNotifications);
setError(null);
} else {
console.log("No data yet or error in data:", data, queryError);
}
}, [data]);
}, [data, queryError]);
useEffect(() => {
if (queryError || mutationError) {
@@ -88,7 +118,7 @@ export function NotificationCenterContainer({ visible, onClose }) {
}
}, [queryError, mutationError]);
const loadMore = () => {
const loadMore = useCallback(() => {
if (!loading && data?.notifications.length) {
fetchMore({
variables: { offset: data.notifications.length },
@@ -100,7 +130,7 @@ export function NotificationCenterContainer({ visible, onClose }) {
}
}).catch((err) => setError(err.message));
}
};
}, [data?.notifications?.length, fetchMore, loading]);
const handleToggleUnreadOnly = (value) => {
setShowUnreadOnly(value);
@@ -122,11 +152,20 @@ export function NotificationCenterContainer({ visible, onClose }) {
window.addEventListener("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 (
// Remove NotificationContext.Provider
<NotificationCenterComponent
visible={visible}
onClose={onClose}

View File

@@ -4,7 +4,8 @@ import { auth } from "../../firebase/firebase.utils";
import { store } from "../../redux/store";
import { addAlerts, setWssStatus } from "../../redux/application/application.actions";
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 socketRef = useRef(null);
@@ -79,39 +80,88 @@ const useSocket = (bodyshop) => {
};
const handleNotification = (data) => {
const { jobId, bodyShopId, notifications, notificationId } = data;
console.log("handleNotification - Received", { jobId, bodyShopId, notificationId, notifications });
const { jobId, bodyShopId, notificationId, associationId, notifications } = data; // Changed to associationId (capital I)
console.log("handleNotification - Received", {
jobId,
bodyShopId,
notificationId,
associationId,
notifications
});
// Construct the notification object to match the cache/database format
const newNotification = {
__typename: "notifications",
id: notificationId,
jobid: jobId,
associationid: null,
scenario_text: notifications.map((notif) => notif.body),
associationid: associationId || null, // Use associationId from socket, default to null if missing
scenario_text: JSON.stringify(notifications.map((notif) => notif.body)), // Stringify as in the cache
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(),
read: null,
__typename: "notifications"
read: null // Assume unread unless specified otherwise
};
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({
id: "ROOT_QUERY",
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 } }) {
// Fallback if aggregate isnt in schema yet
if (!existing) return existing;
console.log("Updating unread count", existing.aggregate.count + 1);
const isUnread = newNotification.read === null;
const countChange = isUnread ? 1 : 0;
console.log("Updating unread count from socket:", existing.aggregate.count + countChange);
return {
...existing,
aggregate: {
...existing.aggregate,
count: existing.aggregate.count + 1
count: existing.aggregate.count + countChange
}
};
}
@@ -183,7 +233,7 @@ const useSocket = (bodyshop) => {
socketRef.current = null;
}
};
}, [bodyshop, client.cache]);
}, [bodyshop, notification]);
return { socket: socketRef.current, clientId };
};

View File

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

View File

@@ -3776,7 +3776,11 @@
"add-watchers": "Add Watchers",
"employee-search": "Search for an Employee",
"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": {
"remove": "remove"

View File

@@ -4940,6 +4940,7 @@
_eq: X-Hasura-User-Id
- active:
_eq: true
allow_aggregations: true
comment: ""
update_permissions:
- 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
for (const [user, bodyShopData] of Object.entries(allNotifications)) {
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)) {
const notificationId = notificationIdMap.get(`${user}:${bodyShopId}`);
if (userMapping && userMapping[bodyShopId]?.socketIds) {
userMapping[bodyShopId].socketIds.forEach((socketId) => {
logger.logger.debug(
`Emitting to socket ${socketId}: ${JSON.stringify({
jobId,
bodyShopId,
notifications,
notificationId
})}`
);
ioRedis.to(socketId).emit("notification", {
jobId,
bodyShopId,
notifications,
notificationId
notificationId,
associationId // now included in the emit payload
});
});
logger.logger.info(