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 + + +
+ + +
+
+ { + 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}

+ +
+
+
+ + +
+ +
+
+ ); +} 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 ( +