From 4f1c0b99968420f153842638ba2485918d8014be Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Mon, 24 Feb 2025 18:04:15 -0500 Subject: [PATCH] feature/IO-3096-GlobalNotifications - Checkpoint - Notification Center --- client/src/App/App.jsx | 111 ++++++++++++++++++ .../components/header/header.component.jsx | 26 +++- .../notification-center.component.jsx | 19 ++- .../notification-center.container.jsx | 57 +++++++-- client/src/contexts/SocketIO/useSocket.jsx | 86 +++++++++++--- client/src/graphql/notifications.queries.js | 2 +- client/src/translations/en_us/common.json | 6 +- hasura/metadata/tables.yaml | 1 + server/notifications/queues/appQueue.js | 15 +-- 9 files changed, 275 insertions(+), 48 deletions(-) diff --git a/client/src/App/App.jsx b/client/src/App/App.jsx index 26f44fc8d..906981c14 100644 --- a/client/src/App/App.jsx +++ b/client/src/App/App.jsx @@ -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 isn’t authorized + }); + if (currentUser.authorized === null) { return ; } diff --git a/client/src/components/header/header.component.jsx b/client/src/components/header/header.component.jsx index bc887edec..48fe65e8b 100644 --- a/client/src/components/header/header.component.jsx +++ b/client/src/components/header/header.component.jsx @@ -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: ( - + ), diff --git a/client/src/components/notification-center/notification-center.component.jsx b/client/src/components/notification-center/notification-center.component.jsx index 8b33be3ef..1c91717f7 100644 --- a/client/src/components/notification-center/notification-center.component.jsx +++ b/client/src/components/notification-center/notification-center.component.jsx @@ -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 ( 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 (
diff --git a/client/src/components/notification-center/notification-center.container.jsx b/client/src/components/notification-center/notification-center.container.jsx index ff5e26d22..2085fc6c5 100644 --- a/client/src/components/notification-center/notification-center.container.jsx +++ b/client/src/components/notification-center/notification-center.container.jsx @@ -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 { 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 isn’t 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 }; }; diff --git a/client/src/graphql/notifications.queries.js b/client/src/graphql/notifications.queries.js index 44e0d3da2..6a782ba75 100644 --- a/client/src/graphql/notifications.queries.js +++ b/client/src/graphql/notifications.queries.js @@ -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 diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 75781d098..1f58aa777 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -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" diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index 0fb3007c5..ba6d7a12c 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -4940,6 +4940,7 @@ _eq: X-Hasura-User-Id - active: _eq: true + allow_aggregations: true comment: "" update_permissions: - role: user diff --git a/server/notifications/queues/appQueue.js b/server/notifications/queues/appQueue.js index a600fb525..668ffd8c6 100644 --- a/server/notifications/queues/appQueue.js +++ b/server/notifications/queues/appQueue.js @@ -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(