[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:
2026-03-30 18:05:54 +09:00
parent e763249342
commit f558073ef8
28 changed files with 2578 additions and 10 deletions

View File

@@ -0,0 +1,141 @@
import { Server, Socket } from 'socket.io';
import jwt from 'jsonwebtoken';
import config from '../config/environment';
import { messengerService } from '../services/messengerService';
import { JwtPayload } from '../types/auth';
interface AuthenticatedSocket extends Socket {
data: {
userId: string;
userName: string;
companyCode: string;
};
}
export function initMessengerSocket(io: Server) {
// JWT authentication middleware
io.use((socket, next) => {
const token = socket.handshake.auth?.token || socket.handshake.query?.token;
if (!token) {
return next(new Error('Authentication required'));
}
try {
const decoded = jwt.verify(token as string, config.jwt.secret) as JwtPayload;
socket.data.userId = decoded.userId;
socket.data.userName = decoded.userName;
socket.data.companyCode = decoded.companyCode || '';
next();
} catch (error) {
next(new Error('Invalid token'));
}
});
io.on('connection', async (socket: AuthenticatedSocket) => {
const { userId, companyCode } = socket.data;
console.log(`[Messenger] User connected: ${userId}`);
// join_rooms: subscribe to all user's rooms
socket.on('join_rooms', async () => {
try {
const roomIds = await messengerService.getUserRoomIds(userId, companyCode);
for (const roomId of roomIds) {
socket.join(`${companyCode}:${roomId}`);
}
socket.emit('rooms_joined', { roomIds });
} catch (error) {
console.error('[Messenger] join_rooms error:', error);
socket.emit('error', { message: 'Failed to join rooms' });
}
});
// send_message: save and broadcast
socket.on('send_message', async (data: {
room_id: number;
content: string;
message_type?: string;
parent_message_id?: number;
}) => {
try {
const message = await messengerService.sendMessage(
data.room_id,
userId,
companyCode,
data.content,
data.message_type || 'text',
data.parent_message_id
);
io.to(`${companyCode}:${data.room_id}`).emit('new_message', message);
} catch (error) {
console.error('[Messenger] send_message error:', error);
socket.emit('error', { message: 'Failed to send message' });
}
});
// message_read: update last_read_at
socket.on('message_read', async (data: { room_id: number }) => {
try {
await messengerService.markAsRead(data.room_id, userId);
io.to(`${companyCode}:${data.room_id}`).emit('user_read', {
room_id: data.room_id,
user_id: userId,
read_at: new Date().toISOString(),
});
} catch (error) {
console.error('[Messenger] message_read error:', error);
}
});
// typing indicators
socket.on('typing_start', (data: { room_id: number }) => {
socket.to(`${companyCode}:${data.room_id}`).emit('user_typing', {
room_id: data.room_id,
user_id: userId,
user_name: socket.data.userName,
});
});
socket.on('typing_stop', (data: { room_id: number }) => {
socket.to(`${companyCode}:${data.room_id}`).emit('user_stop_typing', {
room_id: data.room_id,
user_id: userId,
});
});
// reactions
socket.on('add_reaction', async (data: { message_id: number; emoji: string; room_id: number }) => {
try {
await messengerService.addReaction(data.message_id, userId, data.emoji);
io.to(`${companyCode}:${data.room_id}`).emit('reaction_added', {
message_id: data.message_id,
user_id: userId,
emoji: data.emoji,
});
} catch (error) {
console.error('[Messenger] add_reaction error:', error);
}
});
socket.on('remove_reaction', async (data: { message_id: number; emoji: string; room_id: number }) => {
try {
await messengerService.removeReaction(data.message_id, userId, data.emoji);
io.to(`${companyCode}:${data.room_id}`).emit('reaction_removed', {
message_id: data.message_id,
user_id: userId,
emoji: data.emoji,
});
} catch (error) {
console.error('[Messenger] remove_reaction error:', error);
}
});
// join a specific room (e.g., after creating a new room)
socket.on('join_room', (data: { room_id: number }) => {
socket.join(`${companyCode}:${data.room_id}`);
});
socket.on('disconnect', () => {
console.log(`[Messenger] User disconnected: ${userId}`);
});
});
}