From 0342c0d2950bcc80df8d4528b78fbca596e53536 Mon Sep 17 00:00:00 2001 From: Gal Podlipnik Date: Thu, 12 Jun 2025 15:37:07 +0200 Subject: [PATCH] ui/ux crud and stuff --- backend/src/controllers/chatController.ts | 60 +++- backend/src/controllers/userController.ts | 17 + backend/src/index.ts | 6 +- backend/src/middleware/validation.ts | 20 ++ backend/src/routes/chatRoutes.ts | 12 +- backend/src/routes/index.ts | 2 + backend/src/routes/userRoutes.ts | 11 + backend/src/services/chatService.ts | 328 +++++++++++++++++- backend/src/services/userService.ts | 72 ++++ backend/src/types/index.ts | 21 ++ .../src/components/ChatRoomSettingsDialog.tsx | 276 +++++++++++++++ frontend/src/components/ChatSidebar.tsx | 52 ++- frontend/src/components/Navbar.tsx | 28 +- frontend/src/components/SettingsDialog.tsx | 220 ++++++++++++ frontend/src/lib/api.ts | 28 ++ frontend/src/stores/chatStore.ts | 46 ++- frontend/src/types/index.ts | 20 ++ 17 files changed, 1190 insertions(+), 29 deletions(-) create mode 100644 backend/src/controllers/userController.ts create mode 100644 backend/src/routes/userRoutes.ts create mode 100644 backend/src/services/userService.ts create mode 100644 frontend/src/components/ChatRoomSettingsDialog.tsx create mode 100644 frontend/src/components/SettingsDialog.tsx diff --git a/backend/src/controllers/chatController.ts b/backend/src/controllers/chatController.ts index b785b43..a059a6a 100644 --- a/backend/src/controllers/chatController.ts +++ b/backend/src/controllers/chatController.ts @@ -1,5 +1,12 @@ import { ChatService } from "@/services/chatService.js"; -import { AuthenticatedRequest, CreateChatRoomRequest, GetMessagesQuery } from "@/types/index.js"; +import { + AddChatRoomMemberRequest, + AuthenticatedRequest, + CreateChatRoomRequest, + GetMessagesQuery, + RemoveChatRoomMemberRequest, + UpdateChatRoomRequest, +} from "@/types/index.js"; import type { Response } from "express"; export class ChatController { @@ -40,4 +47,55 @@ export class ChatController { const result = await ChatService.getMessages(query); res.status(result.success ? 200 : 500).json(result); } + + static async updateChatRoom(req: AuthenticatedRequest, res: Response) { + if (!req.user) { + res.status(401).json({ success: false, error: "Unauthorized" }); + return; + } + + const roomId = req.params.roomId; + const data: UpdateChatRoomRequest = req.body; + + const result = await ChatService.updateChatRoom(req.user.userId, roomId, data); + res.status(result.success ? 200 : result.status || 400).json(result); + } + + static async deleteChatRoom(req: AuthenticatedRequest, res: Response) { + if (!req.user) { + res.status(401).json({ success: false, error: "Unauthorized" }); + return; + } + + const roomId = req.params.roomId; + + const result = await ChatService.deleteChatRoom(req.user.userId, roomId); + res.status(result.success ? 200 : result.status || 500).json(result); + } + + static async addChatRoomMember(req: AuthenticatedRequest, res: Response) { + if (!req.user) { + res.status(401).json({ success: false, error: "Unauthorized" }); + return; + } + + const roomId = req.params.roomId; + const data: AddChatRoomMemberRequest = req.body; + + const result = await ChatService.addChatRoomMember(req.user.userId, roomId, data); + res.status(result.success ? 200 : result.status || 500).json(result); + } + + static async removeChatRoomMember(req: AuthenticatedRequest, res: Response) { + if (!req.user) { + res.status(401).json({ success: false, error: "Unauthorized" }); + return; + } + + const roomId = req.params.roomId; + const data: RemoveChatRoomMemberRequest = req.body; + + const result = await ChatService.removeChatRoomMember(req.user.userId, roomId, data); + res.status(result.success ? 200 : result.status || 500).json(result); + } } diff --git a/backend/src/controllers/userController.ts b/backend/src/controllers/userController.ts new file mode 100644 index 0000000..497556b --- /dev/null +++ b/backend/src/controllers/userController.ts @@ -0,0 +1,17 @@ +import { UserService } from "@/services/userService"; +import { AuthenticatedRequest, UpdateUserRequest } from "@/types"; +import { Response } from "express"; + +export class UserController { + static async updateProfile(req: AuthenticatedRequest, res: Response) { + if (!req.user) { + res.status(401).json({ success: false, error: "Unauthorized" }); + return; + } + + const data: UpdateUserRequest = req.body; + const result = await UserService.updateProfile(req.user.userId, data); + + res.status(result.success ? 200 : 400).json(result); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 22ad4d9..e51af53 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -13,19 +13,19 @@ const app = express(); const server = http.createServer(app); const io = new Server(server, { cors: { - methods: ["GET", "POST"], + methods: ["GET", "POST", "PUT", "DELETE"], origin: "http://localhost:5173", }, }); app.use( cors({ - methods: ["GET", "POST"], + methods: ["GET", "POST", "PUT", "DELETE"], origin: "http://localhost:5173", }), ); -app.use(express.json()); +app.use(express.json({ limit: "5mb" })); app.use("/api", apiRoutes); diff --git a/backend/src/middleware/validation.ts b/backend/src/middleware/validation.ts index fbc0a17..215325a 100644 --- a/backend/src/middleware/validation.ts +++ b/backend/src/middleware/validation.ts @@ -36,3 +36,23 @@ export const createChatRoomSchema = z.object({ description: z.string().optional(), memberUsernames: z.array(z.string()).default([]), }); + +export const updateUserSchema = z.object({ + username: z.string().min(3, "Username must be at least 3 characters long").optional(), + email: z.string().email("Invalid email format").optional(), + password: z.string().min(6, "Password must be at least 6 characters long").optional(), + avatar: z.string().nullable().optional(), +}); + +export const updateChatRoomSchema = z.object({ + name: z.string().min(1, "Room name is required"), + description: z.string().optional(), +}); + +export const addChatRoomMemberSchema = z.object({ + username: z.string().min(1, "Username is required"), +}); + +export const removeChatRoomMemberSchema = z.object({ + userId: z.string().min(1, "User ID is required"), +}); diff --git a/backend/src/routes/chatRoutes.ts b/backend/src/routes/chatRoutes.ts index 436fb23..4d5e816 100644 --- a/backend/src/routes/chatRoutes.ts +++ b/backend/src/routes/chatRoutes.ts @@ -1,6 +1,12 @@ import { ChatController } from "@/controllers/chatController.js"; import { authenticateToken } from "@/middleware/auth.js"; -import { createChatRoomSchema, validate } from "@/middleware/validation.js"; +import { + addChatRoomMemberSchema, + createChatRoomSchema, + removeChatRoomMemberSchema, + updateChatRoomSchema, + validate, +} from "@/middleware/validation.js"; import { Router } from "express"; const router = Router(); @@ -9,6 +15,10 @@ router.use(authenticateToken); router.get("/chat-rooms", ChatController.getChatRooms); router.post("/chat-rooms", validate(createChatRoomSchema), ChatController.createChatRoom); +router.put("/chat-rooms/:roomId", validate(updateChatRoomSchema), ChatController.updateChatRoom); +router.delete("/chat-rooms/:roomId", ChatController.deleteChatRoom); +router.post("/chat-rooms/:roomId/members", validate(addChatRoomMemberSchema), ChatController.addChatRoomMember); +router.delete("/chat-rooms/:roomId/members", validate(removeChatRoomMemberSchema), ChatController.removeChatRoomMember); router.get("/messages/:roomId", ChatController.getMessages); export { router as chatRoutes }; diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index ad9a5c7..ff6c942 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -1,10 +1,12 @@ import { Router } from "express"; import { authRoutes } from "./authRoutes.js"; import { chatRoutes } from "./chatRoutes.js"; +import { userRoutes } from "./userRoutes.js"; const router = Router(); router.use("/auth", authRoutes); +router.use("/users", userRoutes); router.use("/", chatRoutes); export { router as apiRoutes }; diff --git a/backend/src/routes/userRoutes.ts b/backend/src/routes/userRoutes.ts new file mode 100644 index 0000000..0706456 --- /dev/null +++ b/backend/src/routes/userRoutes.ts @@ -0,0 +1,11 @@ +import { UserController } from "@/controllers/userController.js"; +import { authenticateToken } from "@/middleware/auth.js"; +import { updateUserSchema, validate } from "@/middleware/validation.js"; +import { Router } from "express"; + +const router = Router(); + +router.use(authenticateToken); +router.put("/profile", validate(updateUserSchema), UserController.updateProfile); + +export { router as userRoutes }; diff --git a/backend/src/services/chatService.ts b/backend/src/services/chatService.ts index f7102d7..4065ba1 100644 --- a/backend/src/services/chatService.ts +++ b/backend/src/services/chatService.ts @@ -1,5 +1,12 @@ import { prisma } from "@/config/database.js"; -import { ApiResponse, CreateChatRoomRequest, GetMessagesQuery } from "@/types/index.js"; +import { + AddChatRoomMemberRequest, + ApiResponse, + CreateChatRoomRequest, + GetMessagesQuery, + RemoveChatRoomMemberRequest, + UpdateChatRoomRequest, +} from "@/types/index.js"; export class ChatService { static async getChatRooms(userId: string): Promise { @@ -201,4 +208,323 @@ export class ChatService { return false; } } + + static async updateChatRoom(userId: string, roomId: string, data: UpdateChatRoomRequest): Promise { + try { + const membership = await prisma.chatRoomMember.findUnique({ + where: { + userId_roomId: { + userId, + roomId, + }, + }, + }); + + if (!membership) { + return { + success: false, + error: "You are not a member of this chat room", + status: 403, + }; + } + + if (membership.role !== "admin") { + return { + success: false, + error: "Only admins can update chat room settings", + status: 403, + }; + } + + const updatedRoom = await prisma.chatRoom.update({ + where: { id: roomId }, + data: { + name: data.name, + description: data.description, + updatedAt: new Date(), + }, + include: { + members: { + include: { + user: { + select: { + id: true, + username: true, + isOnline: true, + lastSeen: true, + avatar: true, + }, + }, + }, + }, + _count: { + select: { + messages: true, + }, + }, + }, + }); + + return { + success: true, + data: updatedRoom, + }; + } catch (error) { + console.error("Update chat room error:", error); + return { + success: false, + error: "Failed to update chat room", + status: 500, + }; + } + } + + static async deleteChatRoom(userId: string, roomId: string): Promise { + try { + const membership = await prisma.chatRoomMember.findUnique({ + where: { + userId_roomId: { + userId, + roomId, + }, + }, + }); + + if (!membership) { + return { + success: false, + error: "You are not a member of this chat room", + status: 403, + }; + } + + if (membership.role !== "admin") { + return { + success: false, + error: "Only admins can delete chat rooms", + status: 403, + }; + } + + await prisma.chatRoom.delete({ + where: { id: roomId }, + }); + + return { + success: true, + data: { id: roomId }, + }; + } catch (error) { + console.error("Delete chat room error:", error); + return { + success: false, + error: "Failed to delete chat room", + status: 500, + }; + } + } + + static async addChatRoomMember(userId: string, roomId: string, data: AddChatRoomMemberRequest): Promise { + try { + const membership = await prisma.chatRoomMember.findUnique({ + where: { + userId_roomId: { + userId, + roomId, + }, + }, + }); + + if (!membership) { + return { + success: false, + error: "You are not a member of this chat room", + status: 403, + }; + } + + if (membership.role !== "admin") { + return { + success: false, + error: "Only admins can add members", + status: 403, + }; + } + + const user = await prisma.user.findUnique({ + where: { username: data.username }, + }); + + if (!user) { + return { + success: false, + error: "User not found", + status: 404, + }; + } + + const existingMembership = await prisma.chatRoomMember.findUnique({ + where: { + userId_roomId: { + userId: user.id, + roomId, + }, + }, + }); + + if (existingMembership) { + return { + success: false, + error: "User is already a member of this chat room", + status: 409, + }; + } + + await prisma.chatRoomMember.create({ + data: { + userId: user.id, + roomId, + role: "member", + }, + }); + + const updatedRoom = await prisma.chatRoom.findUnique({ + where: { id: roomId }, + include: { + members: { + include: { + user: { + select: { + id: true, + username: true, + isOnline: true, + lastSeen: true, + avatar: true, + }, + }, + }, + }, + _count: { + select: { + messages: true, + }, + }, + }, + }); + + return { + success: true, + data: updatedRoom, + }; + } catch (error) { + console.error("Add chat room member error:", error); + return { + success: false, + error: "Failed to add member to chat room", + status: 500, + }; + } + } + + static async removeChatRoomMember(userId: string, roomId: string, data: RemoveChatRoomMemberRequest): Promise { + try { + const membership = await prisma.chatRoomMember.findUnique({ + where: { + userId_roomId: { + userId, + roomId, + }, + }, + }); + + if (!membership) { + return { + success: false, + error: "You are not a member of this chat room", + status: 403, + }; + } + + if (membership.role !== "admin") { + return { + success: false, + error: "Only admins can remove members", + status: 403, + }; + } + + // Check if target user is an admin + const targetMembership = await prisma.chatRoomMember.findUnique({ + where: { + userId_roomId: { + userId: data.userId, + roomId, + }, + }, + }); + + if (!targetMembership) { + return { + success: false, + error: "User is not a member of this chat room", + status: 404, + }; + } + + if (targetMembership.role === "admin") { + return { + success: false, + error: "Cannot remove an admin from the chat room", + status: 403, + }; + } + + // Remove user from chat room + await prisma.chatRoomMember.delete({ + where: { + userId_roomId: { + userId: data.userId, + roomId, + }, + }, + }); + + // Get updated chat room + const updatedRoom = await prisma.chatRoom.findUnique({ + where: { id: roomId }, + include: { + members: { + include: { + user: { + select: { + id: true, + username: true, + isOnline: true, + lastSeen: true, + avatar: true, + }, + }, + }, + }, + _count: { + select: { + messages: true, + }, + }, + }, + }); + + return { + success: true, + data: updatedRoom, + }; + } catch (error) { + console.error("Remove chat room member error:", error); + return { + success: false, + error: "Failed to remove member from chat room", + status: 500, + }; + } + } } diff --git a/backend/src/services/userService.ts b/backend/src/services/userService.ts new file mode 100644 index 0000000..30365b6 --- /dev/null +++ b/backend/src/services/userService.ts @@ -0,0 +1,72 @@ +import { prisma } from "@/config/database.js"; +import { ApiResponse, UpdateUserRequest } from "@/types/index.js"; +import bcrypt from "bcryptjs"; + +export class UserService { + static async updateProfile(userId: string, data: UpdateUserRequest): Promise { + try { + if (data.username) { + const existingUser = await prisma.user.findFirst({ + where: { + username: data.username, + id: { not: userId }, + }, + }); + + if (existingUser) { + return { + success: false, + error: "Username already taken", + }; + } + } + + if (data.email) { + const existingUser = await prisma.user.findFirst({ + where: { + email: data.email, + id: { not: userId }, + }, + }); + + if (existingUser) { + return { + success: false, + error: "Email already taken", + }; + } + } + + const updateData: Partial = {}; + if (data.username) updateData.username = data.username; + if (data.email) updateData.email = data.email; + if (data.avatar !== undefined) updateData.avatar = data.avatar; + + if (data.password) { + updateData.password = await bcrypt.hash(data.password, 12); + } + + const updatedUser = await prisma.user.update({ + where: { id: userId }, + data: updateData, + select: { + id: true, + username: true, + email: true, + avatar: true, + }, + }); + + return { + success: true, + data: updatedUser, + }; + } catch (error) { + console.error("Update profile error:", error); + return { + success: false, + error: "Failed to update profile", + }; + } + } +} diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 7f95273..5568a5d 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -42,6 +42,7 @@ export type ApiResponse = { data?: T; error?: string; message?: string; + status?: number; }; export type CreateChatRoomRequest = { @@ -50,6 +51,13 @@ export type CreateChatRoomRequest = { memberUsernames: string[]; }; +export type UpdateUserRequest = { + username?: string; + email?: string; + password?: string; + avatar?: string | null; +}; + export type PaginationQuery = { page?: string; limit?: string; @@ -69,3 +77,16 @@ export type LoginRequest = { email: string; password: string; }; + +export type UpdateChatRoomRequest = { + name: string; + description?: string; +}; + +export type AddChatRoomMemberRequest = { + username: string; +}; + +export type RemoveChatRoomMemberRequest = { + userId: string; +}; diff --git a/frontend/src/components/ChatRoomSettingsDialog.tsx b/frontend/src/components/ChatRoomSettingsDialog.tsx new file mode 100644 index 0000000..6ba3744 --- /dev/null +++ b/frontend/src/components/ChatRoomSettingsDialog.tsx @@ -0,0 +1,276 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { chatAPI } from '@/lib/api'; +import { useChatStore } from '@/stores/chatStore'; +import type { ChatRoom, UpdateChatRoomRequest } from '@/types'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Loader2, Trash2, UserMinus, UserPlus } from 'lucide-react'; +import { useState, type FC, type FormEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { toast } from 'sonner'; +import { Button } from './ui/button'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'; +import { Input } from './ui/input'; +import { Label } from './ui/label'; +import { Textarea } from './ui/textarea'; + +type ChatRoomSettingsDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + room: ChatRoom | null; +}; + +export const ChatRoomSettingsDialog: FC = ({ + open, + onOpenChange, + room, +}) => { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { updateChatRoom, removeChatRoom } = useChatStore(); + const [newMemberUsername, setNewMemberUsername] = useState(''); + const [formData, setFormData] = useState({ + name: room?.name || '', + description: room?.description || '', + }); + + useState(() => { + if (room) { + setFormData({ + name: room.name, + description: room.description || '', + }); + } + }); + + const updateRoomMutation = useMutation({ + mutationKey: ['updateChatRoom'], + mutationFn: (data: UpdateChatRoomRequest) => + chatAPI.updateChatRoom(room?.id || '', data), + onSuccess: (response) => { + if (response.data.success) { + updateChatRoom(response.data.data); + toast.success('Chat room updated successfully'); + } + }, + onError: (error: any) => { + toast.error(error.response?.data?.error || 'Failed to update chat room'); + }, + }); + + const addMemberMutation = useMutation({ + mutationKey: ['addMember'], + mutationFn: (username: string) => + chatAPI.addChatRoomMember(room?.id || '', { username }), + onSuccess: (response) => { + if (response.data.success) { + updateChatRoom(response.data.data); + setNewMemberUsername(''); + toast.success('Member added successfully'); + } + }, + onError: (error: any) => { + toast.error(error.response?.data?.error || 'Failed to add member'); + }, + }); + + const removeMemberMutation = useMutation({ + mutationKey: ['removeMember'], + mutationFn: (userId: string) => + chatAPI.removeChatRoomMember(room?.id || '', { userId }), + onSuccess: (response) => { + if (response.data.success) { + updateChatRoom(response.data.data); + toast.success('Member removed successfully'); + } + }, + onError: (error: any) => { + toast.error(error.response?.data?.error || 'Failed to remove member'); + }, + }); + + const deleteRoomMutation = useMutation({ + mutationKey: ['deleteChatRoom'], + mutationFn: () => chatAPI.deleteChatRoom(room?.id || ''), + onSuccess: (response) => { + if (response.data.success) { + removeChatRoom(room?.id || ''); + onOpenChange(false); + navigate('/'); + toast.success('Chat room deleted successfully'); + queryClient.invalidateQueries({ queryKey: ['chatRooms'] }); + } + }, + onError: (error: any) => { + toast.error(error.response?.data?.error || 'Failed to delete chat room'); + }, + }); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (room) { + updateRoomMutation.mutate(formData); + } + }; + + const handleAddMember = (e: FormEvent) => { + e.preventDefault(); + if (room && newMemberUsername) { + addMemberMutation.mutate(newMemberUsername); + } + }; + + const handleRemoveMember = (userId: string) => { + if (room) { + removeMemberMutation.mutate(userId); + } + }; + + const handleDeleteRoom = () => { + if ( + window.confirm( + 'Are you sure you want to delete this chat room? This action cannot be undone.' + ) + ) { + deleteRoomMutation.mutate(); + } + }; + + if (!room) return null; + + return ( + + + + Room Settings + + +
+
+ + + setFormData({ ...formData, name: e.target.value }) + } + required + /> +
+ +
+ +