diff --git a/backend/src/app.ts b/backend/src/app.ts index 3081a23..52b5d73 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -15,8 +15,9 @@ app.use(express.json()); app.use(cookieParser()); // <-- Add this middleware HERE app.use( cors({ - origin: process.env.FRONTEND_URL || "http://localhost:5173", + origin: process.env.FRONTEND_URL || "http://localhost:5174", credentials: true, + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], }) ); //initialize passport diff --git a/backend/src/controllers/roomController.ts b/backend/src/controllers/roomController.ts index 00ce7d0..ad984b5 100644 --- a/backend/src/controllers/roomController.ts +++ b/backend/src/controllers/roomController.ts @@ -1,74 +1,144 @@ import type { Request, Response } from "express"; import Room from "../models/roomModel.js"; import type { IRoom } from "../models/roomModel.js"; -import * as express from "express"; import mongoose from "mongoose"; +import { io } from "../server.js"; + declare global { - namespace Express { - interface Request { - userId?: string; // or number, depending on your ID type - } + namespace Express { + interface Request { + userId?: string; } + } } -// Create a new room +// ---------------------- Create Room ---------------------- export const createRoom = async (req: Request, res: Response) => { - try { - const { name } = req.body; - if (!name) return res.status(400).json({ message: "Room name is required" }); - - const existingRoom = await Room.findOne({ name }); - if (existingRoom) return res.status(400).json({ message: "Room already exists" }); - - const room: IRoom = new Room({ name, members: [req.userId] }); - await room.save(); - res.status(201).json(room); - } catch (error) { - res.status(500).json({ message: "Server error", error }); - } + try { + const { name } = req.body; + if (!name) return res.status(400).json({ message: "Room name is required" }); + if (!req.userId) return res.status(401).json({ message: "Unauthorized" }); + + const existingRoom = await Room.findOne({ name }); + if (existingRoom) + return res.status(400).json({ message: "Room with this name already exists" }); + + const room: IRoom = new Room({ name, members: [req.userId] }); + await room.save(); + + io.emit("room-created", { roomId: room._id, name: room.name }); + res.status(201).json(room); + } catch (error: any) { + console.error("Create room error:", error.message); + res.status(500).json({ message: "Server error", error: error.message }); + } }; -// List all rooms +// ---------------------- List Rooms ---------------------- export const listRooms = async (_req: Request, res: Response) => { - try { - const rooms = await Room.find().populate("members", "username email"); - res.json(rooms); - } catch (error) { - res.status(500).json({ message: "Server error", error }); - } + try { + const rooms = await Room.find().populate("members", "username email"); + res.json(rooms); + } catch (error: any) { + console.error("List rooms error:", error.message); + res.status(500).json({ message: "Server error", error: error.message }); + } }; -// Join a room +// ---------------------- Join Room ---------------------- export const joinRoom = async (req: Request, res: Response) => { - try { - const { roomId } = req.params; - const room = await Room.findById(roomId); - if (!room) return res.status(404).json({ message: "Room not found" }); - const userId = new mongoose.Types.ObjectId(req.userId!); - if (!room.members.includes(userId)) { - room.members.push(userId); - await room.save(); - } - - res.json(room); - } catch (error) { - res.status(500).json({ message: "Server error", error }); + try { + const roomIdOrName = req.params.roomIdOrName; + const userId = req.userId; + + if (!roomIdOrName) return res.status(400).json({ message: "Room name or ID is required" }); + if (!userId) return res.status(401).json({ message: "Unauthorized - Missing userId" }); + + let room = await Room.findOne({ name: roomIdOrName }); + if (!room && mongoose.Types.ObjectId.isValid(roomIdOrName)) { + room = await Room.findById(roomIdOrName); + } + + if (!room) { + console.error("Join room error: Room not found for", roomIdOrName); + return res.status(404).json({ message: "Room not found" }); } + + const userObjId = new mongoose.Types.ObjectId(userId); + if (!room.members.some(m => m.equals(userObjId))) { + room.members.push(userObjId); + await room.save(); + } + + const updatedRoom = await Room.findById(room._id).populate("members", "username email"); + io.to(room._id.toString()).emit("user-joined", { userId, roomId: room._id }); + io.to(room._id.toString()).emit("update-members", updatedRoom?.members || []); + + res.json(updatedRoom); + } catch (error: any) { + console.error("Join room error:", error.message, error.stack); + res.status(500).json({ message: "Server error", error: error.message }); + } }; -// Leave a room +// ---------------------- Leave Room ---------------------- export const leaveRoom = async (req: Request, res: Response) => { - try { - const { roomId } = req.params; - const room = await Room.findById(roomId); - if (!room) return res.status(404).json({ message: "Room not found" }); + try { + const { roomId } = req.params; + const userId = req.userId; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const room = await Room.findById(roomId); + if (!room) return res.status(404).json({ message: "Room not found" }); + + const wasMember = room.members.some(m => m.toString() === userId); + if (!wasMember) + return res.status(400).json({ message: "You are not a member of this room" }); - room.members = room.members.filter(member => member.toString() !== req.userId); - await room.save(); + room.members = room.members.filter(m => m.toString() !== userId); + await room.save(); - res.json({ message: "Left room successfully", room }); - } catch (error) { - res.status(500).json({ message: "Server error", error }); + io.to(roomId).emit("user-left", { userId, roomId }); + const updatedRoom = await Room.findById(roomId).populate("members", "username email"); + io.to(roomId).emit("update-members", updatedRoom?.members || []); + + if (room.members.length === 0) { + await Room.findByIdAndDelete(roomId); + io.to(roomId).emit("room-ended", { roomId, reason: "empty" }); + io.socketsLeave(roomId); } + + res.json({ message: "Left room successfully" }); + } catch (error: any) { + console.error("Leave room error:", error.message); + res.status(500).json({ message: "Server error", error: error.message }); + } +}; + +// ---------------------- End Room (Host Only) ---------------------- +export const endRoom = async (req: Request, res: Response) => { + try { + const { roomId } = req.params; + const userId = req.userId; + + if (!roomId) return res.status(400).json({ message: "Missing room ID" }); + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const room = await Room.findById(roomId); + if (!room) return res.status(404).json({ message: "Room not found" }); + + const hostId = room.members[0]?.toString(); + if (hostId !== userId) + return res.status(403).json({ message: "Only the host can end the room" }); + + await Room.findByIdAndDelete(roomId); + io.to(roomId).emit("room-ended", { roomId, endedBy: userId }); + io.socketsLeave(roomId); + + res.json({ message: "Room ended successfully" }); + } catch (error: any) { + console.error("End room error:", error.message); + res.status(500).json({ message: "Server error", error: error.message }); + } }; diff --git a/backend/src/models/roomModel.ts b/backend/src/models/roomModel.ts index 92eca01..9f34ec3 100644 --- a/backend/src/models/roomModel.ts +++ b/backend/src/models/roomModel.ts @@ -1,15 +1,31 @@ import mongoose, { Schema, Document } from "mongoose"; export interface IRoom extends Document { - name: string; - members: mongoose.Types.ObjectId[]; - createdAt: Date; + name: string; + members: mongoose.Types.ObjectId[]; + host: mongoose.Types.ObjectId; // ๐Ÿ‘ˆ identifies who created/owns the room + isActive: boolean; // ๐Ÿ‘ˆ track whether the room is still active + createdAt: Date; } const roomSchema: Schema = new Schema({ - name: { type: String, required: true, unique: true }, - members: [{ type: mongoose.Schema.Types.ObjectId, ref: "User" }], - createdAt: { type: Date, default: Date.now }, + name: { type: String, required: true, unique: true }, + members: [{ type: mongoose.Schema.Types.ObjectId, ref: "User" }], + host: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, // host reference + isActive: { type: Boolean, default: true }, // true until ended or empty + createdAt: { type: Date, default: Date.now }, }); +// โœ… Optional cleanup or logic +// When all members leave, mark as inactive automatically (not delete immediately) +roomSchema.methods.deactivateIfEmpty = async function () { + if (this.members.length === 0) { + this.isActive = false; + await this.save(); + } +}; + +// Optional: auto-remove inactive rooms after certain time +// roomSchema.index({ createdAt: 1 }, { expireAfterSeconds: 7 * 24 * 60 * 60 }); // expires in 7 days + export default mongoose.model("Room", roomSchema); diff --git a/backend/src/routes/roomRoutes.ts b/backend/src/routes/roomRoutes.ts index c532e9e..39a647b 100644 --- a/backend/src/routes/roomRoutes.ts +++ b/backend/src/routes/roomRoutes.ts @@ -1,5 +1,11 @@ import { Router } from "express"; -import { createRoom, listRooms, joinRoom, leaveRoom } from "../controllers/roomController.js"; +import { + createRoom, + listRooms, + joinRoom, + leaveRoom, + endRoom, +} from "../controllers/roomController.js"; import { protect } from "../middleware/authMiddleware.js"; const router = Router(); @@ -7,9 +13,10 @@ const router = Router(); // All routes require authentication router.use(protect); -router.post("/createRoom", createRoom); // Create room -router.get("/listRooms", listRooms); // List all rooms -router.post("/:roomId/join", joinRoom); // Join a room -router.post("/:roomId/leave", leaveRoom); // Leave a room +router.post("/createRoom", createRoom); +router.get("/listRooms", listRooms); +router.post("/:roomIdOrName/join", joinRoom); +router.post("/:roomId/leave", leaveRoom); +router.post("/:roomId/end", endRoom); export default router; diff --git a/backend/src/server.ts b/backend/src/server.ts index a54ce21..a76848d 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -6,16 +6,12 @@ import dotenv from "dotenv"; import cors from "cors"; import { Session } from "./models/sessionModel.js"; -import { ChatMessage } from "./models/chatMessageModel.js"; // <-- make sure this file exists and exports model +import { ChatMessage } from "./models/chatMessageModel.js"; +import Room from "./models/roomModel.js"; // โœ… Import for room events import app from "./app.js"; dotenv.config(); -// // ------------------- App Setup ------------------- -// const app = express(); -// app.use(express.json()); -// app.use(cors()); - const PORT = process.env.PORT || 3000; const MONGO_URI = process.env.MONGO_URI as string; @@ -29,25 +25,32 @@ const io = new SocketIOServer(httpServer, { io.on("connection", (socket) => { console.log(`๐ŸŸข User connected: ${socket.id}`); + // ---------------- Join Room ---------------- socket.on("join-room", async (roomId: string, userName: string) => { try { socket.join(roomId); console.log(`๐Ÿ‘ฅ ${userName} joined room ${roomId}`); - // Fetch last 50 messages from DB + // Notify others + io.to(roomId).emit("user-joined", { userName, roomId }); + + // Fetch recent chat messages const recentMessages = await ChatMessage.find({ roomId }) .sort({ timestamp: -1 }) .limit(50) .lean(); - - // Send history in correct order (oldest โ†’ newest) socket.emit("chat-history", recentMessages.reverse()); + + // Update members list + const room = await Room.findById(roomId).populate("members", "username email"); + io.to(roomId).emit("update-members", room?.members || []); } catch (error) { console.error(`โŒ Error fetching chat history for ${roomId}:`, error); socket.emit("error", { message: "Failed to fetch chat history." }); } }); + // ---------------- Chat Message ---------------- socket.on( "chat-message", async ({ @@ -60,7 +63,6 @@ io.on("connection", (socket) => { text: string; }) => { try { - // Save message to MongoDB const message = new ChatMessage({ roomId, user, @@ -69,7 +71,6 @@ io.on("connection", (socket) => { }); await message.save(); - // Broadcast message to everyone in the room io.to(roomId).emit("chat-message", { roomId, user, @@ -85,6 +86,67 @@ io.on("connection", (socket) => { } ); + // ---------------- Leave Room ---------------- + socket.on( + "leave-room", + async ({ + roomId, + userId, + userName, + }: { + roomId: string; + userId: string; + userName: string; + }) => { + try { + socket.leave(roomId); + console.log(`๐Ÿšช ${userName} left room ${roomId}`); + + const room = await Room.findById(roomId); + if (room) { + room.members = room.members.filter((m) => m.toString() !== userId); + await room.save(); + + // Notify others + io.to(roomId).emit("user-left", { userName, roomId }); + + // Update member list + const updatedRoom = await Room.findById(roomId).populate( + "members", + "username email" + ); + io.to(roomId).emit("update-members", updatedRoom?.members || []); + + // Delete room if empty + if (room.members.length === 0) { + await Room.findByIdAndDelete(roomId); + io.to(roomId).emit("room-ended", { roomId, reason: "empty" }); + io.socketsLeave(roomId); + console.log(`๐Ÿ’ฃ Room ${roomId} deleted (empty)`); + } + } + } catch (error) { + console.error("โŒ Leave room error:", error); + } + } + ); + + // ---------------- End Room (Host Only) ---------------- + socket.on( + "end-room", + async ({ roomId, host }: { roomId: string; host: string }) => { + try { + await Room.findByIdAndDelete(roomId); + io.to(roomId).emit("room-ended", { roomId, endedBy: host }); + io.socketsLeave(roomId); + console.log(`๐Ÿ’ฅ Room ${roomId} ended by host ${host}`); + } catch (error) { + console.error("โŒ End room error:", error); + } + } + ); + + // ---------------- Disconnect ---------------- socket.on("disconnect", () => { console.log(`๐Ÿ”ด User disconnected: ${socket.id}`); }); @@ -103,20 +165,6 @@ mongoose .connect(MONGO_URI) .then(() => { console.log("๐Ÿ—„๏ธ MongoDB connected successfully!"); - - - // cron.schedule("0 2 * * *", async () => { - // const expiryDate = new Date(); - // expiryDate.setDate(expiryDate.getDate() - 7); - // try { - // const result = await Session.deleteMany({ createdAt: { $lt: expiryDate } }); - // console.log(`๐Ÿงน Cleanup complete โ€” ${result.deletedCount} expired sessions removed`); - // } catch (error) { - // console.error("โŒ Session cleanup failed:", error); - // } - // }); - - httpServer.listen(PORT, () => { console.log(`๐Ÿš€ Server running on port ${PORT}`); console.log(`๐Ÿ“ก Socket.io real-time chat ready`); @@ -127,14 +175,5 @@ mongoose process.exit(1); }); -// // ------------------- Graceful Shutdown ------------------- -// process.on("SIGINT", () => { -// console.log("\n๐Ÿ›‘ Shutting down chat server..."); -// httpServer.close(() => { -// console.log("โœ… HTTP server closed."); -// mongoose.connection.close(false, () => { -// console.log("๐Ÿ—„๏ธ MongoDB connection closed."); -// process.exit(0); -// }); -// }); -// }); +// ------------------- Export Socket.IO ------------------- +export { io }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 18dafe3..ae6eeec 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -159,7 +159,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1386,7 +1385,6 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -1470,7 +1468,6 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz", "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -3022,7 +3019,6 @@ "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3324,7 +3320,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -5635,7 +5630,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5837,7 +5831,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5847,7 +5840,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6901,7 +6893,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -7206,7 +7197,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a05ddaa..a0ee2bb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,8 @@ import OAuthSuccess from "./pages/OAuthSuccess.js"; import RoomActions from "./pages/RoomActions.js"; import ErrorBoundary from "./components/ErrorBoundary.js"; import "./index.css" +import CreateRoom from "./pages/CreateRoom.js"; +import JoinRoom from "./pages/JoinRoom.js"; const queryClient = new QueryClient(); const App = () => ( @@ -25,6 +27,8 @@ const App = () => ( } /> } /> } /> + } /> {/* โœ… */} + } /> {/* โœ… */} diff --git a/frontend/src/pages/CreateRoom.tsx b/frontend/src/pages/CreateRoom.tsx new file mode 100644 index 0000000..5d8cf3f --- /dev/null +++ b/frontend/src/pages/CreateRoom.tsx @@ -0,0 +1,64 @@ +// src/pages/CreateRoom.tsx +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Button } from "../components/ui/button"; +import axios from "axios"; + +export default function CreateRoom() { + const [roomName, setRoomName] = useState(""); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + const API_BASE = "http://localhost:3000/api"; + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!roomName.trim()) return alert("Please enter a room name!"); + + setLoading(true); + try { + const token = localStorage.getItem("token"); + const res = await axios.post( + `${API_BASE}/rooms/createRoom`, + { name: roomName }, + { + headers: { Authorization: `Bearer ${token}` }, + withCredentials: true, + } + ); + + alert("Room created successfully!"); + navigate(`/room/${res.data._id}`); // or navigate("/room-actions") + } catch (err: any) { + console.error(err); + alert(err.response?.data?.message || "Failed to create room."); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

