[RAPID] feat: 메신저 기능 구현 (Socket.IO 실시간 채팅)
- DB: messenger_rooms/participants/messages/reactions/files 테이블 생성 - Backend: REST API 9개 엔드포인트 + Socket.IO 실시간 핸들러 - Frontend: Gmail 스타일 FAB + 모달, 채팅방 목록, 채팅 패널 - 기능: DM/그룹/채널, 파일 첨부, 이모지 리액션, 멘션, 스레드 - 알림: 토스트 on/off 토글, FAB 읽지 않은 배지 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> [RAPID-fix] 메신저 API snake_case→camelCase 변환 및 Socket.IO URL 수정 - useRooms/useMessages/useCompanyUsers 훅에서 DB 응답 camelCase 변환 - Socket.IO 기본 연결 URL 3001 → 8080 수정 - runMigration.ts 마이그레이션 파일 경로 수정 (../../ → ../../../) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> [RAPID-fix] 방 생성 API camelCase/snake_case 호환 처리 - createRoom 컨트롤러에서 participantIds/type/name (camelCase) fallback 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> [RAPID-fix] 메시지 전송 API 추가 (sendMessage 라우트/컨트롤러 누락) - POST /api/messenger/rooms/:roomId/messages 라우트 등록 - MessengerController.sendMessage 메서드 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
// ============================================
|
||||
// Types
|
||||
// ============================================
|
||||
export interface Room {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "dm" | "group" | "channel";
|
||||
lastMessage?: string;
|
||||
lastMessageAt?: string;
|
||||
unreadCount: number;
|
||||
participants: Participant[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Participant {
|
||||
userId: string;
|
||||
userName: string;
|
||||
photo?: string | null;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
roomId: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
senderPhoto?: string | null;
|
||||
content: string;
|
||||
type: "text" | "file" | "system";
|
||||
fileUrl?: string;
|
||||
fileName?: string;
|
||||
reactions: Reaction[];
|
||||
threadCount?: number;
|
||||
parentId?: string | null;
|
||||
isDeleted: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
emoji: string;
|
||||
users: { userId: string; userName: string }[];
|
||||
}
|
||||
|
||||
export interface CompanyUser {
|
||||
userId: string;
|
||||
userName: string;
|
||||
deptName?: string;
|
||||
positionName?: string;
|
||||
photo?: string | null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API helpers
|
||||
// ============================================
|
||||
async function fetchApi<T>(url: string): Promise<T> {
|
||||
const res = await apiClient.get(url);
|
||||
return res.data?.data ?? res.data;
|
||||
}
|
||||
|
||||
async function postApi<T>(url: string, data?: unknown): Promise<T> {
|
||||
const res = await apiClient.post(url, data);
|
||||
return res.data?.data ?? res.data;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Hooks
|
||||
// ============================================
|
||||
export function useRooms() {
|
||||
return useQuery<Room[]>({
|
||||
queryKey: ["messenger", "rooms"],
|
||||
queryFn: async () => {
|
||||
const data = await fetchApi<any[]>("/messenger/rooms");
|
||||
return data.map((r) => ({
|
||||
id: String(r.id),
|
||||
name: r.room_name ?? r.name ?? "",
|
||||
type: r.room_type ?? r.type,
|
||||
lastMessage: r.last_message ?? r.lastMessage,
|
||||
lastMessageAt: r.last_message_at ?? r.lastMessageAt,
|
||||
unreadCount: r.unread_count ?? r.unreadCount ?? 0,
|
||||
description: r.description,
|
||||
participants: (r.participants ?? []).map((p: any) => ({
|
||||
userId: p.user_id ?? p.userId,
|
||||
userName: p.user_name ?? p.userName,
|
||||
photo: p.photo ? `data:image/jpeg;base64,${p.photo}` : null,
|
||||
})),
|
||||
}));
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMessages(roomId: string | null) {
|
||||
return useQuery<Message[]>({
|
||||
queryKey: ["messenger", "messages", roomId],
|
||||
queryFn: async () => {
|
||||
const data = await fetchApi<any[]>(`/messenger/rooms/${roomId}/messages`);
|
||||
return data.map((m) => ({
|
||||
id: String(m.id),
|
||||
roomId: String(m.room_id ?? m.roomId),
|
||||
senderId: m.sender_id ?? m.senderId,
|
||||
senderName: m.sender_name ?? m.senderName ?? m.sender_id,
|
||||
senderPhoto: m.sender_photo
|
||||
? `data:image/jpeg;base64,${m.sender_photo}`
|
||||
: (m.senderPhoto ?? null),
|
||||
content: m.content ?? "",
|
||||
type: m.message_type ?? m.type ?? "text",
|
||||
fileUrl: m.file_url ?? m.fileUrl,
|
||||
fileName: m.file_name ?? m.fileName,
|
||||
reactions: m.reactions ?? [],
|
||||
threadCount: m.thread_count ?? m.threadCount ?? 0,
|
||||
parentId: m.parent_message_id ?? m.parentId ?? null,
|
||||
isDeleted: m.is_deleted ?? m.isDeleted ?? false,
|
||||
createdAt: m.created_at ?? m.createdAt,
|
||||
}));
|
||||
},
|
||||
enabled: !!roomId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCompanyUsers() {
|
||||
return useQuery<CompanyUser[]>({
|
||||
queryKey: ["messenger", "users"],
|
||||
queryFn: async () => {
|
||||
const data = await fetchApi<any[]>("/messenger/users");
|
||||
return data.map((u) => ({
|
||||
userId: u.user_id ?? u.userId,
|
||||
userName: u.user_name ?? u.userName,
|
||||
deptName: u.dept_name ?? u.deptName,
|
||||
photo: u.photo ? `data:image/jpeg;base64,${u.photo}` : null,
|
||||
}));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnreadCount() {
|
||||
return useQuery<number>({
|
||||
queryKey: ["messenger", "unread"],
|
||||
queryFn: () => fetchApi("/messenger/unread"),
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendMessage() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: { roomId: string; content: string; type?: string; parentId?: string | null }) =>
|
||||
postApi(`/messenger/rooms/${payload.roomId}/messages`, payload),
|
||||
onSuccess: (_data, variables) => {
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "messages", variables.roomId] });
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "rooms"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateRoom() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: { type: "dm" | "group" | "channel"; name?: string; description?: string; participantIds: string[] }) =>
|
||||
postApi<Room>("/messenger/rooms", payload),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "rooms"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkAsRead() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (roomId: string) => postApi(`/messenger/rooms/${roomId}/read`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "unread"] });
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "rooms"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddReaction() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: { messageId: string; roomId: string; emoji: string }) =>
|
||||
postApi(`/messenger/messages/${payload.messageId}/reactions`, { emoji: payload.emoji }),
|
||||
onSuccess: (_data, variables) => {
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "messages", variables.roomId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadFile() {
|
||||
return useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const res = await apiClient.post("/messenger/files/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return res.data?.data ?? res.data;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { useMessengerContext } from "@/contexts/MessengerContext";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8080";
|
||||
|
||||
interface NewMessageEvent {
|
||||
roomId: string;
|
||||
message: {
|
||||
id: string;
|
||||
content: string;
|
||||
senderName: string;
|
||||
senderId: string;
|
||||
createdAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface TypingEvent {
|
||||
roomId: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
}
|
||||
|
||||
export function useMessengerSocket() {
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const { selectedRoomId, notificationEnabled } = useMessengerContext();
|
||||
const { toast } = useToast();
|
||||
const [onlineUsers, setOnlineUsers] = useState<Set<string>>(new Set());
|
||||
const [typingUsers, setTypingUsers] = useState<Map<string, string[]>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("authToken");
|
||||
if (!token) return;
|
||||
|
||||
const socket = io(BACKEND_URL, {
|
||||
path: "/socket.io",
|
||||
auth: { token },
|
||||
transports: ["websocket", "polling"],
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on("user_online", (data: { userId: string; online: boolean }) => {
|
||||
setOnlineUsers((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (data.online) next.add(data.userId);
|
||||
else next.delete(data.userId);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("new_message", (data: NewMessageEvent) => {
|
||||
if (data.roomId !== selectedRoomId && notificationEnabled) {
|
||||
toast({
|
||||
title: data.message.senderName,
|
||||
description: data.message.content.slice(0, 50),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("typing_start", (data: TypingEvent) => {
|
||||
setTypingUsers((prev) => {
|
||||
const next = new Map(prev);
|
||||
const users = next.get(data.roomId) || [];
|
||||
if (!users.includes(data.userName)) {
|
||||
next.set(data.roomId, [...users, data.userName]);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("typing_stop", (data: TypingEvent) => {
|
||||
setTypingUsers((prev) => {
|
||||
const next = new Map(prev);
|
||||
const users = next.get(data.roomId) || [];
|
||||
next.set(
|
||||
data.roomId,
|
||||
users.filter((u) => u !== data.userName)
|
||||
);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [selectedRoomId, notificationEnabled, toast]);
|
||||
|
||||
const emitTypingStart = useCallback(
|
||||
(roomId: string) => {
|
||||
socketRef.current?.emit("typing_start", { roomId });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const emitTypingStop = useCallback(
|
||||
(roomId: string) => {
|
||||
socketRef.current?.emit("typing_stop", { roomId });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return { socket: socketRef, onlineUsers, typingUsers, emitTypingStart, emitTypingStop };
|
||||
}
|
||||
Reference in New Issue
Block a user