Skip to content

Commit e500d98

Browse files
authored
Merge pull request #183 from CS3219-AY2425S1/feat/collab-service/collab-page
Add collab page
2 parents 295cb00 + 759666a commit e500d98

File tree

21 files changed

+769
-58
lines changed

21 files changed

+769
-58
lines changed

collab-service/app/controller/collab-controller.js

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,25 @@ import {
33
getRoomId,
44
heartbeat,
55
getAllRooms,
6+
getQuestionIdByRoomId,
67
} from "../model/repository.js";
78
import crypto from "crypto";
89

910
// Create a room between two users
1011
export async function createRoom(req, res) {
11-
const { user1, user2 } = req.body;
12+
const { user1, user2, question_id } = req.body;
1213

13-
if (!user1 || !user2) {
14-
return res.status(400).json({ error: "Both user1 and user2 are required" });
14+
if (!user1 || !user2 || !question_id) {
15+
return res.status(400).json({ error: "user1,user2 and question_id are required" });
1516
}
1617

1718
// Generate a unique room ID by hashing the two user IDs
19+
const timeSalt = new Date().toISOString().slice(0, 13);
1820
const roomId = crypto
1921
.createHash("sha256")
20-
.update(user1 + user2)
22+
.update(user1 + user2 + timeSalt)
2123
.digest("hex");
22-
const room = await newRoom(user1, user2, roomId);
24+
const room = await newRoom(user1, user2, roomId, question_id);
2325

2426
if (room) {
2527
res.status(201).json(room);
@@ -72,3 +74,20 @@ export async function getAllRoomsController(req, res) {
7274
res.status(500).json({ error: "Failed to retrieve rooms" });
7375
}
7476
}
77+
78+
// Get QuestionId from the room based on the roomId
79+
export async function getQuestionId(req, res) {
80+
const { roomId } = req.params;
81+
82+
if (!roomId) {
83+
return res.status(400).json({ error: "Room ID is required" });
84+
}
85+
86+
const questionId = await getQuestionIdByRoomId(roomId);
87+
88+
if (questionId) {
89+
res.status(200).json({ questionId });
90+
} else {
91+
res.status(404).json({ error: `Question ID not found for room ID: ${roomId}` });
92+
}
93+
}

collab-service/app/model/repository.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@ export async function connectToMongo() {
55
await connect(process.env.DB_URI);
66
}
77

8-
export async function newRoom(user1, user2, roomId) {
8+
export async function newRoom(user1, user2, roomId, questionId) {
99
try {
10+
// Remove any existing rooms where either user1 or user2 is a participant
11+
await UsersSession.deleteMany({ users: { $in: [user1, user2] } });
12+
1013
const newRoom = new UsersSession({
1114
users: [user1, user2],
1215
roomId: roomId,
16+
questionId: questionId,
1317
lastUpdated: new Date(),
1418
});
1519

@@ -102,3 +106,13 @@ export async function addMessageToChat(roomId, userId, text) {
102106
throw error;
103107
}
104108
}
109+
110+
export async function getQuestionIdByRoomId(roomId) {
111+
try {
112+
const room = await UsersSession.findOne({ roomId });
113+
return room ? room.questionId : null;
114+
} catch (error) {
115+
console.error(`Error finding questionId for roomId ${roomId}:`, error);
116+
return null;
117+
}
118+
}

collab-service/app/model/usersSession-model.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ const usersSessionSchema = new Schema({
3131
type: String,
3232
required: true,
3333
},
34+
questionId: {
35+
type: String,
36+
required: true,
37+
},
3438
lastUpdated: {
3539
type: Date,
3640
required: true,

collab-service/app/routes/collab-routes.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
getRoomByUser,
55
updateHeartbeat,
66
getAllRoomsController,
7+
getQuestionId
78
} from "../controller/collab-controller.js";
89

910
const router = express.Router();
@@ -16,4 +17,6 @@ router.patch("/heartbeat/:roomId", updateHeartbeat);
1617

1718
router.get("/rooms", getAllRoomsController);
1819

20+
router.get("/rooms/:roomId/questionId", getQuestionId);
21+
1922
export default router;

docker-compose.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ services:
77
- USER_SVC_PORT=$USER_SVC_PORT
88
- QUESTION_SVC_PORT=$QUESTION_SVC_PORT
99
- MATCHING_SVC_PORT=$MATCHING_SVC_PORT
10+
- COLLAB_SVC_PORT=$COLLAB_SVC_PORT
1011
ports:
1112
- $FRONTEND_PORT:$FRONTEND_PORT
1213
depends_on:
1314
- question-service
1415
- user-service
16+
- collab-service
1517
environment:
1618
- PORT=$FRONTEND_PORT
1719

@@ -46,8 +48,12 @@ services:
4648
- PORT=$MATCHING_SVC_PORT
4749
- REDIS_HOST=redis
4850
- REDIS_PORT=$REDIS_PORT
51+
- QUESTION_SVC_PORT=$QUESTION_SVC_PORT
52+
- COLLAB_SVC_PORT=$COLLAB_SVC_PORT
4953
depends_on:
5054
- redis
55+
- question-service
56+
- collab-service
5157

5258
redis:
5359
image: redis:7.4-alpine

frontend/Dockerfile

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@ FROM node:20-alpine AS base
33
ARG BASE_URI \
44
USER_SVC_PORT \
55
QUESTION_SVC_PORT \
6-
MATCHING_SVC_PORT
6+
MATCHING_SVC_PORT \
7+
COLLAB_SVC_PORT
78
WORKDIR /app
89
COPY package.json .
910
COPY yarn.lock .
1011
RUN yarn install --frozen-lockfile
1112
ENV NEXT_PUBLIC_BASE_URI=$BASE_URI \
1213
NEXT_PUBLIC_USER_SVC_PORT=$USER_SVC_PORT \
1314
NEXT_PUBLIC_QUESTION_SVC_PORT=$QUESTION_SVC_PORT \
14-
NEXT_PUBLIC_MATCHING_SVC_PORT=$MATCHING_SVC_PORT
15+
NEXT_PUBLIC_MATCHING_SVC_PORT=$MATCHING_SVC_PORT \
16+
NEXT_PUBLIC_COLLAB_SVC_PORT=$COLLAB_SVC_PORT
1517

1618
# Production build stage
1719
FROM base AS build
Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
1-
import dynamic from "next/dynamic";
1+
import AuthPageWrapper from "@/components/auth/auth-page-wrapper";
2+
import CollabRoom from "@/components/collab/collab-room";
3+
import { Suspense } from "react";
24

3-
const MonacoEditor = dynamic(
4-
() => import("@/components/collab/monaco-editor"),
5-
{
6-
ssr: false,
7-
}
8-
);
9-
10-
export default function CollabRoom({
5+
export default function CollabPage({
116
params,
127
}: {
138
params: { room_id: string };
149
}) {
15-
return <MonacoEditor roomId={params.room_id} />;
10+
return (
11+
<AuthPageWrapper requireLoggedIn>
12+
<Suspense>
13+
<CollabRoom roomId={params.room_id} />
14+
</Suspense>
15+
</AuthPageWrapper>
16+
);
1617
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"use client";
2+
3+
import React, { useState, useEffect, useRef } from "react";
4+
import { Button } from "@/components/ui/button";
5+
import { Input } from "@/components/ui/input";
6+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
7+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
8+
import { ScrollArea } from "@/components/ui/scroll-area";
9+
import { Send } from "lucide-react";
10+
import { io, Socket } from "socket.io-client";
11+
import { useAuth } from "@/app/auth/auth-context";
12+
import LoadingScreen from "@/components/common/loading-screen";
13+
14+
interface Message {
15+
id: string;
16+
userId: string;
17+
text: string;
18+
timestamp: Date;
19+
}
20+
21+
export default function Chat({ roomId }: { roomId: string }) {
22+
const auth = useAuth();
23+
const own_user_id = auth?.user?.id;
24+
const [socket, setSocket] = useState<Socket | null>(null);
25+
const [chatTarget, setChatTarget] = useState<string>("partner");
26+
const [newMessage, setNewMessage] = useState<string>("");
27+
const [partnerMessages, setPartnerMessages] = useState<Message[]>([]);
28+
const [aiMessages, setAiMessages] = useState<Message[]>([]);
29+
const [isConnected, setIsConnected] = useState(false);
30+
const lastMessageRef = useRef<HTMLDivElement | null>(null);
31+
32+
useEffect(() => {
33+
if (!auth?.user?.id) return; // Avoid connecting if user is not authenticated
34+
35+
const socketInstance = io(
36+
process.env.NEXT_PUBLIC_COLLAB_SERVICE_URL || "http://localhost:3002",
37+
{
38+
auth: { userId: own_user_id },
39+
}
40+
);
41+
42+
socketInstance.on("connect", () => {
43+
console.log("Connected to Socket.IO");
44+
setIsConnected(true);
45+
socketInstance.emit("joinRoom", roomId);
46+
});
47+
48+
socketInstance.on("disconnect", () => {
49+
console.log("Disconnected from Socket.IO");
50+
setIsConnected(false);
51+
});
52+
53+
socketInstance.on("chatMessage", (message: Message) => {
54+
setPartnerMessages((prev) => [...prev, message]);
55+
});
56+
57+
setSocket(socketInstance);
58+
59+
return () => {
60+
socketInstance.disconnect();
61+
};
62+
}, [roomId, own_user_id, auth?.user?.id]);
63+
64+
useEffect(() => {
65+
const scrollWithDelay = () => {
66+
setTimeout(() => {
67+
if (lastMessageRef.current) {
68+
lastMessageRef.current.scrollIntoView({ behavior: "smooth" });
69+
}
70+
}, 100); // Delay to ensure the DOM is fully rendered
71+
};
72+
73+
scrollWithDelay();
74+
}, [partnerMessages, aiMessages, chatTarget]);
75+
76+
const sendMessage = () => {
77+
if (!newMessage.trim() || !socket || !isConnected || !own_user_id) return;
78+
79+
const message = {
80+
id: crypto.randomUUID(),
81+
userId: own_user_id,
82+
text: newMessage,
83+
timestamp: new Date(),
84+
};
85+
86+
if (chatTarget === "partner") {
87+
socket.emit("sendMessage", {
88+
roomId,
89+
userId: own_user_id,
90+
text: newMessage,
91+
});
92+
} else {
93+
setAiMessages((prev) => [...prev, message]);
94+
}
95+
96+
setNewMessage("");
97+
};
98+
99+
const formatTimestamp = (date: Date) => {
100+
return new Date(date).toLocaleTimeString([], {
101+
hour: "2-digit",
102+
minute: "2-digit",
103+
});
104+
};
105+
106+
const renderMessage = (message: Message, isOwnMessage: boolean) => (
107+
<div
108+
key={message.id}
109+
className={`p-2 rounded-lg mb-2 max-w-[80%] ${
110+
isOwnMessage
111+
? "ml-auto bg-blue-500 text-white"
112+
: "bg-gray-100 dark:bg-gray-800"
113+
}`}
114+
>
115+
<div className="text-sm">{message.text}</div>
116+
<div
117+
className={`text-xs ${isOwnMessage ? "text-blue-100" : "text-gray-500"}`}
118+
>
119+
{formatTimestamp(message.timestamp)}
120+
</div>
121+
</div>
122+
);
123+
124+
if (!own_user_id) {
125+
return <LoadingScreen />;
126+
}
127+
128+
return (
129+
<Card className="flex flex-col">
130+
<CardHeader>
131+
<CardTitle className="flex justify-between items-center">
132+
Chat
133+
<span
134+
className={`h-2 w-2 rounded-full ${isConnected ? "bg-green-500" : "bg-red-500"}`}
135+
/>
136+
</CardTitle>
137+
</CardHeader>
138+
<CardContent className="flex-1 flex flex-col">
139+
<Tabs
140+
value={chatTarget}
141+
onValueChange={setChatTarget}
142+
className="flex-col"
143+
>
144+
<TabsList className="flex-shrink-0 mb-2">
145+
<TabsTrigger value="partner">Partner Chat</TabsTrigger>
146+
<TabsTrigger value="ai">AI Chat</TabsTrigger>
147+
</TabsList>
148+
<TabsContent value="partner" className="h-full">
149+
<ScrollArea className="h-[calc(70vh-280px)]">
150+
<div className="pr-4 space-y-2">
151+
{partnerMessages.map((msg) =>
152+
renderMessage(msg, msg.userId === own_user_id)
153+
)}
154+
<div ref={lastMessageRef} />
155+
</div>
156+
</ScrollArea>
157+
</TabsContent>
158+
<TabsContent value="ai" className="h-full">
159+
<ScrollArea className="h-[calc(70vh-280px)]">
160+
<div className="pr-4 space-y-2">
161+
{aiMessages.map((msg) =>
162+
renderMessage(msg, msg.userId === own_user_id)
163+
)}
164+
<div ref={lastMessageRef} />
165+
</div>
166+
</ScrollArea>
167+
</TabsContent>
168+
</Tabs>
169+
<div className="flex space-x-2 mt-4 pt-4 border-t">
170+
<Input
171+
value={newMessage}
172+
onChange={(e) => setNewMessage(e.target.value)}
173+
placeholder={`Message ${chatTarget === "partner" ? "your partner" : "AI assistant"}...`}
174+
onKeyDown={(e) => e.key === "Enter" && sendMessage()}
175+
disabled={!isConnected}
176+
/>
177+
<Button onClick={sendMessage} disabled={!isConnected}>
178+
<Send className="h-4 w-4" />
179+
</Button>
180+
</div>
181+
</CardContent>
182+
</Card>
183+
);
184+
}

0 commit comments

Comments
 (0)