[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:
141
backend-node/src/socket/messengerSocket.ts
Normal file
141
backend-node/src/socket/messengerSocket.ts
Normal 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}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user