const { admin } = require("../firebase/firebase-handler"); const FortellisLogger = require("../fortellis/fortellis-logger"); const RRLogger = require("../rr/rr-logger"); const { FortellisJobExport, FortellisSelectedCustomer } = require("../fortellis/fortellis"); const CdkCalculateAllocations = require("../cdk/cdk-calculate-allocations").default; const { exportJobToRR } = require("../rr/rr-job-export"); const lookupApi = require("../rr/rr-lookup"); const redisSocketEvents = ({ io, redisHelpers: { setSessionData, getSessionData, addUserSocketMapping, removeUserSocketMapping, refreshUserSocketTTL, getUserSocketMappingByBodyshop, setSessionTransactionData, getSessionTransactionData, clearSessionTransactionData }, ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }, logger }) => { // Logging helper functions const createLogEvent = (socket, level, message) => { logger.log("ioredis-log-event", level, socket?.user?.email, null, { wsmessage: message }); }; // Socket Auth Middleware const authMiddleware = async (socket, next) => { const { token, bodyshopId } = socket.handshake.auth; if (!token) { return next(new Error("Authentication error - no authorization token.")); } if (!bodyshopId) { return next(new Error("Authentication error - no bodyshopId provided.")); } try { const user = await admin.auth().verifyIdToken(token); socket.user = user; socket.bodyshopId = bodyshopId; socket.data = socket.data || {}; socket.data.authToken = token; // Used to update legacy sockets if (socket.handshake?.auth) { socket.handshake.auth.token = token; socket.handshake.auth.bodyshopId = bodyshopId; } await addUserSocketMapping(user.email, socket.id, bodyshopId); next(); } catch (error) { next(new Error(`Authentication error: ${error.message}`)); } }; // Register Socket Events const registerSocketEvents = (socket) => { // Token Update Events const registerUpdateEvents = (socket) => { let latestTokenTimestamp = 0; const updateToken = async ({ token, bodyshopId }) => { const currentTimestamp = Date.now(); latestTokenTimestamp = currentTimestamp; if (!token || !bodyshopId) { socket.emit("token-updated", { success: false, error: "Token or bodyshopId missing" }); return; } try { const user = await admin.auth().verifyIdToken(token); if (currentTimestamp < latestTokenTimestamp) { createLogEvent(socket, "warn", "Outdated token validation skipped."); return; } socket.user = user; socket.bodyshopId = bodyshopId; // 🔑 keep the live token in a mutable place used by downstream code socket.data = socket.data || {}; socket.data.authToken = token; // also keep handshake in sync for any legacy reads if (socket.handshake?.auth) { socket.handshake.auth.token = token; socket.handshake.auth.bodyshopId = bodyshopId; } await refreshUserSocketTTL(user.email, bodyshopId); socket.emit("token-updated", { success: true }); } catch (error) { if (error.code === "auth/id-token-expired") { createLogEvent(socket, "warn", "Stale token received, waiting for new token"); socket.emit("token-updated", { success: false, error: "Stale token." }); return; } createLogEvent(socket, "error", `Token update failed for socket ID: ${socket.id}, Error: ${error.message}`); socket.emit("token-updated", { success: false, error: error.message }); socket.disconnect(); } }; socket.on("update-token", updateToken); }; // Room Broadcast Events const registerRoomAndBroadcastEvents = (socket) => { const joinBodyshopRoom = (bodyshopUUID) => { try { const room = getBodyshopRoom(bodyshopUUID); socket.join(room); } catch (error) { createLogEvent(socket, "error", `Error joining room: ${error}`); } }; const leaveBodyshopRoom = (bodyshopUUID) => { try { const room = getBodyshopRoom(bodyshopUUID); socket.leave(room); } catch (error) { createLogEvent(socket, "error", `Error joining room: ${error}`); } }; const broadcastToBodyshopRoom = (bodyshopUUID, message) => { try { const room = getBodyshopRoom(bodyshopUUID); io.to(room).emit("bodyshop-message", message); } catch (error) { createLogEvent(socket, "error", `Error getting room: ${error}`); } }; socket.on("join-bodyshop-room", joinBodyshopRoom); socket.on("leave-bodyshop-room", leaveBodyshopRoom); socket.on("broadcast-to-bodyshop", broadcastToBodyshopRoom); }; // Disconnect Events const registerDisconnectEvents = (socket) => { const disconnect = async () => { if (socket.user?.email) { await removeUserSocketMapping(socket.user.email, socket.id); } // Leave all rooms except the default room (socket.id) const rooms = Array.from(socket.rooms).filter((room) => room !== socket.id); for (const room of rooms) { socket.leave(room); } }; socket.on("disconnect", disconnect); }; // Messaging Events const registerMessagingEvents = (socket) => { const joinConversationRoom = async ({ bodyshopId, conversationId }) => { try { const room = getBodyshopConversationRoom({ bodyshopId, conversationId }); socket.join(room); } catch (error) { logger.log("Failed to Join Conversation Room", "error", "io-redis", null, { bodyshopId, conversationId, error: error.message, stack: error.stack }); } }; const leaveConversationRoom = ({ bodyshopId, conversationId }) => { try { const room = getBodyshopConversationRoom({ bodyshopId, conversationId }); socket.leave(room); } catch (error) { logger.log("Failed to Leave Conversation Room", "error", "io-redis", null, { bodyshopId, conversationId, error: error.message, stack: error.stack }); } }; const conversationModified = ({ bodyshopId, conversationId, ...fields }) => { try { // Retrieve the room name for the conversation const room = getBodyshopRoom(bodyshopId); // Emit the updated data to all clients in the room io.to(room).emit("conversation-changed", { conversationId, ...fields }); } catch (error) { logger.log("Failed to handle conversation modification", "error", "io-redis", null, { bodyshopId, conversationId, fields, error: error.message, stack: error.stack }); } }; socket.on("conversation-modified", conversationModified); socket.on("join-bodyshop-conversation", joinConversationRoom); socket.on("leave-bodyshop-conversation", leaveConversationRoom); }; // Sync Notification Read Events const registerSyncEvents = (socket) => { socket.on("sync-notification-read", async ({ email, bodyshopId, notificationId }) => { try { const socketMapping = await getUserSocketMappingByBodyshop(email, bodyshopId); const timestamp = new Date().toISOString(); if (socketMapping?.socketIds) { socketMapping?.socketIds.forEach((socketId) => { if (socketId !== socket.id) { // Avoid sending back to the originating socket io.to(socketId).emit("sync-notification-read", { notificationId, timestamp }); } }); } } catch (error) { createLogEvent(socket, "error", `Error syncing notification read: ${error.message}`); } }); socket.on("sync-all-notifications-read", async ({ email, bodyshopId }) => { try { const socketMapping = await getUserSocketMappingByBodyshop(email, bodyshopId); const timestamp = new Date().toISOString(); if (socketMapping?.socketIds) { socketMapping?.socketIds.forEach((socketId) => { if (socketId !== socket.id) { // Avoid sending back to the originating socket io.to(socketId).emit("sync-all-notifications-read", { timestamp }); } }); } } catch (error) { createLogEvent(socket, "error", `Error syncing all notifications read: ${error.message}`); } }); }; //Fortellis/CDK Handlers const registerFortellisEvents = (socket) => { socket.on("fortellis-export-job", async ({ jobid, txEnvelope }) => { try { await FortellisJobExport({ socket, redisHelpers: { setSessionData, getSessionData, addUserSocketMapping, removeUserSocketMapping, refreshUserSocketTTL, getUserSocketMappingByBodyshop, setSessionTransactionData, getSessionTransactionData, clearSessionTransactionData }, ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }, jobid, txEnvelope }); } catch (error) { FortellisLogger(socket, "error", `Error during Fortellis export : ${error.message}`); logger.log("fortellis-job-export-error", "error", null, null, { message: error.message, stack: error.stack }); } }); socket.on("fortellis-selected-customer", async ({ jobid, selectedCustomerId }) => { try { await FortellisSelectedCustomer({ socket, redisHelpers: { setSessionData, getSessionData, addUserSocketMapping, removeUserSocketMapping, refreshUserSocketTTL, getUserSocketMappingByBodyshop, setSessionTransactionData, getSessionTransactionData, clearSessionTransactionData }, ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }, jobid, selectedCustomerId }); } catch (error) { FortellisLogger(socket, "error", `Error during Fortellis export : ${error.message}`); logger.log("fortellis-selectd-customer-error", "error", null, null, { message: error.message, stack: error.stack }); } }); socket.on("fortellis-calculate-allocations", async (jobid, callback) => { try { const allocations = await CdkCalculateAllocations(socket, jobid); callback(allocations); } catch (error) { FortellisLogger(socket, "error", `Error during Fortellis export : ${error.message}`); logger.log("fortellis-selectd-customer-error", "error", null, null, { message: error.message, stack: error.stack }); } }); }; // Task Events const registerTaskEvents = (socket) => { socket.on("task-created", (payload) => { if (!payload) return; const room = getBodyshopRoom(socket.bodyshopId); io.to(room).emit("bodyshop-message", { type: "task-created", payload }); }); socket.on("task-updated", (payload) => { if (!payload) return; const room = getBodyshopRoom(socket.bodyshopId); io.to(room).emit("bodyshop-message", { type: "task-updated", payload }); }); socket.on("task-deleted", (payload) => { if (!payload?.id) return; const room = getBodyshopRoom(socket.bodyshopId); io.to(room).emit("bodyshop-message", { type: "task-deleted", payload }); }); }; // Reynolds & Reynolds socket events (uses new client-backed ops) const registerRREvents = (socket) => { const log = (level, message, ctx) => RRLogger(socket)(level, message, ctx); const resolveBodyshopId = (payload, job) => payload?.bodyshopId || socket.bodyshopId || job?.shopid || job?.bodyshopId; const resolveJobId = (explicitJobId, payload, job) => explicitJobId || payload?.jobid || payload?.txEnvelope?.jobid || job?.id || payload?.txEnvelope?.job?.id || null; // Orchestrated Export (Customer → Vehicle → Repair Order) socket.on("rr-export-job", async (payload = {}) => { try { const job = payload.job || payload.txEnvelope?.job; const options = payload.options || payload.txEnvelope?.options || {}; const bodyshopId = resolveBodyshopId(payload, job); const jobid = resolveJobId(payload.jobid, payload, job); if (!job) { log("error", "RR export missing job payload", { jobid }); return; } if (!bodyshopId) { log("error", "RR export missing bodyshopId", { jobid }); return; } const result = await exportJobToRR({ bodyshopId, job, logger: log, ...options }); // Broadcast keyed by bodyshop + include jobid const room = getBodyshopRoom(bodyshopId); io.to(room).emit("rr-export-job:result", { jobid, bodyshopId, result }); } catch (error) { const jobid = resolveJobId(payload?.jobid, payload, payload?.job || payload?.txEnvelope?.job); log("error", `Error during RR export: ${error.message}`, { jobid, stack: error.stack }); logger.log("rr-job-export-error", "error", null, null, { jobid, message: error.message, stack: error.stack }); } }); // Combined search (customer/vehicle) socket.on("rr-lookup-combined", async ({ jobid, params } = {}, cb) => { try { const bodyshopId = resolveBodyshopId({ bodyshopId: params?.bodyshopId }, null); const resolvedJobId = resolveJobId(jobid, { jobid }, null); if (!bodyshopId) throw new Error("Missing bodyshopId"); const res = await lookupApi.combinedSearch({ bodyshopId, ...(params || {}) }); cb?.({ jobid: resolvedJobId, data: res?.data ?? res }); } catch (e) { log("error", `RR combined lookup error: ${e.message}`, { jobid }); cb?.({ jobid, error: e.message }); } }); // Get Advisors socket.on("rr-get-advisors", async ({ jobid, params } = {}, cb) => { try { const bodyshopId = resolveBodyshopId({ bodyshopId: params?.bodyshopId }, null); const resolvedJobId = resolveJobId(jobid, { jobid }, null); if (!bodyshopId) throw new Error("Missing bodyshopId"); const res = await lookupApi.getAdvisors({ bodyshopId, ...(params || {}) }); cb?.({ jobid: resolvedJobId, data: res?.data ?? res }); } catch (e) { log("error", `RR get advisors error: ${e.message}`, { jobid }); cb?.({ jobid, error: e.message }); } }); // Get Parts socket.on("rr-get-parts", async ({ jobid, params } = {}, cb) => { try { const bodyshopId = resolveBodyshopId({ bodyshopId: params?.bodyshopId }, null); const resolvedJobId = resolveJobId(jobid, { jobid }, null); if (!bodyshopId) throw new Error("Missing bodyshopId"); const res = await lookupApi.getParts({ bodyshopId, ...(params || {}) }); cb?.({ jobid: resolvedJobId, data: res?.data ?? res }); } catch (e) { log("error", `RR get parts error: ${e.message}`, { jobid }); cb?.({ jobid, error: e.message }); } }); // Optional: Selected customer — currently a no-op for RR socket.on("rr-selected-customer", async ({ jobid, selectedCustomerId } = {}) => { const resolvedJobId = resolveJobId(jobid, { jobid }, null); log("info", "rr-selected-customer not implemented for RR (no-op)", { jobid: resolvedJobId, selectedCustomerId }); }); // Calculate allocations (CDK utility unchanged) socket.on("rr-calculate-allocations", async (jobid, callback) => { try { const resolvedJobId = resolveJobId(jobid, { jobid }, null); const allocations = await CdkCalculateAllocations(socket, resolvedJobId); callback({ jobid: resolvedJobId, allocations }); } catch (error) { log("error", `Error during RR calculate allocations: ${error.message}`, { jobid, stack: error.stack }); logger.log("rr-calc-allocations-error", "error", null, null, { jobid, message: error.message, stack: error.stack }); callback?.({ jobid, error: error.message }); } }); }; // Call Handlers registerRoomAndBroadcastEvents(socket); registerUpdateEvents(socket); registerMessagingEvents(socket); registerDisconnectEvents(socket); registerSyncEvents(socket); registerTaskEvents(socket); registerFortellisEvents(socket); registerRREvents(socket); }; // Associate Middleware and Handlers io.use(authMiddleware); io.on("connection", registerSocketEvents); }; module.exports = { redisSocketEvents };