import { useEffect, useRef } from 'react' import { useNavigate } from 'react-router' import { useQueryClient } from '@tanstack/react-query' import { useChatInitQuery, useChatMessagesQuery, useChatSendMutation, mapMessage, chatKeys, } from '@/apis/chat' import { ErrorCode, getApiErrorMessage, isApiError } from '@/apis/types' import { negotiationKeys, SessionStatus } from '@/apis' import { toast } from '@/lib' import { useChatStore } from '@/features/chat/stores/useChatStore' import { useChatInitStore } from '@/features/chat/stores/useChatInitStore' import type { ChatMessage, UserInputType } from '@/features/chat/types' // 더 이상 대화를 이어갈 수 없는(세션 종료/마감/권한) 코드 → 입력 잠금 후 목록으로 복귀. const TERMINAL_CODES = new Set([ ErrorCode.CHAT_NOT_IN_PROGRESS, ErrorCode.NEGO_QUOTATION_CLOSED, ErrorCode.NEGO_DEADLINE_PASSED, ErrorCode.NEGO_FORBIDDEN, ErrorCode.NEGO_NOT_FOUND, ]) // 진입 로드(init/messages) 시 '잘못된 접근'으로 볼 코드 → 권한 없음 / 없는(또는 남의) 세션 / // 진입 불가 상태(미참여·거부). 토스트 안내 후 목록으로 복귀시킨다. (인증≠인가 — 로그아웃하지 않는다) const INVALID_ACCESS_CODES = new Set([ ErrorCode.NEGO_FORBIDDEN, ErrorCode.NEGO_NOT_FOUND, ErrorCode.NEGO_NOT_PARTICIPABLE, ]) // 화면이 보는 대화 상태가 서버와 어긋났을 수 있는 코드 → 서버 기준으로 메시지를 다시 불러와 리싱크. const RESYNC_CODES = new Set([ ErrorCode.CHAT_INPUT_MODE_MISMATCH, // 보낸 입력이 직전 봇이 요구한 모드와 불일치 ErrorCode.CHAT_AGENT_TIMEOUT, // agent 타임아웃(서버/agent 상태가 앞서 있을 수 있음) ]) let tempSeq = 0 // 낙관적 유저 말풍선 생성 (전송 즉시 표시). 서버 확정 메시지는 send 응답으로 append 한다. function makeUserMessage(text: string, inputType: UserInputType): ChatMessage { return { chat_id: `temp-user-${tempSeq++}`, sender: 'user', bot_chat_type: null, user_input_type: inputType, script: text, chat_end: false, next_input_mode: null, next_input_type: null, step: '', display_step: '', summary: null, indicator_value: null, } } /** * 채팅 페이지 컨트롤러: init/messages 조회를 스토어에 적재하고, * append-only 전송 구현을 스토어에 주입한다. (mock 을 대체) */ export function useChatController(sessionId: string) { const navigate = useNavigate() const queryClient = useQueryClient() const setInitData = useChatInitStore((s) => s.setInitData) const initQuery = useChatInitQuery(sessionId) const messagesQuery = useChatMessagesQuery(sessionId) // mutate 는 참조가 안정적이다. useMutation 반환 객체 전체를 아래 effect deps 에 넣으면 // mutate 호출→pending 리렌더마다 객체 identity 가 바뀌어 effect 가 teardown(reset())→재바인딩되고, // staleTime:Infinity 인 메시지 쿼리는 재조회되지 않아 스토어가 빈 채로 남아 채팅이 사라진다. const { mutate: sendMutate } = useChatSendMutation(sessionId) // 진입 로드 실패. 권한 없음/없는 세션(잘못된 접근)이면 토스트 후 목록으로 복귀시킨다. const loadError = initQuery.error ?? messagesQuery.error const isInvalidAccess = isApiError(loadError) && INVALID_ACCESS_CODES.has(loadError.code) // 이미 리다이렉트한 sessionId 를 기록(boolean 이 아니라 sessionId). 리마운트 없이 sessionId 가 바뀌면 // (브라우저 뒤로/앞으로 등) 값이 달라져 가드가 자연 해제 → 두 번째 무권한 세션도 토스트+복귀가 동작한다. const redirectedSessionRef = useRef(null) useEffect(() => { if (!isInvalidAccess || redirectedSessionRef.current === sessionId) return redirectedSessionRef.current = sessionId toast.error('잘못된 접근입니다.') navigate('/list', { replace: true }) }, [isInvalidAccess, sessionId, navigate]) // init 메타 → 스토어 useEffect(() => { if (initQuery.data) setInitData(initQuery.data) }, [initQuery.data, setInitData]) // 대화 히스토리 → 스토어 (진입 1회) useEffect(() => { if (messagesQuery.data) useChatStore.getState().setMessages(messagesQuery.data) }, [messagesQuery.data]) // sessionId + 전송 구현 주입 useEffect(() => { const store = useChatStore.getState() store.setSessionId(sessionId) store.bindSend((text, inputType = 'text') => { const optimistic = makeUserMessage(text, inputType) store.appendMessage(optimistic) store.setIsLoading(true) store.setPriceErrorMessage('') sendMutate( { user_input: text, user_input_type: inputType === 'text' ? null : inputType }, { onSuccess: (data) => { const s = useChatStore.getState() if (data.message) s.appendMessage(mapMessage(data.message)) s.setIsLoading(false) // 협상 종료 전이(완료/거부 등): 목록 캐시만 무효화해 /list 복귀 시 최신 상태를 보장한다. // init 은 '진입 메타'라 화면에서 재조회하지 않는다 — 재조회하면 미참여/거부 진입 게이트(1301)에 // 걸려 방금 정상 종료한 사용자가 튕겨난다. 재진입 시 최신 status 는 언마운트의 removeQueries 가 보장한다. if (data.session_status !== SessionStatus.IN_PROGRESS || data.message?.chat_end) { queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() }) } }, onError: (error) => { const s = useChatStore.getState() // 낙관적 메시지 롤백 (서버에 저장되지 않음) s.setMessages(s.messages.filter((m) => m.chat_id !== optimistic.chat_id)) s.setIsLoading(false) const code = isApiError(error) ? error.code : 0 // 가격 범위 초과: 입력창 인라인 에러로 표시(페이지 유지) if (code === ErrorCode.CHAT_PRICE_OUT_OF_RANGE) { s.setPriceErrorMessage(getApiErrorMessage(error)) return } toast.error(getApiErrorMessage(error, '협상 처리 중 오류가 발생했습니다.')) // 입력-모드 불일치/타임아웃: 서버 기준으로 대화를 다시 불러와 화면을 리싱크한다. // (messages 쿼리가 갱신되면 위 effect 가 스토어 messages 를 덮어써 버튼/입력창이 서버 상태에 맞춰진다.) if (RESYNC_CODES.has(code)) { queryClient.invalidateQueries({ queryKey: chatKeys.messages(sessionId) }) } // 마감/종료/권한 등 더 진행 불가한 상태면 잠시 후 목록으로 복귀 if (TERMINAL_CODES.has(code)) { s.bindSend(null) // 입력 잠금(추가 전송 차단) // 서버 기준 세션 상태가 이미 바뀐 것 — 복귀할 목록 캐시를 무효화한다. (init 은 언마운트에서 정리) queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() }) setTimeout(() => navigate('/list'), 1500) } }, }, ) }) return () => { useChatStore.getState().reset() // 방문 간 sticky(wasInProgress) 누수 방지 — 다른 세션으로 전환/이탈 시 초기화한다. useChatInitStore.getState().reset() // init 은 진입 메타 캐시 — 비워 두어 재진입이 항상 최신 status 로 진입 게이트(1301)를 다시 타게 한다. queryClient.removeQueries({ queryKey: chatKeys.init(sessionId) }) // messages 는 staleTime:Infinity 라 캐시가 남으면 재진입해도 다시 안 부른다 — // 오프닝 생성 전(협상생성 상태)에 받은 빈 목록이 눌러앉아 채팅이 빈 화면으로 굳는다. queryClient.removeQueries({ queryKey: chatKeys.messages(sessionId) }) } }, [sessionId, sendMutate, navigate, queryClient]) return { // 잘못된 접근이면 목록으로 리다이렉트되는 동안 스피너를 유지(ErrorPage 깜빡임 방지). isInitLoading: initQuery.isLoading || messagesQuery.isLoading || isInvalidAccess, // 잘못된 접근은 토스트+리다이렉트로 처리하므로 ErrorPage(재시도)로 노출하지 않는다. initError: isInvalidAccess ? null : loadError, // 로드 실패(서버/네트워크) 시 init·messages 를 함께 재조회한다. refetchInit: () => { void initQuery.refetch() void messagesQuery.refetch() }, } }