diff --git a/Backend/MatchingService/Dockerfile b/Backend/MatchingService/Dockerfile new file mode 100644 index 0000000000..4a25a56749 --- /dev/null +++ b/Backend/MatchingService/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20 + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . + +EXPOSE 3003 +CMD ["npm", "start"] diff --git a/Backend/MatchingService/app.js b/Backend/MatchingService/app.js index 82efb1fff0..66be1da456 100644 --- a/Backend/MatchingService/app.js +++ b/Backend/MatchingService/app.js @@ -1,28 +1,32 @@ const express = require('express'); const cors = require("cors"); const dotenv = require("dotenv"); -//const matchmakingRouter = require("./controllers/matchmaking"); -//const { consumeQueue } = require('./rabbitmq/subscriber'); -//const { setupRabbitMQ } = require('./rabbitmq/setup'); -//const { publishToQueue } = require('./rabbitmq/publisher') +const matchmakingRouter = require("./controllers/matchmaking"); +const { consumeQueue, consumeDLQ } = require('./rabbitmq/subscriber'); +const { setupRabbitMQ } = require('./rabbitmq/setup'); dotenv.config(); const app = express(); app.use(cors()); app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.options("*", cors()); app.use('/api/match', matchmakingRouter); // TODO: Start consuming RabbitMQ queues -/* + setupRabbitMQ().then(() => { consumeQueue().catch(console.error); - publishToQueue("user_234", "easy", "python") - publishToQueue("user_100", "easy", "java") + consumeDLQ().catch(console.error); + + // publishToQueue({userId: "user_1", difficulty: "easy", language: "java"}) + // publishToQueue({userId: "user_2", difficulty: "easy", language: "python"}) + // publishToQueue({userId: "user_3", difficulty: "easy", language: "java"}) }) -*/ + module.exports = app; \ No newline at end of file diff --git a/Backend/MatchingService/controllers/matchmaking.js b/Backend/MatchingService/controllers/matchmaking.js index 74090d790b..b907fff795 100644 --- a/Backend/MatchingService/controllers/matchmaking.js +++ b/Backend/MatchingService/controllers/matchmaking.js @@ -1,17 +1,18 @@ -// TODO: WRITE API FOR MATCHING USER, REMEMBER TO DEAL WITH CORS ALLOW ACCESS ORIGIN ERROR +// WRITE API FOR MATCHING USER, REMEMBER TO DEAL WITH CORS ALLOW ACCESS ORIGIN ERROR +// Cors settled in app.js + -/* const express = require('express'); const router = express.Router(); const { publishToQueue } = require('../rabbitmq/publisher'); // Route for frontend to send user matching info -router.post('/match', async (req, res) => { - const { userId, language, difficulty } = req.body; +router.post('/enterMatchmaking', async (req, res) => { + const { userId, difficulty, language } = req.body; try { // Publish user info to RabbitMQ - await publishToQueue(userId, language, difficulty); + await publishToQueue({userId: userId, difficulty: difficulty, language: language}); res.status(200).send('User info sent for matching.'); } catch (error) { console.error('Error publishing user info:', error); @@ -19,5 +20,18 @@ router.post('/match', async (req, res) => { } }); -module.exports = router; -*/ \ No newline at end of file +// This is for the alternative where the player also listens to a queue after entering matchmaking +/* +router.post('/waitMatch', async (req, res) => { + try { + // Start consuming RabbitMQ queues + // await consumeQueue(); + res.status(200).send('Waiting for match...'); + } catch (error) { + console.error('Error consuming RabbitMQ queue:', error); + res.status(500).send('Error in matchmaking process.'); + } +}) + */ + +module.exports = router; \ No newline at end of file diff --git a/Backend/MatchingService/package-lock.json b/Backend/MatchingService/package-lock.json index f672f6f0a9..e84a30d900 100644 --- a/Backend/MatchingService/package-lock.json +++ b/Backend/MatchingService/package-lock.json @@ -8,7 +8,8 @@ "amqplib": "^0.10.4", "cors": "^2.8.5", "dotenv": "^16.4.5", - "express": "^4.21.1" + "express": "^4.21.1", + "ws": "^8.18.0" } }, "node_modules/@acuminous/bitsyntax": { @@ -889,6 +890,26 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/Backend/MatchingService/package.json b/Backend/MatchingService/package.json index c522c7b1d1..7c26162f52 100644 --- a/Backend/MatchingService/package.json +++ b/Backend/MatchingService/package.json @@ -3,6 +3,11 @@ "amqplib": "^0.10.4", "cors": "^2.8.5", "dotenv": "^16.4.5", - "express": "^4.21.1" + "express": "^4.21.1", + "ws": "^8.18.0" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "node index.js" } } diff --git a/Backend/MatchingService/rabbitmq/publisher.js b/Backend/MatchingService/rabbitmq/publisher.js index 8d86a27942..97b9eb9c33 100644 --- a/Backend/MatchingService/rabbitmq/publisher.js +++ b/Backend/MatchingService/rabbitmq/publisher.js @@ -1,36 +1,63 @@ const amqp = require('amqplib'); -// TODO: Write function to publish to rabbitMQ +const { matching_exchange_name } = require('./setup.js'); + +let channel = null; // Store a persistent channel connection + +async function connectToRabbitMQ() { + if (!channel) { + try { + const connection = await amqp.connect(process.env.RABBITMQ_URL); + channel = await connection.createChannel(); + console.log("RabbitMQ channel created"); + } catch (error) { + console.error('Error creating RabbitMQ channel:', error); + } + } + return channel; +} -/* -async function publishToQueue(userId, difficulty, language) { +async function publishToQueue({userId, difficulty, language}) { try { - const connection = await amqp.connect(process.env.RABBITMQ_URL); - const channel = await connection.createChannel(); - const matching_exchange_name = 'matching_exchange'; + const channel = await connectToRabbitMQ(); // Reuse persistent connection const routingKey = `${difficulty}.${language}`; - const queueName = `${difficulty}.${language}`; - if (queueInfo) { - channel.publish(matching_exchange_name, routingKey, Buffer.from(JSON.stringify({ userId, language, difficulty }))); + // Publish the message to the exchange + const messageSent = channel.publish( + matching_exchange_name, + routingKey, + Buffer.from(JSON.stringify({ userId, difficulty, language })) + ); - console.log(`Published user: ${userId} with routing key: ${routingKey}`); + if (messageSent) { + console.log(`Message sent: ${userId} -> ${routingKey}`); } else { - console.log(`Cannot publish message: Queue ${queueName} does not exist`); + console.error(`Message NOT sent: ${userId} -> ${routingKey}`); } - - - await channel.close(); - await connection.close(); } catch (error) { console.error('Error publishing to RabbitMQ:', error); } } -module.exports = { publishToQueue }; -*/ - - - - - +async function publishCancelRequest({ userId }) { + try { + const channel = await connectToRabbitMQ(); // Reuse persistent connection + const routingKey = 'cancel'; // Define a routing key for cancellation + + // Publish the cancel message to the exchange + const messageSent = channel.publish( + matching_exchange_name, + routingKey, + Buffer.from(JSON.stringify({ userId })) + ); + + if (messageSent) { + console.log(`Cancel request sent: ${userId}`); + } else { + console.error(`Cancel request NOT sent: ${userId}`); + } + } catch (error) { + console.error('Error publishing cancel request to RabbitMQ:', error); + } +} +module.exports = { publishToQueue, publishCancelRequest }; diff --git a/Backend/MatchingService/rabbitmq/setup.js b/Backend/MatchingService/rabbitmq/setup.js index 79e17c9651..a2cc7bf202 100644 --- a/Backend/MatchingService/rabbitmq/setup.js +++ b/Backend/MatchingService/rabbitmq/setup.js @@ -1,12 +1,24 @@ const amqp = require("amqplib"); +const matching_exchange_name = "matching_exchange"; +const dead_letter_exchange_name = "dead_letter_exchange"; +const dead_letter_queue_name = "dead_letter_queue"; +const cancel_queue_name = "cancel_queue"; +const queueNames = [ + 'easy.python', + 'easy.java', + 'easy.cplusplus', + 'medium.python', + 'medium.java', + 'medium.cplusplus', + 'hard.python', + 'hard.java', + 'hard.cplusplus', +]; + async function setupRabbitMQ() { try { - const connection = await amqp.connect(process.env.RABBITMQ_URL) - .catch((error) => { - console.error("Error connecting to RabbitMQ:", error); - return null; - }); + const connection = await amqp.connect(process.env.RABBITMQ_URL); if (!connection) { return; @@ -14,65 +26,46 @@ async function setupRabbitMQ() { const channel = await connection.createChannel(); - // Declare matching exchange to be bind to queues - const matching_exchange_name = "matching_exchange"; - await channel.assertExchange(matching_exchange_name, "topic", { durable: false }); + // Declare the matching exchange (topic) + await channel.assertExchange(matching_exchange_name, "topic", { durable: true }); - // Declare dead letter exchange - const dead_letter_exchange_name = "dead_letter_exchange"; - await channel.assertExchange(dead_letter_exchange_name, "fanout", { durable: false }); + // Declare the dead-letter exchange (fanout) + await channel.assertExchange(dead_letter_exchange_name, "fanout", { durable: true }); - const queueNames = [ - 'easy.python', - 'easy.java', - 'easy.cplusplus', - 'medium.python', - 'medium.java', - 'medium.cplusplus', - 'hard.python', - 'hard.java', - 'hard.cplusplus' - ] + // Declare and bind all main queues with TTL and DLQ bindings + for (let queueName of queueNames) { + await channel.deleteQueue(queueName); // Ensure we start fresh for each setup - // Create and bind queues to exchange with the routing keys - for (let name of queueNames) { - /* - try { - await channel.deleteQueue(name); - } catch (err) { - console.log(`Queue ${name} does not exist or could not be deleted: ${err.message}`); - } - */ - await channel.assertQueue(name, - { durable: false, // durable=false ensures queue will survive broker restarts - arguments: { - 'x-dead-letter-exchange': dead_letter_exchange_name // set dead letter exchange - } - - }); + await channel.assertQueue(queueName, { + durable: true, + arguments: { + 'x-message-ttl': 10000, // 60 seconds TTL + 'x-dead-letter-exchange': dead_letter_exchange_name // Bind to dead-letter exchange + } + }); - await channel.bindQueue(name, matching_exchange_name, name); // e.g. messages with routing key easy.python goes to easy.python queue + await channel.bindQueue(queueName, matching_exchange_name, queueName); // Bind to exchange } - // Create and bind queue to exchange (if we want only 1 queue) - // await channel.assertQueue(name, { durable: false }) - // await channel.bindQueue(name, matching_exchange_name, '#') // all messages go to this queue because of a wildcard pattern + // Delete DLQ before asserting it + await channel.deleteQueue(dead_letter_queue_name); - // Create and bind dead letter queue - // const dead_letter_queue_name = "dead_letter_queue"; - // await channel.assertQueue(deadLetterQueueName, { durable: false }); - // await channel.bindQueue(deadLetterQueueName, deadLetterExchangeName, ''); // Bind all messages to this queue + // Declare the dead-letter queue and bind it to the dead-letter exchange + await channel.assertQueue(dead_letter_queue_name, { durable: true }); + await channel.bindQueue(dead_letter_queue_name, dead_letter_exchange_name, ''); // Bind with no routing key + // Declare and bind the cancel queue + await channel.deleteQueue(cancel_queue_name); // Delete any existing cancel queue + await channel.assertQueue(cancel_queue_name, { durable: true }); // Declare the cancel queue + await channel.bindQueue(cancel_queue_name, matching_exchange_name, 'cancel'); // Bind with the "cancel" routing key - console.log("RabbitMQ setup complete with queues and bindings.") + console.log("RabbitMQ setup complete with queues, DLQ, and bindings."); await channel.close(); await connection.close(); } catch (error) { - console.log('Error setting up RabbitMQ:', error); + console.error("Error setting up RabbitMQ:", error); } } -module.exports = { setupRabbitMQ }; - -setupRabbitMQ() \ No newline at end of file +module.exports = { setupRabbitMQ, matching_exchange_name, queueNames, dead_letter_queue_name , cancel_queue_name}; \ No newline at end of file diff --git a/Backend/MatchingService/rabbitmq/subscriber.js b/Backend/MatchingService/rabbitmq/subscriber.js index 4191e89142..e77b378809 100644 --- a/Backend/MatchingService/rabbitmq/subscriber.js +++ b/Backend/MatchingService/rabbitmq/subscriber.js @@ -1,38 +1,242 @@ const amqp = require('amqplib'); +const { queueNames } = require('./setup.js'); +// const { matchUsers } = require('../services/matchingService.js'); +const { notifyUsers } = require('../websocket/websocket'); // TODO: Subscribe and acknowledge messages with user info when timeout/user matched -//const { matchUsers } = require('../services/matchingService'); +// To remember what goes in a subscriber use some Acronym +// Connect, Assert, Process, E - for Acknowledge + +const dead_letter_queue_name = "dead_letter_queue"; +const timeoutMap = {}; + +// Local dictionary to store waiting users +const waitingUsers = {}; + +// using promises to handle errors and ensure clearing of timer. +function matchUsers(channel, msg, userId, language, difficulty) { + const criteriaKey = `${difficulty}.${language}`; + + // If the criteria key does not exist, create it + if (!waitingUsers[criteriaKey]) { + waitingUsers[criteriaKey] = []; + } + + // Store both the userId, message, and the channel in waitingUsers + waitingUsers[criteriaKey].push({ userId, msg, channel }); + console.log(`User ${userId} added to ${criteriaKey}. Waiting list: ${waitingUsers[criteriaKey].length}`); + + // Check if there are 2 or more users waiting for this criteria + if (waitingUsers[criteriaKey].length >= 2) { + const matchedUsers = waitingUsers[criteriaKey].splice(0, 2); // Match the first two users + console.log(`Matched users: ${matchedUsers.map(user => user.userId)}`); + + // Notify users of the match + notifyUsers(matchedUsers.map(user => user.userId), 'Match found!', 'match'); + + // Acknowledge the messages for both matched users + matchedUsers.forEach(({ msg, channel }) => { + acknowledgeMessage(channel, msg); + }); + + return true; + } + + return false; +} + + +async function acknowledgeMessage(channel, msg) { + return new Promise((resolve, reject) => { + try { + channel.ack(msg); + console.log(`Acknowledged message for user: ${JSON.parse(msg.content).userId}`); + clearTimeout(timeoutMap[JSON.parse(msg.content).userId]); // Clear any pending timeout + delete timeoutMap[JSON.parse(msg.content).userId]; // Clean up + resolve(); + } catch (error) { + console.error(`Failed to acknowledge message:`, error); + reject(error); + } + }); +} + +async function rejectMessage(channel, msg, userId) { + return new Promise((resolve, reject) => { + try { + // Get user data from the message to find the correct key in waitingUsers + const userData = JSON.parse(msg.content.toString()); + const { language, difficulty } = userData; + + // Correctly creating the criteriaKey using template literals + const criteriaKey = `${difficulty}.${language}`; + + + // Find the user in the waitingUsers list and remove them + if (waitingUsers[criteriaKey]) { + // Find the index of the user in the waiting list + const userIndex = waitingUsers[criteriaKey].findIndex(user => user.userId === userId); + + if (userIndex !== -1) { + // Remove the user from the waiting list + waitingUsers[criteriaKey].splice(userIndex, 1); + console.log(`Removed user ${userId} from waiting list for ${criteriaKey}`); + } + } + + // Reject the message without requeuing + channel.reject(msg, false); // Reject without requeuing + console.log(`Rejected message for user: ${userId}`); + + // Clean up the timeoutMap + if (timeoutMap[userId]) { + clearTimeout(timeoutMap[userId]); + delete timeoutMap[userId]; + } + + resolve(); + } catch (error) { + console.error(`Failed to reject message for user ${userId}:, error`); + reject(error); + } + }); +} -/* async function consumeQueue() { try { const connection = await amqp.connect(process.env.RABBITMQ_URL); const channel = await connection.createChannel(); - const exchange = 'matching_exchange'; - // Consuming messages from multiple queues (already created in setup) - const queueNames = ['easy.python', 'easy.java', 'medium.python', 'medium.java', 'hard.python', 'hard.java']; - - console.log("Waiting for users...") + console.log("Waiting for users..."); + // Process + subscribe to each matchmaking queue for (let queueName of queueNames) { - channel.consume(queueName, (msg) => { + await channel.consume(queueName, async (msg) => { if (msg !== null) { const userData = JSON.parse(msg.content.toString()); - // const { userId, language, difficulty } = userData; + const { userId, language, difficulty } = userData; // Perform the matching logic - // matchUsers(userId, language, difficulty); - console.log(userData); + console.log(`Received user ${userId} with ${language} and ${difficulty}`); + + // Call matchUsers with channel, message, and user details + const matched = matchUsers(channel, msg, userId, language, difficulty); + + if (!matched) { + console.log(`No match for ${userId}, waiting for rejection timeout.`); - channel.ack(msg); + const timeoutId = setTimeout(async () => { + await rejectMessage(channel, msg, userId); + }, 10000); // 10 seconds delay + + timeoutMap[userId] = timeoutId; + } } - }); + }, { noAck: false }); // Ensure manual acknowledgment } + + console.log("Listening to matchmaking queues"); + + await consumeCancelQueue(); + console.log("Listening to Cancel Queue"); } catch (error) { console.error('Error consuming RabbitMQ queue:', error); } } -*/ +async function consumeDLQ() { + try { + const connection = await amqp.connect(process.env.RABBITMQ_URL); + const channel = await connection.createChannel(); + + // Consume messages from the DLQ + await channel.consume(dead_letter_queue_name, (msg) => { + if (msg !== null) { + const messageContent = JSON.parse(msg.content.toString()); + const { userId, difficulty, language } = messageContent; + + console.log(`Received message from DLQ for user: ${userId}`); + + // Notify the user via WebSocket + notifyUsers(userId, `Match not found for ${difficulty} ${language}, please try again.`, 'rejection'); + + // Acknowledge the message (so it's removed from the DLQ) + channel.ack(msg); + } + }); + + console.log(`Listening to Dead Letter Queue for unmatched users...`); + } catch (error) { + console.error('Error consuming from DLQ:', error); + } +} + +async function consumeCancelQueue() { + try { + const connection = await amqp.connect(process.env.RABBITMQ_URL); + const channel = await connection.createChannel(); + + // Subscribe to the cancel queue + await channel.consume('cancel_queue', async (msg) => { + if (msg !== null) { + const { userId } = JSON.parse(msg.content.toString()); + console.log(`Received cancel request for user: ${userId}`); + + // Process the cancel request + await cancelMatching(channel, msg, userId); + } + }, { noAck: false }); // Ensure manual acknowledgment + + console.log("Listening for cancel requests"); + } catch (error) { + console.error('Error consuming cancel queue:', error); + } +} + +async function cancelMatching(cancelChannel, cancelMsg, userId) { + try { + let foundOriginalMsg = false; + + // Loop through waitingUsers to find the original message for the user + Object.keys(waitingUsers).forEach(criteriaKey => { + const userIndex = waitingUsers[criteriaKey].findIndex(user => user.userId === userId); + + if (userIndex !== -1) { + const { msg, channel } = waitingUsers[criteriaKey][userIndex]; // Get original msg and its channel + + // Acknowledge the original matchmaking message from the queue (e.g., easy.python) + if (msg && channel) { + console.log(`Acknowledging original message for user ${userId} in queue ${criteriaKey}`); + channel.ack(msg); // Use the same channel that consumed the message to acknowledge it + foundOriginalMsg = true; + } + + // Remove the user from the waiting list + waitingUsers[criteriaKey].splice(userIndex, 1); + console.log(`User ${userId} removed from waiting list for ${criteriaKey}`); + } + }); + + // If original message not found, log a warning + if (!foundOriginalMsg) { + console.warn(`Original message for user ${userId} not found in matchmaking queues.`); + } + + // Clear any timeouts for the user + if (timeoutMap[userId]) { + clearTimeout(timeoutMap[userId]); + delete timeoutMap[userId]; + } + + // Acknowledge the cancel message from the cancel queue + cancelChannel.ack(cancelMsg); + console.log(`Cancel processed for user ${userId}`); + + } catch (error) { + console.error(`Failed to process cancel for user ${userId}:`, error); + } +} + + +module.exports = { consumeQueue, consumeDLQ }; \ No newline at end of file diff --git a/Backend/MatchingService/services/matchingService.js b/Backend/MatchingService/services/matchingService.js deleted file mode 100644 index a8f431ae50..0000000000 --- a/Backend/MatchingService/services/matchingService.js +++ /dev/null @@ -1,32 +0,0 @@ -// TODO: Matching users logic - - -/* -const { notifyUsers } = require('./websocket/websocket) -const waitingUsers = {}; - -function matchUsers(userId, language, difficulty) { - const criteriaKey = `${difficulty}.${language}`; - - if (!waitingUsers[criteriaKey]) { - waitingUsers[criteriaKey] = []; - } - - waitingUsers[criteriaKey].push(userId); - console.log(`User ${userId} added to ${criteriaKey}. Waiting list: ${waitingUsers[criteriaKey].length}`); - - // Check if there are 2 or more users waiting for this criteria - if (waitingUsers[criteriaKey].length >= 2) { - const matchedUsers = waitingUsers[criteriaKey].splice(0, 2); // Match the first two users - console.log(`Matched users: ${matchedUsers}`); - - // Send match success (this could trigger WebSocket communication) - notifyUsers(matchedUsers); - return true; - } - - return false; -} - -module.exports = { matchUsers }; -*/ \ No newline at end of file diff --git a/Backend/MatchingService/websocket/websocket.js b/Backend/MatchingService/websocket/websocket.js index 5abf34f5e9..89531623e0 100644 --- a/Backend/MatchingService/websocket/websocket.js +++ b/Backend/MatchingService/websocket/websocket.js @@ -1,14 +1,42 @@ -// TODO: Write socket logic to connect backend to frontend here - -/* +const { publishToQueue , publishCancelRequest} = require('../rabbitmq/publisher'); const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 }); wss.on('connection', (ws) => { console.log('User connected to WebSocket'); - - ws.on('message', (message) => { - console.log(`Received message: ${message}`); + + // Listen for messages from the frontend + ws.on('message', async (message) => { + try { + console.log(`Received message: ${message}`); + + // Parse the message to extract userId, difficulty, and language + const { userId, difficulty, language , action} = JSON.parse(message); + + // Store userId in WebSocket connection + ws.userId = userId; + + if (action === 'match') { + // Call the RabbitMQ publisher to publish this message to the queue + await publishToQueue({ userId, difficulty, language }); + console.log('Message published to RabbitMQ'); + + // Notify the user that their message has been processed successfully + ws.send(JSON.stringify({ status: 'su9ccess', message: 'Match request sent!' })); + } else if (action === 'cancel') { + await publishCancelRequest({ userId }); + console.log('Cancel request published to RabbitMQ'); + + // Notify the user that their cancel request has been processed successfully + ws.send(JSON.stringify({ status: 'success', message: 'Match request cancelled!' })); + } + + + + } catch (error) { + console.error('Error handling WebSocket message:', error); + ws.send(JSON.stringify({ status: 'error', message: 'Match request failed!' })); + } }); ws.on('close', () => { @@ -16,13 +44,28 @@ wss.on('connection', (ws) => { }); }); -function notifyUsers(userId, message) { + +/** + * Notify users through WebSocket. + * @param {string|array} userId - User ID or an array of user IDs to notify. + * @param {string} message - The message to send. + * @param {string} type - The type of message (e.g., 'match' or 'rejection'). + */ +function notifyUsers(userId, message, type) { + console.log(`Notifying user(s): ${userId}, Message: ${message}, Type: ${type}`); + + const userIds = Array.isArray(userId) ? userId : [userId]; // Convert to array if single user + wss.clients.forEach((client) => { - if (client.readyState === WebSocket.OPEN) { - client.send(JSON.stringify({ userId, message })); + if (client.readyState === WebSocket.OPEN && userIds.includes(client.userId)) { + console.log(`Notifying client: ${client.userId}`); + client.send(JSON.stringify({ + userId: client.userId, + message, + type + })); } }); } module.exports = { notifyUsers }; -*/ \ No newline at end of file diff --git a/Backend/user-service/Dockerfile b/Backend/user-service/Dockerfile index 6d624d200a..a2809796d5 100644 --- a/Backend/user-service/Dockerfile +++ b/Backend/user-service/Dockerfile @@ -7,5 +7,5 @@ RUN npm install COPY . . -EXPOSE 3002 +EXPOSE 8080 CMD ["npm", "start"] diff --git a/Frontend/src/components/NavigationBar.jsx b/Frontend/src/components/NavigationBar.jsx index 2720880c55..82ad0fdafe 100644 --- a/Frontend/src/components/NavigationBar.jsx +++ b/Frontend/src/components/NavigationBar.jsx @@ -14,9 +14,14 @@ function NavigationBar() { return ( - - PeerPrep - + + window.scrollTo({ top: 0, behavior: 'smooth' })} // Scroll to top of home page on click + style={{ cursor: 'pointer' }} + > + PeerPrep + + ); diff --git a/Frontend/src/components/Sidebar.jsx b/Frontend/src/components/Sidebar.jsx index 014fb87f80..e07d7c3365 100644 --- a/Frontend/src/components/Sidebar.jsx +++ b/Frontend/src/components/Sidebar.jsx @@ -1,9 +1,80 @@ -import Stack from 'react-bootstrap/Stack' +import React, { useState, useEffect } from 'react'; +import Stack from 'react-bootstrap/Stack'; import Button from 'react-bootstrap/Button'; import Card from 'react-bootstrap/Card'; import Form from 'react-bootstrap/Form'; +import Modal from "react-bootstrap/Modal"; +import Matching from "./matching/Matching"; +import SuccessfulMatch from "./matching/SuccessfulMatch"; +import UnsuccessfulMatch from "./matching/UnsuccessfulMatch"; +import CriteriaDisplay from './matching/CriteriaDisplay'; +const { getUserFromToken } = require('./user/userAvatarBox'); function Sidebar() { + const [ws, setWs] = useState(null); + const [difficulty, setDifficulty] = useState(''); + const [language, setLanguage] = useState(''); + const [userId, setUserId] = useState(null); + + const [showMatching, setShowMatching] = useState(false); + const [showSuccessfulMatch, setShowSuccessfulMatch] = useState(false); + const [showUnsuccessfulMatch, setShowUnsuccessfulMatch] = useState(false); + + const handleShowMatching = () => setShowMatching(true); + const handleCloseMatching = () => setShowMatching(false); + + const handleCloseSuccessfulMatch = () => setShowSuccessfulMatch(false); + const handleCloseUnsuccessfulMatch = () => setShowUnsuccessfulMatch(false); + + useEffect(() => { + const fetchUser = async () => { + const user = await getUserFromToken(); + if (user !== "No User") { + setUserId(user.username); // Set the username in state + } else { + setUserId("Guest"); // Fallback in case no user is found + } + }; + + fetchUser(); + + const websocket = new WebSocket('ws://localhost:8080'); + + websocket.onmessage = (event) => { + const { message, type } = JSON.parse(event.data); + console.log(`Notification from server: ${message}`); + + if (type === 'match') { + setShowMatching(false); + setShowSuccessfulMatch(true); + } else if (type === 'rejection') { + setShowMatching(false); + setShowUnsuccessfulMatch(true); + } + }; + + setWs(websocket); + + return () => { + websocket.close(); + }; + }, []); + + const handleMatch = () => { + if (ws && difficulty && language) { + handleShowMatching(); + setShowUnsuccessfulMatch(false); + ws.send(JSON.stringify({ userId, difficulty, language , action: 'match'})); // Send to server + } else { + alert('Please select a difficulty and language.'); + } + }; + + const handleCancel = () => { + handleCloseMatching(); + ws.send(JSON.stringify({ userId, action: 'cancel' })); + }; + return ( @@ -15,19 +86,41 @@ function Sidebar() {
- {' '} - {' '} - {' '} + {' '} + {' '} + {' '}
- - - - - + { + const selectedLanguage = e.target.value === "C++" ? "cplusplus" : e.target.value.toLowerCase(); + setLanguage(selectedLanguage); + }}> + + + + - + + {/* Display Criteria Selected*/} + + + + + {/* Modals */} + + + + + + + + + + +
- ) + ); } export default Sidebar; diff --git a/Frontend/src/components/matching/CriteriaDisplay.jsx b/Frontend/src/components/matching/CriteriaDisplay.jsx new file mode 100644 index 0000000000..da7a75c384 --- /dev/null +++ b/Frontend/src/components/matching/CriteriaDisplay.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Card, Badge } from 'react-bootstrap'; + +const getBadgeVariant = (difficulty) => { + switch (difficulty) { + case 'easy': + return 'success'; + case 'medium': + return 'warning'; + case 'hard': + return 'danger'; + default: + return 'secondary'; // in case no difficulty chosen + } +} + +const capitalizeFirstLetter = (difficulty) => { + + if (difficulty == "cplusplus") { + return difficulty.charAt(0).toUpperCase() + "++" + } + + return difficulty.charAt(0).toUpperCase() + difficulty.slice(1); +} + +const CriteriaDisplay = ({ difficulty, language }) => { + return ( + + + Selected Criteria + + + Difficulty: {capitalizeFirstLetter(difficulty ? difficulty : "Not Selected")} +

+ Language: {capitalizeFirstLetter(language ? language : "Not Selected")} +
+
+
+
+ ) +}; + +export default CriteriaDisplay; \ No newline at end of file diff --git a/Frontend/src/components/matching/Matching.jsx b/Frontend/src/components/matching/Matching.jsx new file mode 100644 index 0000000000..b27a9cbd32 --- /dev/null +++ b/Frontend/src/components/matching/Matching.jsx @@ -0,0 +1,35 @@ +import React, { useState, useEffect } from 'react'; +import CloseButton from 'react-bootstrap/esm/CloseButton'; +import '../../css/Matching.css'; + +function Matching({ handleCancel }) { + const [seconds, setSeconds] = useState(0); + const [minutes, setMinutes] = useState(0); + + useEffect(() => { + const timer = setInterval(() => { + setSeconds((prevSeconds) => { + if (prevSeconds === 59) { + setMinutes((prevMinutes) => prevMinutes + 1); + return 0; + } else { + return prevSeconds + 1; + } + }); + }, 1000); + + return () => clearInterval(timer); // Cleanup timer on unmount + }, []); + + return ( +
+
+

Matching you....

+

{`${minutes < 10 ? `0${minutes}` : minutes}:${seconds < 10 ? `0${seconds}` : seconds}`}

+ +
+
+ ); +} + +export default Matching; diff --git a/Frontend/src/components/matching/SuccessfulMatch.jsx b/Frontend/src/components/matching/SuccessfulMatch.jsx new file mode 100644 index 0000000000..196d4394b2 --- /dev/null +++ b/Frontend/src/components/matching/SuccessfulMatch.jsx @@ -0,0 +1,14 @@ +import React, { useState } from 'react'; +import CloseButton from 'react-bootstrap/esm/CloseButton'; + +function SuccessfulMatch({ handleClose }) { + return ( +
+
+

We found you a match!

+
+
+ ); +} + +export default SuccessfulMatch; diff --git a/Frontend/src/components/matching/UnsuccessfulMatch.jsx b/Frontend/src/components/matching/UnsuccessfulMatch.jsx new file mode 100644 index 0000000000..c7ece21ef0 --- /dev/null +++ b/Frontend/src/components/matching/UnsuccessfulMatch.jsx @@ -0,0 +1,17 @@ +import React, { useState } from 'react'; +import CloseButton from 'react-bootstrap/esm/CloseButton'; + +function UnsuccessfulMatch({ handleClose, handleMatch}) { + return ( +
+
+
+ +

Sorry, we are unable to find you a match.

+ +
+
+ ); +} + +export default UnsuccessfulMatch; diff --git a/Frontend/src/components/user/userAvatarBox.jsx b/Frontend/src/components/user/userAvatarBox.jsx index 53c7fdaac8..b1aac8e2bb 100644 --- a/Frontend/src/components/user/userAvatarBox.jsx +++ b/Frontend/src/components/user/userAvatarBox.jsx @@ -5,7 +5,7 @@ import {jwtDecode} from "jwt-decode"; import userService from "../../services/users"; -async function getUserFromToken() { +export async function getUserFromToken() { const jwtToken = sessionStorage.getItem('jwt_token'); if (jwtToken) { const decodedToken = jwtDecode(jwtToken); diff --git a/Frontend/src/css/Matching.css b/Frontend/src/css/Matching.css new file mode 100644 index 0000000000..90aecdf30d --- /dev/null +++ b/Frontend/src/css/Matching.css @@ -0,0 +1,39 @@ +.matching-container { + position: relative; + text-align: center; + font-family: Arial, sans-serif; + padding: 60px; + border-radius: 10px; + } + + .matching-text h2 { + font-size: 2rem; + margin-bottom: 20px; + } + + .matching-text p { + font-size: 1rem; + margin-top: 20px; + } + + .close-button { + position: absolute; + top: 10px; + right: 10px; + /* background-color: red; */ + color: white; + width: 25px; + height: 25px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + cursor: pointer; + } + + .matching-modal-body { + display: flex; + justify-content: center; + align-items: center; + height: 200px; /* Adjust as per your requirement */ + } diff --git a/Frontend/src/services/matchmaking.js b/Frontend/src/services/matchmaking.js new file mode 100644 index 0000000000..1af65fdef5 --- /dev/null +++ b/Frontend/src/services/matchmaking.js @@ -0,0 +1,7 @@ +import axios from 'axios'; + +const baseUrl = 'http://localhost:3003'; + +const enterMatchmaking = async (user) => { + return await axios.post(`${baseUrl}/api/match/enterMatchmaking`, user); +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 35c7f50422..dbb28eb7f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,13 @@ services: ports: - "3002:3002" + matching-service: + build: + context: ./Backend/MatchingService + dockerfile: Dockerfile + ports: + - "8080:8080" + frontend: build: context: ./Frontend