[RAPID-fix] 메신저 사용자 목록 회사 전환 시 캐시 격리 - useRooms/useCompanyUsers queryKey에 companyCode 포함 - 회사 전환 시 다른 회사 사용자가 캐시에서 노출되던 문제 수정 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> [RAPID-fix] 메신저 버그 수정 (8건) - 방 생성 후 자동 입장 + 커서 포커스 - DM 헤더 상대방 이름, 그룹 "이름1, 이름2 외 N명" 표시 - 채팅방 이름 인라인 수정 기능 추가 - Socket.IO join_rooms 누락 수정 → 실시간 메시지 수신 정상화 - new_message 이벤트 수신 시 React Query 캐시 무효화 - 토스트 알림 stale closure 수정 (ref 패턴 적용) - 타이핑 이벤트명 백엔드 일치 (user_typing/user_stop_typing) - 메시지 순서 역전 수정 (.reverse()) - unread queryKey 불일치 수정 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> [RAPID-fix] REST API 메시지 전송 시 Socket.IO broadcast 추가 - socketManager.ts 모듈 생성 (io 전역 공유) - sendMessage 컨트롤러에서 io.to(room).emit('new_message') broadcast - 상대방 말풍선 너비 고정 수정 (items-start 추가) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
406 lines
14 KiB
TypeScript
406 lines
14 KiB
TypeScript
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);
|
|
// Reverse so messages are in chronological order (query uses DESC for cursor pagination)
|
|
const messages: MessengerMessage[] = result.rows.reverse();
|
|
|
|
// 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();
|