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 { GET_NOTIFICATIONS, GET_UNREAD_COUNT, MARK_ALL_NOTIFICATIONS_READ, MARK_NOTIFICATION_READ, UPDATE_NOTIFICATIONS_READ_FRAGMENT } from "../../graphql/notifications.queries.js"; import { useMutation } from "@apollo/client"; import { useTranslation } from "react-i18next"; import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { INITIAL_NOTIFICATIONS, SocketContext } from "./useSocket.js"; const LIMIT = INITIAL_NOTIFICATIONS; /** * Socket Provider - Scenario Notifications / Web Socket related items * @param children * @param bodyshop * @param navigate * @param currentUser * @returns {JSX.Element} * @constructor */ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => { const socketRef = useRef(null); const [clientId, setClientId] = useState(null); const [isConnected, setIsConnected] = useState(false); const [socketInitialized, setSocketInitialized] = useState(false); const notification = useNotification(); const userAssociationId = bodyshop?.associations?.[0]?.id; const { t } = useTranslation(); const { treatments: { Realtime_Notifications_UI } } = useSplitTreatments({ attributes: {}, names: ["Realtime_Notifications_UI"], splitKey: bodyshop?.imexshopid }); const [markNotificationRead] = useMutation(MARK_NOTIFICATION_READ, { update: (cache, { data: { update_notifications } }) => { const timestamp = new Date().toISOString(); const updatedNotification = update_notifications.returning[0]; cache.modify({ fields: { notifications(existing = [], { readField }) { return existing.map((notif) => readField("id", notif) === updatedNotification.id ? { ...notif, read: timestamp } : notif ); } } }); const unreadCountQuery = cache.readQuery({ query: GET_UNREAD_COUNT, variables: { associationid: userAssociationId } }); if (unreadCountQuery?.notifications_aggregate?.aggregate?.count > 0) { cache.writeQuery({ query: GET_UNREAD_COUNT, variables: { associationid: userAssociationId }, data: { notifications_aggregate: { ...unreadCountQuery.notifications_aggregate, aggregate: { ...unreadCountQuery.notifications_aggregate.aggregate, count: unreadCountQuery.notifications_aggregate.aggregate.count - 1 } } } }); } if (socketRef.current && isConnected) { socketRef.current.emit("sync-notification-read", { email: currentUser?.email, bodyshopId: bodyshop.id, notificationId: updatedNotification.id }); } }, onError: (err) => console.error("MARK_NOTIFICATION_READ error:", { message: err?.message, stack: err?.stack }) }); const [markAllNotificationsRead] = useMutation(MARK_ALL_NOTIFICATIONS_READ, { variables: { associationid: userAssociationId }, update: (cache) => { const timestamp = new Date().toISOString(); cache.modify({ fields: { notifications(existing = [], { readField }) { return existing.map((notif) => readField("read", notif) === null && readField("associationid", notif) === userAssociationId ? { ...notif, read: timestamp } : notif ); }, notifications_aggregate() { return { aggregate: { count: 0, __typename: "notifications_aggregate_fields" } }; } } }); const baseWhereClause = { associationid: { _eq: userAssociationId } }; const cachedNotifications = cache.readQuery({ query: GET_NOTIFICATIONS, variables: { limit: INITIAL_NOTIFICATIONS, offset: 0, where: baseWhereClause } }); if (cachedNotifications?.notifications) { cache.writeQuery({ query: GET_NOTIFICATIONS, variables: { limit: INITIAL_NOTIFICATIONS, offset: 0, where: baseWhereClause }, data: { notifications: cachedNotifications.notifications.map((notif) => notif.read === null ? { ...notif, read: timestamp } : notif ) } }); } if (socketRef.current && isConnected) { socketRef.current.emit("sync-all-notifications-read", { email: currentUser?.email, bodyshopId: bodyshop.id }); } }, onError: (err) => console.error("MARK_ALL_NOTIFICATIONS_READ error:", err) }); const checkAndReconnect = () => { if (socketRef.current && !socketRef.current.connected) { console.log("Attempting manual reconnect due to event trigger"); socketRef.current.connect(); } }; useEffect(() => { const initializeSocket = async (token) => { if (!bodyshop || !bodyshop.id || socketRef.current) 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: 60000, randomizationFactor: 0.5, transports: ["websocket", "polling"], // Add this to prefer WebSocket with polling fallback rememberUpgrade: true }); socketRef.current = socketInstance; setSocketInitialized(true); const handleBodyshopMessage = (message) => { if (!message || !message.type) return; switch (message.type) { case "alert-update": store.dispatch(addAlerts(message.payload)); break; case "task-created": case "task-updated": case "task-deleted": const payload = message.payload; const assignedToId = bodyshop?.employees?.find((e) => e.user_email === currentUser?.email)?.id; if (!assignedToId || payload.assigned_to !== assignedToId) return; const dueVars = { bodyshop: bodyshop?.id, assigned_to: assignedToId, order: [{ due_date: "asc" }, { created_at: "desc" }] }; const noDueVars = { bodyshop: bodyshop?.id, assigned_to: assignedToId, order: [{ created_at: "desc" }], limit: LIMIT, offset: 0 }; const whereBase = { bodyshopid: { _eq: bodyshop?.id }, assigned_to: { _eq: assignedToId }, deleted: { _eq: false }, completed: { _eq: false } }; const whereDue = { ...whereBase, due_date: { _is_null: false } }; const whereNoDue = { ...whereBase, due_date: { _is_null: true } }; // Helper to invalidate a cache entry const invalidateCache = (fieldName, args) => { try { client.cache.evict({ id: "ROOT_QUERY", fieldName, args }); } catch (error) { console.error("Error invalidating cache:", error); } }; // Invalidate lists and aggregates based on event type if (message.type === "task-deleted" || message.type === "task-updated") { // Invalidate both lists and no due aggregate for deletes and updates invalidateCache("tasks", { where: whereDue, order_by: dueVars.order }); invalidateCache("tasks", { where: whereNoDue, order_by: noDueVars.order, limit: noDueVars.limit, offset: noDueVars.offset }); invalidateCache("tasks_aggregate", { where: whereNoDue }); } else if (message.type === "task-created") { // For creates, invalidate the target list and no due aggregate if applicable const hasDue = !!payload.due_date; if (hasDue) { invalidateCache("tasks", { where: whereDue, order_by: dueVars.order }); } else { invalidateCache("tasks", { where: whereNoDue, order_by: noDueVars.order, limit: noDueVars.limit, offset: noDueVars.offset }); invalidateCache("tasks_aggregate", { where: whereNoDue }); } } // Always invalidate the total count for all events (handles creates, deletes, updates including completions) invalidateCache("tasks_aggregate", { where: whereBase }); // Garbage collect after evictions client.cache.gc(); break; default: break; } }; const handleConnect = () => { socketInstance.emit("join-bodyshop-room", bodyshop.id); setClientId(socketInstance.id); setIsConnected(true); store.dispatch(setWssStatus("connected")); }; 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) => { if (Realtime_Notifications_UI?.treatment !== "on") { return; } const { jobId, jobRoNumber, notificationId, associationId, notifications } = data; if (associationId !== userAssociationId) return; const newNotification = { __typename: "notifications", id: notificationId, jobid: jobId, associationid: associationId, 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, job: { ro_number: jobRoNumber } }; const baseVariables = { limit: INITIAL_NOTIFICATIONS, offset: 0, where: { associationid: { _eq: userAssociationId } } }; try { const existingNotifications = client.cache.readQuery({ query: GET_NOTIFICATIONS, variables: baseVariables })?.notifications || []; if (!existingNotifications.some((n) => n.id === newNotification.id)) { client.cache.writeQuery({ query: GET_NOTIFICATIONS, variables: baseVariables, data: { notifications: [newNotification, ...existingNotifications].sort( (a, b) => new Date(b.created_at) - new Date(a.created_at) ) }, broadcast: true }); const unreadVariables = { ...baseVariables, where: { ...baseVariables.where, read: { _is_null: true } } }; const unreadNotifications = client.cache.readQuery({ query: GET_NOTIFICATIONS, variables: unreadVariables })?.notifications || []; if (newNotification.read === null && !unreadNotifications.some((n) => n.id === newNotification.id)) { client.cache.writeQuery({ query: GET_NOTIFICATIONS, variables: unreadVariables, data: { notifications: [newNotification, ...unreadNotifications].sort( (a, b) => new Date(b.created_at) - new Date(a.created_at) ) }, broadcast: true }); } client.cache.modify({ id: "ROOT_QUERY", fields: { notifications_aggregate(existing = { aggregate: { count: 0 } }) { return { ...existing, aggregate: { ...existing.aggregate, count: existing.aggregate.count + (newNotification.read === null ? 1 : 0) } }; } } }); notification.info({ message: (