From 6b3e6cce5e9641fb1a65745bc4d0e135d2b390f4 Mon Sep 17 00:00:00 2001 From: syc0123 Date: Mon, 30 Mar 2026 18:41:13 +0900 Subject: [PATCH] =?UTF-8?q?[RAPID-micro]=20=EC=83=88=20=EB=8C=80=ED=99=94?= =?UTF-8?q?=20=EB=AA=A8=EB=8B=AC=20z-index=20=EB=A9=94=EC=8B=A0=EC=A0=80?= =?UTF-8?q?=20=EC=9C=84=EB=A1=9C=20=EC=83=81=ED=96=A5=20(10000/10001)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [RAPID-fix] 메신저 사용자 목록 회사 전환 시 캐시 격리 - useRooms/useCompanyUsers queryKey에 companyCode 포함 - 회사 전환 시 다른 회사 사용자가 캐시에서 노출되던 문제 수정 Co-Authored-By: Claude Sonnet 4.6 [RAPID-fix] 메신저 버그 수정 (8건) - 방 생성 후 자동 입장 + 커서 포커스 - DM 헤더 상대방 이름, 그룹 "이름1, 이름2 외 N명" 표시 - 채팅방 이름 인라인 수정 기능 추가 - Socket.IO join_rooms 누락 수정 → 실시간 메시지 수신 정상화 - new_message 이벤트 수신 시 React Query 캐시 무효화 - 토스트 알림 stale closure 수정 (ref 패턴 적용) - 타이핑 이벤트명 백엔드 일치 (user_typing/user_stop_typing) - 메시지 순서 역전 수정 (.reverse()) - unread queryKey 불일치 수정 Co-Authored-By: Claude Sonnet 4.6 [RAPID-fix] REST API 메시지 전송 시 Socket.IO broadcast 추가 - socketManager.ts 모듈 생성 (io 전역 공유) - sendMessage 컨트롤러에서 io.to(room).emit('new_message') broadcast - 상대방 말풍선 너비 고정 수정 (items-start 추가) Co-Authored-By: Claude Sonnet 4.6 --- backend-node/src/app.ts | 2 + .../src/controllers/messengerController.ts | 8 ++ backend-node/src/services/messengerService.ts | 3 +- backend-node/src/socket/socketManager.ts | 11 +++ frontend/components/messenger/ChatPanel.tsx | 68 ++++++++++++++-- .../components/messenger/MessageInput.tsx | 7 ++ frontend/components/messenger/MessageItem.tsx | 2 +- frontend/components/ui/dialog.tsx | 8 +- frontend/hooks/useMessenger.ts | 20 ++++- frontend/hooks/useMessengerSocket.ts | 77 +++++++++++++------ 10 files changed, 166 insertions(+), 40 deletions(-) create mode 100644 backend-node/src/socket/socketManager.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 06591522..d0532997 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -416,10 +416,12 @@ const server = app.listen(PORT, HOST, async () => { try { const { Server: SocketIOServer } = await import("socket.io"); const { initMessengerSocket } = await import("./socket/messengerSocket"); + const { setIo } = await import("./socket/socketManager"); const io = new SocketIOServer(server, { cors: { origin: "*", methods: ["GET", "POST"] }, path: "/socket.io", }); + setIo(io); initMessengerSocket(io); logger.info("💬 Socket.IO messenger initialized"); } catch (error) { diff --git a/backend-node/src/controllers/messengerController.ts b/backend-node/src/controllers/messengerController.ts index 25e1ee50..b1fe2e11 100644 --- a/backend-node/src/controllers/messengerController.ts +++ b/backend-node/src/controllers/messengerController.ts @@ -1,6 +1,7 @@ import { Request, Response } from 'express'; import { messengerService } from '../services/messengerService'; import { AuthenticatedRequest } from '../types/auth'; +import { getIo } from '../socket/socketManager'; import path from 'path'; class MessengerController { @@ -69,6 +70,13 @@ class MessengerController { } const message = await messengerService.sendMessage(roomId, user.userId, user.companyCode!, content, messageType, parentId); + + // Broadcast to all room participants via Socket.IO + const io = getIo(); + if (io) { + io.to(`${user.companyCode}:${roomId}`).emit('new_message', message); + } + res.json({ success: true, data: message }); } catch (error) { const err = error as Error; diff --git a/backend-node/src/services/messengerService.ts b/backend-node/src/services/messengerService.ts index d3ff7f13..36da6678 100644 --- a/backend-node/src/services/messengerService.ts +++ b/backend-node/src/services/messengerService.ts @@ -161,7 +161,8 @@ class MessengerService { } const result = await PostgreSQLService.query(query, params); - const messages: MessengerMessage[] = result.rows; + // Reverse so messages are in chronological order (query uses DESC for cursor pagination) + const messages: MessengerMessage[] = result.rows.reverse(); // Attach reactions and files if (messages.length > 0) { diff --git a/backend-node/src/socket/socketManager.ts b/backend-node/src/socket/socketManager.ts new file mode 100644 index 00000000..309ef6c0 --- /dev/null +++ b/backend-node/src/socket/socketManager.ts @@ -0,0 +1,11 @@ +import { Server } from 'socket.io'; + +let _io: Server | null = null; + +export function setIo(io: Server) { + _io = io; +} + +export function getIo(): Server | null { + return _io; +} diff --git a/frontend/components/messenger/ChatPanel.tsx b/frontend/components/messenger/ChatPanel.tsx index 5b969157..28101f38 100644 --- a/frontend/components/messenger/ChatPanel.tsx +++ b/frontend/components/messenger/ChatPanel.tsx @@ -1,9 +1,9 @@ "use client"; -import { useEffect, useRef } from "react"; -import { MessageSquare } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { MessageSquare, Pencil, Check, X } from "lucide-react"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { useMessages, useMarkAsRead } from "@/hooks/useMessenger"; +import { useMessages, useMarkAsRead, useUpdateRoom } from "@/hooks/useMessenger"; import { useAuth } from "@/hooks/useAuth"; import { useMessengerContext } from "@/contexts/MessengerContext"; import { useMessengerSocket } from "@/hooks/useMessengerSocket"; @@ -20,8 +20,12 @@ export function ChatPanel({ room }: ChatPanelProps) { const { selectedRoomId } = useMessengerContext(); const { data: messages } = useMessages(selectedRoomId); const markAsRead = useMarkAsRead(); + const updateRoom = useUpdateRoom(); const { emitTypingStart, emitTypingStop, typingUsers } = useMessengerSocket(); const bottomRef = useRef(null); + const [isEditingName, setIsEditingName] = useState(false); + const [editName, setEditName] = useState(""); + const editInputRef = useRef(null); useEffect(() => { if (selectedRoomId) { @@ -60,14 +64,64 @@ export function ChatPanel({ room }: ChatPanelProps) { return prev !== curr; }; + // Compute display name based on room type + const displayName = (() => { + if (room.type === "dm") { + const other = room.participants.find((p) => p.userId !== user?.userId); + return other?.userName ?? room.name; + } + if (room.name) return room.name; + const others = room.participants.filter((p) => p.userId !== user?.userId); + if (others.length <= 2) { + return others.map((p) => p.userName).join(", "); + } + return `${others[0].userName}, ${others[1].userName} 외 ${others.length - 2}명`; + })(); + return (
{/* Header */}
-

{room.name}

- - {room.participants.length}명 - + {isEditingName ? ( +
{ + e.preventDefault(); + const trimmed = editName.trim(); + if (trimmed && trimmed !== room.name) { + updateRoom.mutate({ roomId: room.id, name: trimmed }); + } + setIsEditingName(false); + }} + > + setEditName(e.target.value)} + className="font-semibold text-sm bg-transparent border-b border-primary outline-none flex-1 min-w-0" + autoFocus + /> + + +
+ ) : ( + <> +

{displayName}

+ + + {room.participants.length}명 + + + )}
{/* Messages */} diff --git a/frontend/components/messenger/MessageInput.tsx b/frontend/components/messenger/MessageInput.tsx index 13f2c605..10bed48f 100644 --- a/frontend/components/messenger/MessageInput.tsx +++ b/frontend/components/messenger/MessageInput.tsx @@ -41,6 +41,13 @@ export function MessageInput({ roomId, onTypingStart, onTypingStop }: MessageInp adjustHeight(); }, [text, adjustHeight]); + // Auto-focus when room changes + useEffect(() => { + if (roomId) { + textareaRef.current?.focus(); + } + }, [roomId]); + const handleSend = useCallback(() => { const trimmed = text.trim(); if (!trimmed) return; diff --git a/frontend/components/messenger/MessageItem.tsx b/frontend/components/messenger/MessageItem.tsx index e647d64a..da9074a1 100644 --- a/frontend/components/messenger/MessageItem.tsx +++ b/frontend/components/messenger/MessageItem.tsx @@ -53,7 +53,7 @@ export function MessageItem({ message, isOwn, showAvatar }: MessageItemProps) {
)} -
+
{showAvatar && !isOwn && ( {message.senderName} diff --git a/frontend/components/ui/dialog.tsx b/frontend/components/ui/dialog.tsx index 26ea392f..c8e2dad4 100644 --- a/frontend/components/ui/dialog.tsx +++ b/frontend/components/ui/dialog.tsx @@ -59,7 +59,7 @@ const DialogOverlay = React.forwardRef<
{scoped ? (
) : ( - + )} (url: string, data?: unknown): Promise { // Hooks // ============================================ export function useRooms() { + const { user } = useAuth(); + const companyCode = user?.companyCode || user?.company_code; return useQuery({ - queryKey: ["messenger", "rooms"], + queryKey: ["messenger", "rooms", companyCode], queryFn: async () => { const data = await fetchApi("/messenger/rooms"); return data.map((r) => ({ @@ -122,8 +125,10 @@ export function useMessages(roomId: string | null) { } export function useCompanyUsers() { + const { user } = useAuth(); + const companyCode = user?.companyCode || user?.company_code; return useQuery({ - queryKey: ["messenger", "users"], + queryKey: ["messenger", "users", companyCode], queryFn: async () => { const data = await fetchApi("/messenger/users"); return data.map((u) => ({ @@ -189,6 +194,17 @@ export function useAddReaction() { }); } +export function useUpdateRoom() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ roomId, name }: { roomId: string; name: string }) => + apiClient.put(`/messenger/rooms/${roomId}`, { room_name: name }).then((res) => res.data?.data ?? res.data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["messenger", "rooms"] }); + }, + }); +} + export function useUploadFile() { return useMutation({ mutationFn: async (file: File) => { diff --git a/frontend/hooks/useMessengerSocket.ts b/frontend/hooks/useMessengerSocket.ts index a7e26981..4ba8a3d1 100644 --- a/frontend/hooks/useMessengerSocket.ts +++ b/frontend/hooks/useMessengerSocket.ts @@ -2,35 +2,44 @@ import { useEffect, useRef, useCallback, useState } from "react"; import { io, Socket } from "socket.io-client"; +import { useQueryClient } from "@tanstack/react-query"; import { useMessengerContext } from "@/contexts/MessengerContext"; import { useToast } from "@/hooks/use-toast"; const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8080"; interface NewMessageEvent { - roomId: string; - message: { - id: string; - content: string; - senderName: string; - senderId: string; - createdAt: string; - }; + room_id: number; + sender_name: string; + sender_id: string; + content: string; } interface TypingEvent { - roomId: string; - userId: string; - userName: string; + room_id: number; + user_id: string; + user_name: string; } export function useMessengerSocket() { const socketRef = useRef(null); const { selectedRoomId, notificationEnabled } = useMessengerContext(); + const selectedRoomIdRef = useRef(selectedRoomId); + const notificationEnabledRef = useRef(notificationEnabled); const { toast } = useToast(); + const qc = useQueryClient(); const [onlineUsers, setOnlineUsers] = useState>(new Set()); const [typingUsers, setTypingUsers] = useState>(new Map()); + // Keep refs in sync so socket handlers use latest values + useEffect(() => { + selectedRoomIdRef.current = selectedRoomId; + }, [selectedRoomId]); + + useEffect(() => { + notificationEnabledRef.current = notificationEnabled; + }, [notificationEnabled]); + useEffect(() => { const token = localStorage.getItem("authToken"); if (!token) return; @@ -43,6 +52,11 @@ export function useMessengerSocket() { socketRef.current = socket; + // BUG-8: Join rooms on connect + socket.on("connect", () => { + socket.emit("join_rooms"); + }); + socket.on("user_online", (data: { userId: string; online: boolean }) => { setOnlineUsers((prev) => { const next = new Set(prev); @@ -52,33 +66,45 @@ export function useMessengerSocket() { }); }); + // BUG-5 & BUG-8: Handle new_message with cache invalidation and toast socket.on("new_message", (data: NewMessageEvent) => { - if (data.roomId !== selectedRoomId && notificationEnabled) { + const roomIdStr = String(data.room_id); + + // Invalidate React Query caches + qc.invalidateQueries({ queryKey: ["messenger", "messages", roomIdStr] }); + qc.invalidateQueries({ queryKey: ["messenger", "rooms"] }); + qc.invalidateQueries({ queryKey: ["messenger", "unread"] }); + + // Toast for messages in other rooms + if (roomIdStr !== selectedRoomIdRef.current && notificationEnabledRef.current) { toast({ - title: data.message.senderName, - description: data.message.content.slice(0, 50), + title: data.sender_name, + description: data.content?.slice(0, 50), }); } }); - socket.on("typing_start", (data: TypingEvent) => { + // BUG-7: Backend emits "user_typing" / "user_stop_typing", not "typing_start" / "typing_stop" + socket.on("user_typing", (data: TypingEvent) => { + const roomIdStr = String(data.room_id); setTypingUsers((prev) => { const next = new Map(prev); - const users = next.get(data.roomId) || []; - if (!users.includes(data.userName)) { - next.set(data.roomId, [...users, data.userName]); + const users = next.get(roomIdStr) || []; + if (!users.includes(data.user_name)) { + next.set(roomIdStr, [...users, data.user_name]); } return next; }); }); - socket.on("typing_stop", (data: TypingEvent) => { + socket.on("user_stop_typing", (data: TypingEvent) => { + const roomIdStr = String(data.room_id); setTypingUsers((prev) => { const next = new Map(prev); - const users = next.get(data.roomId) || []; + const users = next.get(roomIdStr) || []; next.set( - data.roomId, - users.filter((u) => u !== data.userName) + roomIdStr, + users.filter((u) => u !== data.user_name) ); return next; }); @@ -88,18 +114,19 @@ export function useMessengerSocket() { socket.disconnect(); socketRef.current = null; }; - }, [selectedRoomId, notificationEnabled, toast]); + }, [toast, qc]); + // BUG-7: Backend expects { room_id }, not { roomId } const emitTypingStart = useCallback( (roomId: string) => { - socketRef.current?.emit("typing_start", { roomId }); + socketRef.current?.emit("typing_start", { room_id: Number(roomId) }); }, [] ); const emitTypingStop = useCallback( (roomId: string) => { - socketRef.current?.emit("typing_stop", { roomId }); + socketRef.current?.emit("typing_stop", { room_id: Number(roomId) }); }, [] );