Files
vexplor_dev/backend-node/src/services/messengerService.ts

406 lines
14 KiB
TypeScript
Raw Normal View History

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