From c0bf7829531a09af03b6772bf06d930212ad5e8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=84=B1=EC=A7=84?= Date: Wed, 15 Jul 2026 12:34:58 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84(#6?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspaces/[workspaceId]/chat/page.tsx | 16 ++ src/entities/chat/api/chat-actions.ts | 86 ++++++++ src/entities/chat/api/get-chat-room.ts | 62 ++++++ src/entities/chat/index.ts | 4 + src/entities/chat/model/chat-query.ts | 2 + src/entities/chat/model/chat.types.ts | 28 +++ src/features/manage-chat/index.ts | 4 + .../manage-chat/model/use-chat-room.ts | 194 ++++++++++++++++++ src/features/manage-chat/ui/ChatComposer.tsx | 59 ++++++ .../manage-chat/ui/ChatMessageList.tsx | 103 ++++++++++ src/views/chat/index.ts | 2 + src/views/chat/ui/ChatView.tsx | 118 +++++++++++ ...5100000_enable_workspace_chat_realtime.sql | 35 ++++ 13 files changed, 713 insertions(+) create mode 100644 src/app/workspaces/[workspaceId]/chat/page.tsx create mode 100644 src/entities/chat/api/chat-actions.ts create mode 100644 src/entities/chat/api/get-chat-room.ts create mode 100644 src/entities/chat/index.ts create mode 100644 src/entities/chat/model/chat-query.ts create mode 100644 src/entities/chat/model/chat.types.ts create mode 100644 src/features/manage-chat/index.ts create mode 100644 src/features/manage-chat/model/use-chat-room.ts create mode 100644 src/features/manage-chat/ui/ChatComposer.tsx create mode 100644 src/features/manage-chat/ui/ChatMessageList.tsx create mode 100644 src/views/chat/index.ts create mode 100644 src/views/chat/ui/ChatView.tsx create mode 100644 supabase/migrations/20260715100000_enable_workspace_chat_realtime.sql diff --git a/src/app/workspaces/[workspaceId]/chat/page.tsx b/src/app/workspaces/[workspaceId]/chat/page.tsx new file mode 100644 index 0000000..3cff06f --- /dev/null +++ b/src/app/workspaces/[workspaceId]/chat/page.tsx @@ -0,0 +1,16 @@ +// 워크스페이스 채팅 페이지의 서버 진입점으로 초기 메시지를 조회해 클라이언트 화면에 전달합니다. +import { getChatRoom } from '@/entities/chat'; +import { ChatView } from '@/views/chat'; + +interface ChatPageProps { + params: Promise<{ + workspaceId: string; + }>; +} + +export default async function ChatPage({ params }: ChatPageProps) { + const { workspaceId } = await params; + const initialData = await getChatRoom(workspaceId); + + return ; +} diff --git a/src/entities/chat/api/chat-actions.ts b/src/entities/chat/api/chat-actions.ts new file mode 100644 index 0000000..cce755c --- /dev/null +++ b/src/entities/chat/api/chat-actions.ts @@ -0,0 +1,86 @@ +'use server'; + +// 채팅 메시지를 현재 로그인한 워크스페이스 멤버 명의로 저장합니다. +import { z } from 'zod'; +import { getCurrentUserId } from '@/shared/api/supabase/current-user'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; +import type { ChatMessage } from '../model/chat.types'; + +const uuidSchema = z.guid(); +const chatMessageSchema = z.object({ + workspaceId: uuidSchema, + content: z.string().trim().min(1, '메시지를 입력해주세요.').max(2_000), +}); + +export type ChatActionResult = { ok: true; data: T } | { ok: false; message: string }; + +class ChatActionError extends Error {} + +function throwChatActionError(message: string): never { + throw new ChatActionError(message); +} + +function toActionFailure(error: unknown, fallbackMessage: string): ChatActionResult { + if (error instanceof ChatActionError) return { ok: false, message: error.message }; + + console.error('[chat action] 예상하지 못한 오류:', error); + return { ok: false, message: fallbackMessage }; +} + +async function getCurrentWorkspaceMember(workspaceId: string) { + const supabase = await createSupabaseServerClient(); + const currentUserId = await getCurrentUserId(); + const { data: member, error } = await supabase + .from('workspace_members') + .select('user_id, workspace_nickname') + .eq('workspace_id', workspaceId) + .eq('user_id', currentUserId) + .maybeSingle(); + + if (error) { + console.error('[chat action] 워크스페이스 멤버 확인 실패:', error); + throwChatActionError('워크스페이스 멤버 정보를 확인하지 못했습니다.'); + } + + if (!member) throwChatActionError('워크스페이스 멤버만 메시지를 보낼 수 있습니다.'); + + return { supabase, member }; +} + +export async function sendChatMessage(input: { + workspaceId: string; + content: string; +}): Promise> { + try { + const value = chatMessageSchema.parse(input); + const { supabase, member } = await getCurrentWorkspaceMember(value.workspaceId); + const { data, error } = await supabase + .from('chat_messages') + .insert({ + workspace_id: value.workspaceId, + sender_id: member.user_id, + content: value.content, + }) + .select('id, workspace_id, sender_id, content, created_at') + .single(); + + if (error) { + console.error('[chat action] 메시지 저장 실패:', error); + throwChatActionError('메시지 전송에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } + + return { + ok: true, + data: { + id: data.id, + workspaceId: data.workspace_id, + senderId: data.sender_id, + senderName: member.workspace_nickname || '사용자', + content: data.content, + createdAt: data.created_at, + }, + }; + } catch (error) { + return toActionFailure(error, '메시지 전송에 실패했습니다. 입력값을 확인해주세요.'); + } +} diff --git a/src/entities/chat/api/get-chat-room.ts b/src/entities/chat/api/get-chat-room.ts new file mode 100644 index 0000000..d7617ba --- /dev/null +++ b/src/entities/chat/api/get-chat-room.ts @@ -0,0 +1,62 @@ +'use server'; + +// 워크스페이스 채팅방의 최근 메시지와 참여자 정보를 서버에서 함께 조회합니다. +import { z } from 'zod'; +import { getCurrentUserId } from '@/shared/api/supabase/current-user'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; +import type { ChatMessage, ChatRoomData } from '../model/chat.types'; + +// 초기 화면 부하를 제한하는 최근 메시지 조회 개수입니다. 이전 기록은 후속 페이지네이션으로 확장합니다. +const CHAT_MESSAGE_PAGE_SIZE = 50; +const workspaceIdSchema = z.guid(); + +export async function getChatRoom(workspaceId: string): Promise { + const parsedWorkspaceId = workspaceIdSchema.parse(workspaceId); + const supabase = await createSupabaseServerClient(); + const currentUserId = await getCurrentUserId(); + + const [{ data: messages, error: messageError }, { data: members, error: memberError }] = + await Promise.all([ + supabase + .from('chat_messages') + .select('id, workspace_id, sender_id, content, created_at') + .eq('workspace_id', parsedWorkspaceId) + .order('created_at', { ascending: false }) + .limit(CHAT_MESSAGE_PAGE_SIZE), + supabase + .from('workspace_members') + .select('user_id, workspace_nickname, role') + .eq('workspace_id', parsedWorkspaceId) + .order('joined_at', { ascending: true }), + ]); + + if (messageError) throw new Error(`채팅 메시지 조회에 실패했습니다: ${messageError.message}`); + if (memberError) throw new Error(`채팅 참여자 조회에 실패했습니다: ${memberError.message}`); + + // Realtime INSERT 이벤트에도 동일한 표시 이름을 즉시 붙이기 위한 멤버 이름 맵입니다. + const participantNameById = new Map( + (members ?? []).map((member) => [member.user_id, member.workspace_nickname || '사용자']), + ); + const sortedMessages = [...(messages ?? [])].reverse(); + + return { + messages: sortedMessages.map((message): ChatMessage => ({ + id: message.id, + workspaceId: message.workspace_id, + senderId: message.sender_id, + senderName: message.sender_id + ? (participantNameById.get(message.sender_id) ?? '알 수 없음') + : '탈퇴한 사용자', + content: message.content, + createdAt: message.created_at, + })), + participants: (members ?? []).map((member) => ({ + userId: member.user_id, + name: member.workspace_nickname || '사용자', + })), + viewer: (() => { + const currentMember = (members ?? []).find((member) => member.user_id === currentUserId); + return currentMember ? { userId: currentMember.user_id, role: currentMember.role } : null; + })(), + }; +} diff --git a/src/entities/chat/index.ts b/src/entities/chat/index.ts new file mode 100644 index 0000000..7bfd738 --- /dev/null +++ b/src/entities/chat/index.ts @@ -0,0 +1,4 @@ +// 채팅 도메인이 상위 레이어에 제공하는 조회·저장 API와 타입 공개 진입점입니다. +export { getChatRoom } from './api/get-chat-room'; +export { chatRoomQueryKey } from './model/chat-query'; +export type { ChatMessage, ChatParticipant, ChatRoomData, ChatViewer } from './model/chat.types'; diff --git a/src/entities/chat/model/chat-query.ts b/src/entities/chat/model/chat-query.ts new file mode 100644 index 0000000..84b1469 --- /dev/null +++ b/src/entities/chat/model/chat-query.ts @@ -0,0 +1,2 @@ +// 워크스페이스마다 독립적인 채팅 캐시와 Realtime 이벤트를 연결하는 Query Key입니다. +export const chatRoomQueryKey = (workspaceId: string) => ['chat-room', workspaceId] as const; diff --git a/src/entities/chat/model/chat.types.ts b/src/entities/chat/model/chat.types.ts new file mode 100644 index 0000000..4b58dce --- /dev/null +++ b/src/entities/chat/model/chat.types.ts @@ -0,0 +1,28 @@ +// 채팅 화면과 Realtime 구독에서 공통으로 사용하는 워크스페이스 메시지 형태입니다. +export interface ChatMessage { + id: string; + workspaceId: string; + senderId: string | null; + senderName: string; + content: string; + createdAt: string; +} + +// 채팅 보조 패널에 표시할 워크스페이스 참여자 정보입니다. +export interface ChatParticipant { + userId: string; + name: string; +} + +// 현재 사용자의 메시지 정렬과 전송 권한에 사용하는 최소 멤버 정보입니다. +export interface ChatViewer { + userId: string; + role: 'owner' | 'member'; +} + +// 초기 조회와 TanStack Query 캐시에 저장할 채팅방 단위 데이터입니다. +export interface ChatRoomData { + messages: ChatMessage[]; + participants: ChatParticipant[]; + viewer: ChatViewer | null; +} diff --git a/src/features/manage-chat/index.ts b/src/features/manage-chat/index.ts new file mode 100644 index 0000000..11b3b0a --- /dev/null +++ b/src/features/manage-chat/index.ts @@ -0,0 +1,4 @@ +// 채팅 화면에서 사용하는 상태 훅과 UI 컴포넌트의 공개 진입점입니다. +export { useChatRoom } from './model/use-chat-room'; +export { ChatComposer } from './ui/ChatComposer'; +export { ChatMessageList } from './ui/ChatMessageList'; diff --git a/src/features/manage-chat/model/use-chat-room.ts b/src/features/manage-chat/model/use-chat-room.ts new file mode 100644 index 0000000..5aba576 --- /dev/null +++ b/src/features/manage-chat/model/use-chat-room.ts @@ -0,0 +1,194 @@ +'use client'; + +// TanStack Query 캐시와 Supabase Realtime 구독을 연결해 채팅방 상태를 관리합니다. +import { useEffect, useRef, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { sendChatMessage } from '@/entities/chat/api/chat-actions'; +import { getChatRoom } from '@/entities/chat/api/get-chat-room'; +import { chatRoomQueryKey } from '@/entities/chat/model/chat-query'; +import type { ChatMessage, ChatRoomData } from '@/entities/chat'; +import { getSupabaseBrowserClient } from '@/shared/api/supabase/client'; +import type { GenericTables } from '@/shared/model/supabase.types'; + +type ChatMessageRow = GenericTables<'chat_messages'>; + +function appendMessage( + data: ChatRoomData | undefined, + message: ChatMessage, +): ChatRoomData | undefined { + if (!data || data.messages.some((item) => item.id === message.id)) return data; + + return { ...data, messages: [...data.messages, message] }; +} + +function replaceMessage( + data: ChatRoomData | undefined, + temporaryMessageId: string, + message: ChatMessage, +): ChatRoomData | undefined { + if (!data) return data; + + const messagesWithoutTemporary = data.messages.filter((item) => item.id !== temporaryMessageId); + if (messagesWithoutTemporary.some((item) => item.id === message.id)) { + return { ...data, messages: messagesWithoutTemporary }; + } + + return { ...data, messages: [...messagesWithoutTemporary, message] }; +} + +function toRealtimeMessage(row: ChatMessageRow, data: ChatRoomData): ChatMessage { + const participant = data.participants.find((item) => item.userId === row.sender_id); + + return { + id: row.id, + workspaceId: row.workspace_id, + senderId: row.sender_id, + senderName: participant?.name ?? (row.sender_id ? '알 수 없음' : '탈퇴한 사용자'), + content: row.content, + createdAt: row.created_at, + }; +} + +interface UseChatRoomParams { + workspaceId: string; + initialData: ChatRoomData; +} + +export function useChatRoom({ workspaceId, initialData }: UseChatRoomParams) { + // 같은 워크스페이스의 채팅 데이터만 갱신하기 위한 TanStack Query 클라이언트입니다. + const queryClient = useQueryClient(); + // 최초 조회 실패와 백그라운드 갱신 실패를 한 번만 알리기 위한 참조값입니다. + const notifiedQueryError = useRef(null); + // 사용자가 입력 중인 메시지 내용입니다. + const [draft, setDraft] = useState(''); + // Supabase Realtime 채널의 실제 구독 상태를 UI에 전달합니다. + const [connectionStatus, setConnectionStatus] = useState('CONNECTING'); + // React 상태 갱신 전 연속 Enter·클릭 이벤트가 중복 전송되는 것을 즉시 막는 잠금값입니다. + const isSendingRef = useRef(false); + const { + data = initialData, + error, + isError, + isPending, + isRefetchError, + } = useQuery({ + queryKey: chatRoomQueryKey(workspaceId), + queryFn: () => getChatRoom(workspaceId), + initialData, + }); + const sendMutation = useMutation({ mutationFn: sendChatMessage }); + + useEffect(() => { + if (!error || (!isError && !isRefetchError)) { + notifiedQueryError.current = null; + return; + } + + if (notifiedQueryError.current === error) return; + + notifiedQueryError.current = error; + toast.error('채팅 메시지를 새로고침하지 못했습니다. 잠시 후 다시 시도해주세요.'); + }, [error, isError, isRefetchError]); + + useEffect(() => { + // 워크스페이스 UUID를 채널명과 DB filter에 함께 사용해 다른 채팅방 이벤트를 분리한다. + const supabase = getSupabaseBrowserClient(); + const channel = supabase + .channel(`workspace-chat:${workspaceId}`) + .on( + 'postgres_changes', + { + event: 'INSERT', + schema: 'public', + table: 'chat_messages', + filter: `workspace_id=eq.${workspaceId}`, + }, + (payload) => { + queryClient.setQueryData(chatRoomQueryKey(workspaceId), (currentData) => { + if (!currentData) return currentData; + return appendMessage( + currentData, + toRealtimeMessage(payload.new as ChatMessageRow, currentData), + ); + }); + }, + ) + .subscribe((status) => { + setConnectionStatus(status); + }); + + return () => { + void supabase.removeChannel(channel); + }; + }, [queryClient, workspaceId]); + + const sendMessage = async () => { + const content = draft.trim(); + if (!content || isSendingRef.current) return; + + isSendingRef.current = true; + // 서버 왕복 전에 내 화면에 바로 표시할 임시 메시지입니다. + const temporaryMessageId = `pending-${crypto.randomUUID()}`; + const temporaryMessage: ChatMessage = { + id: temporaryMessageId, + workspaceId, + senderId: data.viewer?.userId ?? null, + senderName: + data.participants.find((participant) => participant.userId === data.viewer?.userId)?.name ?? + '나', + content, + createdAt: new Date().toISOString(), + }; + + queryClient.setQueryData(chatRoomQueryKey(workspaceId), (currentData) => + appendMessage(currentData, temporaryMessage), + ); + setDraft(''); + + try { + const result = await sendMutation.mutateAsync({ workspaceId, content }); + if (!result.ok) { + queryClient.setQueryData(chatRoomQueryKey(workspaceId), (currentData) => + currentData + ? { + ...currentData, + messages: currentData.messages.filter((item) => item.id !== temporaryMessageId), + } + : currentData, + ); + toast.error(result.message); + return; + } + + // Realtime이 먼저 도착해도 id 중복 없이 임시 메시지만 실제 DB 행으로 교체한다. + queryClient.setQueryData(chatRoomQueryKey(workspaceId), (currentData) => + replaceMessage(currentData, temporaryMessageId, result.data), + ); + } catch { + queryClient.setQueryData(chatRoomQueryKey(workspaceId), (currentData) => + currentData + ? { + ...currentData, + messages: currentData.messages.filter((item) => item.id !== temporaryMessageId), + } + : currentData, + ); + toast.error('메시지 전송에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } finally { + isSendingRef.current = false; + } + }; + + return { + messages: data.messages, + participants: data.participants, + viewer: data.viewer, + draft, + setDraft, + sendMessage, + isSending: sendMutation.isPending, + isLoading: isPending, + connectionStatus, + }; +} diff --git a/src/features/manage-chat/ui/ChatComposer.tsx b/src/features/manage-chat/ui/ChatComposer.tsx new file mode 100644 index 0000000..d2f20c0 --- /dev/null +++ b/src/features/manage-chat/ui/ChatComposer.tsx @@ -0,0 +1,59 @@ +'use client'; + +// 파일 첨부 없이 텍스트 메시지 작성과 전송만 제공하는 채팅 입력 영역입니다. +import { Send } from 'lucide-react'; + +interface ChatComposerProps { + draft: string; + isSending: boolean; + onDraftChange: (value: string) => void; + onSend: () => void; +} + +export function ChatComposer({ draft, isSending, onDraftChange, onSend }: ChatComposerProps) { + // 공백만 있는 메시지와 전송 중 중복 요청을 막는 전송 가능 여부입니다. + const canSend = draft.trim().length > 0 && !isSending; + + return ( +
{ + event.preventDefault(); + onSend(); + }} + > + +
+