diff --git a/backend/src/__tests__/services/room.service.test.ts b/backend/src/__tests__/services/room.service.test.ts index 103e531..ff2a49f 100644 --- a/backend/src/__tests__/services/room.service.test.ts +++ b/backend/src/__tests__/services/room.service.test.ts @@ -19,6 +19,13 @@ import express from "express"; import roomRoutes from "../../routes/room.route"; import { makeRoom, getRoomById } from "../../services/room.service"; import { prisma } from "../../db/prisma"; +import { redis } from "../../db/redis"; + +jest.mock("../../db/redis", () => ({ + redis: { + hGetAll: jest.fn(), + }, +})); const mockMakeRoom = makeRoom as jest.Mock; const mockUpdate = prisma.room.update as jest.Mock; @@ -35,7 +42,6 @@ app.use("/", roomRoutes); beforeEach(() => jest.clearAllMocks()); - // ── room.service unit tests ─────────────────────────────────────────────────── describe("room.service — makeRoom", () => { @@ -136,26 +142,45 @@ describe("POST /:roomId/save — saveRoomCode", () => { it("updates room and returns { success: true }", async () => { mockFindUnique.mockResolvedValueOnce({ - id: "room-123", code: "console.log('hi')", language: "javascript", createdAt: new Date(), userId: "test-user", + id: "room-123", + code: "console.log('hi')", + language: "javascript", + createdAt: new Date(), + userId: "test-user", }); mockUpdate.mockResolvedValueOnce({ - id: "room-123", code: "print('hi')", language: "python", createdAt: new Date(), + id: "room-123", + code: "print('hi')", + language: "python", + createdAt: new Date(), + }); + + // Redis mock (Jest) + (redis.hGetAll as jest.Mock).mockResolvedValueOnce({ + "socket-1": JSON.stringify({ + userId: "test-user", + socketId: "socket-1", + username: "rahul", + color: "#fff", + }), }); const res = await request(app) .post("/room-123/save") .set("Authorization", `Bearer ${token}`) - .send({ code: "print('hi')", language: "python" }); // ✅ valid enum value + .send({ code: "print('hi')", language: "python" }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); + expect(mockUpdate).toHaveBeenCalledWith({ where: { id: "room-123" }, data: { code: "print('hi')", language: "python" }, }); }); + it("returns 500 with { error } when DB update fails", async () => { mockFindUnique.mockResolvedValueOnce({ id: "room-123", code: "console.log('hi')", language: "javascript", createdAt: new Date(), userId: "test-user", diff --git a/backend/src/controllers/room.controller.ts b/backend/src/controllers/room.controller.ts index 7151fd7..4762a1e 100644 --- a/backend/src/controllers/room.controller.ts +++ b/backend/src/controllers/room.controller.ts @@ -2,7 +2,7 @@ import { Response } from "express"; import { prisma } from "../db/prisma"; import { makeRoom, createRoom as createRoomService } from "../services/room.service"; import { AuthRequest } from "../middleware/auth.middleware"; -import { RoomParticipants } from "../store/roomParticipants"; +import { redis } from "../db/redis"; // GET ROOM DATA export const getRoomData = async (req: AuthRequest, res: Response) => { @@ -56,14 +56,19 @@ export const saveRoomCode = async (req: AuthRequest, res: Response) => { } const userId = req.user?.userId; - if (!userId) { return res.status(401).json({ error: "Unauthorized"}); } const isOwner = room.userId === userId; - const isParticipant = RoomParticipants.get(roomId)?.has(userId); - + const users = await redis.hGetAll(`room:${roomId}:users`); + const isParticipant = Object.values(users) + .map(u => { + try { return JSON.parse(u); } catch { return null; } + }) + .filter(Boolean) + .some((u: any) => u.userId === userId); + if (!isOwner && !isParticipant) { return res.status(403).json({ error: "Unauthorized" }); } diff --git a/backend/src/sockets/socket.ts b/backend/src/sockets/socket.ts index 1e95748..59dc092 100644 --- a/backend/src/sockets/socket.ts +++ b/backend/src/sockets/socket.ts @@ -4,7 +4,6 @@ import { logger } from "../utils/logger"; import { prisma } from "../db/prisma" import http from "http"; import jwt from "jsonwebtoken"; -import { RoomParticipants } from "../store/roomParticipants"; import { redis, redisSub } from "../db/redis"; import { createAdapter } from "@socket.io/redis-adapter"; @@ -16,12 +15,9 @@ const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? "http://localhost:5173") .split(",") .map((o) => o.trim()); -const SocketToColor = new Map(); - const USER_COLORS = [ "#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", - "#FFEAA7", "#DDA0DD", "#98D8C8", "#F7DC6F" -]; + "#FFEAA7", "#DDA0DD", "#98D8C8", "#F7DC6F" ]; interface ParsedUser { userId: string; @@ -69,8 +65,7 @@ export const initSocket = (server: http.Server) => { if (!RoomId) return; const existingRoom = await redis.get(`socket:${socket.id}:room`); if (existingRoom) return; - //if (SocketToRoom.has(socket.id)) return; - + const user = socket.data.user; // from JWT if (!user) return; const userId = user.userId; @@ -93,12 +88,6 @@ export const initSocket = (server: http.Server) => { socket.join(RoomId); await redis.set(`socket:${socket.id}:room`, RoomId); - // ROOM PARTICIPANTS - if (!RoomParticipants.has(RoomId)) { - RoomParticipants.set(RoomId, new Set()); - } - RoomParticipants.get(RoomId)!.add(userId); - const usersKey = `room:${RoomId}:users`; // get existing users @@ -107,8 +96,7 @@ export const initSocket = (server: http.Server) => { // assign color const color = USER_COLORS[userCount % USER_COLORS.length] ?? "#4ECDCA"; - SocketToColor.set(socket.id, color); - + // store user await redis.hSet( usersKey, @@ -156,7 +144,6 @@ export const initSocket = (server: http.Server) => { // CODE EDITOR socket.on("content-edited", async ({ code, language }: RoomPayload) => { - //const roomId = SocketToRoom.get(socket.id); const roomId = await redis.get(`socket:${socket.id}:room`); if (!roomId) return; @@ -176,7 +163,15 @@ export const initSocket = (server: http.Server) => { if (!roomId) return; const user = socket.data.user; - const color = SocketToColor.get(socket.id) ?? "#4ECDCA"; + + const userStr = await redis.hGet(`room:${roomId}:users`, socket.id); + let color = "#4ECDCA" + + if (userStr) { + try { + color = JSON.parse(userStr).color; + } catch {} + } // broadcast to others in the room socket.to(roomId).emit("cursor-move", @@ -197,7 +192,6 @@ export const initSocket = (server: http.Server) => { if (!socket.data.user) return; const user = socket.data.user; const userId = user?.userId; - //const room = RoomToUsers.get(roomId); const usersKey = `room:${roomId}:users`; // REMOVE FROM PARTICIPANTS @@ -214,23 +208,7 @@ export const initSocket = (server: http.Server) => { try { return JSON.parse(u); } catch { return null; } }) .filter(Boolean); - - // check still connected - const stillConnected = parsedUsers - .some( - (u: any) => - u.userId === userId && u.socketId !== socket.id - ); - - // updated participants - if (!stillConnected) { - RoomParticipants.get(roomId)?.delete(userId); - - if (RoomParticipants.get(roomId)?.size === 0) { - RoomParticipants.delete(roomId); - } - } - + const uniqueUsersMap = new Map(); for (const u of parsedUsers) { @@ -250,8 +228,6 @@ export const initSocket = (server: http.Server) => { } await redis.del(`socket:${socket.id}:room`); - SocketToColor.delete(socket.id); - logger.info("[SOCKET] User disconnected", { socketId: socket.id, roomId }); } }); diff --git a/backend/src/store/roomParticipants.ts b/backend/src/store/roomParticipants.ts deleted file mode 100644 index 3695363..0000000 --- a/backend/src/store/roomParticipants.ts +++ /dev/null @@ -1 +0,0 @@ -export const RoomParticipants = new Map>(); \ No newline at end of file diff --git a/frontend/src/components/AIPanel.tsx b/frontend/src/components/AIPanel.tsx index ec935ef..995d1f5 100644 --- a/frontend/src/components/AIPanel.tsx +++ b/frontend/src/components/AIPanel.tsx @@ -5,6 +5,7 @@ import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import { FiCopy } from "react-icons/fi"; import { IoSend } from "react-icons/io5"; import { AiOutlineClear } from "react-icons/ai"; +import { useRef, useState, useCallback } from "react"; import toast from "react-hot-toast"; import api from "../lib/authAxios"; @@ -47,6 +48,53 @@ export default function AIPanel({ aiOutputRef, bottomRef, }: AIPanelProps) { + // ── Resizable AI output panel ───────────────────────────────────────────── + const AI_MIN_HEIGHT = 120; + const AI_DEFAULT_HEIGHT = 340; + const [aiPanelHeight, setAiPanelHeight] = useState(AI_DEFAULT_HEIGHT); + const aiPanelHeightRef = useRef(AI_DEFAULT_HEIGHT); // always current, no stale closure + const dragStartY = useRef(0); + + // Keep ref in sync with state so onDragStart never reads stale height + const handleHeightChange = (h: number) => { + aiPanelHeightRef.current = h; + setAiPanelHeight(h); + }; + + // Auto-grow when new messages arrive (up to 500px max, then scroll) + const prevHistoryLen = useRef(history.length); + if (history.length !== prevHistoryLen.current) { + prevHistoryLen.current = history.length; + if (aiPanelHeightRef.current < 500) { + const grown = Math.min(500, aiPanelHeightRef.current + 60); + handleHeightChange(grown); + } + } + + const onDragStart = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + dragStartY.current = e.clientY; + const startHeight = aiPanelHeightRef.current; // read from ref — never stale + + const onMouseMove = (ev: MouseEvent) => { + const delta = dragStartY.current - ev.clientY; + const newHeight = Math.max(AI_MIN_HEIGHT, startHeight + delta); + handleHeightChange(newHeight); + }; + + const onMouseUp = () => { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + document.body.style.cursor = "ns-resize"; + document.body.style.userSelect = "none"; + }, []); // stable — reads height from ref, not closure + const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text); toast.success("Copied!"); @@ -173,11 +221,27 @@ export default function AIPanel({ )} - {/* AI CHAT OUTPUT */} + {/* DRAG HANDLE — sits between output box and AI panel */} +
+ {/* Visual grip dots */} +
+ {[0, 1, 2].map((i) => ( + + ))} +
+
+ + {/* AI CHAT OUTPUT — resizable */}
{/* Sticky header */}
+