966 lines
41 KiB
JavaScript
966 lines
41 KiB
JavaScript
"use strict";
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.WebSocketService = void 0;
|
|
const socket_io_1 = require("socket.io");
|
|
const JWTService_1 = require("./JWTService");
|
|
const ChatRepository_1 = require("../../Infrastructure/Repository/ChatRepository");
|
|
const ChatArchiveRepository_1 = require("../../Infrastructure/Repository/ChatArchiveRepository");
|
|
const UserRepository_1 = require("../../Infrastructure/Repository/UserRepository");
|
|
const ChatAggregate_1 = require("../../Domain/Chat/ChatAggregate");
|
|
const UserAggregate_1 = require("../../Domain/User/UserAggregate");
|
|
const Logger_1 = require("./Logger");
|
|
const RedisService_1 = require("./RedisService");
|
|
const uuid_1 = require("uuid");
|
|
class WebSocketService {
|
|
constructor(httpServer) {
|
|
this.connectedUsers = new Map();
|
|
this.userMessageCounts = new Map();
|
|
this.io = new socket_io_1.Server(httpServer, {
|
|
cors: {
|
|
origin: ['http://localhost:3000', 'http://localhost:3001', 'http://localhost:8080'],
|
|
methods: ['GET', 'POST'],
|
|
credentials: true
|
|
}
|
|
});
|
|
this.jwtService = new JWTService_1.JWTService();
|
|
this.chatRepository = new ChatRepository_1.ChatRepository();
|
|
this.chatArchiveRepository = new ChatArchiveRepository_1.ChatArchiveRepository();
|
|
this.userRepository = new UserRepository_1.UserRepository();
|
|
this.redisService = RedisService_1.RedisService.getInstance();
|
|
this.chatTimeout = parseInt(process.env.CHAT_INACTIVITY_TIMEOUT_MINUTES || '30');
|
|
this.maxMessagesPerUser = parseInt(process.env.CHAT_MAX_MESSAGES_PER_USER || '100');
|
|
this.messageCleanupWeeks = parseInt(process.env.CHAT_MESSAGE_CLEANUP_WEEKS || '4');
|
|
// Initialize Redis connection
|
|
this.initializeRedis();
|
|
this.setupSocketHandlers();
|
|
this.setupArchivingScheduler();
|
|
(0, Logger_1.logRequest)('WebSocket service initialized', undefined, undefined, {
|
|
chatTimeoutMinutes: this.chatTimeout
|
|
});
|
|
}
|
|
async initializeRedis() {
|
|
try {
|
|
await this.redisService.connect();
|
|
}
|
|
catch (error) {
|
|
(0, Logger_1.logError)('Failed to initialize Redis connection', error);
|
|
}
|
|
}
|
|
setupSocketHandlers() {
|
|
this.io.use(async (socket, next) => {
|
|
try {
|
|
const token = socket.handshake.auth.token || socket.handshake.headers.cookie
|
|
?.split(';')
|
|
.find(c => c.trim().startsWith('auth_token='))
|
|
?.split('=')[1];
|
|
if (!token) {
|
|
(0, Logger_1.logWarning)('WebSocket connection rejected - No token provided', {
|
|
socketId: socket.id,
|
|
ip: socket.handshake.address
|
|
});
|
|
return next(new Error('Authentication required'));
|
|
}
|
|
// Create a mock request object for JWT verification
|
|
const mockRequest = {
|
|
headers: {
|
|
authorization: `Bearer ${token}`,
|
|
cookie: `auth_token=${token}`
|
|
},
|
|
cookies: {
|
|
auth_token: token
|
|
}
|
|
};
|
|
const payload = this.jwtService.verify(mockRequest);
|
|
if (!payload) {
|
|
(0, Logger_1.logWarning)('WebSocket connection rejected - Invalid token', {
|
|
socketId: socket.id,
|
|
ip: socket.handshake.address
|
|
});
|
|
return next(new Error('Invalid token'));
|
|
}
|
|
socket.userId = payload.userId;
|
|
socket.authLevel = payload.authLevel;
|
|
socket.userStatus = payload.userStatus;
|
|
socket.orgId = payload.orgId;
|
|
(0, Logger_1.logAuth)('WebSocket connection authenticated', payload.userId, {
|
|
socketId: socket.id,
|
|
authLevel: payload.authLevel,
|
|
userStatus: payload.userStatus,
|
|
orgId: payload.orgId
|
|
});
|
|
next();
|
|
}
|
|
catch (error) {
|
|
(0, Logger_1.logError)('WebSocket authentication error', error);
|
|
next(new Error('Authentication failed'));
|
|
}
|
|
});
|
|
this.io.on('connection', (socket) => {
|
|
this.handleConnection(socket);
|
|
});
|
|
}
|
|
async handleConnection(socket) {
|
|
const userId = socket.userId;
|
|
// Store connected user
|
|
this.connectedUsers.set(userId, socket);
|
|
// Load user's active chats and join rooms
|
|
try {
|
|
const userChats = await this.chatRepository.findActiveChatsForUser(userId);
|
|
const chatIds = userChats.map(chat => chat.id);
|
|
// Join all chat rooms
|
|
chatIds.forEach(chatId => {
|
|
socket.join(chatId);
|
|
});
|
|
// Store user's chat memberships in Redis
|
|
await this.redisService.setActiveUser(userId, {
|
|
userId,
|
|
activeChatIds: chatIds,
|
|
lastActivity: new Date(),
|
|
isOnline: true
|
|
});
|
|
// Also store each active chat in Redis
|
|
for (const chat of userChats) {
|
|
await this.redisService.setActiveChat(chat.id, {
|
|
chatId: chat.id,
|
|
participants: chat.users,
|
|
lastActivity: chat.lastActivity || new Date(),
|
|
messageCount: chat.messages.length,
|
|
chatType: chat.type,
|
|
gameId: chat.gameId || undefined,
|
|
name: chat.name || undefined
|
|
});
|
|
}
|
|
(0, Logger_1.logAuth)('User connected to WebSocket', userId, {
|
|
socketId: socket.id,
|
|
activeChats: chatIds.length
|
|
});
|
|
// Send user their active chats with unread counts
|
|
const chatsWithUnread = await Promise.all(userChats.map(async (chat) => ({
|
|
id: chat.id,
|
|
type: chat.type,
|
|
name: chat.name,
|
|
gameId: chat.gameId,
|
|
users: chat.users,
|
|
lastActivity: chat.lastActivity,
|
|
unreadCount: this.calculateUnreadMessages(chat, userId),
|
|
isArchived: false
|
|
})));
|
|
socket.emit('chats:list', chatsWithUnread);
|
|
}
|
|
catch (error) {
|
|
(0, Logger_1.logError)('Error loading user chats on connection', error, undefined, undefined);
|
|
socket.emit('error', { message: 'Failed to load chats' });
|
|
}
|
|
// Setup event handlers
|
|
socket.on('chat:join', (data) => this.handleJoinChat(socket, data));
|
|
socket.on('chat:leave', (data) => this.handleLeaveChat(socket, data));
|
|
socket.on('message:send', (data) => this.handleSendMessage(socket, data));
|
|
socket.on('group:create', (data) => this.handleCreateGroup(socket, data));
|
|
socket.on('chat:direct', (data) => this.handleCreateDirectChat(socket, data));
|
|
socket.on('game:chat:create', (data) => this.handleCreateGameChat(socket, data));
|
|
socket.on('chat:history', (data) => this.handleGetChatHistory(socket, data));
|
|
socket.on('chat:delete', (data) => this.handleDeleteChat(socket, data));
|
|
socket.on('chat:archive:delete', (data) => this.handleDeleteChatArchive(socket, data));
|
|
socket.on('message:delete', (data) => this.handleDeleteMessage(socket, data));
|
|
socket.on('disconnect', () => this.handleDisconnection(socket));
|
|
}
|
|
async handleJoinChat(socket, data) {
|
|
try {
|
|
const userId = socket.userId;
|
|
const chat = await this.chatRepository.findById(data.chatId);
|
|
if (!chat) {
|
|
socket.emit('error', { message: 'Chat not found' });
|
|
return;
|
|
}
|
|
// Check if user is member of this chat
|
|
if (!chat.users.includes(userId)) {
|
|
socket.emit('error', { message: 'Unauthorized to join this chat' });
|
|
return;
|
|
}
|
|
// Join the chat room
|
|
socket.join(data.chatId);
|
|
// Add to user's active chats in Redis
|
|
await this.redisService.addUserToChat(userId, data.chatId);
|
|
// Update chat activity in Redis
|
|
await this.redisService.updateChatActivity(data.chatId);
|
|
// Update last activity in database
|
|
await this.chatRepository.update(data.chatId, { lastActivity: new Date() });
|
|
(0, Logger_1.logAuth)('User joined chat', userId, {
|
|
chatId: data.chatId,
|
|
chatType: chat.type
|
|
});
|
|
socket.emit('chat:joined', {
|
|
chatId: data.chatId,
|
|
messages: chat.messages.slice(-10) // Last 10 messages
|
|
});
|
|
}
|
|
catch (error) {
|
|
(0, Logger_1.logError)('Error joining chat', error);
|
|
socket.emit('error', { message: 'Failed to join chat' });
|
|
}
|
|
}
|
|
async handleLeaveChat(socket, data) {
|
|
try {
|
|
const userId = socket.userId;
|
|
// Leave the chat room
|
|
socket.leave(data.chatId);
|
|
// Remove from user's active chats in Redis
|
|
await this.redisService.removeUserFromChat(userId, data.chatId);
|
|
(0, Logger_1.logAuth)('User left chat', userId, {
|
|
chatId: data.chatId
|
|
});
|
|
socket.emit('chat:left', { chatId: data.chatId });
|
|
}
|
|
catch (error) {
|
|
(0, Logger_1.logError)('Error leaving chat', error);
|
|
socket.emit('error', { message: 'Failed to leave chat' });
|
|
}
|
|
}
|
|
async handleSendMessage(socket, data) {
|
|
try {
|
|
const userId = socket.userId;
|
|
// Rate limiting check
|
|
if (!this.checkMessageRateLimit(userId)) {
|
|
socket.emit('error', { message: `Rate limit exceeded. Maximum ${this.maxMessagesPerUser} messages per minute allowed.` });
|
|
return;
|
|
}
|
|
// Validate message is string and not empty
|
|
if (typeof data.message !== 'string' || !data.message.trim()) {
|
|
socket.emit('error', { message: 'Message must be a non-empty string' });
|
|
return;
|
|
}
|
|
const chat = await this.chatRepository.findById(data.chatId);
|
|
if (!chat) {
|
|
socket.emit('error', { message: 'Chat not found' });
|
|
return;
|
|
}
|
|
// Check if user is member of this chat
|
|
if (!chat.users.includes(userId)) {
|
|
socket.emit('error', { message: 'Unauthorized to send message to this chat' });
|
|
return;
|
|
}
|
|
// Create message
|
|
const message = {
|
|
id: (0, uuid_1.v4)(),
|
|
date: new Date(),
|
|
userid: userId,
|
|
text: data.message.trim()
|
|
};
|
|
// Manage message history based on chat type
|
|
let updatedMessages = [...chat.messages, message];
|
|
updatedMessages = this.pruneMessages(updatedMessages, chat.type);
|
|
// Update chat
|
|
await this.chatRepository.update(data.chatId, {
|
|
messages: updatedMessages,
|
|
lastActivity: new Date()
|
|
});
|
|
// Update chat activity in Redis with new message count
|
|
await this.redisService.updateChatActivity(data.chatId, updatedMessages.length);
|
|
// Broadcast to all users in the chat room
|
|
this.io.to(data.chatId).emit('message:received', {
|
|
chatId: data.chatId,
|
|
message: message
|
|
});
|
|
// Send notifications to offline users
|
|
await this.notifyOfflineUsers(chat, message);
|
|
(0, Logger_1.logAuth)('Message sent', userId, {
|
|
chatId: data.chatId,
|
|
messageLength: data.message.length,
|
|
chatType: chat.type
|
|
});
|
|
}
|
|
catch (error) {
|
|
(0, Logger_1.logError)('Error sending message', error);
|
|
socket.emit('error', { message: 'Failed to send message' });
|
|
}
|
|
}
|
|
async handleCreateGroup(socket, data) {
|
|
try {
|
|
const userId = socket.userId;
|
|
// Check if user is premium (required to create groups)
|
|
const user = await this.userRepository.findById(userId);
|
|
if (!user || user.state !== UserAggregate_1.UserState.VERIFIED_PREMIUM) {
|
|
socket.emit('error', { message: 'Premium subscription required to create groups' });
|
|
return;
|
|
}
|
|
// Validate group data
|
|
if (!data.name?.trim()) {
|
|
socket.emit('error', { message: 'Group name is required' });
|
|
return;
|
|
}
|
|
if (!data.userIds || data.userIds.length === 0) {
|
|
socket.emit('error', { message: 'At least one member is required' });
|
|
return;
|
|
}
|
|
// Verify all users exist
|
|
const members = await Promise.all(data.userIds.map(id => this.userRepository.findById(id)));
|
|
if (members.some(member => !member)) {
|
|
socket.emit('error', { message: 'One or more users not found' });
|
|
return;
|
|
}
|
|
// Create group chat
|
|
const groupChat = await this.chatRepository.create({
|
|
type: ChatAggregate_1.ChatType.GROUP,
|
|
name: data.name.trim(),
|
|
createdBy: userId,
|
|
users: [userId, ...data.userIds], // Include creator
|
|
messages: [],
|
|
lastActivity: new Date()
|
|
});
|
|
// Add all members to the group room and store in Redis
|
|
const allMemberIds = data.userIds.concat(userId);
|
|
for (const memberId of allMemberIds) {
|
|
const memberSocket = this.connectedUsers.get(memberId);
|
|
if (memberSocket) {
|
|
memberSocket.join(groupChat.id);
|
|
}
|
|
// Update user's chat list in Redis
|
|
await this.redisService.addUserToChat(memberId, groupChat.id);
|
|
}
|
|
// Store the group chat in Redis
|
|
await this.redisService.setActiveChat(groupChat.id, {
|
|
chatId: groupChat.id,
|
|
participants: allMemberIds,
|
|
lastActivity: new Date(),
|
|
messageCount: 0,
|
|
chatType: 'group',
|
|
name: groupChat.name || undefined
|
|
});
|
|
// Notify all members
|
|
this.io.to(groupChat.id).emit('group:created', {
|
|
chat: {
|
|
id: groupChat.id,
|
|
type: groupChat.type,
|
|
name: groupChat.name,
|
|
createdBy: groupChat.createdBy,
|
|
users: groupChat.users,
|
|
messages: []
|
|
}
|
|
});
|
|
(0, Logger_1.logAuth)('Group created', userId, {
|
|
groupId: groupChat.id,
|
|
groupName: data.name,
|
|
memberCount: groupChat.users.length
|
|
});
|
|
}
|
|
catch (error) {
|
|
(0, Logger_1.logError)('Error creating group', error);
|
|
socket.emit('error', { message: 'Failed to create group' });
|
|
}
|
|
}
|
|
async handleCreateDirectChat(socket, data) {
|
|
try {
|
|
const userId = socket.userId;
|
|
// Validate target user exists
|
|
const targetUser = await this.userRepository.findById(data.targetUserId);
|
|
if (!targetUser) {
|
|
socket.emit('error', { message: 'Target user not found' });
|
|
return;
|
|
}
|
|
// Check if direct chat already exists
|
|
const existingChats = await this.chatRepository.findByUserId(userId);
|
|
const existingDirectChat = existingChats.find(chat => chat.type === ChatAggregate_1.ChatType.DIRECT &&
|
|
chat.users.length === 2 &&
|
|
chat.users.includes(data.targetUserId));
|
|
if (existingDirectChat) {
|
|
socket.emit('chat:direct:exists', {
|
|
chatId: existingDirectChat.id
|
|
});
|
|
return;
|
|
}
|
|
// Create direct chat
|
|
const directChat = await this.chatRepository.create({
|
|
type: ChatAggregate_1.ChatType.DIRECT,
|
|
users: [userId, data.targetUserId],
|
|
messages: [],
|
|
lastActivity: new Date()
|
|
});
|
|
// Add both users to the chat room if they're online and store in Redis
|
|
const memberIds = [userId, data.targetUserId];
|
|
for (const memberId of memberIds) {
|
|
const memberSocket = this.connectedUsers.get(memberId);
|
|
if (memberSocket) {
|
|
memberSocket.join(directChat.id);
|
|
}
|
|
// Update user's chat list in Redis
|
|
await this.redisService.addUserToChat(memberId, directChat.id);
|
|
}
|
|
// Store the direct chat in Redis
|
|
await this.redisService.setActiveChat(directChat.id, {
|
|
chatId: directChat.id,
|
|
participants: memberIds,
|
|
lastActivity: new Date(),
|
|
messageCount: 0,
|
|
chatType: 'direct'
|
|
});
|
|
// Notify both users
|
|
this.io.to(directChat.id).emit('chat:direct:created', {
|
|
chat: {
|
|
id: directChat.id,
|
|
type: directChat.type,
|
|
users: directChat.users,
|
|
messages: []
|
|
}
|
|
});
|
|
(0, Logger_1.logAuth)('Direct chat created', userId, {
|
|
chatId: directChat.id,
|
|
targetUserId: data.targetUserId
|
|
});
|
|
}
|
|
catch (error) {
|
|
(0, Logger_1.logError)('Error creating direct chat', error);
|
|
socket.emit('error', { message: 'Failed to create direct chat' });
|
|
}
|
|
}
|
|
async handleCreateGameChat(socket, data) {
|
|
try {
|
|
const userId = socket.userId;
|
|
// Check if game chat already exists
|
|
const existingGameChat = await this.chatRepository.findByGameId(data.gameId);
|
|
if (existingGameChat) {
|
|
socket.emit('game:chat:exists', {
|
|
chatId: existingGameChat.id
|
|
});
|
|
return;
|
|
}
|
|
// Create game chat
|
|
const gameChat = await this.chatRepository.create({
|
|
type: ChatAggregate_1.ChatType.GAME,
|
|
name: data.gameName,
|
|
gameId: data.gameId,
|
|
users: data.playerIds,
|
|
messages: [],
|
|
lastActivity: new Date()
|
|
});
|
|
// Add all players to the game chat room if they're online and store in Redis
|
|
for (const playerId of data.playerIds) {
|
|
const playerSocket = this.connectedUsers.get(playerId);
|
|
if (playerSocket) {
|
|
playerSocket.join(gameChat.id);
|
|
}
|
|
// Update user's chat list in Redis
|
|
await this.redisService.addUserToChat(playerId, gameChat.id);
|
|
}
|
|
// Store the game chat in Redis
|
|
await this.redisService.setActiveChat(gameChat.id, {
|
|
chatId: gameChat.id,
|
|
participants: data.playerIds,
|
|
lastActivity: new Date(),
|
|
messageCount: 0,
|
|
chatType: 'game',
|
|
gameId: gameChat.gameId || undefined,
|
|
name: gameChat.name || undefined
|
|
});
|
|
// Notify all players
|
|
this.io.to(gameChat.id).emit('game:chat:created', {
|
|
chat: {
|
|
id: gameChat.id,
|
|
type: gameChat.type,
|
|
name: gameChat.name,
|
|
gameId: gameChat.gameId,
|
|
users: gameChat.users,
|
|
messages: []
|
|
}
|
|
});
|
|
(0, Logger_1.logAuth)('Game chat created', userId, {
|
|
chatId: gameChat.id,
|
|
gameId: data.gameId,
|
|
gameName: data.gameName,
|
|
playerCount: data.playerIds.length
|
|
});
|
|
}
|
|
catch (error) {
|
|
(0, Logger_1.logError)('Error creating game chat', error);
|
|
socket.emit('error', { message: 'Failed to create game chat' });
|
|
}
|
|
}
|
|
async handleGetChatHistory(socket, data) {
|
|
try {
|
|
const userId = socket.userId;
|
|
const chat = await this.chatRepository.findById(data.chatId);
|
|
if (!chat) {
|
|
// Check if it's archived
|
|
const archived = await this.chatRepository.getArchivedChat(data.chatId);
|
|
if (archived) {
|
|
socket.emit('chat:history:archived', {
|
|
chatId: data.chatId,
|
|
messages: archived.archivedMessages,
|
|
chatType: archived.chatType,
|
|
isGameChat: archived.chatType === ChatAggregate_1.ChatType.GAME
|
|
});
|
|
}
|
|
else {
|
|
socket.emit('error', { message: 'Chat not found' });
|
|
}
|
|
return;
|
|
}
|
|
// Check if user has access
|
|
if (!chat.users.includes(userId)) {
|
|
socket.emit('error', { message: 'Unauthorized to view this chat' });
|
|
return;
|
|
}
|
|
socket.emit('chat:history', {
|
|
chatId: data.chatId,
|
|
messages: chat.messages,
|
|
chatInfo: {
|
|
type: chat.type,
|
|
name: chat.name,
|
|
gameId: chat.gameId,
|
|
users: chat.users
|
|
}
|
|
});
|
|
}
|
|
catch (error) {
|
|
(0, Logger_1.logError)('Error getting chat history', error);
|
|
socket.emit('error', { message: 'Failed to get chat history' });
|
|
}
|
|
}
|
|
async handleDeleteChat(socket, data) {
|
|
try {
|
|
const userId = socket.userId;
|
|
const chat = await this.chatRepository.findById(data.chatId);
|
|
if (!chat) {
|
|
socket.emit('error', { message: 'Chat not found' });
|
|
return;
|
|
}
|
|
// Check if user is member of this chat
|
|
if (!chat.users.includes(userId)) {
|
|
socket.emit('error', { message: 'Unauthorized to delete this chat' });
|
|
return;
|
|
}
|
|
// Perform soft delete
|
|
const deletedChat = await this.chatRepository.softDelete(data.chatId);
|
|
if (!deletedChat) {
|
|
socket.emit('error', { message: 'Failed to delete chat' });
|
|
return;
|
|
}
|
|
// Remove from Redis active chats
|
|
await this.redisService.removeActiveChat(data.chatId);
|
|
// Notify all participants that the chat has been deleted
|
|
this.io.to(data.chatId).emit('chat:deleted', {
|
|
chatId: data.chatId,
|
|
deletedBy: userId
|
|
});
|
|
// Remove all users from the chat room
|
|
for (const participantId of chat.users) {
|
|
const participantSocket = this.connectedUsers.get(participantId);
|
|
if (participantSocket) {
|
|
participantSocket.leave(data.chatId);
|
|
}
|
|
// Remove from user's active chats in Redis
|
|
await this.redisService.removeUserFromChat(participantId, data.chatId);
|
|
}
|
|
(0, Logger_1.logAuth)('Chat deleted', userId, {
|
|
chatId: data.chatId,
|
|
chatType: chat.type,
|
|
participantCount: chat.users.length
|
|
});
|
|
socket.emit('chat:delete:success', {
|
|
chatId: data.chatId,
|
|
message: 'Chat deleted successfully'
|
|
});
|
|
}
|
|
catch (error) {
|
|
(0, Logger_1.logError)('Error deleting chat', error);
|
|
socket.emit('error', { message: 'Failed to delete chat' });
|
|
}
|
|
}
|
|
async handleDeleteChatArchive(socket, data) {
|
|
try {
|
|
const userId = socket.userId;
|
|
const archive = await this.chatArchiveRepository.findById(data.archiveId);
|
|
if (!archive) {
|
|
socket.emit('error', { message: 'Chat archive not found' });
|
|
return;
|
|
}
|
|
// Check if user was a participant in the archived chat
|
|
if (!archive.participants.includes(userId)) {
|
|
socket.emit('error', { message: 'Unauthorized to delete this chat archive' });
|
|
return;
|
|
}
|
|
// Hard delete the archive (since it's already archived)
|
|
await this.chatArchiveRepository.delete(data.archiveId);
|
|
(0, Logger_1.logAuth)('Chat archive deleted', userId, {
|
|
archiveId: data.archiveId,
|
|
originalChatId: archive.chatId,
|
|
chatType: archive.chatType,
|
|
participantCount: archive.participants.length
|
|
});
|
|
socket.emit('chat:archive:delete:success', {
|
|
archiveId: data.archiveId,
|
|
message: 'Chat archive deleted successfully'
|
|
});
|
|
}
|
|
catch (error) {
|
|
(0, Logger_1.logError)('Error deleting chat archive', error);
|
|
socket.emit('error', { message: 'Failed to delete chat archive' });
|
|
}
|
|
}
|
|
async handleDeleteMessage(socket, data) {
|
|
try {
|
|
const userId = socket.userId;
|
|
// Check if user has admin/moderator privileges
|
|
const user = await this.userRepository.findById(userId);
|
|
if (!user || user.state !== UserAggregate_1.UserState.ADMIN) { // Check if user is admin
|
|
socket.emit('error', { message: 'Insufficient permissions to delete messages' });
|
|
return;
|
|
}
|
|
const success = await this.deleteMessage(data.chatId, data.messageId, userId);
|
|
if (success) {
|
|
socket.emit('message:delete:success', {
|
|
chatId: data.chatId,
|
|
messageId: data.messageId,
|
|
message: 'Message deleted successfully'
|
|
});
|
|
}
|
|
else {
|
|
socket.emit('error', { message: 'Failed to delete message or message not found' });
|
|
}
|
|
}
|
|
catch (error) {
|
|
(0, Logger_1.logError)('Error handling delete message request', error);
|
|
socket.emit('error', { message: 'Failed to delete message' });
|
|
}
|
|
}
|
|
async handleDisconnection(socket) {
|
|
const userId = socket.userId;
|
|
if (userId) {
|
|
this.connectedUsers.delete(userId);
|
|
// Update user status in Redis
|
|
const userData = await this.redisService.getActiveUser(userId);
|
|
if (userData) {
|
|
userData.isOnline = false;
|
|
userData.lastActivity = new Date();
|
|
await this.redisService.setActiveUser(userId, userData);
|
|
}
|
|
(0, Logger_1.logAuth)('User disconnected from WebSocket', userId, {
|
|
socketId: socket.id
|
|
});
|
|
}
|
|
}
|
|
// Utility methods
|
|
calculateUnreadMessages(chat, userId) {
|
|
// Simple implementation - count messages after user's last seen
|
|
// In production, you'd store lastSeen timestamp per user per chat
|
|
return chat.messages.filter(msg => msg.userid !== userId).length;
|
|
}
|
|
pruneMessages(messages, chatType) {
|
|
const twoWeeksAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000);
|
|
// Remove messages older than 2 weeks
|
|
let prunedMessages = messages.filter(msg => new Date(msg.date) > twoWeeksAgo);
|
|
// For group chats, only apply the 2-week time limit (unlimited messages per user)
|
|
if (chatType === ChatAggregate_1.ChatType.GROUP) {
|
|
return prunedMessages.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
|
}
|
|
// For direct and game chats, apply both time limit and per-user message limit
|
|
// Group by user and keep last 10 messages per user
|
|
const messagesByUser = new Map();
|
|
prunedMessages.forEach(msg => {
|
|
if (!messagesByUser.has(msg.userid)) {
|
|
messagesByUser.set(msg.userid, []);
|
|
}
|
|
messagesByUser.get(msg.userid).push(msg);
|
|
});
|
|
// Keep only last 10 messages per user
|
|
const finalMessages = [];
|
|
messagesByUser.forEach((userMessages, userId) => {
|
|
const last10 = userMessages.slice(-10);
|
|
finalMessages.push(...last10);
|
|
});
|
|
// Sort by date
|
|
return finalMessages.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
|
}
|
|
async notifyOfflineUsers(chat, message) {
|
|
// Find users who are not currently connected
|
|
const offlineUsers = chat.users.filter(userId => userId !== message.userid && !this.connectedUsers.has(userId));
|
|
// In a real implementation, you would send push notifications or emails here
|
|
if (offlineUsers.length > 0) {
|
|
(0, Logger_1.logRequest)('Offline users to notify', undefined, undefined, {
|
|
chatId: chat.id,
|
|
offlineUserCount: offlineUsers.length,
|
|
messageFrom: message.userid
|
|
});
|
|
}
|
|
}
|
|
setupArchivingScheduler() {
|
|
// Run every hour to check for inactive chats
|
|
setInterval(async () => {
|
|
try {
|
|
// First, cleanup inactive chats from Redis and get their IDs
|
|
const inactiveChatIds = await this.redisService.cleanupInactiveChats(this.chatTimeout);
|
|
// Archive the inactive chats in the database
|
|
for (const chatId of inactiveChatIds) {
|
|
const chat = await this.chatRepository.findById(chatId);
|
|
if (chat) {
|
|
await this.chatRepository.archiveChat(chat);
|
|
(0, Logger_1.logRequest)('Chat archived due to inactivity', undefined, undefined, {
|
|
chatId: chat.id,
|
|
chatType: chat.type,
|
|
lastActivity: chat.lastActivity,
|
|
messageCount: chat.messages.length
|
|
});
|
|
}
|
|
}
|
|
// Also find inactive chats from database that might not be in Redis
|
|
const dbInactiveChats = await this.chatRepository.findInactiveChats(this.chatTimeout);
|
|
const additionalInactiveChats = dbInactiveChats.filter(chat => !inactiveChatIds.includes(chat.id));
|
|
for (const chat of additionalInactiveChats) {
|
|
await this.chatRepository.archiveChat(chat);
|
|
(0, Logger_1.logRequest)('Chat archived due to inactivity (from DB)', undefined, undefined, {
|
|
chatId: chat.id,
|
|
chatType: chat.type,
|
|
lastActivity: chat.lastActivity,
|
|
messageCount: chat.messages.length
|
|
});
|
|
}
|
|
const totalArchived = inactiveChatIds.length + additionalInactiveChats.length;
|
|
if (totalArchived > 0) {
|
|
(0, Logger_1.logRequest)('Chat archiving completed', undefined, undefined, {
|
|
archivedCount: totalArchived,
|
|
redisCleanedUp: inactiveChatIds.length,
|
|
databaseCleanedUp: additionalInactiveChats.length,
|
|
timeoutMinutes: this.chatTimeout
|
|
});
|
|
}
|
|
// Cleanup old messages from archived chats based on messageCleanupWeeks
|
|
await this.cleanupOldMessages();
|
|
}
|
|
catch (error) {
|
|
(0, Logger_1.logError)('Error in chat archiving scheduler', error);
|
|
}
|
|
}, 60 * 60 * 1000); // 1 hour
|
|
// Also run message count cleanup every 5 minutes
|
|
setInterval(() => {
|
|
this.cleanupMessageCounts();
|
|
}, 5 * 60 * 1000); // 5 minutes
|
|
}
|
|
// Public methods for game integration
|
|
async createGameChat(gameId, gameName, playerIds) {
|
|
try {
|
|
const existingGameChat = await this.chatRepository.findByGameId(gameId);
|
|
if (existingGameChat) {
|
|
return existingGameChat;
|
|
}
|
|
const gameChat = await this.chatRepository.create({
|
|
type: ChatAggregate_1.ChatType.GAME,
|
|
name: gameName,
|
|
gameId: gameId,
|
|
users: playerIds,
|
|
messages: [],
|
|
lastActivity: new Date()
|
|
});
|
|
// Notify connected players
|
|
playerIds.forEach(playerId => {
|
|
const playerSocket = this.connectedUsers.get(playerId);
|
|
if (playerSocket) {
|
|
playerSocket.join(gameChat.id);
|
|
playerSocket.emit('game:chat:created', {
|
|
chat: {
|
|
id: gameChat.id,
|
|
type: gameChat.type,
|
|
name: gameChat.name,
|
|
gameId: gameChat.gameId,
|
|
users: gameChat.users,
|
|
messages: []
|
|
}
|
|
});
|
|
}
|
|
});
|
|
return gameChat;
|
|
}
|
|
catch (error) {
|
|
(0, Logger_1.logError)('Error creating game chat programmatically', error);
|
|
return null;
|
|
}
|
|
}
|
|
getConnectedUserCount() {
|
|
return this.connectedUsers.size;
|
|
}
|
|
isUserConnected(userId) {
|
|
return this.connectedUsers.has(userId);
|
|
}
|
|
async cleanup() {
|
|
try {
|
|
await this.redisService.disconnect();
|
|
}
|
|
catch (error) {
|
|
(0, Logger_1.logError)('Error during WebSocket service cleanup', error);
|
|
}
|
|
}
|
|
/**
|
|
* Manually trigger cleanup of old messages and chats
|
|
* This can be called by admin endpoints for maintenance
|
|
*/
|
|
async triggerManualCleanup() {
|
|
try {
|
|
const cutoffDate = new Date();
|
|
cutoffDate.setDate(cutoffDate.getDate() - (this.messageCleanupWeeks * 7));
|
|
// Clean up old archived messages
|
|
const deletedArchivesCount = await this.chatArchiveRepository.cleanup(this.messageCleanupWeeks * 7);
|
|
// Clean up soft-deleted chats
|
|
const softDeletedChats = await this.chatRepository.findByPageIncludingDeleted(0, 1000);
|
|
let deletedChatsCount = 0;
|
|
for (const chat of softDeletedChats.chats) {
|
|
if (chat.state === 2 && chat.updateDate < cutoffDate) { // SOFT_DELETE state = 2
|
|
await this.chatRepository.delete(chat.id); // Hard delete
|
|
deletedChatsCount++;
|
|
}
|
|
}
|
|
(0, Logger_1.logRequest)('Manual cleanup triggered', undefined, undefined, {
|
|
cutoffDate: cutoffDate.toISOString(),
|
|
cleanupWeeks: this.messageCleanupWeeks,
|
|
deletedArchives: deletedArchivesCount,
|
|
deletedChats: deletedChatsCount,
|
|
triggeredBy: 'manual'
|
|
});
|
|
return { deletedArchives: deletedArchivesCount, deletedChats: deletedChatsCount };
|
|
}
|
|
catch (error) {
|
|
(0, Logger_1.logError)('Error during manual cleanup', error);
|
|
throw error;
|
|
}
|
|
}
|
|
/**
|
|
* Clean up old messages from archived chats based on messageCleanupWeeks setting
|
|
*/
|
|
async cleanupOldMessages() {
|
|
try {
|
|
const cutoffDate = new Date();
|
|
cutoffDate.setDate(cutoffDate.getDate() - (this.messageCleanupWeeks * 7));
|
|
// Clean up old archived messages using ChatArchiveRepository
|
|
const deletedArchivesCount = await this.chatArchiveRepository.cleanup(this.messageCleanupWeeks * 7);
|
|
// Also clean up soft-deleted chats from the main repository
|
|
// Get all soft-deleted chats that are older than the cleanup period
|
|
const softDeletedChats = await this.chatRepository.findByPageIncludingDeleted(0, 1000);
|
|
let deletedChatsCount = 0;
|
|
for (const chat of softDeletedChats.chats) {
|
|
if (chat.state === 2 && chat.updateDate < cutoffDate) { // SOFT_DELETE state = 2
|
|
await this.chatRepository.delete(chat.id); // Hard delete
|
|
deletedChatsCount++;
|
|
}
|
|
}
|
|
(0, Logger_1.logRequest)('Old message cleanup completed', undefined, undefined, {
|
|
cutoffDate: cutoffDate.toISOString(),
|
|
cleanupWeeks: this.messageCleanupWeeks,
|
|
deletedArchives: deletedArchivesCount,
|
|
deletedChats: deletedChatsCount,
|
|
note: 'Cleanup completed using both ChatRepository and ChatArchiveRepository'
|
|
});
|
|
}
|
|
catch (error) {
|
|
(0, Logger_1.logError)('Error cleaning up old messages', error);
|
|
}
|
|
}
|
|
/**
|
|
* Check if user has exceeded message rate limit
|
|
* @param userId User ID to check
|
|
* @returns true if within limit, false if exceeded
|
|
*/
|
|
checkMessageRateLimit(userId) {
|
|
const now = Date.now();
|
|
const minute = 60 * 1000; // 1 minute in milliseconds
|
|
const userStats = this.userMessageCounts.get(userId) || { count: 0, lastReset: now };
|
|
// Reset counter if more than a minute has passed
|
|
if (now - userStats.lastReset >= minute) {
|
|
userStats.count = 0;
|
|
userStats.lastReset = now;
|
|
}
|
|
// Check if user is within limits
|
|
if (userStats.count >= this.maxMessagesPerUser) {
|
|
return false;
|
|
}
|
|
// Increment counter
|
|
userStats.count++;
|
|
this.userMessageCounts.set(userId, userStats);
|
|
return true;
|
|
}
|
|
/**
|
|
* Delete a specific message from chat history
|
|
* This can be used for moderation purposes
|
|
*/
|
|
async deleteMessage(chatId, messageId, moderatorUserId) {
|
|
try {
|
|
// Get the chat
|
|
const chat = await this.chatRepository.findById(chatId);
|
|
if (!chat) {
|
|
// Check archived chats
|
|
const archivedChat = await this.chatRepository.getArchivedChat(chatId);
|
|
if (!archivedChat) {
|
|
(0, Logger_1.logWarning)('Chat not found for message deletion', {
|
|
chatId,
|
|
messageId,
|
|
moderatorUserId
|
|
});
|
|
return false;
|
|
}
|
|
// Remove message from archived chat
|
|
const updatedMessages = archivedChat.archivedMessages.filter(msg => msg.id !== messageId);
|
|
if (updatedMessages.length === archivedChat.archivedMessages.length) {
|
|
(0, Logger_1.logWarning)('Message not found in archived chat', {
|
|
chatId,
|
|
messageId,
|
|
moderatorUserId
|
|
});
|
|
return false;
|
|
}
|
|
// Update archived chat
|
|
await this.chatArchiveRepository.create({
|
|
...archivedChat,
|
|
archivedMessages: updatedMessages
|
|
});
|
|
(0, Logger_1.logAuth)('Message deleted from archived chat', moderatorUserId, {
|
|
chatId,
|
|
messageId,
|
|
originalMessageCount: archivedChat.archivedMessages.length,
|
|
newMessageCount: updatedMessages.length
|
|
});
|
|
return true;
|
|
}
|
|
// Remove message from active chat
|
|
const updatedMessages = chat.messages.filter(msg => msg.id !== messageId);
|
|
if (updatedMessages.length === chat.messages.length) {
|
|
(0, Logger_1.logWarning)('Message not found in active chat', {
|
|
chatId,
|
|
messageId,
|
|
moderatorUserId
|
|
});
|
|
return false;
|
|
}
|
|
// Update active chat
|
|
await this.chatRepository.update(chatId, {
|
|
messages: updatedMessages
|
|
});
|
|
// Notify all users in the chat about message deletion
|
|
this.io.to(chatId).emit('message:deleted', {
|
|
chatId,
|
|
messageId,
|
|
deletedBy: moderatorUserId
|
|
});
|
|
(0, Logger_1.logAuth)('Message deleted from active chat', moderatorUserId, {
|
|
chatId,
|
|
messageId,
|
|
originalMessageCount: chat.messages.length,
|
|
newMessageCount: updatedMessages.length
|
|
});
|
|
return true;
|
|
}
|
|
catch (error) {
|
|
(0, Logger_1.logError)('Error deleting message', error);
|
|
return false;
|
|
}
|
|
}
|
|
/**
|
|
* Clean up old user message count entries (called periodically)
|
|
*/
|
|
cleanupMessageCounts() {
|
|
const now = Date.now();
|
|
const minute = 60 * 1000;
|
|
for (const [userId, stats] of this.userMessageCounts.entries()) {
|
|
if (now - stats.lastReset >= minute * 5) { // Keep for 5 minutes
|
|
this.userMessageCounts.delete(userId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
exports.WebSocketService = WebSocketService;
|
|
//# sourceMappingURL=WebSocketService.js.map
|