diff --git a/docker-compose.yml b/docker-compose.yml index 0a22368..1b8ea21 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,7 @@ services: ports: - "${POSTGRES_PORT}:5432" volumes: - - postgres_data:/var/lib/postgresql/data + - postgres_data:/var/lib/postgresql # MinIO Object Storage minio: diff --git a/scripts/issue-chat.html b/scripts/issue-chat.html new file mode 100644 index 0000000..00fb33f --- /dev/null +++ b/scripts/issue-chat.html @@ -0,0 +1,153 @@ + + + + + + Issue Namespace Chat (test) + + + +

Issue Namespace Chat — Test Page

+
+ + + + + + +
+ +
+
Not connected
+ +
+
+
+ +
+ + + + +
+ + + + + diff --git a/scripts/issue-client1.js b/scripts/issue-client1.js new file mode 100644 index 0000000..f273f35 --- /dev/null +++ b/scripts/issue-client1.js @@ -0,0 +1,21 @@ +import { io } from "socket.io-client"; + +const socket = io("http://localhost:5050/issue-30", { + auth: { userId: 1, role: "building_manager" } +}); + +socket.on("connect", () => { + console.log("connected", socket.id); + + // broadcast to everyone in the issue + socket.emit("send_message_to_all", { text: "hello everyone from user 1" }); + + // private message to user with id 2 + socket.emit("send_message_to_user", { to: 2, text: "private hello to user 2 from user 1" }); +}); + +socket.on("receive_message", (msg) => { + console.log("receive_message:", msg); +}); + +socket.on("disconnect", () => console.log("disconnected")); \ No newline at end of file diff --git a/scripts/socket-client-ME.js b/scripts/socket-client-ME.js new file mode 100644 index 0000000..dcafc55 --- /dev/null +++ b/scripts/socket-client-ME.js @@ -0,0 +1,18 @@ +import { io } from "socket.io-client"; + +const socket = io("http://localhost:5050/new-issue", { + // auth: { token: "YOUR_JWT" }, // uncomment if server uses token +}); + +socket.on("connect", () => { + console.log("connected", socket.id); + socket.emit("send_message", { text: "hello from ME" }); +}); + +socket.on("new_issue", (issue) => { + console.log("new_issue received:", issue); +}); + +socket.on("disconnect", () => { + console.log("disconnected"); +}); \ No newline at end of file diff --git a/scripts/socket-client-Technician.js b/scripts/socket-client-Technician.js new file mode 100644 index 0000000..0ab84ad --- /dev/null +++ b/scripts/socket-client-Technician.js @@ -0,0 +1,16 @@ +import { io } from "socket.io-client"; + +const socket = io("http://localhost:5050/assign", { + //auth: { userId: "TECHNICIAN_ID_HERE", role: "technician" } // replace with real id or use query +}); + +socket.on("connect", () => { + console.log("connected", socket.id); + socket.emit("send_message", { text: "hello from Technician" }); +}); + +socket.on("assigned_issue", (issue) => { + console.log("assigned_issue received:", issue); +}); + +socket.on("disconnect", () => console.log("disconnected")); \ No newline at end of file diff --git a/src/__tests__/socket.test.js b/src/__tests__/socket.test.js index 0a38121..d6c7f83 100644 --- a/src/__tests__/socket.test.js +++ b/src/__tests__/socket.test.js @@ -1,15 +1,19 @@ import { createServer } from "http"; import { io as Client } from "socket.io-client"; -import { setupSocket } from "../socket/socket.js"; +import { setupSocket, makeDynamicNamespace } from "../socket/socket.js"; import { jest } from '@jest/globals'; describe("Socket.IO Basic Communication", () => { let httpServer, socketServer, port; - let client1, client2; + let issueClient1, issueClient2; + + // Set a timeout for the entire test suite + jest.setTimeout(10000); beforeAll(() => { // Suppress console logs during tests jest.spyOn(console, "log").mockImplementation(() => { }); + jest.spyOn(console, "error").mockImplementation(() => { }); }); beforeAll((done) => { @@ -19,8 +23,23 @@ describe("Socket.IO Basic Communication", () => { httpServer.listen(() => { port = httpServer.address().port; - client1 = new Client(`http://localhost:${port}`); - client2 = new Client(`http://localhost:${port}`); + // Create a dynamic namespace first + const testIssueId = 123; + makeDynamicNamespace(testIssueId); + + // Connect clients to the dynamic namespace + issueClient1 = new Client(`http://localhost:${port}/issue-${testIssueId}`, { + forceNew: true, + timeout: 5000, + transports: ['websocket'], + query: { userId: 'user1' } + }); + issueClient2 = new Client(`http://localhost:${port}/issue-${testIssueId}`, { + forceNew: true, + timeout: 5000, + transports: ['websocket'], + query: { userId: 'user2' } + }); let connectedCount = 0; const checkDone = () => { @@ -28,43 +47,91 @@ describe("Socket.IO Basic Communication", () => { if (connectedCount === 2) done(); }; - client1.on("connect", checkDone); - client2.on("connect", checkDone); + const handleError = (error) => { + done(error); + }; + + issueClient1.on("connect", checkDone); + issueClient2.on("connect", checkDone); + issueClient1.on("connect_error", handleError); + issueClient2.on("connect_error", handleError); }); }); - afterAll(async () => { - if (client1.connected) client1.disconnect(); - if (client2.connected) client2.disconnect(); + afterEach(() => { + // Clean up event listeners after each test + if (issueClient1) { + issueClient1.removeAllListeners("receive_message"); + } + if (issueClient2) { + issueClient2.removeAllListeners("receive_message"); + } + }); - await new Promise(resolve => socketServer.close(resolve)); - await new Promise(resolve => httpServer.close(resolve)); + afterAll(async () => { + // Close clients first + if (issueClient1?.connected) { + issueClient1.disconnect(); + } + if (issueClient2?.connected) { + issueClient2.disconnect(); + } + + // Wait a bit for disconnections to process + await new Promise(resolve => setTimeout(resolve, 100)); + + // Close socket server + if (socketServer) { + await new Promise(resolve => { + socketServer.close(() => { + resolve(); + }); + }); + } + + // Close HTTP server + if (httpServer && httpServer.listening) { + await new Promise(resolve => { + httpServer.close(() => { + resolve(); + }); + }); + } }); - test("should broadcast message to other clients", (done) => { + test("should broadcast message to all clients in issue room", (done) => { const testMessage = { text: "Hello world" }; - client2.once("receive_message", (msg) => { + issueClient2.once("receive_message", (msg) => { expect(msg).toEqual(testMessage); done(); }); - client1.emit("send_message", testMessage); + issueClient1.emit("send_message_to_all", testMessage); }); test("sender should not receive its own message", (done) => { const testMessage = { text: "Sender check" }; + let messageReceived = false; // If sender receives a message, fail - client2.once("receive_message", () => { + const messageHandler = () => { + messageReceived = true; done(new Error("Sender received its own message!")); - }); + }; - // Emit from client2 - client2.emit("send_message", testMessage); + issueClient2.once("receive_message", messageHandler); - // Wait a bit to ensure no message arrives - setTimeout(done, 500); + // Emit from client2 + issueClient2.emit("send_message_to_all", testMessage); + + // Wait a bit to ensure no message arrives, then clean up + setTimeout(() => { + issueClient2.removeListener("receive_message", messageHandler); + if (!messageReceived) { + done(); + } + }, 300); }); }); diff --git a/src/controllers/cashRequestController.js b/src/controllers/cashRequestController.js index 4ae362d..c9d2dc0 100644 --- a/src/controllers/cashRequestController.js +++ b/src/controllers/cashRequestController.js @@ -1,3 +1,4 @@ +import { issueRealtimeUpdate } from '../socket/socket.js'; /** * Cash Request Controller * Handles HTTP requests for petty cash requests @@ -110,6 +111,13 @@ export const createCashRequest = async (req, res) => { description }); + try { + // Notify via realtime update that a new cash request has been created for the issue + issueRealtimeUpdate(issue_id, newCashRequest); + } catch (err) { + console.error('issueRealtimeUpdate error after creating cash request:', err); + } + res.status(201).json({ success: true, message: 'Petty cash request created successfully', @@ -161,6 +169,13 @@ export const updateCashRequest = async (req, res) => { const updatedCashRequest = await CashRequestService.updateCashRequest(id, updateData); + try { + // Notify via realtime update that the cash request has been updated + issueRealtimeUpdate(updatedCashRequest.issue_id, updatedCashRequest); + } catch (err) { + console.error('issueRealtimeUpdate error after updating cash request:', err); + } + if (!updatedCashRequest) { return res.status(404).json({ success: false, @@ -226,6 +241,13 @@ export const approveCashRequest = async (req, res) => { const approvedRequest = await CashRequestService.approveCashRequest(id); + try { + // Notify via realtime update that the cash request has been approved + issueRealtimeUpdate(approvedRequest.issue_id, approvedRequest); + } catch (err) { + console.error('issueRealtimeUpdate error after approving cash request:', err); + } + if (!approvedRequest) { return res.status(404).json({ success: false, @@ -266,6 +288,13 @@ export const rejectCashRequest = async (req, res) => { }); } + try { + // Notify via realtime update that the cash request has been rejected + issueRealtimeUpdate(rejectedRequest.issue_id, rejectedRequest); + } catch (err) { + console.error('issueRealtimeUpdate error after rejecting cash request:', err); + } + res.status(200).json({ success: true, message: 'Cash request rejected successfully', diff --git a/src/controllers/issueController.js b/src/controllers/issueController.js index 8305d30..a64a57e 100644 --- a/src/controllers/issueController.js +++ b/src/controllers/issueController.js @@ -1,4 +1,5 @@ import issueService from '../services/issueService.js'; +import { notifyNewIssue, notifyAssign, makeDynamicNamespace, issueRealtimeUpdate, removeDynamicNamespace } from '../socket/socket.js'; class IssueController { // POST /api/issues - Create new issue @@ -47,6 +48,16 @@ class IssueController { const result = await issueService.createIssue(issueData); if (result.success) { + // Notify all Maintenance Executives about the new issue(real-time) + try { notifyNewIssue(result); } catch (e) { console.error(e);} + + try { + // Create dynamic namespaces for real-time communication + makeDynamicNamespace(result.data.id); + } catch (nsErr) { + console.error('makeDynamicNamespace failed:', nsErr); + } + return res.status(201).json(result); } else { return res.status(400).json(result); @@ -155,6 +166,14 @@ class IssueController { const result = await issueService.updateIssue(parseInt(id), updateData); + try { + // Notify via realtime update that the issue has been updated + issueRealtimeUpdate(result.data.id, result); + } catch (err) { + console.error('issueRealtimeUpdate error after updating issue:', err); + } + + if (result.success) { return res.status(200).json(result); } else { @@ -184,6 +203,8 @@ class IssueController { const result = await issueService.deleteIssue(parseInt(id)); if (result.success) { + // Remove all connections and delete the namespace for the issue + removeDynamicNamespace(parseInt(id)); return res.status(200).json(result); } else { return res.status(404).json(result); @@ -220,6 +241,20 @@ class IssueController { const result = await issueService.assignTechnician(parseInt(id), parseInt(technician_id)); if (result.success) { + // Notify via realtime update that the technician has been assigned + try { + issueRealtimeUpdate(parseInt(id), result); + } catch (emitErr) { + console.error('issueRealtimeUpdate failed:', emitErr); + } + + // broadcast assignment to the assigned technician (real-time) + try { + notifyAssign(parseInt(technician_id), result); + } catch (emitErr) { + console.error('notifyAssign failed:', emitErr); + } + return res.status(200).json(result); } else { return res.status(400).json(result); @@ -256,6 +291,16 @@ class IssueController { const result = await issueService.assignMaintenanceExecutive(parseInt(id), parseInt(maintenance_executive_id)); if (result.success) { + // Notify via realtime update that the maintenance executive has been assigned + try { + issueRealtimeUpdate(parseInt(id), result); + } catch (emitErr) { + console.error('issueRealtimeUpdate failed:', emitErr); + } + + // Notify all Maintenance Executives about the new issue(real-time) update with assigned ME + try { notifyNewIssue(result); } catch (e) { console.error(e);} + return res.status(200).json(result); } else { return res.status(400).json(result); @@ -292,6 +337,13 @@ class IssueController { const result = await issueService.assignThirdParty(parseInt(id), parseInt(third_party_id)); if (result.success) { + // Notify via realtime update that the third party has been assigned + try { + issueRealtimeUpdate(parseInt(id), result); + } catch (emitErr) { + console.error('issueRealtimeUpdate failed:', emitErr); + } + return res.status(200).json(result); } else { return res.status(400).json(result); @@ -336,6 +388,24 @@ class IssueController { const result = await issueService.updateStatus(parseInt(id), status); if (result.success) { + // Notify via realtime update that the issue status has been updated (in chat interface) + try { + issueRealtimeUpdate(parseInt(id), result); + } catch (emitErr) { + console.error('issueRealtimeUpdate failed:', emitErr); + } + + // Notify Maintenance Executives about the issue status update(real-time in home dashboard) + try { notifyNewIssue(result); } catch (e) { console.error(e);} + + // Notify assigned technician about the issue status update(real-time in home dashboard) + try { notifyAssign(result.data.technician_id, result); } catch (e) { console.error(e);} + + // Remove all connections and delete the namespace for the issue + if (result.data.status === 'Closed' || result.data.status === 'Done') { + removeDynamicNamespace(result.data.id); + } + return res.status(200).json(result); } else { return res.status(400).json(result); diff --git a/src/socket/socket.js b/src/socket/socket.js index 38de0fd..15dd122 100644 --- a/src/socket/socket.js +++ b/src/socket/socket.js @@ -1,11 +1,20 @@ import 'dotenv/config'; import { Server } from "socket.io"; +import messageService from '../services/messageService.js'; import jwt from "jsonwebtoken"; +let ioInstance = null; +let newIssue = null; +let assign = null; + export const setupSocket = (server) => { const io = new Server(server, { cors: { origin: "*" } }); + ioInstance = io; + + newIssue = io.of("/new-issue"); // the "new-issue" namespace for creating new issues + assign = io.of("/assign"); // the "assign" namespace for assigning issues // Middleware to authenticate user // io.use((socket, next) => { @@ -20,17 +29,46 @@ export const setupSocket = (server) => { // next(new Error("Invalid token")); // } // }); + let roleSpecificID = 3; // temp for testing + let role = "technician"; // temp for testing + + + newIssue.on("connection", async (socket) => { + // testing without auth + role = "maintenance_executive"; + + if (role !== "maintenance_executive") { + console.log(`Non-ME tried to connect to new-issue namespace: ${socket.id}`); + socket.disconnect(); + return; + } + console.log(`User connected(new-issue) with socket ${socket.id}`); + + socket.on("send_message", (message) => { + console.log(message); + }); + + socket.on("disconnect", () => { + console.log(`User disconnected: ${socket.id}`); + }); + }); - io.on("connection", async (socket) => { - console.log(`User connected with socket ${socket.id}`); + assign.on("connection", async (socket) => { + // testing without auth + role = "technician"; - //Recieve message - socket.on("send_message", async (message) => { - console.log(`Message received from ${socket.id}:`, message); + if (role !== "technician") { + console.log(`Non-technician tried to connect to assign namespace: ${socket.id}`); + socket.disconnect(); + return; + } + const room = `technician:${roleSpecificID}`; + socket.join(room); + console.log(`Technician ${roleSpecificID} joined room: ${room} with socket ${socket.id}`); - // Broadcast to everyone EXCEPT the sender - socket.broadcast.emit("receive_message", message); - }) + socket.on("send_message", (message) => { + console.log(message); + }); socket.on("disconnect", () => { console.log(`User disconnected: ${socket.id}`); @@ -38,4 +76,120 @@ export const setupSocket = (server) => { }); return io; +} + +//Emit a new_issue event to all clients(Maintenance Executives) connected to the /new-issue namespace. +export function notifyNewIssue(issue) { + if (!newIssue) return; + try { + newIssue.emit('new_issue', issue); + } catch (err) { + console.error('notifyNewIssue error:', err); + } +} + +//Emit an assigned_issue event to a specific technician in the /assign namespace. +export function notifyAssign(id,issue) { + if (!assign) return; + try { + const room = `technician:${id}`; + assign.to(room).emit('assigned_issue', issue); + } catch (err) { + console.error('notifyAssign error:', err); + } +} + +// Create a dynamic namespace for a specific issue ID +export function makeDynamicNamespace(issueId) { + if (!ioInstance) return null; + + console.log(`makeDynamicNamespace for issueId: ${issueId}`); + const dynamicNamespace = ioInstance.of(`/issue-${issueId}`); + + dynamicNamespace.on("connection", (socket) => { + + // For testing without auth + const userId = socket.handshake?.auth?.userId || socket.handshake?.query?.userId || socket.data?.userId || socket.id; + + console.log(`User connected to /issue-${issueId} with socket ${socket.id}`); + + // room for all users in this issue + const issueRoom = `issue:${issueId}`; + socket.join(issueRoom); + console.log(`User ${userId} joined room: ${issueRoom}`); + + // personal room for user + const userRoom = `user:${userId}`; + socket.join(userRoom); + console.log(`User ${userId} joined room: ${userRoom}`); + + socket.on("send_message_to_all", (message) => { + console.log(message); + socket.to(issueRoom).emit("receive_message", message); + }); + + socket.on("send_message_to_user", async (message, receiverId) => { + console.log(message); + socket.to(`user:${receiverId}`).emit("receive_message", message); + try { + await messageService.createMessage({ + body: message.text, + sender_id: userId, + receiver_id: receiverId, + issue_id: issueId, + }); + } catch (err) { + console.error('createMessage error:', err); + } + }); + + socket.on("disconnect", () => { + console.log(`User disconnected: ${socket.id}`); + }); + }); + + return dynamicNamespace; +} + +export function removeDynamicNamespace(issueId) { + if (!ioInstance) return; + + try { + const namespaceName = `/issue-${issueId}`; + const namespace = ioInstance.of(namespaceName); + + console.log(`Removing dynamic namespace: ${namespaceName}`); + + // Get all sockets in the namespace and disconnect them + namespace.fetchSockets().then((sockets) => { + sockets.forEach((socket) => { + console.log(`Disconnecting socket ${socket.id} from namespace ${namespaceName}`); + socket.disconnect(true); + }); + + // Remove all listeners from the namespace + namespace.removeAllListeners(); + + // Delete the namespace from the server + ioInstance._nsps.delete(namespaceName); + + console.log(`Dynamic namespace ${namespaceName} removed successfully`); + }).catch((err) => { + console.error(`Error removing namespace ${namespaceName}:`, err); + }); + + } catch (err) { + console.error('removeDynamicNamespace error:', err); + } +} + +// Emit an issue_update event to all clients connected to the /issue- namespace. +export function issueRealtimeUpdate(issueId, issueData) { + if (!ioInstance) return; + try { + const namespace = ioInstance.of(`/issue-${issueId}`); + namespace.emit('issue_update', issueData); + } catch (err) { + console.error('issueRealtimeUpdate error:', err); + } } \ No newline at end of file