[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:
404
backend-node/src/services/messengerService.ts
Normal file
404
backend-node/src/services/messengerService.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
import { PostgreSQLService } from '../database/PostgreSQLService';
|
||||
import {
|
||||
MessengerRoom,
|
||||
MessengerMessage,
|
||||
MessengerFile,
|
||||
MessengerUser,
|
||||
CreateRoomRequest,
|
||||
MessengerParticipant,
|
||||
} from '../types/messenger';
|
||||
|
||||
class MessengerService {
|
||||
/**
|
||||
* Get rooms for a user with last message and unread count
|
||||
*/
|
||||
async getRooms(userId: string, companyCode: string): Promise<MessengerRoom[]> {
|
||||
const result = await PostgreSQLService.query(
|
||||
`SELECT r.*,
|
||||
m.content AS last_message,
|
||||
m.created_at AS last_message_at,
|
||||
m.sender_id AS last_sender_id,
|
||||
COALESCE(unread.cnt, 0)::int AS unread_count
|
||||
FROM messenger_rooms r
|
||||
INNER JOIN messenger_participants p ON p.room_id = r.id AND p.user_id = $1
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT content, created_at, sender_id
|
||||
FROM messenger_messages
|
||||
WHERE room_id = r.id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
) m ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*)::int AS cnt
|
||||
FROM messenger_messages
|
||||
WHERE room_id = r.id
|
||||
AND created_at > p.last_read_at
|
||||
AND sender_id != $1
|
||||
) unread ON true
|
||||
WHERE r.company_code = $2
|
||||
ORDER BY COALESCE(m.created_at, r.created_at) DESC`,
|
||||
[userId, companyCode]
|
||||
);
|
||||
|
||||
// Attach participants to each room
|
||||
const rooms: MessengerRoom[] = result.rows;
|
||||
if (rooms.length > 0) {
|
||||
const roomIds = rooms.map((r) => r.id);
|
||||
const partResult = await PostgreSQLService.query(
|
||||
`SELECT mp.*, ui.user_name, ui.dept_name,
|
||||
CASE WHEN ui.photo IS NOT NULL THEN encode(ui.photo, 'base64') ELSE NULL END AS photo
|
||||
FROM messenger_participants mp
|
||||
LEFT JOIN user_info ui ON ui.user_id = mp.user_id AND ui.company_code = mp.company_code
|
||||
WHERE mp.room_id = ANY($1)`,
|
||||
[roomIds]
|
||||
);
|
||||
const partMap = new Map<number, MessengerParticipant[]>();
|
||||
for (const p of partResult.rows) {
|
||||
if (!partMap.has(p.room_id)) partMap.set(p.room_id, []);
|
||||
partMap.get(p.room_id)!.push(p);
|
||||
}
|
||||
for (const room of rooms) {
|
||||
room.participants = partMap.get(room.id) || [];
|
||||
}
|
||||
}
|
||||
|
||||
return rooms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a room. For DM, return existing room if one already exists between the two users.
|
||||
*/
|
||||
async createRoom(
|
||||
creatorId: string,
|
||||
companyCode: string,
|
||||
data: CreateRoomRequest
|
||||
): Promise<MessengerRoom> {
|
||||
// DM duplicate check
|
||||
if (data.room_type === 'dm' && data.participant_ids.length === 1) {
|
||||
const otherUserId = data.participant_ids[0];
|
||||
const existing = await PostgreSQLService.query(
|
||||
`SELECT r.* FROM messenger_rooms r
|
||||
WHERE r.company_code = $1 AND r.room_type = 'dm'
|
||||
AND EXISTS (SELECT 1 FROM messenger_participants WHERE room_id = r.id AND user_id = $2)
|
||||
AND EXISTS (SELECT 1 FROM messenger_participants WHERE room_id = r.id AND user_id = $3)
|
||||
AND (SELECT COUNT(*) FROM messenger_participants WHERE room_id = r.id) = 2
|
||||
LIMIT 1`,
|
||||
[companyCode, creatorId, otherUserId]
|
||||
);
|
||||
if (existing.rows.length > 0) {
|
||||
return existing.rows[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Create room
|
||||
const roomResult = await PostgreSQLService.query(
|
||||
`INSERT INTO messenger_rooms (company_code, room_type, room_name, created_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *`,
|
||||
[companyCode, data.room_type, data.room_name || null, creatorId]
|
||||
);
|
||||
const room: MessengerRoom = roomResult.rows[0];
|
||||
|
||||
// Add participants (creator + others)
|
||||
const allParticipants = [creatorId, ...data.participant_ids.filter((id) => id !== creatorId)];
|
||||
for (const uid of allParticipants) {
|
||||
await PostgreSQLService.query(
|
||||
`INSERT INTO messenger_participants (room_id, user_id, company_code)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (room_id, user_id) DO NOTHING`,
|
||||
[room.id, uid, companyCode]
|
||||
);
|
||||
}
|
||||
|
||||
return room;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages with cursor-based pagination
|
||||
*/
|
||||
async getMessages(
|
||||
roomId: number,
|
||||
userId: string,
|
||||
companyCode: string,
|
||||
limit: number = 50,
|
||||
before?: number
|
||||
): Promise<MessengerMessage[]> {
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (before) {
|
||||
query = `SELECT msg.*,
|
||||
ui.user_name AS sender_name,
|
||||
CASE WHEN ui.photo IS NOT NULL THEN encode(ui.photo, 'base64') ELSE NULL END AS sender_photo,
|
||||
COALESCE(tc.thread_count, 0)::int AS thread_count
|
||||
FROM messenger_messages msg
|
||||
LEFT JOIN user_info ui ON ui.user_id = msg.sender_id AND ui.company_code = msg.company_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*)::int AS thread_count
|
||||
FROM messenger_messages
|
||||
WHERE parent_message_id = msg.id
|
||||
) tc ON true
|
||||
WHERE msg.room_id = $1 AND msg.company_code = $2 AND msg.id < $3
|
||||
ORDER BY msg.created_at DESC
|
||||
LIMIT $4`;
|
||||
params = [roomId, companyCode, before, limit];
|
||||
} else {
|
||||
query = `SELECT msg.*,
|
||||
ui.user_name AS sender_name,
|
||||
CASE WHEN ui.photo IS NOT NULL THEN encode(ui.photo, 'base64') ELSE NULL END AS sender_photo,
|
||||
COALESCE(tc.thread_count, 0)::int AS thread_count
|
||||
FROM messenger_messages msg
|
||||
LEFT JOIN user_info ui ON ui.user_id = msg.sender_id AND ui.company_code = msg.company_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*)::int AS thread_count
|
||||
FROM messenger_messages
|
||||
WHERE parent_message_id = msg.id
|
||||
) tc ON true
|
||||
WHERE msg.room_id = $1 AND msg.company_code = $2
|
||||
ORDER BY msg.created_at DESC
|
||||
LIMIT $3`;
|
||||
params = [roomId, companyCode, limit];
|
||||
}
|
||||
|
||||
const result = await PostgreSQLService.query(query, params);
|
||||
const messages: MessengerMessage[] = result.rows;
|
||||
|
||||
// Attach reactions and files
|
||||
if (messages.length > 0) {
|
||||
const msgIds = messages.map((m) => m.id);
|
||||
|
||||
const [reactionsResult, filesResult] = await Promise.all([
|
||||
PostgreSQLService.query(
|
||||
`SELECT * FROM messenger_reactions WHERE message_id = ANY($1)`,
|
||||
[msgIds]
|
||||
),
|
||||
PostgreSQLService.query(
|
||||
`SELECT * FROM messenger_files WHERE message_id = ANY($1)`,
|
||||
[msgIds]
|
||||
),
|
||||
]);
|
||||
|
||||
const reactionsMap = new Map<number, any[]>();
|
||||
for (const r of reactionsResult.rows) {
|
||||
if (!reactionsMap.has(r.message_id)) reactionsMap.set(r.message_id, []);
|
||||
reactionsMap.get(r.message_id)!.push(r);
|
||||
}
|
||||
|
||||
const filesMap = new Map<number, any[]>();
|
||||
for (const f of filesResult.rows) {
|
||||
if (!filesMap.has(f.message_id)) filesMap.set(f.message_id, []);
|
||||
filesMap.get(f.message_id)!.push(f);
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
msg.reactions = reactionsMap.get(msg.id) || [];
|
||||
msg.files = filesMap.get(msg.id) || [];
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message and return the saved message
|
||||
*/
|
||||
async sendMessage(
|
||||
roomId: number,
|
||||
senderId: string,
|
||||
companyCode: string,
|
||||
content: string,
|
||||
messageType: string = 'text',
|
||||
parentMessageId?: number
|
||||
): Promise<MessengerMessage> {
|
||||
const result = await PostgreSQLService.query(
|
||||
`INSERT INTO messenger_messages (room_id, sender_id, company_code, content, message_type, parent_message_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[roomId, senderId, companyCode, content, messageType, parentMessageId || null]
|
||||
);
|
||||
|
||||
// Update room's updated_at
|
||||
await PostgreSQLService.query(
|
||||
`UPDATE messenger_rooms SET updated_at = NOW() WHERE id = $1`,
|
||||
[roomId]
|
||||
);
|
||||
|
||||
// Get sender info
|
||||
const userResult = await PostgreSQLService.query(
|
||||
`SELECT user_name,
|
||||
CASE WHEN photo IS NOT NULL THEN encode(photo, 'base64') ELSE NULL END AS photo
|
||||
FROM user_info WHERE user_id = $1 AND company_code = $2`,
|
||||
[senderId, companyCode]
|
||||
);
|
||||
|
||||
const message = result.rows[0];
|
||||
if (userResult.rows.length > 0) {
|
||||
message.sender_name = userResult.rows[0].user_name;
|
||||
message.sender_photo = userResult.rows[0].photo;
|
||||
}
|
||||
message.reactions = [];
|
||||
message.files = [];
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark messages as read
|
||||
*/
|
||||
async markAsRead(roomId: number, userId: string): Promise<void> {
|
||||
await PostgreSQLService.query(
|
||||
`UPDATE messenger_participants SET last_read_at = NOW()
|
||||
WHERE room_id = $1 AND user_id = $2`,
|
||||
[roomId, userId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get company users for user picker
|
||||
*/
|
||||
async getCompanyUsers(companyCode: string, excludeUserId?: string): Promise<MessengerUser[]> {
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (excludeUserId) {
|
||||
query = `SELECT user_id, user_name, dept_name, email,
|
||||
CASE WHEN photo IS NOT NULL THEN encode(photo, 'base64') ELSE NULL END AS photo
|
||||
FROM user_info
|
||||
WHERE company_code = $1 AND user_id != $2
|
||||
ORDER BY user_name`;
|
||||
params = [companyCode, excludeUserId];
|
||||
} else {
|
||||
query = `SELECT user_id, user_name, dept_name, email,
|
||||
CASE WHEN photo IS NOT NULL THEN encode(photo, 'base64') ELSE NULL END AS photo
|
||||
FROM user_info
|
||||
WHERE company_code = $1
|
||||
ORDER BY user_name`;
|
||||
params = [companyCode];
|
||||
}
|
||||
|
||||
const result = await PostgreSQLService.query(query, params);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a reaction to a message
|
||||
*/
|
||||
async addReaction(messageId: number, userId: string, emoji: string): Promise<void> {
|
||||
await PostgreSQLService.query(
|
||||
`INSERT INTO messenger_reactions (message_id, user_id, emoji)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (message_id, user_id, emoji) DO NOTHING`,
|
||||
[messageId, userId, emoji]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a reaction from a message
|
||||
*/
|
||||
async removeReaction(messageId: number, userId: string, emoji: string): Promise<void> {
|
||||
await PostgreSQLService.query(
|
||||
`DELETE FROM messenger_reactions
|
||||
WHERE message_id = $1 AND user_id = $2 AND emoji = $3`,
|
||||
[messageId, userId, emoji]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total unread message count for badge
|
||||
*/
|
||||
async getUnreadCount(userId: string, companyCode: string): Promise<number> {
|
||||
const result = await PostgreSQLService.query(
|
||||
`SELECT COALESCE(SUM(cnt), 0)::int AS total_unread
|
||||
FROM (
|
||||
SELECT COUNT(*) AS cnt
|
||||
FROM messenger_participants p
|
||||
INNER JOIN messenger_messages m ON m.room_id = p.room_id
|
||||
AND m.created_at > p.last_read_at
|
||||
AND m.sender_id != $1
|
||||
WHERE p.user_id = $1 AND p.company_code = $2
|
||||
GROUP BY p.room_id
|
||||
) sub`,
|
||||
[userId, companyCode]
|
||||
);
|
||||
return result.rows[0]?.total_unread || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save file info for a message
|
||||
*/
|
||||
async saveFile(
|
||||
messageId: number,
|
||||
fileInfo: { originalName: string; storedName: string; filePath: string; fileSize: number; mimeType: string }
|
||||
): Promise<MessengerFile> {
|
||||
const result = await PostgreSQLService.query(
|
||||
`INSERT INTO messenger_files (message_id, original_name, stored_name, file_path, file_size, mime_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[messageId, fileInfo.originalName, fileInfo.storedName, fileInfo.filePath, fileInfo.fileSize, fileInfo.mimeType]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get room by ID with participants
|
||||
*/
|
||||
async getRoomById(roomId: number, companyCode: string): Promise<MessengerRoom | null> {
|
||||
const result = await PostgreSQLService.query(
|
||||
`SELECT * FROM messenger_rooms WHERE id = $1 AND company_code = $2`,
|
||||
[roomId, companyCode]
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
|
||||
const room: MessengerRoom = result.rows[0];
|
||||
|
||||
const partResult = await PostgreSQLService.query(
|
||||
`SELECT mp.*, ui.user_name, ui.dept_name,
|
||||
CASE WHEN ui.photo IS NOT NULL THEN encode(ui.photo, 'base64') ELSE NULL END AS photo
|
||||
FROM messenger_participants mp
|
||||
LEFT JOIN user_info ui ON ui.user_id = mp.user_id AND ui.company_code = mp.company_code
|
||||
WHERE mp.room_id = $1`,
|
||||
[roomId]
|
||||
);
|
||||
room.participants = partResult.rows;
|
||||
|
||||
return room;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update room name
|
||||
*/
|
||||
async updateRoom(roomId: number, companyCode: string, roomName: string): Promise<MessengerRoom | null> {
|
||||
const result = await PostgreSQLService.query(
|
||||
`UPDATE messenger_rooms SET room_name = $1, updated_at = NOW()
|
||||
WHERE id = $2 AND company_code = $3
|
||||
RETURNING *`,
|
||||
[roomName, roomId, companyCode]
|
||||
);
|
||||
return result.rows.length > 0 ? result.rows[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file by ID
|
||||
*/
|
||||
async getFileById(fileId: number): Promise<MessengerFile | null> {
|
||||
const result = await PostgreSQLService.query(
|
||||
`SELECT * FROM messenger_files WHERE id = $1`,
|
||||
[fileId]
|
||||
);
|
||||
return result.rows.length > 0 ? result.rows[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get participant room IDs for socket join
|
||||
*/
|
||||
async getUserRoomIds(userId: string, companyCode: string): Promise<number[]> {
|
||||
const result = await PostgreSQLService.query(
|
||||
`SELECT room_id FROM messenger_participants
|
||||
WHERE user_id = $1 AND company_code = $2`,
|
||||
[userId, companyCode]
|
||||
);
|
||||
return result.rows.map((r: any) => r.room_id);
|
||||
}
|
||||
}
|
||||
|
||||
export const messengerService = new MessengerService();
|
||||
Reference in New Issue
Block a user