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"; import { QUERY_MY_TASKS_COUNT, QUERY_TASKS_NO_DUE_DATE_PAGINATED, QUERY_TASKS_WITH_DUE_DATES } from "../../graphql/tasks.queries"; 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 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) }); 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: 10000 }); socketRef.current = socketInstance; 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 countVars = { assigned_to: assignedToId, bodyshopid: bodyshop?.id }; // Helper to update a list in cache locally const updateListCache = (query, vars, action) => { // action: 'add' or 'remove' try { const current = client.readQuery({ query, variables: vars }); if (!current) return; // List not loaded yet, skip let updatedTasks = [...current.tasks]; let delta = 0; if (action === "remove") { const prevLength = updatedTasks.length; updatedTasks = updatedTasks.filter((task) => task.id !== payload.id); if (updatedTasks.length < prevLength) delta -= 1; } if (action === "add") { const exists = updatedTasks.some((task) => task.id === payload.id); if (!exists) { const newTask = { ...payload, __typename: "tasks" }; updatedTasks.push(newTask); delta += 1; } } // Sort the array based on the query type if delta !== 0 if (delta !== 0) { if (query === QUERY_TASKS_WITH_DUE_DATES) { updatedTasks.sort((a, b) => { const da = new Date(a.due_date); const db = new Date(b.due_date); if (da < db) return -1; if (da > db) return 1; const ca = new Date(a.created_at); const cb = new Date(b.created_at); return cb - ca; // desc }); } else if (query === QUERY_TASKS_NO_DUE_DATE_PAGINATED) { updatedTasks.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); } } let data = { ...current, tasks: updatedTasks }; // Adjust aggregate count for no due date query if (query === QUERY_TASKS_NO_DUE_DATE_PAGINATED && delta !== 0 && current.tasks_aggregate) { const newCount = Math.max(0, current.tasks_aggregate.aggregate.count + delta); data = { ...data, tasks_aggregate: { ...current.tasks_aggregate, aggregate: { ...current.tasks_aggregate.aggregate, count: newCount } } }; } client.writeQuery({ query, variables: vars, data }); } catch (error) { console.error("Error updating task list cache:", error); } }; // Update lists based on event type if (message.type === "task-deleted") { updateListCache(QUERY_TASKS_WITH_DUE_DATES, dueVars, "remove"); updateListCache(QUERY_TASKS_NO_DUE_DATE_PAGINATED, noDueVars, "remove"); } else { const targetQuery = payload.due_date ? QUERY_TASKS_WITH_DUE_DATES : QUERY_TASKS_NO_DUE_DATE_PAGINATED; const targetVars = payload.due_date ? dueVars : noDueVars; if (message.type === "task-updated") { // Remove from both in case of due_date change updateListCache(QUERY_TASKS_WITH_DUE_DATES, dueVars, "remove"); updateListCache(QUERY_TASKS_NO_DUE_DATE_PAGINATED, noDueVars, "remove"); } // Add to the target list updateListCache(targetQuery, targetVars, "add"); } // Locally update the total count (no network call) try { const currentCountQuery = client.readQuery({ query: QUERY_MY_TASKS_COUNT, variables: countVars }); if (currentCountQuery) { let countDelta = 0; if (message.type === "task-created") countDelta = 1; else if (message.type === "task-deleted") countDelta = -1; // For "task-updated", assume no change to count unless specific logic (e.g., completion) if (countDelta !== 0) { const newCount = Math.max(0, currentCountQuery.tasks_aggregate.aggregate.count + countDelta); client.writeQuery({ query: QUERY_MY_TASKS_COUNT, variables: countVars, data: { tasks_aggregate: { __typename: "tasks_aggregate", aggregate: { __typename: "tasks_aggregate_fields", count: newCount } } } }); } } } catch (error) { console.error("Error updating task count cache:", error); } 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: (