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 { 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(); 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 { // 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 { 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(); 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(); 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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();