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