+ Create a Room +

+
+ setRoomName(e.target.value)} + placeholder="Enter room name" + className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-400" + /> + +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/JoinRoom.tsx b/frontend/src/pages/JoinRoom.tsx new file mode 100644 index 0000000..52611d0 --- /dev/null +++ b/frontend/src/pages/JoinRoom.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Button } from "../components/ui/button"; +import axios from "axios"; + +export default function JoinRoom() { + const [roomName, setRoomName] = useState(""); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + const API_BASE = "http://localhost:3000/api/rooms"; // include /rooms here + + const handleJoin = async (e: React.FormEvent) => { + e.preventDefault(); + if (!roomName.trim()) return alert("Please enter a room name!"); + + setLoading(true); + try { + const token = localStorage.getItem("token"); + if (!token) throw new Error("No auth token found"); + + const res = await axios.post( + `${API_BASE}/${roomName}/join`, + {}, // empty body + { + headers: { Authorization: `Bearer ${token}` }, + } + ); + + alert("Joined room successfully!"); + navigate(`/room/${res.data._id}`); + } catch (err: any) { + console.error("Join room error:", err); + alert(err.response?.data?.message || err.message || "Failed to join room."); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

+ Join a Room +

+
+ setRoomName(e.target.value)} + placeholder="Enter room name" + className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-green-400" + /> + +
+
+
+ ); +} diff --git a/frontend/src/pages/RoomActions.tsx b/frontend/src/pages/RoomActions.tsx index a528513..27457a9 100644 --- a/frontend/src/pages/RoomActions.tsx +++ b/frontend/src/pages/RoomActions.tsx @@ -20,9 +20,11 @@ export default function RoomActions() { try { const res = await axios.get("http://localhost:3000/api/auth/me", { headers: { Authorization: `Bearer ${token}` }, + withCredentials: true, }); setUser(res.data.user); } catch (err) { + console.error("Failed to fetch user:", err); localStorage.removeItem("token"); navigate("/signin"); } finally { @@ -54,7 +56,9 @@ export default function RoomActions() { Room Actions

- ๐Ÿ‘‹ Hello {user?.name ?? "Guest"}, what would you like to do today? + ๐Ÿ‘‹ Hello{" "} + {user?.name ?? "Guest"}, what + would you like to do today?