From fe610095836d6cd13f403e16a328367e0d1d0c8b Mon Sep 17 00:00:00 2001 From: ydking0911 Date: Mon, 20 Apr 2026 12:32:13 +0900 Subject: [PATCH 1/4] fix: persist my room rule edits --- src/pages/myRoomRuleUtils.test.ts | 50 +++++++++++++++++++++++++++++++ src/pages/myRoomRuleUtils.ts | 28 +++++++++++++++++ src/types/bun-test.d.ts | 8 +++++ 3 files changed, 86 insertions(+) create mode 100644 src/pages/myRoomRuleUtils.test.ts create mode 100644 src/pages/myRoomRuleUtils.ts create mode 100644 src/types/bun-test.d.ts diff --git a/src/pages/myRoomRuleUtils.test.ts b/src/pages/myRoomRuleUtils.test.ts new file mode 100644 index 0000000..e39ce5b --- /dev/null +++ b/src/pages/myRoomRuleUtils.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from 'bun:test' + +import { + buildRoomRuleFetchPath, + normalizeRoomRuleTimeValue, + selectSingleOption, +} from './myRoomRuleUtils' + +describe('selectSingleOption', () => { + test('새 옵션을 선택하면 이전 선택은 해제한다', () => { + const options = [ + { text: '진동', selected: true }, + { text: '소리', selected: false }, + ] + + expect(selectSingleOption(options, 1)).toEqual([ + { text: '진동', selected: false }, + { text: '소리', selected: true }, + ]) + }) + + test('이미 선택된 옵션을 다시 누르면 선택 해제한다', () => { + const options = [ + { text: '항상', selected: true }, + { text: '유동적', selected: false }, + ] + + expect(selectSingleOption(options, 0)).toEqual([ + { text: '항상', selected: false }, + { text: '유동적', selected: false }, + ]) + }) +}) + +describe('buildRoomRuleFetchPath', () => { + test('방 규칙 재조회는 roomNo path param 경로를 사용한다', () => { + expect(buildRoomRuleFetchPath('123')).toBe('/api/rooms/123/rule') + }) +}) + +describe('normalizeRoomRuleTimeValue', () => { + test('빈 시간값은 BE 검증을 통과하도록 기본값으로 보정한다', () => { + expect(normalizeRoomRuleTimeValue('')).toBe('00:00') + expect(normalizeRoomRuleTimeValue(' ')).toBe('00:00') + }) + + test('실제 선택된 시간값은 유지한다', () => { + expect(normalizeRoomRuleTimeValue('23시')).toBe('23시') + }) +}) diff --git a/src/pages/myRoomRuleUtils.ts b/src/pages/myRoomRuleUtils.ts new file mode 100644 index 0000000..57fb64e --- /dev/null +++ b/src/pages/myRoomRuleUtils.ts @@ -0,0 +1,28 @@ +export type ChecklistOption = { + text: string + selected?: boolean +} + +export const selectSingleOption = ( + options: ChecklistOption[], + optionIndex: number +): ChecklistOption[] => { + const isCurrentlySelected = options[optionIndex]?.selected ?? false + + if (isCurrentlySelected) { + return options.map((option) => ({ + ...option, + selected: false, + })) + } + + return options.map((option, index) => ({ + ...option, + selected: index === optionIndex, + })) +} + +export const buildRoomRuleFetchPath = (roomNo: string) => `/api/rooms/${roomNo}/rule` + +export const normalizeRoomRuleTimeValue = (value: string) => + value && value.trim() !== '' ? value : '00:00' diff --git a/src/types/bun-test.d.ts b/src/types/bun-test.d.ts new file mode 100644 index 0000000..e5b7e52 --- /dev/null +++ b/src/types/bun-test.d.ts @@ -0,0 +1,8 @@ +declare module 'bun:test' { + export const describe: (name: string, fn: () => void) => void + export const test: (name: string, fn: () => void | Promise) => void + export const expect: (actual: T) => { + toBe(expected: T): void + toEqual(expected: unknown): void + } +} From e345792daa8c8dae2b24ef55bea67072b6904e14 Mon Sep 17 00:00:00 2001 From: ydking0911 Date: Mon, 20 Apr 2026 12:33:04 +0900 Subject: [PATCH 2/4] feat: manage room membership and sync room state --- src/pages/ChatRoomPage.tsx | 220 ++++++++++++++++++++++++--- src/pages/RoomSearchPage.tsx | 147 ++++++++++++------ src/pages/roomSearchDeleteState.d.ts | 16 ++ src/pages/roomSearchDeleteState.js | 35 +++++ src/services/chatApi.ts | 6 + src/services/roomApi.ts | 7 + src/store/chatStore.ts | 36 +++++ 7 files changed, 394 insertions(+), 73 deletions(-) create mode 100644 src/pages/roomSearchDeleteState.d.ts create mode 100644 src/pages/roomSearchDeleteState.js create mode 100644 src/services/roomApi.ts diff --git a/src/pages/ChatRoomPage.tsx b/src/pages/ChatRoomPage.tsx index 5710ae6..93c3851 100644 --- a/src/pages/ChatRoomPage.tsx +++ b/src/pages/ChatRoomPage.tsx @@ -5,10 +5,11 @@ import { Client } from '@stomp/stompjs' import type { IMessage } from '@stomp/stompjs' import SockJS from 'sockjs-client' import toast from 'react-hot-toast' -import { ChevronLeft, Menu, X, LogOut, User, Users, Crown } from 'lucide-react' +import { ChevronLeft, Menu, X, LogOut, User, Users, Crown, MoreHorizontal } from 'lucide-react' import MessageBubble from '@/components/chat/MessageBubble' import MessageInput from '@/components/chat/MessageInput' import { getChatMessages, markAsRead, leaveChatRoom, getChatRoomMembers } from '@/services/chatApi' +import { kickRoommate } from '@/services/roomApi' import { useChatStore } from '@/store/chatStore' import { API_BASE_URL } from '@/utils/api' import apiClient from '@/services/apiClient' @@ -20,6 +21,17 @@ interface RoomMember { isHost: boolean } +interface RoomMemberWithRoomMeta extends RoomMember { + confirmStatus?: 'PENDING' | 'ACCEPTED' | 'COMPLETED' + isMe?: boolean +} + +interface NotificationMessage { + type: 'KICKED_FROM_ROOM' | 'ROOM_DELETED' + roomNo: string + chatRoomNo: string +} + const ChatRoomPage = () => { const { chatRoomNo } = useParams<{ chatRoomNo: string }>() const navigate = useNavigate() @@ -32,6 +44,7 @@ const ChatRoomPage = () => { prependMessages, appendMessage, updateRoomOnNewMessage, + removeChatRoom, } = useChatStore() const currentRoom = chatRooms.find(r => r.chatRoomNo === chatRoomNo) @@ -50,13 +63,17 @@ const ChatRoomPage = () => { const [connected, setConnected] = useState(false) const [showInfoPanel, setShowInfoPanel] = useState(false) const [showLeaveConfirm, setShowLeaveConfirm] = useState(false) - const [members, setMembers] = useState([]) + const [members, setMembers] = useState([]) + const [openMemberMenuUserNo, setOpenMemberMenuUserNo] = useState(null) + const [roommateToKick, setRoommateToKick] = useState(null) const [isWideLayout, setIsWideLayout] = useState(false) const [containerRightOffset, setContainerRightOffset] = useState(0) const stompClientRef = useRef(null) const containerRef = useRef(null) const messagesEndRef = useRef(null) + const fetchMembersRef = useRef<() => Promise>(() => Promise.resolve()) + const markAsReadAndSyncRef = useRef<() => Promise>(() => Promise.resolve()) const messagesContainerRef = useRef(null) const myUserNoRef = useRef(myUserNo) @@ -82,6 +99,14 @@ const ChatRoomPage = () => { myUserNoRef.current = myUserNo }, [myUserNo]) + useEffect(() => { + setMembers([]) + setOpenMemberMenuUserNo(null) + setRoommateToKick(null) + setShowInfoPanel(false) + setShowLeaveConfirm(false) + }, [chatRoomNo]) + const fetchMessages = useCallback(async (options?: { silent?: boolean }) => { if (!chatRoomNo) return if (!options?.silent) { @@ -152,19 +177,65 @@ const ChatRoomPage = () => { }, []) // 멤버 목록 조회 - const fetchMembers = useCallback(() => { - if (!chatRoomNo || isDirect) return - getChatRoomMembers(chatRoomNo) - .then(res => setMembers(res.data)) - .catch(() => { }) - }, [chatRoomNo, isDirect]) + const fetchMembers = useCallback(async () => { + if (!chatRoomNo || isDirect || !currentRoom?.roomNo) return + + try { + const [chatMembersRes, roommatesRes] = await Promise.all([ + getChatRoomMembers(chatRoomNo), + apiClient.get('/api/rooms/me/roommates'), + ]) + + const chatMembers = Array.isArray(chatMembersRes.data) ? chatMembersRes.data : [] + const roommates = Array.isArray(roommatesRes.data) ? roommatesRes.data : [] + + const roommateMap = new Map( + roommates.map((mate: { userNo: string | number; confirmStatus?: string; isMe?: boolean }) => [ + String(mate.userNo), + { + confirmStatus: mate.confirmStatus, + isMe: mate.isMe, + }, + ]) + ) + + const merged: RoomMemberWithRoomMeta[] = chatMembers.map((member: RoomMember) => { + const extra = roommateMap.get(String(member.userNo)) + return { + ...member, + confirmStatus: extra?.confirmStatus as RoomMemberWithRoomMeta['confirmStatus'], + isMe: extra?.isMe, + } + }) + + setMembers(merged) + } catch { + // noop + } + }, [chatRoomNo, isDirect, currentRoom?.roomNo]) + + const amIHost = members.some((member) => member.userNo === myUserNo && member.isHost) + const isRoomCompleted = members.length > 0 && members.every( + (member) => member.confirmStatus === 'COMPLETED' + ) + + const canKickMember = (member: RoomMemberWithRoomMeta) => + amIHost && + !isRoomCompleted && + !member.isMe && + !member.isHost && + currentRoom?.chatRoomType === 'GROUP' + + // ref를 최신 함수로 동기화 (STOMP 클로저에서 stale 방지) + useEffect(() => { fetchMembersRef.current = fetchMembers }, [fetchMembers]) + useEffect(() => { markAsReadAndSyncRef.current = markAsReadAndSync }, [markAsReadAndSync]) // 패널 열릴 때 멤버 목록 로드 useEffect(() => { if (showInfoPanel && !isDirect) { - fetchMembers() + void fetchMembers() } - }, [showInfoPanel]) + }, [showInfoPanel, isDirect, fetchMembers]) // STOMP 연결 useEffect(() => { @@ -182,13 +253,13 @@ const ChatRoomPage = () => { updateRoomOnNewMessage(chatRoomNo, message) // 시스템 메시지(입장/퇴장)이면 멤버 목록 갱신 - if (message.messageType === 'SYSTEM' && showInfoPanelRef.current && !isDirect) { - fetchMembers() + if (message.messageType === 'SYSTEM' && showInfoPanelRef.current) { + void fetchMembersRef.current() } // 채팅방을 보고 있는 중이면 즉시 읽음 처리 if (document.visibilityState === 'visible') { - void markAsReadAndSync() + void markAsReadAndSyncRef.current() } // 하단 근처에 있을 때만 자동 스크롤 @@ -206,6 +277,25 @@ const ChatRoomPage = () => { void fetchMessages({ silent: true }) }) + client.subscribe('/user/queue/notification', (msg: IMessage) => { + const notification: NotificationMessage = JSON.parse(msg.body) + + if (notification.chatRoomNo !== chatRoomNo) return + + if (notification.type === 'KICKED_FROM_ROOM') { + removeChatRoom(notification.chatRoomNo) + toast.error('방에서 강퇴되었습니다.') + navigate('/rooms/search') + return + } + + if (notification.type === 'ROOM_DELETED') { + removeChatRoom(notification.chatRoomNo) + toast.error('방이 삭제되었습니다.') + navigate('/rooms/search') + } + }) + client.subscribe('/user/queue/errors', (msg: IMessage) => { toast.error(msg.body || 'WebSocket 오류가 발생했습니다.') }) @@ -293,6 +383,7 @@ const ChatRoomPage = () => { if (!chatRoomNo) return try { await leaveChatRoom(chatRoomNo) + removeChatRoom(chatRoomNo) toast.success('채팅방을 나갔습니다.') navigate('/chats') } catch (e: unknown) { @@ -308,6 +399,29 @@ const ChatRoomPage = () => { } } + const handleKickFromChatPanel = async () => { + if (!currentRoom?.roomNo || !roommateToKick) return + + try { + await kickRoommate(String(currentRoom.roomNo), roommateToKick.userNo) + toast.success(`${roommateToKick.nickname}님을 내보냈습니다.`) + setRoommateToKick(null) + setOpenMemberMenuUserNo(null) + void fetchMembers() + } catch (e: unknown) { + const code = (e as { response?: { data?: { code?: string } } })?.response?.data?.code + if (code === 'ROOM005') { + toast.error('방장만 룸메이트를 내보낼 수 있습니다.') + } else if (code === 'ROOM009') { + toast.error('룸메이트를 찾을 수 없습니다.') + } else if (code === 'ROOM012') { + toast.error('자기 자신은 내보낼 수 없습니다.') + } else { + toast.error('내보내기에 실패했습니다.') + } + } + } + return (
{

{members.map(member => ( -
-
- -
-
- {member.nickname} - {member.isHost && ( - - )} - {member.userNo === myUserNo && ( - (나) - )} +
+
+
+ +
+
+ {member.nickname} + {member.isHost && ( + + )} + {member.userNo === myUserNo && ( + (나) + )} +
+ + {canKickMember(member) && ( +
+ + + {openMemberMenuUserNo === member.userNo && ( +
+ +
+ )} +
+ )}
))}
@@ -502,6 +646,32 @@ const ChatRoomPage = () => {
)} + + {roommateToKick && ( +
+
+

룸메이트 내보내기

+

+ {roommateToKick.nickname}님을 내보내시겠습니까?
+ 방이 최종 확정된 뒤에는 내보낼 수 없습니다. +

+
+ + +
+
+
+ )}
) } diff --git a/src/pages/RoomSearchPage.tsx b/src/pages/RoomSearchPage.tsx index 2b350e0..c773430 100644 --- a/src/pages/RoomSearchPage.tsx +++ b/src/pages/RoomSearchPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState, useCallback } from 'react' -import { useNavigate } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import { Bell, Search, Plus, Filter, Star } from 'lucide-react' import BottomNavigationBar from '@/components/ui/BottomNavigationBar' import SectionLoading from '@/components/ui/SectionLoading' @@ -10,9 +10,11 @@ import ChatRequestModal from '@/components/modals/ChatRequestModal' import ConfirmModal from '@/components/ui/ConfirmModal' import { Room } from '@/types/room' import { getApiUrl } from '@/utils/api' +import { pruneDeletedRoomState } from './roomSearchDeleteState.js' const RoomSearchPage = () => { const navigate = useNavigate() + const location = useLocation() type Relation = 'recruiting' | 'applied' | 'joined' type ApiRoom = { roomNo: number @@ -48,6 +50,7 @@ const RoomSearchPage = () => { const [roomRules, setRoomRules] = useState>({}) const [roomOtherNotes, setRoomOtherNotes] = useState>({}) const [hasMyRoom, setHasMyRoom] = useState(null) + const isGuest = hasMyRoom === null // Enum을 한글로 변환하는 함수들 (재사용) const mapReturnHomeFromEnum = (enumValue: string): string => { @@ -591,6 +594,20 @@ const RoomSearchPage = () => { const [loadingTab, setLoadingTab] = useState(null) const loadingDelayTimerRef = useRef(null) + const deletedRoomNo = + typeof location.state === 'object' && + location.state !== null && + 'deletedRoomNo' in location.state && + typeof (location.state as { deletedRoomNo?: unknown }).deletedRoomNo === 'string' + ? (location.state as { deletedRoomNo: string }).deletedRoomNo + : null + + const refreshRoomsOnEnter = + typeof location.state === 'object' && + location.state !== null && + 'refreshRooms' in location.state && + Boolean((location.state as { refreshRooms?: unknown }).refreshRooms) + const resetFilters = () => { setFilters({ roomType: [], roomSize: [], residencePeriod: [], sort: 'recent', checklist: {} }) } @@ -834,43 +851,72 @@ const RoomSearchPage = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [filters.checklist, activeTab, recruitingRooms, appliedRooms, joinedRooms]) - // 내가 속한 방 존재 여부 조회 - useEffect(() => { - const checkRoomExist = async () => { - try { - const res = await fetch(getApiUrl('/api/rooms/me/exists'), { - credentials: 'include', - }) - - if (!res.ok) { - setHasMyRoom(false) - return - } + const checkRoomExist = useCallback(async () => { + try { + const res = await fetch(getApiUrl('/api/rooms/me/exists'), { + credentials: 'include', + }) - const contentType = res.headers.get('content-type') ?? '' - const rawBody = await res.text() + if (res.status === 401) { + setHasMyRoom(null) + return + } + if (!res.ok) { + setHasMyRoom(false) + return + } - let data: any - try { - data = rawBody ? JSON.parse(rawBody) : null - } catch (e) { - console.error('[rooms] check exists parse error', { contentType, rawBody }, e) - setHasMyRoom(false) - return - } + const contentType = res.headers.get('content-type') ?? '' + const rawBody = await res.text() - // ResponseEntity 형식: 직접 접근 - const payload = data - setHasMyRoom(!!payload?.isExist) - } catch (err) { - console.error('[rooms] check exists error', err) + let data: any + try { + data = rawBody ? JSON.parse(rawBody) : null + } catch (e) { + console.error('[rooms] check exists parse error', { contentType, rawBody }, e) setHasMyRoom(false) + return } - } - checkRoomExist() + // ResponseEntity 형식: 직접 접근 + const payload = data + setHasMyRoom(!!payload?.isExist) + } catch (err) { + console.error('[rooms] check exists error', err) + setHasMyRoom(false) + } }, []) + // 내가 속한 방 존재 여부 조회 + useEffect(() => { + void checkRoomExist() + }, [checkRoomExist]) + + useEffect(() => { + if (!deletedRoomNo) return + + const nextState = pruneDeletedRoomState( + { + recruitingRooms, + appliedRooms, + joinedRooms, + expandedRoomIds, + roomRules, + roomOtherNotes, + likedRoomIds, + }, + deletedRoomNo + ) + + setRecruitingRooms(nextState.recruitingRooms) + setAppliedRooms(nextState.appliedRooms) + setJoinedRooms(nextState.joinedRooms) + setExpandedRoomIds(nextState.expandedRoomIds) + setRoomRules(nextState.roomRules) + setRoomOtherNotes(nextState.roomOtherNotes) + setLikedRoomIds(nextState.likedRoomIds) + }, [deletedRoomNo]) + const mapRoomTypeToApi = (type: string) => { switch (type) { case '1 기숙사': @@ -1196,6 +1242,7 @@ const RoomSearchPage = () => { const handleVisibility = () => { if (document.visibilityState === 'visible' && activeTab === 'recruiting') { const refreshKey = `recruiting-refresh-${Date.now()}` + void checkRoomExist() void fetchRooms('recruiting', { showLoading: false, requestKey: refreshKey }) } } @@ -1205,7 +1252,16 @@ const RoomSearchPage = () => { window.removeEventListener('focus', handleVisibility) document.removeEventListener('visibilitychange', handleVisibility) } - }, [activeTab, fetchRooms]) + }, [activeTab, checkRoomExist, fetchRooms]) + + useEffect(() => { + if (!refreshRoomsOnEnter) return + + void checkRoomExist() + void fetchRooms('recruiting', { showLoading: false, requestKey: `location-refresh-recruiting-${Date.now()}` }) + void fetchRooms('joined', { showLoading: false, requestKey: `location-refresh-joined-${Date.now()}` }) + void fetchRooms('applied', { showLoading: false, requestKey: `location-refresh-applied-${Date.now()}` }) + }, [checkRoomExist, fetchRooms, refreshRoomsOnEnter]) return (
@@ -1405,9 +1461,8 @@ const RoomSearchPage = () => {
- {/* 방 만들기 버튼 - 로그인한 사용자만 표시 */} - {/* 로그인 여부는 서버의 인증 쿠키 기준으로 판단 */} - {true && ( + {/* 방 만들기 버튼 - 로그인했고 아직 속한 방이 없는 사용자에게만 표시 */} + {!isGuest && hasMyRoom === false && ( - {/* 로그인한 사용자만 다른 탭 표시 - 인증은 서버에서 처리 */} - {true && ( + {/* 로그인한 사용자만 관심/지원 탭 표시 */} + {!isGuest && ( <> + )} +
@@ -1214,28 +1356,35 @@ const MyRoomPage = () => { 초대 링크
+ {isHost && room?.roomStatus !== 'COMPLETED' && !allOtherRoommatesConfirmed && ( +

+ 다른 룸메이트가 모두 확정한 뒤 방장이 마지막으로 최종 확정할 수 있습니다. +

+ )}
{/* 탭 */}
- {(['규칙', '지원자', '룸메이트'] as const).map((tab) => ( + {(['규칙', ...(isHost ? ['지원자'] : []), '룸메이트'] as const).map((tab) => (
-
- - + - -
+ }} + disabled={directChatLoadingId === applicant.userNo} + className="px-3 py-1 text-xs font-semibold text-gray-700 bg-gray-100 border border-gray-200 rounded hover:bg-gray-200 transition-colors disabled:opacity-50" + > + {directChatLoadingId === applicant.userNo ? '...' : '채팅'} + + +
+ )} - {myConfirmStatus === 'PENDING' && ( - )} ) : ( myConfirmStatus === 'PENDING' && ( - + ) )} @@ -2190,7 +2326,7 @@ const MyRoomPage = () => { )} - {showRoommateSettings && mate.confirmStatus === 'PENDING' && !mate.isMe ? ( + {showRoommateSettings && room?.roomStatus !== 'COMPLETED' && !mate.isMe ? ( + + + + + )} + {/* 지원자 수락 확인 모달 */} {applicantToAccept && (
@@ -2561,11 +2713,23 @@ const MyRoomPage = () => { {showConfirmAssignment && (
-

방 배정 확정

+

+ {isHost ? '방 최종 확정' : '방 배정 확정'} +

- 방 배정을 확정하시겠습니까?
- 확정 후에는 방에서 나갈 수 없으며
- 되돌릴 수 없습니다. + {isHost ? ( + <> + 다른 룸메이트가 모두 확정된 상태입니다.
+ 방장이 최종 확정하면 방 전체가 확정되며
+ 이후에는 강퇴할 수 없습니다. + + ) : ( + <> + 방 배정을 확정하시겠습니까?
+ 확정 후에는 되돌릴 수 없으며
+ 방장이 마지막으로 최종 확정하면 방 전체가 확정됩니다. + + )}