diff --git a/collab-service/app/controller/collab-controller.js b/collab-service/app/controller/collab-controller.js index 564954f922..00eefe3841 100644 --- a/collab-service/app/controller/collab-controller.js +++ b/collab-service/app/controller/collab-controller.js @@ -3,23 +3,25 @@ import { getRoomId, heartbeat, getAllRooms, + getQuestionIdByRoomId, } from "../model/repository.js"; import crypto from "crypto"; // Create a room between two users export async function createRoom(req, res) { - const { user1, user2 } = req.body; + const { user1, user2, question_id } = req.body; - if (!user1 || !user2) { - return res.status(400).json({ error: "Both user1 and user2 are required" }); + if (!user1 || !user2 || !question_id) { + return res.status(400).json({ error: "user1,user2 and question_id are required" }); } // Generate a unique room ID by hashing the two user IDs + const timeSalt = new Date().toISOString().slice(0, 13); const roomId = crypto .createHash("sha256") - .update(user1 + user2) + .update(user1 + user2 + timeSalt) .digest("hex"); - const room = await newRoom(user1, user2, roomId); + const room = await newRoom(user1, user2, roomId, question_id); if (room) { res.status(201).json(room); @@ -72,3 +74,20 @@ export async function getAllRoomsController(req, res) { res.status(500).json({ error: "Failed to retrieve rooms" }); } } + +// Get QuestionId from the room based on the roomId +export async function getQuestionId(req, res) { + const { roomId } = req.params; + + if (!roomId) { + return res.status(400).json({ error: "Room ID is required" }); + } + + const questionId = await getQuestionIdByRoomId(roomId); + + if (questionId) { + res.status(200).json({ questionId }); + } else { + res.status(404).json({ error: `Question ID not found for room ID: ${roomId}` }); + } +} \ No newline at end of file diff --git a/collab-service/app/model/repository.js b/collab-service/app/model/repository.js index fb42ac4eb2..a55f9b58c0 100644 --- a/collab-service/app/model/repository.js +++ b/collab-service/app/model/repository.js @@ -5,11 +5,15 @@ export async function connectToMongo() { await connect(process.env.DB_URI); } -export async function newRoom(user1, user2, roomId) { +export async function newRoom(user1, user2, roomId, questionId) { try { + // Remove any existing rooms where either user1 or user2 is a participant + await UsersSession.deleteMany({ users: { $in: [user1, user2] } }); + const newRoom = new UsersSession({ users: [user1, user2], roomId: roomId, + questionId: questionId, lastUpdated: new Date(), }); @@ -102,3 +106,13 @@ export async function addMessageToChat(roomId, userId, text) { throw error; } } + +export async function getQuestionIdByRoomId(roomId) { + try { + const room = await UsersSession.findOne({ roomId }); + return room ? room.questionId : null; + } catch (error) { + console.error(`Error finding questionId for roomId ${roomId}:`, error); + return null; + } +} \ No newline at end of file diff --git a/collab-service/app/model/usersSession-model.js b/collab-service/app/model/usersSession-model.js index 3692755f89..11aeae80f5 100644 --- a/collab-service/app/model/usersSession-model.js +++ b/collab-service/app/model/usersSession-model.js @@ -31,6 +31,10 @@ const usersSessionSchema = new Schema({ type: String, required: true, }, + questionId: { + type: String, + required: true, + }, lastUpdated: { type: Date, required: true, diff --git a/collab-service/app/routes/collab-routes.js b/collab-service/app/routes/collab-routes.js index d3f15ed7ac..f7c381bc8c 100644 --- a/collab-service/app/routes/collab-routes.js +++ b/collab-service/app/routes/collab-routes.js @@ -4,6 +4,7 @@ import { getRoomByUser, updateHeartbeat, getAllRoomsController, + getQuestionId } from "../controller/collab-controller.js"; const router = express.Router(); @@ -16,4 +17,6 @@ router.patch("/heartbeat/:roomId", updateHeartbeat); router.get("/rooms", getAllRoomsController); +router.get("/rooms/:roomId/questionId", getQuestionId); + export default router; diff --git a/docker-compose.yml b/docker-compose.yml index 903f402a86..45a0316f6f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,11 +7,13 @@ services: - USER_SVC_PORT=$USER_SVC_PORT - QUESTION_SVC_PORT=$QUESTION_SVC_PORT - MATCHING_SVC_PORT=$MATCHING_SVC_PORT + - COLLAB_SVC_PORT=$COLLAB_SVC_PORT ports: - $FRONTEND_PORT:$FRONTEND_PORT depends_on: - question-service - user-service + - collab-service environment: - PORT=$FRONTEND_PORT @@ -46,8 +48,12 @@ services: - PORT=$MATCHING_SVC_PORT - REDIS_HOST=redis - REDIS_PORT=$REDIS_PORT + - QUESTION_SVC_PORT=$QUESTION_SVC_PORT + - COLLAB_SVC_PORT=$COLLAB_SVC_PORT depends_on: - redis + - question-service + - collab-service redis: image: redis:7.4-alpine diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 4d7bb93d3e..0a5daff74c 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -3,7 +3,8 @@ FROM node:20-alpine AS base ARG BASE_URI \ USER_SVC_PORT \ QUESTION_SVC_PORT \ - MATCHING_SVC_PORT + MATCHING_SVC_PORT \ + COLLAB_SVC_PORT WORKDIR /app COPY package.json . COPY yarn.lock . @@ -11,7 +12,8 @@ RUN yarn install --frozen-lockfile ENV NEXT_PUBLIC_BASE_URI=$BASE_URI \ NEXT_PUBLIC_USER_SVC_PORT=$USER_SVC_PORT \ NEXT_PUBLIC_QUESTION_SVC_PORT=$QUESTION_SVC_PORT \ - NEXT_PUBLIC_MATCHING_SVC_PORT=$MATCHING_SVC_PORT + NEXT_PUBLIC_MATCHING_SVC_PORT=$MATCHING_SVC_PORT \ + NEXT_PUBLIC_COLLAB_SVC_PORT=$COLLAB_SVC_PORT # Production build stage FROM base AS build diff --git a/frontend/app/app/collab/[room_id]/page.tsx b/frontend/app/app/collab/[room_id]/page.tsx index 9040f42ae4..5aaa70fa18 100644 --- a/frontend/app/app/collab/[room_id]/page.tsx +++ b/frontend/app/app/collab/[room_id]/page.tsx @@ -1,16 +1,17 @@ -import dynamic from "next/dynamic"; +import AuthPageWrapper from "@/components/auth/auth-page-wrapper"; +import CollabRoom from "@/components/collab/collab-room"; +import { Suspense } from "react"; -const MonacoEditor = dynamic( - () => import("@/components/collab/monaco-editor"), - { - ssr: false, - } -); - -export default function CollabRoom({ +export default function CollabPage({ params, }: { params: { room_id: string }; }) { - return ; + return ( + + + + + + ); } diff --git a/frontend/components/collab/chat.tsx b/frontend/components/collab/chat.tsx new file mode 100644 index 0000000000..42953d9843 --- /dev/null +++ b/frontend/components/collab/chat.tsx @@ -0,0 +1,184 @@ +"use client"; + +import React, { useState, useEffect, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Send } from "lucide-react"; +import { io, Socket } from "socket.io-client"; +import { useAuth } from "@/app/auth/auth-context"; +import LoadingScreen from "@/components/common/loading-screen"; + +interface Message { + id: string; + userId: string; + text: string; + timestamp: Date; +} + +export default function Chat({ roomId }: { roomId: string }) { + const auth = useAuth(); + const own_user_id = auth?.user?.id; + const [socket, setSocket] = useState(null); + const [chatTarget, setChatTarget] = useState("partner"); + const [newMessage, setNewMessage] = useState(""); + const [partnerMessages, setPartnerMessages] = useState([]); + const [aiMessages, setAiMessages] = useState([]); + const [isConnected, setIsConnected] = useState(false); + const lastMessageRef = useRef(null); + + useEffect(() => { + if (!auth?.user?.id) return; // Avoid connecting if user is not authenticated + + const socketInstance = io( + process.env.NEXT_PUBLIC_COLLAB_SERVICE_URL || "http://localhost:3002", + { + auth: { userId: own_user_id }, + } + ); + + socketInstance.on("connect", () => { + console.log("Connected to Socket.IO"); + setIsConnected(true); + socketInstance.emit("joinRoom", roomId); + }); + + socketInstance.on("disconnect", () => { + console.log("Disconnected from Socket.IO"); + setIsConnected(false); + }); + + socketInstance.on("chatMessage", (message: Message) => { + setPartnerMessages((prev) => [...prev, message]); + }); + + setSocket(socketInstance); + + return () => { + socketInstance.disconnect(); + }; + }, [roomId, own_user_id, auth?.user?.id]); + + useEffect(() => { + const scrollWithDelay = () => { + setTimeout(() => { + if (lastMessageRef.current) { + lastMessageRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, 100); // Delay to ensure the DOM is fully rendered + }; + + scrollWithDelay(); + }, [partnerMessages, aiMessages, chatTarget]); + + const sendMessage = () => { + if (!newMessage.trim() || !socket || !isConnected || !own_user_id) return; + + const message = { + id: crypto.randomUUID(), + userId: own_user_id, + text: newMessage, + timestamp: new Date(), + }; + + if (chatTarget === "partner") { + socket.emit("sendMessage", { + roomId, + userId: own_user_id, + text: newMessage, + }); + } else { + setAiMessages((prev) => [...prev, message]); + } + + setNewMessage(""); + }; + + const formatTimestamp = (date: Date) => { + return new Date(date).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + }; + + const renderMessage = (message: Message, isOwnMessage: boolean) => ( + + {message.text} + + {formatTimestamp(message.timestamp)} + + + ); + + if (!own_user_id) { + return ; + } + + return ( + + + + Chat + + + + + + + Partner Chat + AI Chat + + + + + {partnerMessages.map((msg) => + renderMessage(msg, msg.userId === own_user_id) + )} + + + + + + + + {aiMessages.map((msg) => + renderMessage(msg, msg.userId === own_user_id) + )} + + + + + + + setNewMessage(e.target.value)} + placeholder={`Message ${chatTarget === "partner" ? "your partner" : "AI assistant"}...`} + onKeyDown={(e) => e.key === "Enter" && sendMessage()} + disabled={!isConnected} + /> + + + + + + + ); +} diff --git a/frontend/components/collab/code-editor.tsx b/frontend/components/collab/code-editor.tsx new file mode 100644 index 0000000000..c2fee0f63e --- /dev/null +++ b/frontend/components/collab/code-editor.tsx @@ -0,0 +1,149 @@ +"use client"; + +import React, { useState, useEffect, useMemo } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import * as Y from "yjs"; +import { WebsocketProvider } from "y-websocket"; +import { MonacoBinding } from "y-monaco"; +import { editor as MonacoEditor } from "monaco-types"; +import Editor, { useMonaco } from "@monaco-editor/react"; + +interface LanguageEntry { + language: string; + value: string; +} + +const languages: Record = { + Javascript: { + language: "javascript", + value: "// some comment", + }, + Python: { + language: "python", + value: "# some comment", + }, + Java: { + language: "java", + value: "// some comment", + }, + "C++": { + language: "cpp", + value: "// some comment", + }, +}; + +export default function CodeEditor({ roomId }: { roomId: string }) { + const monaco = useMonaco(); + const [language, setLanguage] = useState("Javascript"); + const [theme, setTheme] = useState("light"); + const ydoc = useMemo(() => new Y.Doc(), []); + const [editor, setEditor] = + useState(null); + const [provider, setProvider] = useState(null); + const languageMap = useMemo(() => ydoc.getMap("language"), [ydoc]); + + useEffect(() => { + const provider = new WebsocketProvider( + "ws://localhost:3002/yjs", + roomId, + ydoc + ); + setProvider(provider); + return () => { + provider?.destroy(); + ydoc.destroy(); + }; + }, [ydoc, roomId]); + + useEffect(() => { + if (provider == null || editor == null) return; + const monacoBinding = new MonacoBinding( + ydoc.getText(), + editor.getModel()!, + new Set([editor]), + provider?.awareness + ); + return () => { + monacoBinding.destroy(); + }; + }, [ydoc, provider, editor]); + + useEffect(() => { + const handleLanguageChange = () => { + const newLanguage = languageMap.get("selectedLanguage") as string; + if (newLanguage && newLanguage !== language) { + setLanguage(newLanguage); + } + }; + languageMap.set("selectedLanguage", language); + languageMap.observe(handleLanguageChange); + return () => { + languageMap.unobserve(handleLanguageChange); + }; + }, [languageMap, language]); + + useEffect(() => { + if (editor && monaco) { + const model = editor.getModel(); + if (model) { + monaco.editor.setModelLanguage(model, languages[language]?.language); + } + } + }, [language, editor, monaco]); + + useEffect(() => { + if (monaco) { + monaco.editor.setTheme(theme === "dark" ? "vs-dark" : "light"); + } + }, [theme, monaco]); + + return ( + + + + Code Editor + + + + + + + + + {Object.keys(languages).map((lang) => ( + + {lang} + + ))} + + + setTheme(theme === "light" ? "dark" : "light")} + variant={theme === "light" ? "secondary" : "default"} + > + {theme === "light" ? "Light" : "Dark"} Mode + + + + { + setEditor(editor); + }} + theme={theme === "dark" ? "vs-dark" : "light"} + /> + + + + + ); +} diff --git a/frontend/components/collab/collab-room.tsx b/frontend/components/collab/collab-room.tsx new file mode 100644 index 0000000000..474ee5c9ee --- /dev/null +++ b/frontend/components/collab/collab-room.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { Button } from "@/components/ui/button"; +import { X } from "lucide-react"; +import Chat from "./chat"; +import QuestionDisplay from "./question-display"; +import CodeEditor from "./code-editor"; + +export default function CollabRoom({ roomId }: { roomId: string }) { + return ( + + + Collab Room {roomId} + + Leave Room + + + + + + + + + + + + ); +} diff --git a/frontend/components/collab/question-display.tsx b/frontend/components/collab/question-display.tsx new file mode 100644 index 0000000000..3cff6b4ba2 --- /dev/null +++ b/frontend/components/collab/question-display.tsx @@ -0,0 +1,86 @@ +"use client"; +import React, { useEffect, useState } from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import LoadingScreen from "@/components/common/loading-screen"; +import { getQuestion } from "@/lib/api/question-service/get-question"; +import { useAuth } from "@/app/auth/auth-context"; +import { getQuestionId } from "@/lib/api/collab-service/get-questionId"; + +const difficultyColors = { + Easy: "bg-green-500", + Medium: "bg-yellow-500", + Hard: "bg-red-500", +}; + +interface Question { + title: string; + categories: string; + complexity: keyof typeof difficultyColors; + description: string; +} + +export default function QuestionDisplay({ roomId }: { roomId: string }) { + const auth = useAuth(); + const token = auth?.token; + const [question, setQuestion] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchQuestion() { + try { + // Call to the collab microservice to get questionId by roomId + const response = await getQuestionId(roomId); + const data = await response.json(); + + if (data.questionId) { + // Fetch the question details using the questionId + if (token) { + const questionResponse = await getQuestion(token, data.questionId); + const questionData = await questionResponse.json(); + setQuestion(questionData); + } else { + console.error("Token is not available"); + } + } + } catch (error) { + console.error("Error fetching question:", error); + } finally { + setLoading(false); + } + } + + fetchQuestion(); + }, [roomId]); + + if (loading) { + return ; + } + + if (!question) { + return Question not found; + } + + return ( + + + {question.title} + + {question.categories} + + {question.complexity} + + + + + {question.description} + + + ); +} diff --git a/frontend/components/matching/find-match.tsx b/frontend/components/matching/find-match.tsx index 377cf794fc..00e8dd6c30 100644 --- a/frontend/components/matching/find-match.tsx +++ b/frontend/components/matching/find-match.tsx @@ -91,15 +91,15 @@ export default function FindMatch() { responseData = initialResponseData; } - if (responseData.room_id) { + if (responseData) { isMatched = true; - const roomId = responseData.room_id; + const room_id = responseData.room_id; toast({ title: "Matched", description: "Successfully matched", variant: "success", }); - router.push(`/collaboration/${roomId}`); + router.push(`/app/collab/${room_id}`); } else { toast({ title: "Error", @@ -121,38 +121,26 @@ export default function FindMatch() { handleCancel(true); }, waitTimeout); - ws.onmessage = (event) => { - let responseData; - + ws.onmessage = async (event) => { try { - responseData = JSON.parse(event.data); + let responseData = JSON.parse(event.data); if (typeof responseData === "string") { responseData = JSON.parse(responseData); } - } catch (error) { - toast({ - title: "Error", - description: "Unexpected error occured, please try again", - variant: "destructive", - }); - return; - } - const roomId = responseData.room_id; - if (roomId) { - isMatched = true; - setIsSearching(false); - clearTimeout(queueTimeout); - toast({ - title: "Matched", - description: "Successfully matched", - variant: "success", - }); - router.push(`/collaboration/${roomId}`); - } else { + const user1_id = responseData.user1; + const user2_id = responseData.user2; + const room_id = responseData.room_id; + if (user1_id && user2_id) { + isMatched = true; + setIsSearching(false); + clearTimeout(queueTimeout); + router.push(`/app/collab/${room_id}`); + } + } catch (error) { toast({ title: "Error", - description: "Room ID not found", + description: "Unexpected error occurred, please try again", variant: "destructive", }); } @@ -162,7 +150,6 @@ export default function FindMatch() { setIsSearching(false); clearTimeout(queueTimeout); if (!isMatched) { - // Only show this toast if no match was made toast({ title: "Matching Stopped", description: "Matching has been stopped", @@ -171,6 +158,14 @@ export default function FindMatch() { } }; return; + case 404: + toast({ + title: "Error", + description: + "No question with specified difficulty level and complexity exists!", + variant: "destructive", + }); + return; default: toast({ title: "Unknown Error", diff --git a/frontend/components/ui/scroll-area.tsx b/frontend/components/ui/scroll-area.tsx new file mode 100644 index 0000000000..32cf968d23 --- /dev/null +++ b/frontend/components/ui/scroll-area.tsx @@ -0,0 +1,48 @@ +"use client"; + +import * as React from "react"; +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; + +import { cn } from "@/lib/utils"; + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)); +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; + +export { ScrollArea, ScrollBar }; diff --git a/frontend/components/ui/textarea.tsx b/frontend/components/ui/textarea.tsx new file mode 100644 index 0000000000..0f3eac6535 --- /dev/null +++ b/frontend/components/ui/textarea.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +export interface TextareaProps + extends React.TextareaHTMLAttributes {} + +const Textarea = React.forwardRef( + ({ className, ...props }, ref) => { + return ( + + ); + } +); +Textarea.displayName = "Textarea"; + +export { Textarea }; diff --git a/frontend/lib/api/api-uri.ts b/frontend/lib/api/api-uri.ts index cad6a33664..1b83de1586 100644 --- a/frontend/lib/api/api-uri.ts +++ b/frontend/lib/api/api-uri.ts @@ -14,3 +14,6 @@ export const matchingServiceUri: (baseUri: string) => string = (baseUri) => export const matchingServiceWebSockUri: (baseUri: string) => string = ( baseUri ) => constructWebSockUri(baseUri, process.env.NEXT_PUBLIC_MATCHING_SVC_PORT); + +export const collabServiceUri: (baseUri: string) => string = (baseUri) => + constructUri(baseUri, process.env.NEXT_PUBLIC_COLLAB_SVC_PORT); diff --git a/frontend/lib/api/collab-service/get-questionId.ts b/frontend/lib/api/collab-service/get-questionId.ts new file mode 100644 index 0000000000..ad36cc1058 --- /dev/null +++ b/frontend/lib/api/collab-service/get-questionId.ts @@ -0,0 +1,14 @@ +import { collabServiceUri } from "@/lib/api/api-uri"; + +export const getQuestionId = async (roomId: string) => { + const response = await fetch( + `${collabServiceUri(window.location.hostname)}/collab/rooms/${roomId}/questionId`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + } + ); + return response; +}; diff --git a/frontend/lib/api/question-service/get-question.ts b/frontend/lib/api/question-service/get-question.ts new file mode 100644 index 0000000000..5ff59a8eef --- /dev/null +++ b/frontend/lib/api/question-service/get-question.ts @@ -0,0 +1,15 @@ +import { questionServiceUri } from "@/lib/api/api-uri"; + +export const getQuestion = async (jwtToken: string, questionId: string) => { + const response = await fetch( + `${questionServiceUri(window.location.hostname)}/questions/${questionId}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${jwtToken}`, + "Content-Type": "application/json", + }, + } + ); + return response; +}; diff --git a/frontend/package.json b/frontend/package.json index e2dc633b60..9f1df4042d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-progress": "^1.1.0", + "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", @@ -35,6 +36,7 @@ "next-themes": "^0.3.0", "react": "^18", "react-dom": "^18", + "socket.io-client": "^4.8.1", "swr": "^2.2.5", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 34eb030604..d52a7fee60 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -597,6 +597,21 @@ "@radix-ui/react-use-callback-ref" "1.1.0" "@radix-ui/react-use-controllable-state" "1.1.0" +"@radix-ui/react-scroll-area@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.0.tgz#d09fd693728b09c50145935bec6f91efc2661729" + integrity sha512-q2jMBdsJ9zB7QG6ngQNzNwlvxLQqONyL58QbEGwuyRZZb/ARQwk3uQVbCF7GvQVOtV6EU/pDxAw3zRzJZI3rpQ== + dependencies: + "@radix-ui/number" "1.1.0" + "@radix-ui/primitive" "1.1.0" + "@radix-ui/react-compose-refs" "1.1.0" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-direction" "1.1.0" + "@radix-ui/react-presence" "1.1.1" + "@radix-ui/react-primitive" "2.0.0" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-layout-effect" "1.1.0" + "@radix-ui/react-select@^2.1.1": version "2.1.2" resolved "https://registry.yarnpkg.com/@radix-ui/react-select/-/react-select-2.1.2.tgz#2346e118966db793940f6a866fd4cc5db2cc275e" @@ -786,6 +801,11 @@ resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.10.4.tgz#427d5549943a9c6fce808e39ea64dbe60d4047f1" integrity sha512-WJgX9nzTqknM393q1QJDJmoW28kUfEnybeTfVNcNAPnIx210RXm2DiXiHzfNPJNIUUb1tJnz/l4QGtJ30PgWmA== +"@socket.io/component-emitter@~3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2" + integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA== + "@swc/counter@^0.1.3": version "0.1.3" resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9" @@ -1363,7 +1383,7 @@ debug@^3.2.7: dependencies: ms "^2.1.1" -debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5: +debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@~4.3.1, debug@~4.3.2: version "4.3.7" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== @@ -1452,6 +1472,22 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== +engine.io-client@~6.6.1: + version "6.6.2" + resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.6.2.tgz#e0a09e1c90effe5d6264da1c56d7281998f1e50b" + integrity sha512-TAr+NKeoVTjEVW8P3iHguO1LO6RlUz9O5Y8o7EY0fU+gY1NYqas7NN3slpFtbXEsLMHk0h90fJMfKjRkQ0qUIw== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.1" + engine.io-parser "~5.2.1" + ws "~8.17.1" + xmlhttprequest-ssl "~2.1.1" + +engine.io-parser@~5.2.1: + version "5.2.3" + resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz#00dc5b97b1f233a23c9398d0209504cf5f94d92f" + integrity sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q== + encoding-down@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/encoding-down/-/encoding-down-6.3.0.tgz#b1c4eb0e1728c146ecaef8e32963c549e76d082b" @@ -3237,6 +3273,24 @@ slash@^3.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== +socket.io-client@^4.8.1: + version "4.8.1" + resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.8.1.tgz#1941eca135a5490b94281d0323fe2a35f6f291cb" + integrity sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.2" + engine.io-client "~6.6.1" + socket.io-parser "~4.2.4" + +socket.io-parser@~4.2.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.4.tgz#c806966cf7270601e47469ddeec30fbdfda44c83" + integrity sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.1" + source-map-js@^1.0.2, source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" @@ -3707,6 +3761,16 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== +ws@~8.17.1: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== + +xmlhttprequest-ssl@~2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz#e9e8023b3f29ef34b97a859f584c5e6c61418e23" + integrity sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ== + ws@^6.2.1: version "6.2.3" resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.3.tgz#ccc96e4add5fd6fedbc491903075c85c5a11d9ee" diff --git a/matching-service/app/logic/matching.py b/matching-service/app/logic/matching.py index dae82ecce6..8718b0537f 100644 --- a/matching-service/app/logic/matching.py +++ b/matching-service/app/logic/matching.py @@ -5,13 +5,29 @@ from models.match import MatchModel from utils.redis import acquire_lock, redis_client, release_lock from utils.socketmanager import manager -import hashlib +import os +import httpx + +QUESTION_SVC_PORT = os.getenv("QUESTION_SVC_PORT") +QUESTION_SVC_URL = f"http://question-service:{QUESTION_SVC_PORT}" + +COLLAB_SVC_PORT = os.getenv("COLLAB_SVC_PORT") +COLLAB_SVC_URL = f"http://collab-service:{COLLAB_SVC_PORT}" async def find_match_else_enqueue( user_id: str, topic: str, difficulty: str ) -> Union[Response, JSONResponse]: + # Try to fetch a random question with specified criteria from question service + try: + question_id = await fetch_random_question(topic, difficulty) + except Exception as e: + return JSONResponse( + status_code=404, + content={"detail": "No question with specified topic and difficulty exists"} + ) + queue_key = _build_queue_key(topic, difficulty) islocked = await acquire_lock(redis_client, queue_key) @@ -42,14 +58,22 @@ async def find_match_else_enqueue( logger.debug(_get_queue_state_message(topic, difficulty, queue, False)) await release_lock(redis_client, queue_key) - room_id = generate_room_id(matched_user, user_id) + # Try to create a common room for the matched users + try: + room_id = await create_room(user_id, matched_user, question_id) + except Exception as e: + return JSONResponse( + status_code=500, + content={"detail": "Failed to create room"} + ) response = MatchModel( user1=matched_user, user2=user_id, topic=topic, difficulty=difficulty, - room_id=room_id, + question_id=question_id, + room_id = room_id ) await manager.broadcast(matched_user, topic, difficulty, response.json()) await manager.disconnect_all(matched_user, topic, difficulty) @@ -93,10 +117,36 @@ def _get_queue_state_message(topic, difficulty, queue, before: bool): return "Before - " + postfix return "After - " + postfix -# Generate room ID for matched users -def generate_room_id(user1_id: str, user2_id: str) -> str: - # Ensure consistency of room ID by ordering user IDs alphabetically - sorted_users = sorted([user1_id, user2_id]) - concatenated_ids = f"{sorted_users[0]}-{sorted_users[1]}" - return hashlib.sha256(concatenated_ids.encode()).hexdigest()[:10] - +async def fetch_random_question(topic: str, difficulty: str) -> str: + """Fetch a random question from the question service based on topic and difficulty.""" + async with httpx.AsyncClient() as client: + response = await client.get( + f"{QUESTION_SVC_URL}/questions/random", + params={ + "category": topic, + "complexity": difficulty + } + ) + + if response.status_code != 200: + raise Exception(f"Failed to fetch question: {response.text}") + + question_data = response.json() + return question_data["id"] + +async def create_room(user1: str, user2: str, question_id: str) -> str: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{COLLAB_SVC_URL}/collab/create-room", + json={ + "user1": user1, + "user2": user2, + "question_id": question_id + } + ) + + if response.status_code != 201: + raise Exception(f"Failed to create room") + + room_data = response.json() + return room_data["roomId"] \ No newline at end of file diff --git a/matching-service/app/models/match.py b/matching-service/app/models/match.py index a9c0d0631e..c57a30a312 100644 --- a/matching-service/app/models/match.py +++ b/matching-service/app/models/match.py @@ -5,6 +5,7 @@ class MatchModel(BaseModel): user2: str topic: str difficulty: str + question_id: str room_id: str class MessageModel(BaseModel):
{question.description}