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..942f7c2 --- /dev/null +++ b/src/entities/chat/api/chat-actions.ts @@ -0,0 +1,90 @@ +'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({ + // 낙관적 메시지와 DB·Realtime 메시지를 같은 행으로 식별하기 위한 클라이언트 생성 UUID입니다. + messageId: uuidSchema, + 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: { + messageId: string; + 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({ + id: value.messageId, + 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..4df8cd2 --- /dev/null +++ b/src/entities/chat/index.ts @@ -0,0 +1,6 @@ +// 채팅 도메인이 상위 레이어에 제공하는 조회·저장 API와 타입 공개 진입점입니다. +export { sendChatMessage } from './api/chat-actions'; +export { getChatRoom } from './api/get-chat-room'; +export type { ChatActionResult } from './api/chat-actions'; +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..eae291a --- /dev/null +++ b/src/features/manage-chat/model/use-chat-room.ts @@ -0,0 +1,195 @@ +'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 { + chatRoomQueryKey, + getChatRoom, + sendChatMessage, + type ChatMessage, + type ChatRoomData, +} from '@/entities/chat'; +import { getSupabaseBrowserClient } from '@/shared/api/supabase/client'; +import type { GenericTables } from '@/shared/model/supabase.types'; + +type ChatMessageRow = GenericTables<'chat_messages'>; +const CHAT_MESSAGE_LIMIT = 50; + +function mergeMessage( + data: ChatRoomData | undefined, + message: ChatMessage, +): ChatRoomData | undefined { + if (!data) return data; + + // 같은 UUID의 낙관적 메시지는 실제 DB 행으로 교체하고, 캐시는 시간순 최근 50개만 유지합니다. + const messages = [...data.messages.filter((item) => item.id !== message.id), message] + .sort((left, right) => new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime()) + .slice(-CHAT_MESSAGE_LIMIT); + + return { ...data, messages }; +} + +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(''); + // 비동기 전송 중 새로 입력한 draft를 보존하기 위한 최신 입력값 참조입니다. + const draftRef = useRef(''); + // 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 }); + + const updateDraft = (value: string) => { + draftRef.current = value; + setDraft(value); + }; + + 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 mergeMessage( + currentData, + toRealtimeMessage(payload.new as ChatMessageRow, currentData), + ); + }); + }, + ) + .subscribe((status) => { + setConnectionStatus(status); + }); + + return () => { + void supabase.removeChannel(channel); + }; + }, [queryClient, workspaceId]); + + const sendMessage = async () => { + const content = draftRef.current.trim(); + if (!content || isSendingRef.current) return; + + isSendingRef.current = true; + // 서버·Realtime·낙관적 캐시가 같은 행으로 식별할 클라이언트 생성 UUID입니다. + const messageId = crypto.randomUUID(); + const temporaryMessage: ChatMessage = { + id: messageId, + 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) => + mergeMessage(currentData, temporaryMessage), + ); + updateDraft(''); + + const removeOptimisticMessage = () => { + queryClient.setQueryData(chatRoomQueryKey(workspaceId), (currentData) => + currentData + ? { + ...currentData, + messages: currentData.messages.filter((item) => item.id !== messageId), + } + : currentData, + ); + + // 전송 뒤 새 입력이 없을 때만 실패한 내용을 복원해 사용자의 새 입력을 덮어쓰지 않습니다. + if (draftRef.current === '') updateDraft(content); + }; + + try { + const result = await sendMutation.mutateAsync({ messageId, workspaceId, content }); + if (!result.ok) { + removeOptimisticMessage(); + toast.error(result.message); + return; + } + + // Realtime이 먼저 도착해도 같은 UUID의 낙관적 메시지를 실제 DB 행으로 교체합니다. + queryClient.setQueryData(chatRoomQueryKey(workspaceId), (currentData) => + mergeMessage(currentData, result.data), + ); + } catch { + removeOptimisticMessage(); + toast.error('메시지 전송에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } finally { + isSendingRef.current = false; + } + }; + + return { + messages: data.messages, + participants: data.participants, + viewer: data.viewer, + draft, + setDraft: updateDraft, + 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(); + }} + > + +
+