feature/IO-3096-GlobalNotifications - Checkpoint - Notification Center
This commit is contained in:
@@ -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 <LoadingSpinner message={t("general.labels.loggingin")} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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" : ""}`}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 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 };
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -4940,6 +4940,7 @@
|
||||
_eq: X-Hasura-User-Id
|
||||
- active:
|
||||
_eq: true
|
||||
allow_aggregations: true
|
||||
comment: ""
|
||||
update_permissions:
|
||||
- role: user
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user