feature/IO-3096-GlobalNotifications - Checkpoint

This commit is contained in:
Dave Richer
2025-02-25 17:23:35 -05:00
parent 08b7f0e59c
commit c5d00f7641
9 changed files with 510 additions and 566 deletions

View File

@@ -1,13 +1,261 @@
import React, { createContext } from "react";
import useSocket from "./useSocket";
// contexts/SocketIO/socketContext.jsx
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
import SocketIO from "socket.io-client";
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 "../Notifications/notificationContext.jsx";
import { gql } from "@apollo/client";
// Create the SocketContext
const SocketContext = createContext(null);
export const SocketProvider = ({ children, bodyshop }) => {
const { socket, clientId } = useSocket(bodyshop);
const socketRef = useRef(null);
const [clientId, setClientId] = useState(null);
const [isConnected, setIsConnected] = useState(false);
const notification = useNotification();
return <SocketContext.Provider value={{ socket, clientId }}> {children}</SocketContext.Provider>;
useEffect(() => {
const initializeSocket = async (token) => {
if (!bodyshop || !bodyshop.id || socketRef.current) return; // Prevent multiple instances
const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : "";
const socketInstance = SocketIO(endpoint, {
path: "/wss",
withCredentials: true,
auth: { token, bodyshopId: bodyshop.id },
reconnectionAttempts: Infinity,
reconnectionDelay: 2000,
reconnectionDelayMax: 10000
});
socketRef.current = socketInstance;
const handleBodyshopMessage = (message) => {
if (!message || !message.type) return;
switch (message.type) {
case "alert-update":
store.dispatch(addAlerts(message.payload));
break;
default:
break;
}
if (!import.meta.env.DEV) return;
console.log(`Received message for bodyshop ${bodyshop.id}:`, message);
};
const handleConnect = () => {
socketInstance.emit("join-bodyshop-room", bodyshop.id);
setClientId(socketInstance.id);
setIsConnected(true);
store.dispatch(setWssStatus("connected"));
console.log("Socket connected, ID:", socketInstance.id);
};
const handleReconnect = () => {
setIsConnected(true);
store.dispatch(setWssStatus("connected"));
};
const handleConnectionError = (err) => {
console.error("Socket connection error:", err);
setIsConnected(false);
if (err.message.includes("auth/id-token-expired")) {
console.warn("Token expired, refreshing...");
auth.currentUser?.getIdToken(true).then((newToken) => {
socketInstance.auth = { token: newToken };
socketInstance.connect();
});
} else {
store.dispatch(setWssStatus("error"));
}
};
const handleDisconnect = (reason) => {
console.warn("Socket disconnected:", reason);
setIsConnected(false);
store.dispatch(setWssStatus("disconnected"));
if (!socketInstance.connected && reason !== "io server disconnect") {
setTimeout(() => {
if (socketInstance.disconnected) {
console.log("Manually triggering reconnection...");
socketInstance.connect();
}
}, 2000);
}
};
const handleNotification = (data) => {
const { jobId, bodyShopId, notificationId, associationId, notifications } = data;
console.log("Socket Notification Received (ID:", notificationId, "):", {
jobId,
bodyShopId,
associationId,
notifications
});
const newNotification = {
__typename: "notifications",
id: notificationId,
jobid: jobId,
associationid: associationId || null,
scenario_text: JSON.stringify(notifications.map((notif) => notif.body)),
fcm_text: notifications.map((notif) => notif.body).join(". ") + ".",
scenario_meta: JSON.stringify(notifications.map((notif) => notif.variables || {})),
created_at: new Date(notifications[0].timestamp).toISOString(),
read: null
};
try {
const existingNotifications =
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 || [];
if (existingNotifications.some((n) => n.id === newNotification.id)) {
console.log("Duplicate notification detected, skipping:", notificationId);
return;
}
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, ...existingNotifications].sort(
(a, b) => new Date(b.created_at) - new Date(a.created_at)
)
},
broadcast: true
});
console.log("Cache updated with new notification:", newNotification);
client.cache.modify({
id: "ROOT_QUERY",
fields: {
notifications_aggregate(existing = { aggregate: { count: 0 } }) {
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 + countChange
}
};
}
}
});
notification.info({
message: "New Notification",
description: (
<ul>
{notifications.map((notif, index) => (
<li key={index}>{notif.body}</li>
))}
</ul>
),
placement: "topRight",
duration: 5
});
} catch (error) {
console.error("Error in handleNotification:", error);
}
};
socketInstance.on("connect", handleConnect);
socketInstance.on("reconnect", handleReconnect);
socketInstance.on("connect_error", handleConnectionError);
socketInstance.on("disconnect", handleDisconnect);
socketInstance.on("bodyshop-message", handleBodyshopMessage);
socketInstance.on("message", (message) => {
console.log("Raw socket message:", message);
try {
if (typeof message === "string" && message.startsWith("42")) {
const parsedMessage = JSON.parse(message.slice(2));
const [event, data] = parsedMessage;
if (event === "notification") handleNotification(data);
} else if (Array.isArray(message)) {
const [event, data] = message;
if (event === "notification") handleNotification(data);
}
} catch (error) {
console.error("Error parsing socket message:", error);
}
});
socketInstance.on("notification", handleNotification);
};
const unsubscribe = auth.onIdTokenChanged(async (user) => {
if (user) {
const token = await user.getIdToken();
if (socketRef.current) {
socketRef.current.emit("update-token", { token, bodyshopId: bodyshop.id });
} else {
initializeSocket(token);
}
} else {
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
setIsConnected(false);
}
}
});
return () => {
unsubscribe();
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
setIsConnected(false);
}
};
}, [bodyshop, notification]);
return (
<SocketContext.Provider value={{ socket: socketRef.current, clientId, isConnected }}>
{children}
</SocketContext.Provider>
);
};
export const useSocket = () => {
const context = useContext(SocketContext);
if (!context) {
throw new Error("useSocket must be used within a SocketProvider");
}
return context;
};
export default SocketContext;

