Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions backend/src/__tests__/services/room.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -35,7 +42,6 @@ app.use("/", roomRoutes);
beforeEach(() => jest.clearAllMocks());



// ── room.service unit tests ───────────────────────────────────────────────────

describe("room.service — makeRoom", () => {
Expand Down Expand Up @@ -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",
Expand Down
13 changes: 9 additions & 4 deletions backend/src/controllers/room.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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" });
}
Expand Down
50 changes: 13 additions & 37 deletions backend/src/sockets/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -16,12 +15,9 @@ const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? "http://localhost:5173")
.split(",")
.map((o) => o.trim());

const SocketToColor = new Map<string, string>();

const USER_COLORS = [
"#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4",
"#FFEAA7", "#DDA0DD", "#98D8C8", "#F7DC6F"
];
"#FFEAA7", "#DDA0DD", "#98D8C8", "#F7DC6F" ];

interface ParsedUser {
userId: string;
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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;

Expand All @@ -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",
Expand All @@ -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
Expand All @@ -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) {
Expand All @@ -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 });
}
});
Expand Down
1 change: 0 additions & 1 deletion backend/src/store/roomParticipants.ts

This file was deleted.

70 changes: 67 additions & 3 deletions frontend/src/components/AIPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<number>(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!");
Expand Down Expand Up @@ -173,11 +221,27 @@ export default function AIPanel({
)}
</div>

{/* AI CHAT OUTPUT */}
{/* DRAG HANDLE — sits between output box and AI panel */}
<div
onMouseDown={onDragStart}
className="group flex items-center justify-center h-2 cursor-ns-resize
rounded-full mx-1 hover:bg-white/8 transition-colors shrink-0"
title="Drag to resize AI panel"
>
{/* Visual grip dots */}
<div className="flex gap-1 opacity-30 group-hover:opacity-70 transition-opacity">
{[0, 1, 2].map((i) => (
<span key={i} className="w-1 h-1 rounded-full bg-white/60" />
))}
</div>
</div>

{/* AI CHAT OUTPUT — resizable */}
<div
ref={aiOutputRef}
className="flex-1 bg-gray-800/60 border border-white/8 rounded-xl
overflow-auto min-h-0 relative"
className="bg-gray-800/60 border border-white/8 rounded-xl
overflow-auto relative shrink-0"
style={{ height: aiPanelHeight }}
>
{/* Sticky header */}
<div className="sticky top-0 flex items-center justify-between px-3 py-2
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/RightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@ export default function RightPanel({
aiOutputRef, bottomRef,
handleRestoreSnapshot
}: RightPanelProps) {


return (
<div className="w-1/3 bg-gray-900 text-white p-2 flex flex-col gap-2 min-h-0">
<div className="w-1/3 bg-gray-900 text-white p-2 flex flex-col gap-2 min-h-0 overflow-y-auto">

<CodeInputPanel
userInput={userInput}
Expand Down
Loading