feature/IO-3096-GlobalNotifications - Checkpoint, socket to email to bodyshop mapping.

This commit is contained in:
Dave Richer
2025-02-18 11:02:46 -05:00
parent a15f86cc4e
commit c02c36c548
4 changed files with 123 additions and 128 deletions

View File

@@ -1,5 +1,5 @@
import React, { createContext } from "react"; import React, { createContext } from "react";
import useSocket from "./useSocket"; // Import the custom hook import useSocket from "./useSocket";
// Create the SocketContext // Create the SocketContext
const SocketContext = createContext(null); const SocketContext = createContext(null);

View File

@@ -17,7 +17,7 @@ const useSocket = (bodyshop) => {
const socketInstance = SocketIO(endpoint, { const socketInstance = SocketIO(endpoint, {
path: "/wss", path: "/wss",
withCredentials: true, withCredentials: true,
auth: { token }, auth: { token, bodyshopId: bodyshop.id },
reconnectionAttempts: Infinity, reconnectionAttempts: Infinity,
reconnectionDelay: 2000, reconnectionDelay: 2000,
reconnectionDelayMax: 10000 reconnectionDelayMax: 10000
@@ -95,7 +95,7 @@ const useSocket = (bodyshop) => {
if (socketRef.current) { if (socketRef.current) {
// Update token if socket exists // Update token if socket exists
socketRef.current.emit("update-token", token); socketRef.current.emit("update-token", { token, bodyshopId: bodyshop.id });
} else { } else {
// Initialize socket if not already connected // Initialize socket if not already connected
initializeSocket(token); initializeSocket(token);

View File

@@ -73,7 +73,20 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
try { try {
await pubClient.rpush(`socket:${socketId}:${key}`, JSON.stringify(newItem)); await pubClient.rpush(`socket:${socketId}:${key}`, JSON.stringify(newItem));
} catch (error) { } catch (error) {
logger.log(`Error adding item to the end of the list for socket ${socketId}: ${error}`, "ERROR", "redis"); let userEmail = "unknown";
let socketMappings = {};
try {
const userData = await getSessionData(socketId, "user");
if (userData && userData.email) {
userEmail = userData.email;
socketMappings = await getUserSocketMapping(userEmail);
}
} catch (sessionError) {
logger.log(`Failed to fetch session data for socket ${socketId}: ${sessionError}`, "ERROR", "redis");
}
const mappingString = JSON.stringify(socketMappings, null, 2);
const errorMessage = `Error adding item to the end of the list for socket ${socketId}: ${error}. User: ${userEmail}, Socket Mappings: ${mappingString}`;
logger.log(errorMessage, "ERROR", "redis");
} }
}; };
@@ -121,23 +134,81 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
} }
}; };
const addUserSocketMapping = async (email, socketId) => { const addUserSocketMapping = async (email, socketId, bodyshopId) => {
// Using a Redis set allows a user to have multiple active socket ids. const userKey = `user:${email}`;
console.log(`Adding socket ${socketId} to user ${email}`); const bodyshopKey = `${userKey}:bodyshops:${bodyshopId}`;
return pubClient.sadd(`user:${email}:sockets`, socketId); try {
logger.log(`Adding socket ${socketId} to user ${email} for bodyshop ${bodyshopId}`, "debug", "redis");
// Mark the bodyshop as associated with the user in the hash
await pubClient.hset(userKey, `bodyshops:${bodyshopId}`, "1");
// Add the socket ID to the bodyshop-specific set
await pubClient.sadd(bodyshopKey, socketId);
// Set TTL to 24 hours for both keys
await pubClient.expire(userKey, 86400);
await pubClient.expire(bodyshopKey, 86400);
} catch (error) {
logger.log(`Error adding socket mapping for ${email} (bodyshop ${bodyshopId}): ${error}`, "ERROR", "redis");
}
}; };
const removeUserSocketMapping = async (email, socketId) => { const refreshUserSocketTTL = async (email, bodyshopId) => {
console.log(`Removing socket ${socketId} from user ${email}`); const userKey = `user:${email}`;
return pubClient.srem(`user:${email}:sockets`, socketId); const bodyshopKey = `${userKey}:bodyshops:${bodyshopId}`;
try {
const userExists = await pubClient.exists(userKey);
if (userExists) {
await pubClient.expire(userKey, 86400);
}
const bodyshopExists = await pubClient.exists(bodyshopKey);
if (bodyshopExists) {
await pubClient.expire(bodyshopKey, 86400);
logger.log(`Refreshed TTL for ${email} bodyshop ${bodyshopId} socket mapping`, "debug", "redis");
}
} catch (error) {
logger.log(`Error refreshing TTL for ${email} (bodyshop ${bodyshopId}): ${error}`, "ERROR", "redis");
}
};
const removeUserSocketMapping = async (email, socketId, bodyshopId) => {
const userKey = `user:${email}`;
const bodyshopKey = `${userKey}:bodyshops:${bodyshopId}`;
try {
logger.log(`Removing socket ${socketId} from user ${email} for bodyshop ${bodyshopId}`, "DEBUG", "redis");
await pubClient.srem(bodyshopKey, socketId);
// Refresh TTL if there are still sockets, or let it expire
const remainingSockets = await pubClient.scard(bodyshopKey);
if (remainingSockets > 0) {
await pubClient.expire(bodyshopKey, 86400);
} else {
// Optionally remove the bodyshop field from the hash if no sockets remain
await pubClient.hdel(userKey, `bodyshops:${bodyshopId}`);
}
// Refresh user key TTL if there are still bodyshops
const remainingBodyshops = await pubClient.hlen(userKey);
if (remainingBodyshops > 0) {
await pubClient.expire(userKey, 86400);
}
} catch (error) {
logger.log(`Error removing socket mapping for ${email} (bodyshop ${bodyshopId}): ${error}`, "ERROR", "redis");
}
}; };
const getUserSocketMapping = async (email) => { const getUserSocketMapping = async (email) => {
const key = `user:${email}:sockets`; const userKey = `user:${email}`;
try { try {
return await pubClient.smembers(key); // Get all bodyshop fields from the hash
const bodyshops = await pubClient.hkeys(userKey);
const result = {};
for (const bodyshopField of bodyshops) {
const bodyshopId = bodyshopField.split("bodyshops:")[1];
const bodyshopKey = `${userKey}:bodyshops:${bodyshopId}`;
const socketIds = await pubClient.smembers(bodyshopKey);
const ttl = await pubClient.ttl(bodyshopKey);
result[bodyshopId] = { socketIds, ttl };
}
return result;
} catch (error) { } catch (error) {
console.error(`Error retrieving socket IDs for ${email}:`, error); console.error(`Error retrieving socket mappings for ${email}:`, error);
throw error; throw error;
} }
}; };
@@ -157,7 +228,8 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
getUsersInRoom, getUsersInRoom,
addUserSocketMapping, addUserSocketMapping,
removeUserSocketMapping, removeUserSocketMapping,
getUserSocketMapping getUserSocketMapping,
refreshUserSocketTTL
}; };
Object.assign(module.exports, api); Object.assign(module.exports, api);
@@ -167,86 +239,6 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
next(); next();
}); });
// Demo to show how all the helper functions work
// const demoSessionData = async () => {
// const socketId = "testSocketId";
//
// // 1. Test setSessionData and getSessionData
// await setSessionData(socketId, "field1", "Hello, Redis!");
// const field1Value = await getSessionData(socketId, "field1");
// console.log("Retrieved single field value:", field1Value);
//
// // 2. Test setMultipleSessionData and getMultipleSessionData
// await setMultipleSessionData(socketId, { field2: "Second Value", field3: "Third Value" });
// const multipleFields = await getMultipleSessionData(socketId, ["field2", "field3"]);
// console.log("Retrieved multiple field values:", multipleFields);
//
// // 3. Test setMultipleFromArraySessionData
// await setMultipleFromArraySessionData(socketId, [
// ["field4", "Fourth Value"],
// ["field5", "Fifth Value"]
// ]);
//
// // Retrieve and log all fields
// const allFields = await getMultipleSessionData(socketId, ["field1", "field2", "field3", "field4", "field5"]);
// console.log("Retrieved all field values:", allFields);
//
// // 4. Test list functions
// // Add item to the end of a Redis list
// await addItemToEndOfList(socketId, "logEvents", { event: "Log Event 1", timestamp: new Date() });
// await addItemToEndOfList(socketId, "logEvents", { event: "Log Event 2", timestamp: new Date() });
//
// // Add item to the beginning of a Redis list
// await addItemToBeginningOfList(socketId, "logEvents", { event: "First Log Event", timestamp: new Date() });
//
// // Retrieve the entire list
// const logEventsData = await pubClient.lrange(`socket:${socketId}:logEvents`, 0, -1);
// const logEvents = logEventsData.map((item) => JSON.parse(item));
// console.log("Log Events List:", logEvents);
//
// // 5. Test clearList
// await clearList(socketId, "logEvents");
// console.log("Log Events List cleared.");
//
// // Retrieve the list after clearing to confirm it's empty
// const logEventsAfterClear = await pubClient.lrange(`socket:${socketId}:logEvents`, 0, -1);
// console.log("Log Events List after clearing:", logEventsAfterClear); // Should be an empty array
//
// // 6. Test clearSessionData
// await clearSessionData(socketId);
// console.log("Session data cleared.");
//
// // 7. Test room functions
// const roomName = "testRoom";
// const user1 = { id: 1, name: "Alice" };
// const user2 = { id: 2, name: "Bob" };
//
// // Add users to room
// await addUserToRoom(roomName, user1);
// await addUserToRoom(roomName, user2);
//
// // Get users in room
// const usersInRoom = await getUsersInRoom(roomName);
// console.log(`Users in room ${roomName}:`, usersInRoom);
//
// // Remove a user from room
// await removeUserFromRoom(roomName, user1);
//
// // Get users in room after removal
// const usersInRoomAfterRemoval = await getUsersInRoom(roomName);
// console.log(`Users in room ${roomName} after removal:`, usersInRoomAfterRemoval);
//
// // Clean up: remove remaining users from room
// await removeUserFromRoom(roomName, user2);
//
// // Verify room is empty
// const usersInRoomAfterCleanup = await getUsersInRoom(roomName);
// console.log(`Users in room ${roomName} after cleanup:`, usersInRoomAfterCleanup); // Should be empty
// };
// if (process.env.NODE_ENV === "development") {
// demoSessionData();
// }
return api; return api;
}; };