View File

@@ -1,241 +0,0 @@
import { useEffect, useRef, useState } from "react";
import SocketIO from "socket.io-client";
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 "../Notifications/notificationContext.jsx";
import { gql } from "@apollo/client";
const useSocket = (bodyshop) => {
const socketRef = useRef(null);
const [clientId, setClientId] = useState(null);
const notification = useNotification();
useEffect(() => {
const initializeSocket = async (token) => {
if (!bodyshop || !bodyshop.id) return;
const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : "";
const socketInstance = SocketIO(endpoint, {
path: "/wss",
withCredentials: true,
auth: { token, bodyshopId: bodyshop.id },
reconnectionAttempts: Infinity,
reconnectionDelay: 2000,
reconnectionDelayMax: 10000
});
socketRef.current = socketInstance;
const handleBodyshopMessage = (message) => {
if (!message || !message.type) return;
switch (message.type) {
case "alert-update":
store.dispatch(addAlerts(message.payload));
break;
default:
break;
}
if (!import.meta.env.DEV) return;
console.log(`Received message for bodyshop ${bodyshop.id}:`, message);
};
const handleConnect = () => {
socketInstance.emit("join-bodyshop-room", bodyshop.id);
setClientId(socketInstance.id);
store.dispatch(setWssStatus("connected"));
console.log("Socket connected, ID:", socketInstance.id);
};
const handleReconnect = () => {
store.dispatch(setWssStatus("connected"));
};
const handleConnectionError = (err) => {
console.error("Socket connection error:", err);
if (err.message.includes("auth/id-token-expired")) {
console.warn("Token expired, refreshing...");
auth.currentUser?.getIdToken(true).then((newToken) => {
socketInstance.auth = { token: newToken };
socketInstance.connect();
});
} else {
store.dispatch(setWssStatus("error"));
}
};
const handleDisconnect = (reason) => {
console.warn("Socket disconnected:", reason);
store.dispatch(setWssStatus("disconnected"));
if (!socketInstance.connected && reason !== "io server disconnect") {
setTimeout(() => {
if (socketInstance.disconnected) {
console.log("Manually triggering reconnection...");
socketInstance.connect();
}
}, 2000);
}
};
const handleNotification = (data) => {
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: 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: JSON.stringify(notifications.map((notif) => notif.variables || {})), // Stringify as in the cache
created_at: new Date(notifications[0].timestamp).toISOString(),
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_aggregate(existing = { aggregate: { count: 0 } }) {
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 + countChange
}
};
}
}
});
notification.info({
message: "New Notification",
description: (
<ul>
{notifications.map((notif, index) => (
<li key={index}>{notif.body}</li>
))}
</ul>
),
placement: "topRight",
duration: 5
});
} catch (error) {
console.error("Error in handleNotification:", error);
}
};
socketInstance.on("connect", handleConnect);
socketInstance.on("reconnect", handleReconnect);
socketInstance.on("connect_error", handleConnectionError);
socketInstance.on("disconnect", handleDisconnect);
socketInstance.on("bodyshop-message", handleBodyshopMessage);
socketInstance.on("message", (message) => {
console.log("Raw socket message:", message);
try {
if (typeof message === "string" && message.startsWith("42")) {
const parsedMessage = JSON.parse(message.slice(2));
const [event, data] = parsedMessage;
if (event === "notification") handleNotification(data);
} else if (Array.isArray(message)) {
const [event, data] = message;
if (event === "notification") handleNotification(data);
}
} catch (error) {
console.error("Error parsing socket message:", error);
}
});
socketInstance.on("notification", handleNotification);
};
const unsubscribe = auth.onIdTokenChanged(async (user) => {
if (user) {
const token = await user.getIdToken();
if (socketRef.current) {
socketRef.current.emit("update-token", { token, bodyshopId: bodyshop.id });
} else {
initializeSocket(token);
}
} else {
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
}
}
});
return () => {
unsubscribe();
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
}
};
}, [bodyshop, notification]);
return { socket: socketRef.current, clientId };
};
export default useSocket;