-
Notifications
You must be signed in to change notification settings - Fork 3
feat:실시간 채팅 기능 구현(#60) #60
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <ChatView workspaceId={workspaceId} initialData={initialData} />; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<T> = { 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<never> { | ||
| 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<ChatActionResult<ChatMessage>> { | ||
| 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, '메시지 전송에 실패했습니다. 입력값을 확인해주세요.'); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ChatRoomData> { | ||
| 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; | ||
| })(), | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| // 워크스페이스마다 독립적인 채팅 캐시와 Realtime 이벤트를 연결하는 Query Key입니다. | ||
| export const chatRoomQueryKey = (workspaceId: string) => ['chat-room', workspaceId] as const; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| // 채팅 화면에서 사용하는 상태 훅과 UI 컴포넌트의 공개 진입점입니다. | ||
| export { useChatRoom } from './model/use-chat-room'; | ||
| export { ChatComposer } from './ui/ChatComposer'; | ||
| export { ChatMessageList } from './ui/ChatMessageList'; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.