View File

@@ -3,12 +3,13 @@ const { admin } = require("../firebase/firebase-handler");
const redisSocketEvents = ({ const redisSocketEvents = ({
io, io,
redisHelpers: { redisHelpers: {
setSessionData,
clearSessionData,
addUserSocketMapping, addUserSocketMapping,
removeUserSocketMapping, removeUserSocketMapping,
getUserSocketMapping setSessionData,
}, // Note: Used if we persist user to Redis getSessionData,
clearSessionData,
refreshUserSocketTTL
},
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }, ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom },
logger logger
}) => { }) => {
@@ -19,16 +20,18 @@ const redisSocketEvents = ({
// Socket Auth Middleware // Socket Auth Middleware
const authMiddleware = async (socket, next) => { const authMiddleware = async (socket, next) => {
if (!socket.handshake.auth.token) { const { token, bodyshopId } = socket.handshake.auth;
if (!token) {
return next(new Error("Authentication error - no authorization token.")); return next(new Error("Authentication error - no authorization token."));
} }
if (!bodyshopId) {
return next(new Error("Authentication error - no bodyshopId provided."));
}
try { try {
const user = await admin.auth().verifyIdToken(socket.handshake.auth.token); const user = await admin.auth().verifyIdToken(token);
socket.user = user; socket.user = user;
// Persist the user data in Redis for this socket await setSessionData(socket.id, "user", { ...user, bodyshopId });
await setSessionData(socket.id, "user", user); await addUserSocketMapping(user.email, socket.id, bodyshopId);
// Store a mapping from the user's email to the socket id
// await addUserSocketMapping(user.email, socket.id);
next(); next();
} catch (error) { } catch (error) {
next(new Error(`Authentication error: ${error.message}`)); next(new Error(`Authentication error: ${error.message}`));
@@ -37,32 +40,33 @@ const redisSocketEvents = ({
// Register Socket Events // Register Socket Events
const registerSocketEvents = (socket) => { const registerSocketEvents = (socket) => {
// Uncomment for further testing
// createLogEvent(socket, "debug", `Registering RedisIO Socket Events.`);
// getUserSocketMapping(socket.user.email).then((socketIds) => {
// console.log(socketIds);
// });
// Token Update Events // Token Update Events
const registerUpdateEvents = (socket) => { const registerUpdateEvents = (socket) => {
let latestTokenTimestamp = 0; let latestTokenTimestamp = 0;
const updateToken = async (newToken) => { const updateToken = async ({ token, bodyshopId }) => {
const currentTimestamp = Date.now(); const currentTimestamp = Date.now();
latestTokenTimestamp = currentTimestamp; latestTokenTimestamp = currentTimestamp;
if (!token || !bodyshopId) {
socket.emit("token-updated", { success: false, error: "Token or bodyshopId missing" });
return;
}
try { try {
const user = await admin.auth().verifyIdToken(newToken, true); const user = await admin.auth().verifyIdToken(token, true);
if (currentTimestamp < latestTokenTimestamp) { if (currentTimestamp < latestTokenTimestamp) {
createLogEvent(socket, "warn", "Outdated token validation skipped."); createLogEvent(socket, "warn", "Outdated token validation skipped.");
return; return;
} }
socket.user = user; socket.user = user;
// Update the session data in Redis with the new token info await setSessionData(socket.id, "user", { ...user, bodyshopId });
// await setSessionData(socket.id, "user", user); await refreshUserSocketTTL(user.email, bodyshopId);
// Update the mapping with the user's email createLogEvent(
await addUserSocketMapping(user.email, socket.id); socket,
createLogEvent(socket, "debug", `Token updated successfully for socket ID: ${socket.id}`); "debug",
`Token updated successfully for socket ID: ${socket.id} (bodyshop: ${bodyshopId})`
);
socket.emit("token-updated", { success: true }); socket.emit("token-updated", { success: true });
} catch (error) { } catch (error) {
if (error.code === "auth/id-token-expired") { if (error.code === "auth/id-token-expired") {
@@ -119,21 +123,19 @@ const redisSocketEvents = ({
// Disconnect Events // Disconnect Events
const registerDisconnectEvents = (socket) => { const registerDisconnectEvents = (socket) => {
const disconnect = async () => { const disconnect = async () => {
// Remove session data from Redis if (socket.user?.email) {
// await clearSessionData(socket.id); const userData = await getSessionData(socket.id, "user");
const bodyshopId = userData?.bodyshopId;
// Remove the mapping from user email to this socket id, if available if (bodyshopId) {
// if (socket.user?.email) { await removeUserSocketMapping(socket.user.email, socket.id, bodyshopId);
// await removeUserSocketMapping(socket.user.email, socket.id); }
// } await clearSessionData(socket.id);
}
// Leave all joined rooms
const rooms = Array.from(socket.rooms).filter((room) => room !== socket.id); const rooms = Array.from(socket.rooms).filter((room) => room !== socket.id);
for (const room of rooms) { for (const room of rooms) {
socket.leave(room); socket.leave(room);
} }
}; };
socket.on("disconnect", disconnect); socket.on("disconnect", disconnect);
}; };
@@ -152,6 +154,7 @@ const redisSocketEvents = ({
}); });
} }
}; };
const leaveConversationRoom = ({ bodyshopId, conversationId }) => { const leaveConversationRoom = ({ bodyshopId, conversationId }) => {
try { try {
const room = getBodyshopConversationRoom({ bodyshopId, conversationId }); const room = getBodyshopConversationRoom({ bodyshopId, conversationId });