From 830f5ddbfcefde90578f92fb1582c8f7cb422c8d Mon Sep 17 00:00:00 2001 From: Singa-pirate Date: Thu, 7 Nov 2024 00:06:56 +0800 Subject: [PATCH 01/10] Add session history table and API endpoints --- backend/matching-service/src/server.ts | 163 ++++++++++++++++++++++++- 1 file changed, 161 insertions(+), 2 deletions(-) diff --git a/backend/matching-service/src/server.ts b/backend/matching-service/src/server.ts index 144bb71cba..72a5a66f21 100644 --- a/backend/matching-service/src/server.ts +++ b/backend/matching-service/src/server.ts @@ -19,12 +19,16 @@ app.use(express.json()); app.use( cors({ origin: "*", - }), + }) ); const prisma = new PrismaClient(); app.post("/check", async (req, res) => { + if (!req.body.data) { + res.status(400).json({ error: "Request was malformed." }); + return; + } const { userId, roomId } = req.body.data; const record = await prisma.matchRecord.findFirst({ @@ -38,7 +42,6 @@ app.post("/check", async (req, res) => { matchedUserId: userId, }, ], - matched: true, }, }); @@ -49,6 +52,162 @@ app.post("/check", async (req, res) => { } }); +app.get("/session", async (req, res) => { + if (!req.query.userId) { + res.status(400).json({ error: "Request was malformed." }); + return; + } + const userId = req.query.userId as string; + + const record = await prisma.sessionHistory.findFirst({ + where: { + OR: [ + { + userOneId: userId, + }, + { + userTwoId: userId, + }, + ], + }, + orderBy: { + createdAt: "desc", + }, + }); + + if (!record) { + res.status(200).json({ session: null }); + } else { + const session = { + isOngoing: record.isOngoing, + roomNumber: record.roomNumber, + questionId: record.questionId, + otherUserId: record.userOneId === userId ? record.userTwoId : record.userOneId, + submission: record.submission, + }; + res.status(200).json({ session: session }); + } +}); + +app.get("/session-history", async (req, res) => { + if (!req.query.userId) { + res.status(400).json({ error: "Request was malformed." }); + return; + } + const userId = req.query.userId as string; + + const records = await prisma.sessionHistory.findMany({ + where: { + OR: [ + { + userOneId: userId, + }, + { + userTwoId: userId, + }, + ], + }, + orderBy: { + createdAt: "desc", + }, + take: 3, + }); + + if (!records || records.length === 0) { + res.status(200).json({ sessions: [] }); + } else { + const sessions = records.map((record) => ({ + isOngoing: record.isOngoing, + roomNumber: record.roomNumber, + questionId: record.questionId, + otherUserId: record.userOneId === userId ? record.userTwoId : record.userOneId, + submission: record.submission, + })); + res.status(200).json({ sessions: sessions }); + } +}); + +app.put("/leave-session", async (req, res) => { + if (!req.body.data) { + res.status(400).json({ error: "Request was malformed." }); + return; + } + const { userId, roomId } = req.body.data; + const record = await prisma.sessionHistory.findFirst({ + where: { + isOngoing: true, + roomNumber: roomId, + OR: [ + { + userOneId: userId, + }, + { + userTwoId: userId, + }, + ], + }, + }); + if (!record) { + res.status(404).json({ error: "Request did not match with any ongoing session that the user is in." }); + return; + } else { + const isUserOneActive = record.userOneId === userId ? false : record.isUserOneActive; + const isUserTwoActive = record.userTwoId === userId ? false : record.isUserTwoActive; + const isOngoing = isUserOneActive || isUserTwoActive; + await prisma.sessionHistory.update({ + where: { + sessionId: record.sessionId, + }, + data: { + isUserOneActive: isUserOneActive, + isUserTwoActive: isUserTwoActive, + isOngoing: isOngoing, + }, + }); + } + res.status(200).json({ ok: "ok" }); +}); + +app.put("/rejoin-session", async (req, res) => { + if (!req.body.data) { + res.status(400).json({ error: "Request was malformed." }); + return; + } + const { userId, roomId } = req.body.data; + const record = await prisma.sessionHistory.findFirst({ + where: { + isOngoing: true, + roomNumber: roomId, + OR: [ + { + userOneId: userId, + }, + { + userTwoId: userId, + }, + ], + }, + orderBy: { + createdAt: "desc", + }, + }); + if (!record) { + res.status(404).json({ error: "Request did not match with any ongoing session that the user is in." }); + } else { + await prisma.sessionHistory.update({ + where: { + sessionId: record.sessionId, + }, + data: record.userOneId === userId ? { isUserOneActive: true } : { isUserTwoActive: true }, + }); + res.status(200).json({ + roomNumber: record.roomNumber, + questionId: record.questionId, + otherUserId: record.userOneId === userId ? record.userTwoId : record.userOneId, + }); + } +}); + const server = createServer(app); export const io = new Server(server, { cors: { From a32d4aa83ddf10e6baf0262963bbee7b925e757a Mon Sep 17 00:00:00 2001 From: Singa-pirate Date: Thu, 7 Nov 2024 00:07:13 +0800 Subject: [PATCH 02/10] Add files missed in the previous commit --- backend/matching-service/prisma/schema.prisma | 13 +++ .../matching-service/src/matchingService.ts | 98 ++++++------------- 2 files changed, 42 insertions(+), 69 deletions(-) diff --git a/backend/matching-service/prisma/schema.prisma b/backend/matching-service/prisma/schema.prisma index 198f8b223e..7d068c5a4b 100644 --- a/backend/matching-service/prisma/schema.prisma +++ b/backend/matching-service/prisma/schema.prisma @@ -22,3 +22,16 @@ model MatchRecord { roomNumber String // Matched room number questionId Int? // Question ID for the match } + +model SessionHistory { + sessionId Int @id @default(autoincrement()) + roomNumber String + questionId Int + submission String? + isOngoing Boolean @default(true) + userOneId String + userTwoId String + isUserOneActive Boolean @default(true) + isUserTwoActive Boolean @default(true) + createdAt DateTime @default(now()) // Timestamp for the start of session +} diff --git a/backend/matching-service/src/matchingService.ts b/backend/matching-service/src/matchingService.ts index 65239f7e34..29ef700968 100644 --- a/backend/matching-service/src/matchingService.ts +++ b/backend/matching-service/src/matchingService.ts @@ -10,10 +10,7 @@ const prisma = new PrismaClient(); const DELAY_TIME = 30000; const CONFIRM_DELAY_TIME = 10000; -export async function handleMatchingRequest( - userRequest: any, - socketId: string, -) { +export async function handleMatchingRequest(userRequest: any, socketId: string) { userRequest.socketId = socketId; addUserToQueue(userRequest); @@ -37,12 +34,9 @@ function sendConfirmDelayedTimeoutMessage(recordId: string) { recordId: recordId, type: "confirm_timeout", }, - CONFIRM_DELAY_TIME, - ); - console.log( - "Sent delayed message for confirm timeout for recordId: ", - recordId, + CONFIRM_DELAY_TIME ); + console.log("Sent delayed message for confirm timeout for recordId: ", recordId); } export async function handleUserRequest(userRequest: any) { @@ -59,7 +53,7 @@ export async function handleUserRequest(userRequest: any) { console.log("Duplicate socket detected. New socket will be used."); io.to(pastSocketId).emit( "duplicate socket", - "New connection detected for the same user. Please close the current page", + "New connection detected for the same user. Please close the current page" ); // Update socket ID upon potential reconnection @@ -167,20 +161,14 @@ export async function handleMatchingConfirm(userRequest: any) { where: { recordId: matchedRecord.recordId }, data: { isArchived: true }, }); - io.to(matchedRecord.socketId).emit( - "other_declined", - "Match not confirmed. Please try again.", - ); + io.to(matchedRecord.socketId).emit("other_declined", "Match not confirmed. Please try again."); } if (userRecord !== null) { await prisma.matchRecord.update({ where: { recordId: userRecord.recordId }, data: { isArchived: true }, }); - io.to(userRecord.socketId).emit( - "other_declined", - "Match not confirmed. Please try again.", - ); + io.to(userRecord.socketId).emit("other_declined", "Match not confirmed. Please try again."); } return; } @@ -208,24 +196,22 @@ export async function handleMatchingConfirm(userRequest: any) { where: { recordId: userRecord.recordId }, data: { isArchived: true }, }); + await prisma.sessionHistory.create({ + data: { + roomNumber: userRecord.roomNumber, + questionId: userRecord.questionId ?? matchedRecord.questionId ?? 0, + isOngoing: true, + userOneId: userRecord.userId, + userTwoId: matchedRecord.userId, + }, + }); - io.to(userRecord.socketId).emit( - "matching_success", - "Match confirmed. Proceeding to collaboration service.", - ); - io.to(matchedRecord.socketId).emit( - "matching_success", - "Match confirmed. Proceeding to collaboration service.", - ); + io.to(userRecord.socketId).emit("matching_success", "Match confirmed. Proceeding to collaboration service."); + io.to(matchedRecord.socketId).emit("matching_success", "Match confirmed. Proceeding to collaboration service."); // TODO: add further logic here to proceed to collaboration service } else { - console.log( - `User ${userId} confirmed match, waiting for other user to confirm`, - ); - io.to(matchedRecord.socketId).emit( - "other_accepted", - "Other user confirmed match. Please confirm.", - ); + console.log(`User ${userId} confirmed match, waiting for other user to confirm`); + io.to(matchedRecord.socketId).emit("other_accepted", "Other user confirmed match. Please confirm."); } } @@ -246,28 +232,16 @@ export async function handleMatchingDecline(userRequest: any) { where: { recordId: matchedRecord.recordId }, data: { isArchived: true }, }); - io.to(matchedRecord.socketId).emit( - "other_declined", - "Match not confirmed. Please try again.", - ); - io.to(matchedRecord.socketId).emit( - "matching_fail", - "Match not confirmed. Please try again.", - ); + io.to(matchedRecord.socketId).emit("other_declined", "Match not confirmed. Please try again."); + io.to(matchedRecord.socketId).emit("matching_fail", "Match not confirmed. Please try again."); } if (userRecord !== null) { await prisma.matchRecord.update({ where: { recordId: userRecord.recordId }, data: { isArchived: true }, }); - io.to(userRecord.socketId).emit( - "other_declined", - "Match not confirmed. Please try again.", - ); - io.to(userRecord.socketId).emit( - "matching_fail", - "Match not confirmed. Please try again.", - ); + io.to(userRecord.socketId).emit("other_declined", "Match not confirmed. Please try again."); + io.to(userRecord.socketId).emit("matching_fail", "Match not confirmed. Please try again."); } return; @@ -280,23 +254,14 @@ export async function handleMatchingDecline(userRequest: any) { // user decline, match failed regardlessly console.log(`User ${userId} declined match`); - io.to(matchedRecord.socketId).emit( - "other_declined", - "Match not confirmed. Please try again.", - ); + io.to(matchedRecord.socketId).emit("other_declined", "Match not confirmed. Please try again."); await prisma.matchRecord.update({ where: { recordId: matchedRecord.recordId }, data: { isArchived: true }, }); - io.to(userRecord.socketId).emit( - "matching_fail", - "Match not confirmed. Please try again.", - ); - io.to(matchedRecord.socketId).emit( - "matching_fail", - "Match not confirmed. Please try again.", - ); + io.to(userRecord.socketId).emit("matching_fail", "Match not confirmed. Please try again."); + io.to(matchedRecord.socketId).emit("matching_fail", "Match not confirmed. Please try again."); } export async function handleTimeout(userRequest: any) { @@ -327,18 +292,13 @@ export async function handleConfirmTimeout(recordId: string) { console.log(`Timeout: Confirm timeout for recordId ${recordId}`); if (result !== null) { if (result.isConfirmed === false) { - console.log( - `Timeout: Match not confirmed for recordId ${recordId} with userId ${result.userId}`, - ); + console.log(`Timeout: Match not confirmed for recordId ${recordId} with userId ${result.userId}`); } else { console.log( - `Timeout: Match confirmed for recordId ${recordId} with userId ${result.userId} but other user did not confirm`, + `Timeout: Match confirmed for recordId ${recordId} with userId ${result.userId} but other user did not confirm` ); } - io.to(result.socketId).emit( - "matching_fail", - "Match not confirmed. Please try again.", - ); + io.to(result.socketId).emit("matching_fail", "Match not confirmed. Please try again."); await prisma.matchRecord.update({ where: { recordId: recordIdInt }, data: { isArchived: true }, From 7e59267a5a58a68bca6aab9017f20de23e48d693 Mon Sep 17 00:00:00 2001 From: Singa-pirate Date: Fri, 8 Nov 2024 02:41:45 +0800 Subject: [PATCH 03/10] Add recent session history in home page --- .../matching-service/src/matchingService.ts | 4 +- .../RecentSessions/RecentSessions.scss | 31 ++++- .../RecentSessions/RecentSessions.tsx | 125 ++++++++++++++++-- frontend/src/pages/CodeEditor/CodeEditor.tsx | 9 +- frontend/src/services/session.service.ts | 96 ++++++++++++++ 5 files changed, 240 insertions(+), 25 deletions(-) create mode 100644 frontend/src/services/session.service.ts diff --git a/backend/matching-service/src/matchingService.ts b/backend/matching-service/src/matchingService.ts index 29ef700968..a5f4b7e376 100644 --- a/backend/matching-service/src/matchingService.ts +++ b/backend/matching-service/src/matchingService.ts @@ -198,8 +198,8 @@ export async function handleMatchingConfirm(userRequest: any) { }); await prisma.sessionHistory.create({ data: { - roomNumber: userRecord.roomNumber, - questionId: userRecord.questionId ?? matchedRecord.questionId ?? 0, + roomNumber: matchedRecord.roomNumber, + questionId: matchedRecord.questionId ?? userRecord.questionId ?? 0, isOngoing: true, userOneId: userRecord.userId, userTwoId: matchedRecord.userId, diff --git a/frontend/src/components/RecentSessions/RecentSessions.scss b/frontend/src/components/RecentSessions/RecentSessions.scss index b0be9461e7..08ca36c317 100644 --- a/frontend/src/components/RecentSessions/RecentSessions.scss +++ b/frontend/src/components/RecentSessions/RecentSessions.scss @@ -6,13 +6,13 @@ flex-direction: column; padding: 10px 20px; gap: 10px; + height: 340px; &-sessions { - height: 150px; + height: 250px; display: flex; flex-direction: column; - justify-content: center; - padding: 5px 10px; + gap: 10px; &-text { font-size: 0.8rem !important; @@ -21,8 +21,31 @@ } } + &-session { + height: 20%; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 15px 15px; + border-radius: 10px; + border: 0.867px solid var(--Grey-15, #262626); + background: var(--Grey-11, #1C1C1C); + + &-info { + max-width: 70%; + display: flex; + flex-direction: column; + + &-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + } + &-new { - padding-block: 10px; display: flex; flex-direction: column; align-items: center; diff --git a/frontend/src/components/RecentSessions/RecentSessions.tsx b/frontend/src/components/RecentSessions/RecentSessions.tsx index 9d575894ca..e4d565d186 100644 --- a/frontend/src/components/RecentSessions/RecentSessions.tsx +++ b/frontend/src/components/RecentSessions/RecentSessions.tsx @@ -1,12 +1,33 @@ -import { Box, Button, Typography } from "@mui/material"; -import { ReactElement, useContext } from "react"; +import { Box, Button, Chip, Typography } from "@mui/material"; +import { ReactElement, useContext, useEffect, useState } from "react"; import "./RecentSessions.scss"; import { UserContext } from "../../contexts/UserContext"; import { useNavigate } from "react-router-dom"; +import SessionService, { SessionData } from "../../services/session.service"; +import { SessionContext, SessionState } from "../../contexts/SessionContext"; +import { AxiosError } from "axios"; +import { useMainDialog } from "../../contexts/MainDialogContext"; +import Spinner from "../Spinner/Spinner"; const RecentSessions = (): ReactElement => { const { user } = useContext(UserContext); + const { setSessionState, setQuestionId, setRoomNumber } = useContext(SessionContext); + const { setMainDialogTitle, setMainDialogContent, openMainDialog } = useMainDialog(); const navigate = useNavigate(); + const [sessionHistory, setSessionHistory] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchSessionHistory = async () => { + const sessions = await SessionService.getSessionHistory(user!.id as string); + setSessionHistory(sessions); + setLoading(false); + }; + + useEffect(() => { + if (user) { + fetchSessionHistory(); + } + }, [user]); const startNewSession = () => { if (user) { @@ -16,19 +37,99 @@ const RecentSessions = (): ReactElement => { } }; + const findFirstOngoingSession = (sessions: SessionData[]): SessionData | undefined => { + return sessions.find((session) => session.isOngoing); + }; + + const resumeSession = async (roomNumber: string) => { + try { + const rejoinResponse = await SessionService.rejoinSession(user!.id as string, roomNumber); + setSessionState(SessionState.SUCCESS); + setQuestionId(rejoinResponse.questionId); + setRoomNumber(rejoinResponse.roomNumber); + console.log(roomNumber); + navigate(`/code-editor/${rejoinResponse.roomNumber}`); + } catch (error) { + if (error instanceof AxiosError) { + if (error.response?.status === 404) { + fetchSessionHistory(); + setMainDialogTitle("Too late"); + setMainDialogContent("The interview session has ended. Join another one instead!"); + openMainDialog(); + return; + } + } + console.log(error); + setMainDialogTitle("Error"); + setMainDialogContent("An error occurred while trying to resume the session."); + openMainDialog(); + } + }; + + const viewAttempt = (session: SessionData) => { + setMainDialogTitle(`Attempt for "${session.questionId}. ${session.questionTitle}"`); + setMainDialogContent(session.submission ?? "No code was submited during this session."); + openMainDialog(); + }; + return ( - Recent Sessions - - - Session history is under construction, check back in a future milestone! - - - Technical Interview Prep Session + + Recent Sessions + + {loading ? ( + + ) : ( + + {sessionHistory.map((session) => ( + + + + {`${session.questionId}. ${session.questionTitle}`} + + + {`With ${session.otherUserName}`} + + + + {session.isOngoing ? ( + resumeSession(session.roomNumber)} + > + ) : ( + + )} + + + ))} + + )} - + {findFirstOngoingSession(sessionHistory) ? ( + + ) : ( + + )} ); diff --git a/frontend/src/pages/CodeEditor/CodeEditor.tsx b/frontend/src/pages/CodeEditor/CodeEditor.tsx index 24804aaf35..979a1f41b4 100644 --- a/frontend/src/pages/CodeEditor/CodeEditor.tsx +++ b/frontend/src/pages/CodeEditor/CodeEditor.tsx @@ -6,8 +6,6 @@ import { okaidia } from "@uiw/codemirror-theme-okaidia"; import { python } from "@codemirror/lang-python"; import { cpp } from "@codemirror/lang-cpp"; import { java } from "@codemirror/lang-java"; -import Navbar from "../../components/Navbar/Navbar"; -import Footer from "../../components/Footer/Footer"; import Chatbox from "../../components/Chatbox/Chatbox"; import VideoCall from "../../components/VideoCall/VideoCall"; import HintBox from "../../components/HintBox/HintBox"; @@ -29,6 +27,7 @@ import { useConfirmationDialog } from "../../contexts/ConfirmationDialogContext" import Peer, { MediaConnection } from "peerjs"; import TestCases from "../../components/TestCases/TestCases"; import { Circle } from "@mui/icons-material"; +import SessionService from "../../services/session.service"; const COMMUNICATION_WEBSOCKET_URL = process.env.REACT_APP_COMMUNICATION_SERVICE_URL as string; const COLLABORATION_WEBSOCKET_URL = process.env.REACT_APP_COLLABORATION_SERVICE_URL as string; @@ -124,7 +123,6 @@ const CodeEditor: React.FC = () => { const { roomNumber } = useParams(); const [joinedRoom, setJoinedRoom] = useState(false); // New state const [isHintBoxExpanded, setIsHintBoxExpanded] = useState(false); // New state - const [isVideoHovered, setIsVideoHovered] = useState(false); const [isChatboxExpanded, setIsChatboxExpanded] = useState(false); const [isVideoCallExpanded, setIsVideoCallExpanded] = useState(false); const [chatHistory, setChatHistory] = useState([]); @@ -237,6 +235,7 @@ const CodeEditor: React.FC = () => { setConfirmationDialogTitle("Leave Session"); setConfirmationDialogContent("Are you sure you want to leave the session?"); setConfirmationCallBack(() => () => { + SessionService.leaveSession(user?.id as string, roomNumber!); clearSocketsAndPeer(); clearSession(); navigate("/"); @@ -693,8 +692,6 @@ const CodeEditor: React.FC = () => { return (
- -
@@ -839,8 +836,6 @@ const CodeEditor: React.FC = () => { {isHintBoxExpanded && questionData && ( setIsHintBoxExpanded(false)} /> )} - -
); }; diff --git a/frontend/src/services/session.service.ts b/frontend/src/services/session.service.ts new file mode 100644 index 0000000000..da71e729f0 --- /dev/null +++ b/frontend/src/services/session.service.ts @@ -0,0 +1,96 @@ +import axios from "axios"; +import QuestionService from "./question.service"; +import UserService from "./user.service"; +import { User } from "../models/user.model"; + +export interface SessionResponse { + roomNumber: string; + questionId: number; + otherUserId: string; + isOngoing?: boolean; + submission?: string; +} + +// Data needed for displaying a session +export interface SessionData { + roomNumber: string; + questionId: number; + questionTitle: string; + otherUserId: string; + otherUserName: string; + isOngoing?: boolean; + submission?: string; +} + +export default class SessionService { + private static client = SessionService.createClient(); + + private static createClient() { + const client = axios.create({ + baseURL: process.env.REACT_APP_MATCHING_SERVICE_URL as string, + headers: { + "Content-type": "application/json", + }, + }); + client.interceptors.request.use((config) => { + const token = localStorage.getItem("jwt-token"); + if (token) { + config.headers["Authorization"] = token; + } + return config; + }); + return client; + } + + private static async mapSessionResponseToSessionData(session: SessionResponse): Promise { + try { + const { title } = await QuestionService.getQuestion(session.questionId); + const user: User | Error = await UserService.getUser(session.otherUserId); + + if (user instanceof Error) { + throw user; + } + + return { + roomNumber: session.roomNumber, + questionId: session.questionId, + questionTitle: title ?? "Deleted question", + otherUserId: session.otherUserId, + otherUserName: (user.username as string) ?? "Deleted user", + isOngoing: session.isOngoing, + submission: session.submission, + }; + } catch (error) { + return { + roomNumber: session.roomNumber, + questionId: session.questionId, + questionTitle: "Error fetching question", + otherUserId: session.otherUserId, + otherUserName: "Error fetching user", + isOngoing: session.isOngoing, + submission: session.submission, + }; + } + } + + static async getLatestSession(): Promise { + const response = await SessionService.client.get("/session"); + return response.data.session; + } + + static async getSessionHistory(userId: string): Promise { + const response = await SessionService.client.get("/session-history", { params: { userId } }); + const sessions: SessionResponse[] = response.data.sessions; + const sessionData = await Promise.all(sessions.map(this.mapSessionResponseToSessionData)); + return sessionData; + } + + static async leaveSession(userId: string, roomId: string): Promise { + await SessionService.client.put("/leave-session", { data: { userId, roomId } }); + } + + static async rejoinSession(userId: string, roomId: string): Promise { + const response = await SessionService.client.put("/rejoin-session", { data: { userId, roomId } }); + return response.data; + } +} From d8f9ae7143d83e6af316f32b156f33cffcad2fc9 Mon Sep 17 00:00:00 2001 From: Singa-pirate Date: Fri, 8 Nov 2024 02:54:07 +0800 Subject: [PATCH 04/10] Make interview nav tab auto reconnect to ongoing session --- .../RecentSessions/RecentSessions.tsx | 3 +- frontend/src/pages/Interview/Interview.tsx | 45 +++++++++++++++++++ frontend/src/services/session.service.ts | 4 +- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/RecentSessions/RecentSessions.tsx b/frontend/src/components/RecentSessions/RecentSessions.tsx index e4d565d186..52d0123c8e 100644 --- a/frontend/src/components/RecentSessions/RecentSessions.tsx +++ b/frontend/src/components/RecentSessions/RecentSessions.tsx @@ -47,14 +47,13 @@ const RecentSessions = (): ReactElement => { setSessionState(SessionState.SUCCESS); setQuestionId(rejoinResponse.questionId); setRoomNumber(rejoinResponse.roomNumber); - console.log(roomNumber); navigate(`/code-editor/${rejoinResponse.roomNumber}`); } catch (error) { if (error instanceof AxiosError) { if (error.response?.status === 404) { fetchSessionHistory(); setMainDialogTitle("Too late"); - setMainDialogContent("The interview session has ended. Join another one instead!"); + setMainDialogContent("The previous interview session has ended. Join another one instead!"); openMainDialog(); return; } diff --git a/frontend/src/pages/Interview/Interview.tsx b/frontend/src/pages/Interview/Interview.tsx index cec3f237ab..211e2838ec 100644 --- a/frontend/src/pages/Interview/Interview.tsx +++ b/frontend/src/pages/Interview/Interview.tsx @@ -11,6 +11,9 @@ import { Categories, QuestionComplexity } from "../../models/question.model"; import { io } from "socket.io-client"; import { SessionContext, SessionState } from "../../contexts/SessionContext"; import { useMainDialog } from "../../contexts/MainDialogContext"; +import SessionService from "../../services/session.service"; +import { AxiosError } from "axios"; +import Spinner from "../../components/Spinner/Spinner"; const WEBSOCKET_URL = process.env.REACT_APP_MATCHING_SERVICE_URL as string; @@ -38,6 +41,7 @@ const Interview = (): ReactElement => { setQuestionId, } = useContext(SessionContext); const { setMainDialogTitle, setMainDialogContent, openMainDialog } = useMainDialog(); + const [loading, setLoading] = useState(true); const socket = io(WEBSOCKET_URL, { autoConnect: false }); @@ -134,6 +138,44 @@ const Interview = (): ReactElement => { openMainDialog(); }); + const resumeSession = async (roomNumber: string) => { + try { + const rejoinResponse = await SessionService.rejoinSession(user!.id as string, roomNumber); + setSessionState(SessionState.SUCCESS); + setQuestionId(rejoinResponse.questionId); + setRoomNumber(rejoinResponse.roomNumber); + navigate(`/code-editor/${rejoinResponse.roomNumber}`); + } catch (error) { + if (error instanceof AxiosError) { + if (error.response?.status === 404) { + setMainDialogTitle("Too late"); + setMainDialogContent("The previous interview session has ended. Join another one instead!"); + openMainDialog(); + return; + } + } + console.log(error); + setMainDialogTitle("Error"); + setMainDialogContent("An error occurred while trying to resume the session."); + openMainDialog(); + } + }; + + useEffect(() => { + if (user) { + const fetchLatestSession = async () => { + const response = await SessionService.getLatestSession(user.id as string); + if (response && response.isOngoing) { + resumeSession(response.roomNumber); + } else { + setLoading(false); + } + }; + + fetchLatestSession(); + } + }, [user]); + useEffect(() => { if (sessionState === SessionState.SUCCESS && roomNumber) { console.log("Navigating to roomNumber: ", roomNumber); @@ -157,6 +199,9 @@ const Interview = (): ReactElement => { }; const getSessionComponent = (): ReactElement => { + if (loading) { + return ; + } switch (sessionState) { case SessionState.NOT_STARTED: return ; diff --git a/frontend/src/services/session.service.ts b/frontend/src/services/session.service.ts index da71e729f0..2c203e319f 100644 --- a/frontend/src/services/session.service.ts +++ b/frontend/src/services/session.service.ts @@ -73,8 +73,8 @@ export default class SessionService { } } - static async getLatestSession(): Promise { - const response = await SessionService.client.get("/session"); + static async getLatestSession(userId: string): Promise { + const response = await SessionService.client.get("/session", { params: { userId } }); return response.data.session; } From 0a341ff684ecf9816ad7561a1869d9669e4cc506 Mon Sep 17 00:00:00 2001 From: Singa-pirate Date: Fri, 8 Nov 2024 03:14:51 +0800 Subject: [PATCH 05/10] Add function to submit code --- backend/matching-service/src/server.ts | 39 ++++++++++++++ frontend/src/pages/CodeEditor/CodeEditor.tsx | 54 +++++++++++++++----- frontend/src/services/session.service.ts | 4 ++ 3 files changed, 84 insertions(+), 13 deletions(-) diff --git a/backend/matching-service/src/server.ts b/backend/matching-service/src/server.ts index 72a5a66f21..6dc5f16131 100644 --- a/backend/matching-service/src/server.ts +++ b/backend/matching-service/src/server.ts @@ -208,6 +208,45 @@ app.put("/rejoin-session", async (req, res) => { } }); +app.put("/submit-session", async (req, res) => { + if (!req.body.data) { + res.status(400).json({ error: "Request was malformed." }); + return; + } + const { userId, roomId, submission } = req.body.data; + const record = await prisma.sessionHistory.findFirst({ + where: { + isOngoing: true, + roomNumber: roomId, + OR: [ + { + userOneId: userId, + }, + { + userTwoId: userId, + }, + ], + }, + }); + if (!record) { + res.status(404).json({ error: "Request did not match with any ongoing session that the user is in." }); + return; + } else { + await prisma.sessionHistory.update({ + where: { + sessionId: record.sessionId, + }, + data: { + isOngoing: false, + isUserOneActive: false, + isUserTwoActive: false, + submission: submission, + }, + }); + } + res.status(200).json({ ok: "ok" }); +}); + const server = createServer(app); export const io = new Server(server, { cors: { diff --git a/frontend/src/pages/CodeEditor/CodeEditor.tsx b/frontend/src/pages/CodeEditor/CodeEditor.tsx index 979a1f41b4..018e5f9b6b 100644 --- a/frontend/src/pages/CodeEditor/CodeEditor.tsx +++ b/frontend/src/pages/CodeEditor/CodeEditor.tsx @@ -28,6 +28,8 @@ import Peer, { MediaConnection } from "peerjs"; import TestCases from "../../components/TestCases/TestCases"; import { Circle } from "@mui/icons-material"; import SessionService from "../../services/session.service"; +import { AxiosError } from "axios"; +import { useMainDialog } from "../../contexts/MainDialogContext"; const COMMUNICATION_WEBSOCKET_URL = process.env.REACT_APP_COMMUNICATION_SERVICE_URL as string; const COLLABORATION_WEBSOCKET_URL = process.env.REACT_APP_COLLABORATION_SERVICE_URL as string; @@ -113,6 +115,7 @@ const CodeEditor: React.FC = () => { const { sessionState, questionId, clearSession, otherUserId, otherUserProfile } = useContext(SessionContext); const { setConfirmationDialogTitle, setConfirmationDialogContent, setConfirmationCallBack, openConfirmationDialog } = useConfirmationDialog(); + const { setMainDialogTitle, setMainDialogContent, openMainDialog } = useMainDialog(); const navigate = useNavigate(); const [questionData, setQuestionData] = useState(null); @@ -330,6 +333,7 @@ const CodeEditor: React.FC = () => { "Your partner has left the coding session. Would you like to end the session and return to home page?", ); setConfirmationCallBack(() => () => { + SessionService.leaveSession(user?.id as string, roomNumber!); clearSocketsAndPeer(); clearSession(); navigate("/"); @@ -575,10 +579,6 @@ const CodeEditor: React.FC = () => { } }; - const handleHangUp = () => { - setIsVideoCallExpanded(false); - }; - const cursorDecorationsExtension = useMemo(() => { return createCursorDecorations(otherCursors); }, [otherCursors]); @@ -690,6 +690,29 @@ const CodeEditor: React.FC = () => { } }; + const submitAndEndSession = async () => { + try { + setConfirmationDialogTitle("Submit and end session"); + setConfirmationDialogContent( + "You are about to submit your code and end the session for both you and your partner. Are you sure?", + ); + setConfirmationCallBack(() => async () => { + await SessionService.submitSession(user?.id as string, roomNumber!, code); + clearSocketsAndPeer(); + clearSession(); + navigate("/"); + }); + openConfirmationDialog(); + } catch (error) { + setMainDialogTitle("Error"); + setMainDialogContent( + error instanceof AxiosError && error.response?.data.message + ? error.response?.data.message + : "An error occurred while submitting the code.", + ); + } + }; + return (
@@ -723,15 +746,20 @@ const CodeEditor: React.FC = () => { - +
+ + +
{ + await SessionService.client.put("/submit-session", { data: { userId, roomId, submission } }); + } } From c3776227db0dda54e410a35d3fb956cc7e823da1 Mon Sep 17 00:00:00 2001 From: Singa-pirate Date: Fri, 8 Nov 2024 15:39:16 +0800 Subject: [PATCH 06/10] Update websocket logic regarding reconnection --- backend/collaboration-service/.env.sample | 2 + backend/collaboration-service/models.py | 3 +- backend/collaboration-service/server.py | 29 +++++-- .../user_verification.py | 2 +- .../RecentSessions/RecentSessions.scss | 11 +-- .../RecentSessions/RecentSessions.tsx | 77 +++++++++++-------- frontend/src/pages/CodeEditor/CodeEditor.tsx | 47 +++++++---- 7 files changed, 111 insertions(+), 60 deletions(-) diff --git a/backend/collaboration-service/.env.sample b/backend/collaboration-service/.env.sample index 7662569dc5..cad4686653 100644 --- a/backend/collaboration-service/.env.sample +++ b/backend/collaboration-service/.env.sample @@ -1,6 +1,8 @@ LOG_LEVEL=20 USER_SERVICE_URL=http://localhost:3001 +MATCHING_SERVICE_URL=http://localhost:3003 # In Docker LOG_LEVEL=20 USER_SERVICE_URL=http://user:3001 +MATCHING_SERVICE_URL=http://matching:3003 diff --git a/backend/collaboration-service/models.py b/backend/collaboration-service/models.py index f94f1629a2..16623c062f 100644 --- a/backend/collaboration-service/models.py +++ b/backend/collaboration-service/models.py @@ -3,7 +3,8 @@ class User: users: dict[str, "User"] = {} - def __init__(self, username: str, sid: str): + def __init__(self, user_id, username: str, sid: str): + self.user_id = user_id self.username = username self.cursor_position = 0 self.sid = sid diff --git a/backend/collaboration-service/server.py b/backend/collaboration-service/server.py index 29032f7142..5ff3b5f09b 100644 --- a/backend/collaboration-service/server.py +++ b/backend/collaboration-service/server.py @@ -2,8 +2,12 @@ import logging import os import dotenv +import requests dotenv.load_dotenv() +MATCHING_SERVICE_URL = os.environ.get('MATCHING_SERVICE_URL') +if not MATCHING_SERVICE_URL: + raise ValueError('MATCHING_SERVICE_URL environment variable not set') from events import Events from models import Room, User @@ -26,9 +30,11 @@ async def connect(sid, environ): logging.debug(f"connect {token=}") break if token: - username = authenticate(token) - if username: - User(username, sid) + user = authenticate(token) + user_id = user.get('id', None) + username = user.get('username', None) + if user_id and username: + User(user_id, username, sid) logging.info(f"User {username} authenticated and connected with sid {sid}") else: unauthenticated_sids.add(sid) @@ -62,8 +68,13 @@ async def join_request(sid, data): logging.error(f"After join_request, user.room is None for sid {sid}") else: logging.debug(f"User {sid} joined room {room.id}") + + data = { + "user_id": user.user_id, + "room_details": room.details() + } - await sio.emit(Events.JOIN_REQUEST, room.details(), room=room_id) + await sio.emit(Events.JOIN_REQUEST, data, room=room_id) @sio.on(Events.CODE_UPDATED) @@ -167,7 +178,7 @@ async def disconnect(sid): if room is None: logging.error(f"User {sid} has no room during disconnect") return - + room_still_exists = room.remove_user(user) if room_still_exists: @@ -175,4 +186,10 @@ async def disconnect(sid): await sio.emit(Events.USER_LEFT, user.details(), room=room.id) logging.debug(f"Emitted USER_LEFT to room {room.id}") except Exception as e: - logging.error(f"Failed to emit USER_LEFT for room {room.id}: {e}") \ No newline at end of file + logging.error(f"Failed to emit USER_LEFT for room {room.id}: {e}") + + response = requests.put(f"{MATCHING_SERVICE_URL}/leave-session", json={"data": {"roomId": room.id, "userId": user.user_id} }) + if response.status_code != 200: + logging.error(f"Error communicating with matching service: {response.content}") + else: + logging.info(f"Requested matching service to mark user {user.user_id} as having left room {room.id}") diff --git a/backend/collaboration-service/user_verification.py b/backend/collaboration-service/user_verification.py index f0e2adcdc9..96d863ae6b 100644 --- a/backend/collaboration-service/user_verification.py +++ b/backend/collaboration-service/user_verification.py @@ -9,4 +9,4 @@ def authenticate(authorization_header) -> str | None: response = requests.get(f"{USER_SERVICE_URL}/auth/verify-token", headers={'authorization': authorization_header}) if response.status_code != 200: return None - return response.json()["data"]['username'] + return response.json()["data"] diff --git a/frontend/src/components/RecentSessions/RecentSessions.scss b/frontend/src/components/RecentSessions/RecentSessions.scss index 08ca36c317..d620034bcc 100644 --- a/frontend/src/components/RecentSessions/RecentSessions.scss +++ b/frontend/src/components/RecentSessions/RecentSessions.scss @@ -14,10 +14,11 @@ flex-direction: column; gap: 10px; - &-text { - font-size: 0.8rem !important; - font-weight: 200 !important; - color: var(--Grey-Shades-70, var(--Text-Disabled-On-Disabled, #b3b3b3)) !important; + &-message { + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; } } @@ -30,7 +31,7 @@ padding: 15px 15px; border-radius: 10px; border: 0.867px solid var(--Grey-15, #262626); - background: var(--Grey-11, #1C1C1C); + background: var(--Grey-11, #1c1c1c); &-info { max-width: 70%; diff --git a/frontend/src/components/RecentSessions/RecentSessions.tsx b/frontend/src/components/RecentSessions/RecentSessions.tsx index 52d0123c8e..64d2da4554 100644 --- a/frontend/src/components/RecentSessions/RecentSessions.tsx +++ b/frontend/src/components/RecentSessions/RecentSessions.tsx @@ -15,9 +15,10 @@ const RecentSessions = (): ReactElement => { const { setMainDialogTitle, setMainDialogContent, openMainDialog } = useMainDialog(); const navigate = useNavigate(); const [sessionHistory, setSessionHistory] = useState([]); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); const fetchSessionHistory = async () => { + setLoading(true); const sessions = await SessionService.getSessionHistory(user!.id as string); setSessionHistory(sessions); setLoading(false); @@ -67,7 +68,7 @@ const RecentSessions = (): ReactElement => { const viewAttempt = (session: SessionData) => { setMainDialogTitle(`Attempt for "${session.questionId}. ${session.questionTitle}"`); - setMainDialogContent(session.submission ?? "No code was submited during this session."); + setMainDialogContent(session.submission ?? "No code was submitted during this session."); openMainDialog(); }; @@ -80,37 +81,49 @@ const RecentSessions = (): ReactElement => { ) : ( - {sessionHistory.map((session) => ( - - - - {`${session.questionId}. ${session.questionTitle}`} - - - {`With ${session.otherUserName}`} - - - - {session.isOngoing ? ( - resumeSession(session.roomNumber)} - > - ) : ( - - )} - + {!user ? ( + + Log in to view session history - ))} + ) : sessionHistory.length === 0 ? ( + + + No sessions yet + + + ) : ( + sessionHistory.map((session) => ( + + + + {`${session.questionId}. ${session.questionTitle}`} + + + {`With ${session.otherUserName}`} + + + + {session.isOngoing ? ( + resumeSession(session.roomNumber)} + > + ) : ( + + )} + + + )) + )} )} diff --git a/frontend/src/pages/CodeEditor/CodeEditor.tsx b/frontend/src/pages/CodeEditor/CodeEditor.tsx index 018e5f9b6b..0039b67139 100644 --- a/frontend/src/pages/CodeEditor/CodeEditor.tsx +++ b/frontend/src/pages/CodeEditor/CodeEditor.tsx @@ -120,8 +120,19 @@ const CodeEditor: React.FC = () => { const [questionData, setQuestionData] = useState(null); + // Use state + ref combination to handle real-time state change + socket events const [code, setCode] = useState("# Write your solution here\n"); const [language, setLanguage] = useState("python"); + const codeRef = useRef(code); + const languageRef = useRef(language); + + useEffect(() => { + codeRef.current = code; + }, [code]); + + useEffect(() => { + languageRef.current = language; + }, [language]); const { roomNumber } = useParams(); const [joinedRoom, setJoinedRoom] = useState(false); // New state @@ -184,12 +195,6 @@ const CodeEditor: React.FC = () => { }; useEffect(() => { - if (sessionState !== SessionState.SUCCESS) { - navigate("/"); - clearSession(); - return; - } - const fetchQuestionData = async () => { try { const response = await QuestionService.getQuestion(questionId); @@ -208,8 +213,13 @@ const CodeEditor: React.FC = () => { } }; - fetchQuestionData(); - }, [questionId, sessionState, navigate, clearSession]); + if (sessionState !== SessionState.SUCCESS) { + navigate("/"); + clearSession(); + } else { + fetchQuestionData(); + } + }, [questionId, sessionState]); const appendToChatHistory = (newMessage: ChatMessage) => { setChatHistory([...chatHistoryRef.current, newMessage]); @@ -262,10 +272,16 @@ const CodeEditor: React.FC = () => { socket.on("join_request", (data: any) => { console.log("Received join_request data:", data); - if (data.code) { - setCode(data.code); + if (data?.user_id && data.user_id === user?.id) { + // Current user successfully joined a room + setJoinedRoom(true); + } else { + // emit current code and cursor for any new user joining the room + console.log("emitting"); + socket.emit("language_change", { language: languageRef.current, room_id: roomNumber }); + socket.emit("code_updated", { code: codeRef.current }); + socket.emit("cursor_updated", { cursor_position: lastCursorPosition.current }); } - setJoinedRoom(true); // User has successfully joined a room }); socket.on("language_change", (newLanguage: string) => { @@ -289,7 +305,7 @@ const CodeEditor: React.FC = () => { // Handle real-time code updates socket.on("code_updated", (newCode: string) => { - setCode(newCode); + codeRef.current = newCode; }); // Handle cursor updates @@ -643,8 +659,8 @@ const CodeEditor: React.FC = () => { // Prepare payload for the API const payload = { - lang: language, - code: code, + lang: languageRef.current, + code: codeRef.current, customTests: submittedTestCases.map((tc) => ({ input: tc.input, output: tc.expectedOutput || null, @@ -697,7 +713,7 @@ const CodeEditor: React.FC = () => { "You are about to submit your code and end the session for both you and your partner. Are you sure?", ); setConfirmationCallBack(() => async () => { - await SessionService.submitSession(user?.id as string, roomNumber!, code); + await SessionService.submitSession(user?.id as string, roomNumber!, codeRef.current); clearSocketsAndPeer(); clearSession(); navigate("/"); @@ -710,6 +726,7 @@ const CodeEditor: React.FC = () => { ? error.response?.data.message : "An error occurred while submitting the code.", ); + openMainDialog(); } }; From c9319ce694cce06eaf3c7bc11b9ea7097a076732 Mon Sep 17 00:00:00 2001 From: Singa-pirate Date: Fri, 8 Nov 2024 18:40:22 +0800 Subject: [PATCH 07/10] Init session history page --- frontend/src/App.tsx | 2 + frontend/src/components/Navbar/Navbar.tsx | 11 ++ frontend/src/pages/CodeEditor/CodeEditor.tsx | 2 +- .../src/pages/History/History.module.scss | 181 ++++++++++++++++++ frontend/src/pages/History/History.tsx | 96 ++++++++++ frontend/src/pages/Home/Home.tsx | 2 +- 6 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/History/History.module.scss create mode 100644 frontend/src/pages/History/History.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a3861e0ca9..beaf91f275 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import { SessionContextProvider } from "./contexts/SessionContext"; import VideoChat from "./pages/Communication/Commincation"; import CodeEditor from "./pages/CodeEditor/CodeEditor"; import ProtectedRoute from "./util/ProtectedRoute"; +import History from "./pages/History/History"; const theme = createTheme({ typography: { @@ -82,6 +83,7 @@ const App = (): ReactElement => { : } /> } /> : } /> + : } /> } /> { navigate("/interview", { replace: true }); }; + const redirectToHistory = () => { + navigate("/history", { replace: true }); + }; + const redirectToSignUp = () => { navigate("/signup", { replace: true }); }; @@ -54,6 +58,13 @@ const Navbar = (): ReactElement => { ) : ( <> )} + {user ? ( + + ) : ( + <> + )} {user ? ( diff --git a/frontend/src/pages/CodeEditor/CodeEditor.tsx b/frontend/src/pages/CodeEditor/CodeEditor.tsx index 0039b67139..70eb230338 100644 --- a/frontend/src/pages/CodeEditor/CodeEditor.tsx +++ b/frontend/src/pages/CodeEditor/CodeEditor.tsx @@ -305,7 +305,7 @@ const CodeEditor: React.FC = () => { // Handle real-time code updates socket.on("code_updated", (newCode: string) => { - codeRef.current = newCode; + setCode(newCode); }); // Handle cursor updates diff --git a/frontend/src/pages/History/History.module.scss b/frontend/src/pages/History/History.module.scss new file mode 100644 index 0000000000..3628b6b457 --- /dev/null +++ b/frontend/src/pages/History/History.module.scss @@ -0,0 +1,181 @@ +.page { + background: var(--Grey-10, #1a1a1a); + min-height: 100vh; + padding: 40px 80px; + display: flex; + flex-direction: column; + gap: 20px; + align-items: center; +} + +.container { + width: 90%; + margin: 0 auto; + padding: 20px; + background-color: #1c1c1c; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + border-radius: 10px; + color: #f1f1f1; + font-family: "Lexend", "Roboto", "Arial", sans-serif; /* Ensuring the font is coherent */ +} + +h1 { + text-align: center; + color: #caff33; + font-size: 28px; + margin-bottom: 20px; +} + +.table { + width: 100%; + border-collapse: collapse; + background-color: #282828; + border-radius: 8px; + overflow: hidden; +} + +.table th, +.table td { + padding: 10px; + text-align: left; + border-bottom: 1px solid #333; +} + +.table th { + background-color: #333; + font-weight: bold; + color: #caff33; + position: relative; +} + +.table th:nth-child(1) { + width: 20%; +} + +.table th:nth-child(2) { + width: 35%; +} + +.table th:nth-child(3) { + width: 28%; +} + +.table th:nth-child(4) { + width: 17%; +} + +.clickable { + cursor: pointer; + position: relative; + transition: background-color 0.3s ease; +} + +.clickable:hover { + background-color: #3c3c3c; +} + +.clickable::before, +.clickable::after { + content: ""; + position: absolute; + right: 10px; + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; +} + +.clickable::before { + top: 40%; + border-bottom: 6px solid #456300; +} + +.clickable::after { + top: 60%; + border-top: 6px solid #456300; +} + +.asc::before { + border-bottom: 6px solid #caff33; +} + +.desc::after { + border-top: 6px solid #caff33; +} + +.table td { + color: #f1f1f1; +} + +.tags { + display: flex; + gap: 8px; +} + +.tag { + background-color: #61dafb; + color: white; + padding: 5px 10px; + border-radius: 5px; + font-size: 12px; + text-transform: capitalize; +} + +.tag:nth-child(2n) { + background-color: #ff6f61; +} + +.tag:nth-child(3n) { + background-color: #8e44ad; +} + +.tag:nth-child(4n) { + background-color: #2ecc71; +} + +.complexity { + font-weight: bold; + color: #caff33; +} + +.table tr:hover { + background-color: #3a3a3a; +} + +.questionaddicon { + margin-top: 20px !important; +} + +.questionediticon { + color: #f1f1f1 !important; +} + +.questiondeleteicon { + color: red !important; +} + +.questionlinkicon { + width: 80% !important; + color: #caff33 !important; +} + +.questiontitle { + color: #f1f1f1 !important; + text-decoration: underline !important; +} + +.questiontablebody { + padding-top: 10px; + min-height: 20vh; + display: flex; + flex-direction: column; + justify-content: center; +} + +@media (max-width: 768px) { + .table th, + .table td { + padding: 10px; + font-size: 14px; + } +} \ No newline at end of file diff --git a/frontend/src/pages/History/History.tsx b/frontend/src/pages/History/History.tsx new file mode 100644 index 0000000000..7bcd52035e --- /dev/null +++ b/frontend/src/pages/History/History.tsx @@ -0,0 +1,96 @@ +import { ReactElement, useContext, useEffect, useState } from "react"; +import styles from "./History.module.scss"; +import Navbar from "../../components/Navbar/Navbar"; +import { Box, Button, Typography } from "@mui/material"; +import Footer from "../../components/Footer/Footer"; +import Spinner from "../../components/Spinner/Spinner"; +import SessionService, { SessionData } from "../../services/session.service"; +import { UserContext } from "../../contexts/UserContext"; +import { AxiosError } from "axios"; + +const History = (): ReactElement => { + const { user } = useContext(UserContext); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [sessionHistory, setSessionHistory] = useState([]); + + useEffect(() => { + const fetchSessionHistory = async () => { + try { + setLoading(true); + const sessionHistory = await SessionService.getSessionHistory(user?.id as string); + setSessionHistory(sessionHistory); + } catch (error) { + if (error instanceof AxiosError) { + setError(error.message); + } else { + setError("An unexpected error occurred. Please try again later."); + } + } finally { + setLoading(false); + } + }; + + if (user) { + fetchSessionHistory(); + } + }, [user]); + + return ( + + +
+ + Session History + + {loading ? ( + + ) : error ? ( + + {error} + + ) : ( +
+ + + + + + + + + + + {sessionHistory.map((session) => ( + + + + + + + ))} + +
{}} className={`${styles.clickable} ${styles["asc"]}`}> + Start time + {}} className={`${styles.clickable} ${styles["asc"]}`}> + Question + {}} className={`${styles.clickable} ${styles["asc"]}`}> + Partner + Action
{session.questionId} + + {session.otherUserName} + +
+
+ )} +
+
+ + ); +}; + +export default History; diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index 6c4ff732ef..3f0bab52e8 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -1,7 +1,7 @@ import { ReactElement } from "react"; import "./Home.scss"; import Navbar from "../../components/Navbar/Navbar"; -import { Box, Button, Typography } from "@mui/material"; +import { Box, Typography } from "@mui/material"; import CodeEditorImage from "../../assets/code_editor.svg"; import Footer from "../../components/Footer/Footer"; import RecentSessions from "../../components/RecentSessions/RecentSessions"; From 29e4822a71181a7cce21781c27dd111f04a496ab Mon Sep 17 00:00:00 2001 From: Singa-pirate Date: Fri, 8 Nov 2024 22:20:40 +0800 Subject: [PATCH 08/10] Add code vierwe page for viewing submission --- backend/matching-service/prisma/schema.prisma | 1 + backend/matching-service/src/server.ts | 10 +- frontend/src/App.tsx | 2 + .../RecentSessions/RecentSessions.tsx | 10 +- frontend/src/pages/CodeEditor/CodeEditor.tsx | 7 +- frontend/src/pages/CodeViewer/CodeViewer.scss | 146 ++++++++++++++++++ frontend/src/pages/CodeViewer/CodeViewer.tsx | 121 +++++++++++++++ frontend/src/pages/History/History.tsx | 107 +++++++++++-- frontend/src/services/session.service.ts | 22 ++- 9 files changed, 399 insertions(+), 27 deletions(-) create mode 100644 frontend/src/pages/CodeViewer/CodeViewer.scss create mode 100644 frontend/src/pages/CodeViewer/CodeViewer.tsx diff --git a/backend/matching-service/prisma/schema.prisma b/backend/matching-service/prisma/schema.prisma index 7d068c5a4b..020a0b67fc 100644 --- a/backend/matching-service/prisma/schema.prisma +++ b/backend/matching-service/prisma/schema.prisma @@ -28,6 +28,7 @@ model SessionHistory { roomNumber String questionId Int submission String? + language String? isOngoing Boolean @default(true) userOneId String userTwoId String diff --git a/backend/matching-service/src/server.ts b/backend/matching-service/src/server.ts index 6dc5f16131..57dd71b848 100644 --- a/backend/matching-service/src/server.ts +++ b/backend/matching-service/src/server.ts @@ -84,6 +84,8 @@ app.get("/session", async (req, res) => { questionId: record.questionId, otherUserId: record.userOneId === userId ? record.userTwoId : record.userOneId, submission: record.submission, + language: record.language, + createdAt: record.createdAt, }; res.status(200).json({ session: session }); } @@ -95,6 +97,7 @@ app.get("/session-history", async (req, res) => { return; } const userId = req.query.userId as string; + const count = req.query.count ? parseInt(req.query.count as string) : 3; const records = await prisma.sessionHistory.findMany({ where: { @@ -110,7 +113,7 @@ app.get("/session-history", async (req, res) => { orderBy: { createdAt: "desc", }, - take: 3, + take: count, }); if (!records || records.length === 0) { @@ -122,6 +125,8 @@ app.get("/session-history", async (req, res) => { questionId: record.questionId, otherUserId: record.userOneId === userId ? record.userTwoId : record.userOneId, submission: record.submission, + language: record.language, + createdAt: record.createdAt, })); res.status(200).json({ sessions: sessions }); } @@ -213,7 +218,7 @@ app.put("/submit-session", async (req, res) => { res.status(400).json({ error: "Request was malformed." }); return; } - const { userId, roomId, submission } = req.body.data; + const { userId, roomId, submission, language } = req.body.data; const record = await prisma.sessionHistory.findFirst({ where: { isOngoing: true, @@ -241,6 +246,7 @@ app.put("/submit-session", async (req, res) => { isUserOneActive: false, isUserTwoActive: false, submission: submission, + language: language, }, }); } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index beaf91f275..818f9bbbdd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,6 +17,7 @@ import VideoChat from "./pages/Communication/Commincation"; import CodeEditor from "./pages/CodeEditor/CodeEditor"; import ProtectedRoute from "./util/ProtectedRoute"; import History from "./pages/History/History"; +import CodeViewer from "./pages/CodeViewer/CodeViewer"; const theme = createTheme({ typography: { @@ -85,6 +86,7 @@ const App = (): ReactElement => { : } /> : } /> } /> + } /> { } }; - const viewAttempt = (session: SessionData) => { - setMainDialogTitle(`Attempt for "${session.questionId}. ${session.questionTitle}"`); - setMainDialogContent(session.submission ?? "No code was submitted during this session."); - openMainDialog(); + const viewSubmission = (questionId: number, language: Language | undefined, code: string | undefined) => { + navigate("/view", { state: { questionId, language, code } }); }; return ( @@ -114,7 +112,7 @@ const RecentSessions = (): ReactElement => { +
+
+
+ ); +}; + +export default CodeViewer; diff --git a/frontend/src/pages/History/History.tsx b/frontend/src/pages/History/History.tsx index 7bcd52035e..423330f4e7 100644 --- a/frontend/src/pages/History/History.tsx +++ b/frontend/src/pages/History/History.tsx @@ -4,22 +4,33 @@ import Navbar from "../../components/Navbar/Navbar"; import { Box, Button, Typography } from "@mui/material"; import Footer from "../../components/Footer/Footer"; import Spinner from "../../components/Spinner/Spinner"; -import SessionService, { SessionData } from "../../services/session.service"; +import SessionService, { Language, SessionData } from "../../services/session.service"; import { UserContext } from "../../contexts/UserContext"; import { AxiosError } from "axios"; +import { useMainDialog } from "../../contexts/MainDialogContext"; +import { useNavigate } from "react-router-dom"; + +type SortOrder = "asc" | "desc"; +type SortColumn = "createdAt" | "questionId" | "otherUserName"; const History = (): ReactElement => { + const navigate = useNavigate(); const { user } = useContext(UserContext); + const { setMainDialogTitle, setMainDialogContent, openMainDialog } = useMainDialog(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [sessionHistory, setSessionHistory] = useState([]); + const [sortOrder, setSortOrder] = useState("asc"); + const [sortColumn, setSortColumn] = useState("createdAt"); + useEffect(() => { const fetchSessionHistory = async () => { + // for now, fetch the 20 most recent sessions, TODO pagination try { setLoading(true); - const sessionHistory = await SessionService.getSessionHistory(user?.id as string); - setSessionHistory(sessionHistory); + const sessionHistory = await SessionService.getSessionHistory(user?.id as string, 20); + setSessionHistory(sessionHistory.filter((session) => session.isOngoing === false)); } catch (error) { if (error instanceof AxiosError) { setError(error.message); @@ -36,6 +47,58 @@ const History = (): ReactElement => { } }, [user]); + const chooseSortByColumn = (column: SortColumn) => { + if (sortColumn === column) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortColumn(column); + setSortOrder("asc"); + } + console.log(sortColumn, sortOrder); + }; + + const handleSort = () => { + const sortedSessionHistory = [...sessionHistory].sort((s1, s2) => { + if (sortColumn === "createdAt") { + if (sortOrder === "asc") { + // sort by "most recent first" i.e. decreasing timestamp + return (s2.createdAt as Date).getTime() - (s1.createdAt as Date).getTime(); + } else { + return (s1.createdAt as Date).getTime() - (s2.createdAt as Date).getTime(); + } + } else if (sortColumn === "questionId") { + if (sortOrder === "asc") { + // sort by increasing question id + return s1.questionId - s2.questionId; + } else { + return s2.questionId - s1.questionId; + } + } else { + if (sortOrder === "asc") { + // sort by increasing alphabetical order of partner name + return s1.otherUserName.localeCompare(s2.otherUserName); + } else { + return s2.otherUserName.localeCompare(s1.otherUserName); + } + } + }); + setSessionHistory(sortedSessionHistory); + }; + + useEffect(() => { + handleSort(); + }, [sortColumn, sortOrder]); + + const showQuestionDescription = (title: string, description: string) => { + setMainDialogTitle(title); + setMainDialogContent(description); + openMainDialog(); + }; + + const viewSubmission = (questionId: number, language: Language | undefined, code: string | undefined) => { + navigate("/view", { state: { questionId, language, code } }); + }; + return ( @@ -54,13 +117,28 @@ const History = (): ReactElement => { - - - @@ -69,15 +147,26 @@ const History = (): ReactElement => { {sessionHistory.map((session) => ( - + diff --git a/frontend/src/services/session.service.ts b/frontend/src/services/session.service.ts index 8db5f279f1..9664871200 100644 --- a/frontend/src/services/session.service.ts +++ b/frontend/src/services/session.service.ts @@ -3,12 +3,16 @@ import QuestionService from "./question.service"; import UserService from "./user.service"; import { User } from "../models/user.model"; +export type Language = "python" | "cpp" | "java"; + export interface SessionResponse { roomNumber: string; questionId: number; otherUserId: string; isOngoing?: boolean; submission?: string; + language?: Language; + createdAt?: string; } // Data needed for displaying a session @@ -16,10 +20,13 @@ export interface SessionData { roomNumber: string; questionId: number; questionTitle: string; + questionDescription: string; otherUserId: string; otherUserName: string; isOngoing?: boolean; submission?: string; + language?: Language; + createdAt?: Date; } export default class SessionService { @@ -44,7 +51,7 @@ export default class SessionService { private static async mapSessionResponseToSessionData(session: SessionResponse): Promise { try { - const { title } = await QuestionService.getQuestion(session.questionId); + const { title, description } = await QuestionService.getQuestion(session.questionId); const user: User | Error = await UserService.getUser(session.otherUserId); if (user instanceof Error) { @@ -55,20 +62,25 @@ export default class SessionService { roomNumber: session.roomNumber, questionId: session.questionId, questionTitle: title ?? "Deleted question", + questionDescription: description ?? "Description not available", otherUserId: session.otherUserId, otherUserName: (user.username as string) ?? "Deleted user", isOngoing: session.isOngoing, submission: session.submission, + language: session.language, + createdAt: session.createdAt ? new Date(session.createdAt) : undefined, }; } catch (error) { return { roomNumber: session.roomNumber, questionId: session.questionId, questionTitle: "Error fetching question", + questionDescription: "Error fetching question", otherUserId: session.otherUserId, otherUserName: "Error fetching user", isOngoing: session.isOngoing, submission: session.submission, + language: session.language, }; } } @@ -78,8 +90,8 @@ export default class SessionService { return response.data.session; } - static async getSessionHistory(userId: string): Promise { - const response = await SessionService.client.get("/session-history", { params: { userId } }); + static async getSessionHistory(userId: string, count?: number): Promise { + const response = await SessionService.client.get("/session-history", { params: { userId, count } }); const sessions: SessionResponse[] = response.data.sessions; const sessionData = await Promise.all(sessions.map(this.mapSessionResponseToSessionData)); return sessionData; @@ -94,7 +106,7 @@ export default class SessionService { return response.data; } - static async submitSession(userId: string, roomId: string, submission: string): Promise { - await SessionService.client.put("/submit-session", { data: { userId, roomId, submission } }); + static async submitSession(userId: string, roomId: string, submission: string, language: Language): Promise { + await SessionService.client.put("/submit-session", { data: { userId, roomId, submission, language } }); } } From e0f724925f3d7c032cff684d4cbcdd9825e1c3b5 Mon Sep 17 00:00:00 2001 From: Singa-pirate Date: Fri, 8 Nov 2024 22:59:10 +0800 Subject: [PATCH 09/10] Add error handling to session service --- frontend/src/services/session.service.ts | 114 ++++++++++++++++++++--- 1 file changed, 102 insertions(+), 12 deletions(-) diff --git a/frontend/src/services/session.service.ts b/frontend/src/services/session.service.ts index 9664871200..4954940577 100644 --- a/frontend/src/services/session.service.ts +++ b/frontend/src/services/session.service.ts @@ -1,4 +1,4 @@ -import axios from "axios"; +import axios, { AxiosError } from "axios"; import QuestionService from "./question.service"; import UserService from "./user.service"; import { User } from "../models/user.model"; @@ -51,13 +51,18 @@ export default class SessionService { private static async mapSessionResponseToSessionData(session: SessionResponse): Promise { try { - const { title, description } = await QuestionService.getQuestion(session.questionId); + const question = await QuestionService.getQuestion(session.questionId); const user: User | Error = await UserService.getUser(session.otherUserId); + if (question instanceof Error) { + throw question; + } if (user instanceof Error) { throw user; } + const { title, description } = question; + return { roomNumber: session.roomNumber, questionId: session.questionId, @@ -86,27 +91,112 @@ export default class SessionService { } static async getLatestSession(userId: string): Promise { - const response = await SessionService.client.get("/session", { params: { userId } }); - return response.data.session; + try { + const response = await SessionService.client.get("/session", { params: { userId } }); + if (response instanceof AxiosError) { + throw response; + } + return response.data.session; + } catch (error) { + if (error instanceof AxiosError) { + if (error.response?.status === 400) { + console.error("Bad request: ", error.response.data); + } else if (error.response?.status === 404) { + console.error("Session not found: ", error.response.data); + } + } else { + console.error("Unexpected error: ", error); + } + return null; + } } static async getSessionHistory(userId: string, count?: number): Promise { - const response = await SessionService.client.get("/session-history", { params: { userId, count } }); - const sessions: SessionResponse[] = response.data.sessions; - const sessionData = await Promise.all(sessions.map(this.mapSessionResponseToSessionData)); - return sessionData; + try { + const response = await SessionService.client.get("/session-history", { params: { userId, count } }); + if (response instanceof AxiosError) { + throw response; + } + const sessions: SessionResponse[] = response.data.sessions; + const sessionData = await Promise.all(sessions.map(this.mapSessionResponseToSessionData)); + if (sessionData instanceof AxiosError) { + throw sessionData; + } + return sessionData; + } catch (error) { + if (error instanceof AxiosError) { + if (error.response?.status === 400) { + console.error("Bad request: ", error.response.data); + } else if (error.response?.status === 404) { + console.error("Session history not found: ", error.response.data); + } + } else { + console.error("Unexpected error: ", error); + } + return []; + } } static async leaveSession(userId: string, roomId: string): Promise { - await SessionService.client.put("/leave-session", { data: { userId, roomId } }); + try { + const response = await SessionService.client.put("/leave-session", { data: { userId, roomId } }); + if (response instanceof AxiosError) { + throw response; + } + } catch (error) { + if (error instanceof AxiosError) { + console.log(error); + if (error.response?.status === 400) { + console.error("Bad request: ", error.response.data); + } else if (error.response?.status === 404) { + console.error("Session not found: ", error.response.data); + } + } else { + console.error("Unexpected error: ", error); + } + } } static async rejoinSession(userId: string, roomId: string): Promise { - const response = await SessionService.client.put("/rejoin-session", { data: { userId, roomId } }); - return response.data; + try { + const response = await SessionService.client.put("/rejoin-session", { data: { userId, roomId } }); + if (response instanceof AxiosError) { + throw response; + } + return response.data; + } catch (error) { + if (error instanceof AxiosError) { + if (error.response?.status === 400) { + console.error("Bad request: ", error.response.data); + } else if (error.response?.status === 404) { + console.error("Session not found: ", error.response.data); + } + } else { + console.error("Unexpected error: ", error); + } + throw error; + } } static async submitSession(userId: string, roomId: string, submission: string, language: Language): Promise { - await SessionService.client.put("/submit-session", { data: { userId, roomId, submission, language } }); + try { + const response = await SessionService.client.put("/submit-session", { + data: { userId, roomId, submission, language }, + }); + if (response instanceof AxiosError) { + throw response; + } + } catch (error) { + if (error instanceof AxiosError) { + if (error.response?.status === 400) { + console.error("Bad request: ", error.response.data); + } else if (error.response?.status === 404) { + console.error("Session not found: ", error.response.data); + } + } else { + console.error("Unexpected error: ", error); + } + throw error; + } } } From 5beb0775001a6d166af79a4f44373b607d6e1739 Mon Sep 17 00:00:00 2001 From: Singa-pirate Date: Fri, 8 Nov 2024 23:01:30 +0800 Subject: [PATCH 10/10] Fix style --- frontend/src/pages/History/History.module.scss | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/src/pages/History/History.module.scss b/frontend/src/pages/History/History.module.scss index 3628b6b457..fadb6f6ae9 100644 --- a/frontend/src/pages/History/History.module.scss +++ b/frontend/src/pages/History/History.module.scss @@ -1,11 +1,11 @@ .page { - background: var(--Grey-10, #1a1a1a); - min-height: 100vh; - padding: 40px 80px; - display: flex; - flex-direction: column; - gap: 20px; - align-items: center; + background: var(--Grey-10, #1a1a1a); + min-height: 100vh; + padding: 40px 80px; + display: flex; + flex-direction: column; + gap: 20px; + align-items: center; } .container { @@ -178,4 +178,4 @@ h1 { padding: 10px; font-size: 14px; } -} \ No newline at end of file +}
{}} className={`${styles.clickable} ${styles["asc"]}`}> + { + chooseSortByColumn("createdAt"); + }} + className={`${styles.clickable} ` + (sortColumn === "createdAt" ? styles[sortOrder] : "")} + > Start time {}} className={`${styles.clickable} ${styles["asc"]}`}> + { + chooseSortByColumn("questionId"); + }} + className={`${styles.clickable} ` + (sortColumn === "questionId" ? styles[sortOrder] : "")} + > Question {}} className={`${styles.clickable} ${styles["asc"]}`}> + { + chooseSortByColumn("otherUserName"); + }} + className={`${styles.clickable} ` + (sortColumn === "otherUserName" ? styles[sortOrder] : "")} + > Partner Action
{session.questionId}{session.createdAt?.toLocaleString() ?? "Unknown start time"} - {session.otherUserName} -