Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/app/workspaces/[workspaceId]/chat/page.tsx
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} />;
}
90 changes: 90 additions & 0 deletions src/entities/chat/api/chat-actions.ts
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, '메시지 전송에 실패했습니다. 입력값을 확인해주세요.');
}
}
62 changes: 62 additions & 0 deletions src/entities/chat/api/get-chat-room.ts
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;
})(),
};
}
6 changes: 6 additions & 0 deletions src/entities/chat/index.ts
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';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
2 changes: 2 additions & 0 deletions src/entities/chat/model/chat-query.ts
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;
28 changes: 28 additions & 0 deletions src/entities/chat/model/chat.types.ts
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;
}
4 changes: 4 additions & 0 deletions src/features/manage-chat/index.ts
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';
Loading