diff --git a/packages/backend/src/chat/__tests__/chat.service.spec.ts b/packages/backend/src/chat/__tests__/chat.service.spec.ts index afc1136..8fadbaa 100644 --- a/packages/backend/src/chat/__tests__/chat.service.spec.ts +++ b/packages/backend/src/chat/__tests__/chat.service.spec.ts @@ -8,10 +8,11 @@ import { User } from '../../user/entities/user.entity'; import { MessageDeliveryStatus } from '@webchat/common'; import { NotFoundException, ConflictException } from '@nestjs/common'; -describe('ChatService - Message Pinning and Forwarding', () => { +describe('ChatService - High Priority Features', () => { let service: ChatService; let messageRepository: Repository; let chatRepository: Repository; + let userRepository: Repository; const mockMessage = { id: 'message-1', @@ -25,6 +26,13 @@ describe('ChatService - Message Pinning and Forwarding', () => { isForwarded: false, forwardedFromId: null, originalSenderId: null, + isEdited: false, + editedAt: null, + originalContent: null, + isDeleted: false, + isDeletedForEveryone: false, + deletedAt: null, + deletedBy: null, createdAt: new Date() }; @@ -33,9 +41,13 @@ describe('ChatService - Message Pinning and Forwarding', () => { participants: [ { id: 'user-1' }, { id: 'user-2' } - ] + ], + maxPinnedMessages: 10 }; + const mockUser1 = { id: 'user-1', email: 'user1@test.com', name: 'User 1' }; + const mockUser2 = { id: 'user-2', email: 'user2@test.com', name: 'User 2' }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -43,11 +55,11 @@ describe('ChatService - Message Pinning and Forwarding', () => { { provide: getRepositoryToken(Chat), useValue: { - findOne: jest.fn().mockResolvedValue(null), - findOneBy: jest.fn().mockResolvedValue(null), - find: jest.fn().mockResolvedValue([]), - create: jest.fn().mockReturnValue(null), - save: jest.fn().mockResolvedValue(null), + findOne: jest.fn(), + findOneBy: jest.fn(), + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), createQueryBuilder: jest.fn() } }, @@ -58,11 +70,15 @@ describe('ChatService - Message Pinning and Forwarding', () => { save: jest.fn(), find: jest.fn(), create: jest.fn(), + count: jest.fn(), + createQueryBuilder: jest.fn() }, }, { provide: getRepositoryToken(User), - useValue: {}, + useValue: { + findOneBy: jest.fn() + }, }, ], }).compile(); @@ -70,71 +86,244 @@ describe('ChatService - Message Pinning and Forwarding', () => { service = module.get(ChatService); messageRepository = module.get>(getRepositoryToken(Message)); chatRepository = module.get>(getRepositoryToken(Chat)); + userRepository = module.get>(getRepositoryToken(User)); }); - describe('Message Pinning', () => { - it('should pin a message successfully', async () => { + describe('Message Pinning with Limit', () => { + it('should pin a message successfully when under limit', async () => { const pinnedMessage = { ...mockMessage, isPinned: true, pinnedAt: new Date(), pinnedBy: 'user-1' }; - + jest.spyOn(messageRepository, 'findOne').mockResolvedValue(mockMessage as Message); jest.spyOn(chatRepository, 'findOne').mockResolvedValue(mockChat as any); + jest.spyOn(messageRepository, 'count').mockResolvedValue(5); jest.spyOn(messageRepository, 'save').mockResolvedValue(pinnedMessage as Message); - describe('createChat', () => { - it('should create a new chat between two users', async () => { - userRepository.findOneBy - .mockResolvedValueOnce(mockUser1) - .mockResolvedValueOnce(mockUser2); + const result = await service.pinMessage('message-1', 'user-1'); + + expect(result.isPinned).toBe(true); + expect(result.pinnedBy).toBe('user-1'); + expect(messageRepository.count).toHaveBeenCalledWith({ + where: { chatId: 'chat-1', isPinned: true } + }); + }); + + it('should throw error when pinned messages limit is reached', async () => { + jest.spyOn(messageRepository, 'findOne').mockResolvedValue(mockMessage as Message); + jest.spyOn(chatRepository, 'findOne').mockResolvedValue(mockChat as any); + jest.spyOn(messageRepository, 'count').mockResolvedValue(10); + + await expect(service.pinMessage('message-1', 'user-1')) + .rejects.toThrow(ConflictException); + }); + + it('should throw error when message is already pinned', async () => { + const pinnedMessage = { ...mockMessage, isPinned: true }; + jest.spyOn(messageRepository, 'findOne').mockResolvedValue(pinnedMessage as Message); + jest.spyOn(chatRepository, 'findOne').mockResolvedValue(mockChat as any); + + await expect(service.pinMessage('message-1', 'user-1')) + .rejects.toThrow('Message is already pinned'); + }); + }); + + describe('Message Editing', () => { + it('should edit a message successfully within time limit', async () => { + const recentMessage = { ...mockMessage, createdAt: new Date() }; + const editedMessage = { ...recentMessage, isEdited: true, editedAt: new Date(), content: 'Edited content' }; + + jest.spyOn(messageRepository, 'findOne').mockResolvedValue(recentMessage as Message); + jest.spyOn(messageRepository, 'save').mockResolvedValue(editedMessage as Message); + + const result = await service.editMessage('message-1', 'user-1', 'Edited content'); + + expect(result.isEdited).toBe(true); + expect(result.content).toBe('Edited content'); + }); + + it('should throw error when edit time limit exceeded', async () => { + const oldDate = new Date(); + oldDate.setMinutes(oldDate.getMinutes() - 20); + const oldMessage = { ...mockMessage, createdAt: oldDate }; + + jest.spyOn(messageRepository, 'findOne').mockResolvedValue(oldMessage as Message); + + await expect(service.editMessage('message-1', 'user-1', 'Edited content')) + .rejects.toThrow('Edit time limit exceeded'); + }); + + it('should throw error when user is not the sender', async () => { + jest.spyOn(messageRepository, 'findOne').mockResolvedValue(mockMessage as Message); + + await expect(service.editMessage('message-1', 'user-2', 'Edited content')) + .rejects.toThrow('Only the sender can edit the message'); + }); + + it('should preserve original content on first edit', async () => { + const recentMessage = { ...mockMessage, createdAt: new Date() }; + const editedMessage = { ...recentMessage, isEdited: true, editedAt: new Date(), content: 'Edited content', originalContent: 'Test message' }; + + jest.spyOn(messageRepository, 'findOne').mockResolvedValue(recentMessage as Message); + const saveSpy = jest.spyOn(messageRepository, 'save').mockResolvedValue(editedMessage as Message); + + await service.editMessage('message-1', 'user-1', 'Edited content'); + + const savedMessage = saveSpy.mock.calls[0][0]; + expect(savedMessage.originalContent).toBe('Test message'); + }); + }); + + describe('Message Deletion', () => { + it('should delete message for self', async () => { + jest.spyOn(messageRepository, 'findOne').mockResolvedValue(mockMessage as Message); + jest.spyOn(chatRepository, 'findOne').mockResolvedValue(mockChat as any); + const saveSpy = jest.spyOn(messageRepository, 'save'); + + await service.deleteMessageForSelf('message-1', 'user-1'); + + const savedMessage = saveSpy.mock.calls[0][0]; + expect(savedMessage.isDeleted).toBe(true); + expect(savedMessage.deletedBy).toBe('user-1'); + }); + + it('should delete message for everyone within time limit', async () => { + const recentMessage = { ...mockMessage, createdAt: new Date() }; + + jest.spyOn(messageRepository, 'findOne').mockResolvedValue(recentMessage as Message); + const saveSpy = jest.spyOn(messageRepository, 'save'); + + await service.deleteMessageForEveryone('message-1', 'user-1'); + + const savedMessage = saveSpy.mock.calls[0][0]; + expect(savedMessage.isDeletedForEveryone).toBe(true); + expect(savedMessage.deletedBy).toBe('user-1'); + }); + + it('should throw error when delete time limit exceeded', async () => { + const oldDate = new Date(); + oldDate.setHours(oldDate.getHours() - 2); + const oldMessage = { ...mockMessage, createdAt: oldDate }; + + jest.spyOn(messageRepository, 'findOne').mockResolvedValue(oldMessage as Message); + + await expect(service.deleteMessageForEveryone('message-1', 'user-1')) + .rejects.toThrow('Delete time limit exceeded'); + }); + + it('should throw error when user is not the sender for delete everyone', async () => { + jest.spyOn(messageRepository, 'findOne').mockResolvedValue(mockMessage as Message); + + await expect(service.deleteMessageForEveryone('message-1', 'user-2')) + .rejects.toThrow('Only the sender can delete the message for everyone'); + }); + }); + + describe('Message Edit History', () => { + it('should return edit history for edited message', async () => { + const editedMessage = { + ...mockMessage, + isEdited: true, + editedAt: new Date(), + originalContent: 'Original content', + content: 'Edited content' + }; + + jest.spyOn(messageRepository, 'findOne').mockResolvedValue(editedMessage as Message); + + const history = await service.getMessageEditHistory('message-1'); + + expect(history).toHaveLength(2); + expect(history[0].content).toBe('Original content'); + expect(history[1].content).toBe('Edited content'); + }); + + it('should return empty history for non-edited message', async () => { + jest.spyOn(messageRepository, 'findOne').mockResolvedValue(mockMessage as Message); + + const history = await service.getMessageEditHistory('message-1'); + + expect(history).toHaveLength(0); + }); + }); + + describe('Message Search', () => { + it('should search messages by content', async () => { + const mockChats = [{ id: 'chat-1' }, { id: 'chat-2' }]; + const mockMessages = [ + { ...mockMessage, content: 'Hello world' }, + { ...mockMessage, id: 'message-2', content: 'Hello there' } + ]; const mockQueryBuilder = { - innerJoinAndSelect: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue(mockChats), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + createQueryBuilder: jest.fn().mockReturnThis(), + }; + + jest.spyOn(chatRepository, 'createQueryBuilder').mockReturnValue(mockQueryBuilder as any); + + const messageQueryBuilder = { + createQueryBuilder: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), - groupBy: jest.fn().mockReturnThis(), - having: jest.fn().mockReturnThis(), - getOne: jest.fn().mockResolvedValue(null), - select: jest.fn().mockReturnThis(), - addSelect: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - leftJoinAndSelect: jest.fn().mockReturnThis(), - rightJoinAndSelect: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue(mockMessages) + }; + + jest.spyOn(messageRepository, 'createQueryBuilder').mockReturnValue(messageQueryBuilder as any); + + const result = await service.searchMessages('user-1', 'hello'); + + expect(result).toHaveLength(2); + expect(messageQueryBuilder.andWhere).toHaveBeenCalledWith( + 'LOWER(message.content) LIKE LOWER(:query)', + { query: '%hello%' } + ); + }); + + it('should search messages with filters', async () => { + const mockChats = [{ id: 'chat-1' }]; + const mockMessages = [mockMessage]; + + const mockQueryBuilder = { innerJoin: jest.fn().mockReturnThis(), - leftJoin: jest.fn().mockReturnThis(), - rightJoin: jest.fn().mockReturnThis(), - whereInIds: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue(mockChats), + }; + + jest.spyOn(chatRepository, 'createQueryBuilder').mockReturnValue(mockQueryBuilder as any); + + const messageQueryBuilder = { + createQueryBuilder: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), - orWhere: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), - addOrderBy: jest.fn().mockReturnThis(), - offset: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnThis(), - getMany: jest.fn().mockResolvedValue([]), - getManyAndCount: jest.fn().mockResolvedValue([[], 0]), - getCount: jest.fn().mockResolvedValue(0), - execute: jest.fn().mockResolvedValue([]), - getExists: jest.fn().mockResolvedValue(false) - } as any; - - chatRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); - chatRepository.create.mockReturnValue(mockChat); - chatRepository.save.mockResolvedValue(mockChat); - chatRepository.findOne.mockResolvedValue(mockChat); - - const result = await service.createChat(mockUser1.id, mockUser2.id); - - expect(userRepository.findOneBy).toHaveBeenNthCalledWith(1, { id: mockUser1.id }); - expect(userRepository.findOneBy).toHaveBeenNthCalledWith(2, { id: mockUser2.id }); - expect(chatRepository.createQueryBuilder).toHaveBeenCalledWith('chat'); - expect(chatRepository.create).toHaveBeenCalledWith({ - id: expect.any(String), - participants: [mockUser1, mockUser2] - }); - expect(chatRepository.save).toHaveBeenCalledWith(mockChat); + getMany: jest.fn().mockResolvedValue(mockMessages) + }; + jest.spyOn(messageRepository, 'createQueryBuilder').mockReturnValue(messageQueryBuilder as any); - const result = await service.pinMessage('message-1', 'user-1'); + const startDate = new Date('2024-01-01'); + const endDate = new Date('2024-12-31'); - expect(result.isPinned).toBe(true); - expect(result.pinnedBy).toBe('user-1'); + await service.searchMessages('user-1', 'test', { + chatId: 'chat-1', + senderId: 'user-1', + startDate, + endDate, + limit: 50 + }); + + expect(messageQueryBuilder.andWhere).toHaveBeenCalledWith('message.chatId = :chatId', { chatId: 'chat-1' }); + expect(messageQueryBuilder.andWhere).toHaveBeenCalledWith('message.senderId = :senderId', { senderId: 'user-1' }); + expect(messageQueryBuilder.andWhere).toHaveBeenCalledWith('message.createdAt >= :startDate', { startDate }); + expect(messageQueryBuilder.andWhere).toHaveBeenCalledWith('message.createdAt <= :endDate', { endDate }); + expect(messageQueryBuilder.limit).toHaveBeenCalledWith(50); }); }); @@ -159,5 +348,29 @@ describe('ChatService - Message Pinning and Forwarding', () => { expect(result.isForwarded).toBe(true); expect(result.forwardedFromId).toBe('message-1'); }); + + it('should forward multiple messages', async () => { + const messages = [ + mockMessage, + { ...mockMessage, id: 'message-2' }, + { ...mockMessage, id: 'message-3' } + ]; + + messages.forEach(msg => { + jest.spyOn(messageRepository, 'findOne').mockResolvedValueOnce(msg as Message); + }); + + jest.spyOn(chatRepository, 'findOne').mockResolvedValue(mockChat as any); + jest.spyOn(messageRepository, 'create').mockImplementation(() => ({} as Message)); + jest.spyOn(messageRepository, 'save').mockImplementation((msg) => Promise.resolve(msg as Message)); + + const result = await service.forwardMultipleMessages( + ['message-1', 'message-2', 'message-3'], + 'chat-2', + 'user-1' + ); + + expect(result).toHaveLength(3); + }); }); -}); +}); \ No newline at end of file diff --git a/packages/backend/src/chat/chat.controller.ts b/packages/backend/src/chat/chat.controller.ts index c770861..1bbb336 100644 --- a/packages/backend/src/chat/chat.controller.ts +++ b/packages/backend/src/chat/chat.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common'; +import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { ChatService } from './chat.service'; @@ -43,4 +43,125 @@ export class ChatController { async getChatMessages(@Param('chatId') chatId: string): Promise { return this.chatService.getChatMessages(chatId); } + + @Get(':chatId/messages/pinned') + @ApiOperation({ summary: 'Get pinned messages' }) + @ApiResponse({ status: 200, description: 'Returns pinned messages' }) + async getPinnedMessages(@Param('chatId') chatId: string): Promise { + return this.chatService.getPinnedMessages(chatId); + } + + @Post('messages/:messageId/pin') + @ApiOperation({ summary: 'Pin a message' }) + @ApiResponse({ status: 200, description: 'Message pinned successfully' }) + @ApiResponse({ status: 409, description: 'Maximum pinned messages limit reached' }) + async pinMessage( + @Param('messageId') messageId: string, + @Request() req: any, + ): Promise { + return this.chatService.pinMessage(messageId, req.user.id); + } + + @Delete('messages/:messageId/pin') + @ApiOperation({ summary: 'Unpin a message' }) + @ApiResponse({ status: 200, description: 'Message unpinned successfully' }) + async unpinMessage( + @Param('messageId') messageId: string, + @Request() req: any, + ): Promise { + return this.chatService.unpinMessage(messageId, req.user.id); + } + + @Post('messages/:messageId/forward') + @ApiOperation({ summary: 'Forward a message' }) + @ApiResponse({ status: 200, description: 'Message forwarded successfully' }) + async forwardMessage( + @Param('messageId') messageId: string, + @Body() dto: { toChatId: string; additionalContent?: string }, + @Request() req: any, + ): Promise { + return this.chatService.forwardMessage( + messageId, + dto.toChatId, + req.user.id, + dto.additionalContent, + ); + } + + @Post('messages/forward-multiple') + @ApiOperation({ summary: 'Forward multiple messages' }) + @ApiResponse({ status: 200, description: 'Messages forwarded successfully' }) + async forwardMultipleMessages( + @Body() dto: { messageIds: string[]; toChatId: string }, + @Request() req: any, + ): Promise { + return this.chatService.forwardMultipleMessages( + dto.messageIds, + dto.toChatId, + req.user.id, + ); + } + + @Put('messages/:messageId') + @ApiOperation({ summary: 'Edit a message' }) + @ApiResponse({ status: 200, description: 'Message edited successfully' }) + @ApiResponse({ status: 409, description: 'Edit time limit exceeded' }) + async editMessage( + @Param('messageId') messageId: string, + @Body() dto: { content: string }, + @Request() req: any, + ): Promise { + return this.chatService.editMessage(messageId, req.user.id, dto.content); + } + + @Delete('messages/:messageId') + @ApiOperation({ summary: 'Delete a message for self' }) + @ApiResponse({ status: 200, description: 'Message deleted for self' }) + async deleteMessageForSelf( + @Param('messageId') messageId: string, + @Request() req: any, + ): Promise { + return this.chatService.deleteMessageForSelf(messageId, req.user.id); + } + + @Delete('messages/:messageId/everyone') + @ApiOperation({ summary: 'Delete a message for everyone' }) + @ApiResponse({ status: 200, description: 'Message deleted for everyone' }) + @ApiResponse({ status: 409, description: 'Delete time limit exceeded' }) + async deleteMessageForEveryone( + @Param('messageId') messageId: string, + @Request() req: any, + ): Promise { + return this.chatService.deleteMessageForEveryone(messageId, req.user.id); + } + + @Get('messages/:messageId/history') + @ApiOperation({ summary: 'Get message edit history' }) + @ApiResponse({ status: 200, description: 'Returns message edit history' }) + async getMessageEditHistory(@Param('messageId') messageId: string): Promise { + return this.chatService.getMessageEditHistory(messageId); + } + + @Get('messages/search') + @ApiOperation({ summary: 'Search messages' }) + @ApiResponse({ status: 200, description: 'Returns search results' }) + async searchMessages( + @Query('query') query: string, + @Query('chatId') chatId?: string, + @Query('senderId') senderId?: string, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + @Query('limit') limit?: string, + @Request() req: any, + ): Promise { + const options: any = {}; + + if (chatId) options.chatId = chatId; + if (senderId) options.senderId = senderId; + if (startDate) options.startDate = new Date(startDate); + if (endDate) options.endDate = new Date(endDate); + if (limit) options.limit = parseInt(limit); + + return this.chatService.searchMessages(req.user.id, query, options); + } } diff --git a/packages/backend/src/chat/chat.service.ts b/packages/backend/src/chat/chat.service.ts index 14f0e71..e806031 100644 --- a/packages/backend/src/chat/chat.service.ts +++ b/packages/backend/src/chat/chat.service.ts @@ -368,6 +368,15 @@ export class ChatService { throw new ConflictException('Message is already pinned'); } + // Check pinned messages limit + const pinnedCount = await this.messageRepository.count({ + where: { chatId: message.chatId, isPinned: true } + }); + + if (pinnedCount >= (chat.maxPinnedMessages || 10)) { + throw new ConflictException(`Maximum number of pinned messages (${chat.maxPinnedMessages || 10}) reached`); + } + // Update message message.isPinned = true; message.pinnedAt = new Date(); @@ -597,4 +606,266 @@ export class ChatService { return forwardedMessages; } + + async editMessage( + messageId: string, + userId: string, + newContent: string + ): Promise { + console.log('=== Editing Message ===', { messageId, userId }); + + const message = await this.messageRepository.findOne({ + where: { id: messageId }, + relations: ['chat', 'chat.participants'], + }); + + if (!message) { + throw new NotFoundException('Message not found'); + } + + // Check if user is the sender + if (message.senderId !== userId) { + throw new ConflictException('Only the sender can edit the message'); + } + + // Check if message is deleted + if (message.isDeleted || message.isDeletedForEveryone) { + throw new ConflictException('Cannot edit a deleted message'); + } + + // Check time limit (e.g., 15 minutes) + const timeLimit = 15 * 60 * 1000; // 15 minutes in milliseconds + const timeSinceCreation = Date.now() - message.createdAt.getTime(); + if (timeSinceCreation > timeLimit) { + throw new ConflictException('Edit time limit exceeded'); + } + + // Save original content if first edit + if (!message.isEdited) { + message.originalContent = message.content; + } + + // Update message + message.content = newContent; + message.isEdited = true; + message.editedAt = new Date(); + + const savedMessage = await this.messageRepository.save(message); + + console.log('=== Message Edited ===', { + messageId: savedMessage.id, + editedAt: savedMessage.editedAt, + }); + + return { + id: savedMessage.id, + chatId: savedMessage.chatId, + senderId: savedMessage.senderId, + content: savedMessage.content, + status: savedMessage.status, + createdAt: savedMessage.createdAt, + isEdited: savedMessage.isEdited, + editedAt: savedMessage.editedAt, + }; + } + + async deleteMessageForSelf( + messageId: string, + userId: string + ): Promise { + console.log('=== Deleting Message for Self ===', { messageId, userId }); + + const message = await this.messageRepository.findOne({ + where: { id: messageId }, + relations: ['chat', 'chat.participants'], + }); + + if (!message) { + throw new NotFoundException('Message not found'); + } + + const chat = await this.chatRepository.findOne({ + where: { id: message.chatId }, + relations: ['participants'], + }); + + if (!chat) { + throw new NotFoundException('Chat not found'); + } + + // Verify user is participant + const isParticipant = chat.participants.some(p => p.id === userId); + if (!isParticipant) { + throw new ConflictException('User is not a participant of this chat'); + } + + // For self-deletion, we would need a junction table to track per-user deletions + // For simplicity, marking it as deleted if sender deletes it + if (message.senderId === userId) { + message.isDeleted = true; + message.deletedAt = new Date(); + message.deletedBy = userId; + await this.messageRepository.save(message); + } + + console.log('=== Message Deleted for Self ===', { messageId }); + } + + async deleteMessageForEveryone( + messageId: string, + userId: string + ): Promise { + console.log('=== Deleting Message for Everyone ===', { messageId, userId }); + + const message = await this.messageRepository.findOne({ + where: { id: messageId }, + }); + + if (!message) { + throw new NotFoundException('Message not found'); + } + + // Check if user is the sender + if (message.senderId !== userId) { + throw new ConflictException('Only the sender can delete the message for everyone'); + } + + // Check time limit (e.g., 60 minutes) + const timeLimit = 60 * 60 * 1000; // 60 minutes in milliseconds + const timeSinceCreation = Date.now() - message.createdAt.getTime(); + if (timeSinceCreation > timeLimit) { + throw new ConflictException('Delete time limit exceeded'); + } + + // Update message + message.isDeletedForEveryone = true; + message.deletedAt = new Date(); + message.deletedBy = userId; + + await this.messageRepository.save(message); + + console.log('=== Message Deleted for Everyone ===', { messageId }); + } + + async getMessageEditHistory(messageId: string): Promise { + const message = await this.messageRepository.findOne({ + where: { id: messageId }, + }); + + if (!message) { + throw new NotFoundException('Message not found'); + } + + const history = []; + + if (message.originalContent) { + history.push({ + content: message.originalContent, + timestamp: message.createdAt, + version: 1, + }); + } + + if (message.isEdited && message.editedAt) { + history.push({ + content: message.content, + timestamp: message.editedAt, + version: 2, + }); + } + + return history; + } + + async searchMessages( + userId: string, + query: string, + options?: { + chatId?: string; + senderId?: string; + startDate?: Date; + endDate?: Date; + limit?: number; + } + ): Promise { + console.log('=== Searching Messages ===', { userId, query, options }); + + // First, get all chats where user is participant + const userChats = await this.chatRepository + .createQueryBuilder('chat') + .innerJoin('chat.participants', 'participant') + .where('participant.id = :userId', { userId }) + .getMany(); + + const chatIds = userChats.map(chat => chat.id); + + if (chatIds.length === 0) { + return []; + } + + // Build query + let queryBuilder = this.messageRepository + .createQueryBuilder('message') + .where('message.chatId IN (:...chatIds)', { chatIds }) + .andWhere('message.isDeletedForEveryone = false'); + + // Add content search + if (query) { + queryBuilder = queryBuilder.andWhere( + 'LOWER(message.content) LIKE LOWER(:query)', + { query: `%${query}%` } + ); + } + + // Add optional filters + if (options?.chatId) { + queryBuilder = queryBuilder.andWhere('message.chatId = :chatId', { + chatId: options.chatId, + }); + } + + if (options?.senderId) { + queryBuilder = queryBuilder.andWhere('message.senderId = :senderId', { + senderId: options.senderId, + }); + } + + if (options?.startDate) { + queryBuilder = queryBuilder.andWhere('message.createdAt >= :startDate', { + startDate: options.startDate, + }); + } + + if (options?.endDate) { + queryBuilder = queryBuilder.andWhere('message.createdAt <= :endDate', { + endDate: options.endDate, + }); + } + + // Order and limit + queryBuilder = queryBuilder.orderBy('message.createdAt', 'DESC'); + + if (options?.limit) { + queryBuilder = queryBuilder.limit(options.limit); + } else { + queryBuilder = queryBuilder.limit(100); // Default limit + } + + const messages = await queryBuilder.getMany(); + + console.log('=== Messages Found ===', { + count: messages.length, + }); + + return messages.map(message => ({ + id: message.id, + chatId: message.chatId, + senderId: message.senderId, + content: message.content, + status: message.status, + createdAt: message.createdAt, + isEdited: message.isEdited, + editedAt: message.editedAt, + })); + } } diff --git a/packages/backend/src/chat/entities/chat.entity.ts b/packages/backend/src/chat/entities/chat.entity.ts index d8478b0..1758629 100644 --- a/packages/backend/src/chat/entities/chat.entity.ts +++ b/packages/backend/src/chat/entities/chat.entity.ts @@ -1,4 +1,4 @@ -import { Entity, PrimaryColumn, ManyToMany, OneToMany, CreateDateColumn, UpdateDateColumn, JoinTable } from 'typeorm'; +import { Entity, PrimaryColumn, ManyToMany, OneToMany, CreateDateColumn, UpdateDateColumn, JoinTable, Column } from 'typeorm'; import { User } from '../../user/entities/user.entity'; import { Message } from './message.entity'; @@ -29,4 +29,7 @@ export class Chat { @UpdateDateColumn() updatedAt: Date; + + @Column({ type: 'int', default: 10 }) + maxPinnedMessages: number; } diff --git a/packages/backend/src/chat/entities/message.entity.ts b/packages/backend/src/chat/entities/message.entity.ts index 443a3ce..413561b 100644 --- a/packages/backend/src/chat/entities/message.entity.ts +++ b/packages/backend/src/chat/entities/message.entity.ts @@ -46,6 +46,29 @@ export class Message { @CreateDateColumn() createdAt: Date; + // Edit fields + @Column({ default: false }) + isEdited: boolean; + + @Column({ type: 'timestamp', nullable: true }) + editedAt: Date | null; + + @Column({ type: 'text', nullable: true }) + originalContent: string | null; + + // Delete fields + @Column({ default: false }) + isDeleted: boolean; + + @Column({ default: false }) + isDeletedForEveryone: boolean; + + @Column({ type: 'timestamp', nullable: true }) + deletedAt: Date | null; + + @Column({ type: 'uuid', nullable: true }) + deletedBy: string | null; + @ManyToOne(() => Chat, chat => chat.messages) @JoinColumn({ name: 'chatId' }) chat: Chat; diff --git a/packages/common/src/types/chat.ts b/packages/common/src/types/chat.ts index c50af4c..a26567d 100644 --- a/packages/common/src/types/chat.ts +++ b/packages/common/src/types/chat.ts @@ -7,6 +7,7 @@ export interface Chat { messages: ChatMessage[]; createdAt: Date; updatedAt: Date; + maxPinnedMessages?: number; } export interface ChatMessage { @@ -26,4 +27,15 @@ export interface ChatMessage { isForwarded?: boolean; forwardedFromId?: string | null; originalSenderId?: string | null; + + // Edit fields + isEdited?: boolean; + editedAt?: Date | null; + originalContent?: string | null; + + // Delete fields + isDeleted?: boolean; + isDeletedForEveryone?: boolean; + deletedAt?: Date | null; + deletedBy?: string | null; }