diff --git a/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs b/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs new file mode 100644 index 0000000..a299e56 --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs @@ -0,0 +1,248 @@ +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using System; +using System.Threading.Tasks; +using Xunit; +using Govor.API.Hubs; +using Govor.Application.Interfaces.Messages; +using Govor.Application.Interfaces.Messages.Parameters; +using Govor.Core.Models; +using Microsoft.AspNetCore.Hosting; +using Govor.API; // Assuming Startup or Program is here for WebApplicationFactory +using Microsoft.AspNetCore.Authentication.JwtBearer; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.IdentityModel.Tokens; +using System.Text; +using Govor.Contracts.Requests.SignalR; // For MessageRequest etc. +using Govor.Contracts.Responses.SignalR; // For UserMessageResponse etc. +using System.Collections.Generic; +using Microsoft.Extensions.Logging; + +namespace Govor.API.Tests.IntegrationTests.Hubs; + +// Base class for Hub tests to handle TestServer and client creation +public abstract class HubTestBase : IAsyncLifetime +{ + protected TestServer TestServer; + protected HubConnection ClientConnection; + protected Mock MockMessageService; + protected Guid TestUserId = Guid.NewGuid(); + protected string TestUserToken; + + public virtual async Task InitializeAsync() + { + MockMessageService = new Mock(); + TestUserToken = GenerateTestToken(TestUserId.ToString(), "testuser"); + + var webHostBuilder = new WebHostBuilder() + .UseEnvironment("Testing") // Use a specific environment for tests + .ConfigureServices(services => + { + // Replace the real IMessageService with our mock for testing Hub logic + services.AddSingleton(MockMessageService.Object); + + // Configure Authentication + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = false, + ValidateIssuerSigningKey = false, + SignatureValidator = (token, parameters) => new JwtSecurityToken(token), // Bypass signature validation for test tokens + NameClaimType = ClaimTypes.NameIdentifier + }; + // For SignalR, need to allow token in query string for WebSocket + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + var accessToken = context.Request.Query["access_token"]; + if (!string.IsNullOrEmpty(accessToken) && + (context.HttpContext.Request.Path.StartsWithSegments("/hubs/chats"))) // Adjust path if needed + { + context.Token = accessToken; + } + return Task.CompletedTask; + } + }; + }); + services.AddAuthorization(); + services.AddSignalR(); + + // Add other necessary services that ChatsHub might depend on, possibly mocked + // services.AddSingleton(Mock.Of()); // If ChatsHub uses IUserService for groups + }) + .ConfigureLogging(logging => + { + logging.AddDebug(); // Or any other logger + }) + .UseStartup(); // Assuming you have a Startup.cs, or use Program.cs for .NET 6+ + + TestServer = new TestServer(webHostBuilder); + ClientConnection = new HubConnectionBuilder() + .WithUrl($"ws://localhost/hubs/chats", options => // Adjust URL as per your hub route + { + options.HttpMessageHandlerFactory = _ => TestServer.CreateHandler(); + options.AccessTokenProvider = () => Task.FromResult(TestUserToken); + }) + .Build(); + + await ClientConnection.StartAsync(); + } + + public virtual async Task DisposeAsync() + { + if (ClientConnection != null) + { + await ClientConnection.DisposeAsync(); + } + TestServer?.Dispose(); + } + + private string GenerateTestToken(string userId, string userName) + { + var claims = new[] + { + new Claim("userID", userId), // Ensure this matches the claim name used in ChatsHub.GetUserId() + new Claim(ClaimTypes.Name, userName) + }; + var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("your-super-secret-key-that-is-long-enough")); // Use a key from config or a test key + var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + var token = new JwtSecurityToken("TestIssuer", "TestAudience", claims, expires: DateTime.Now.AddHours(1), signingCredentials: credentials); + return new JwtSecurityTokenHandler().WriteToken(token); + } +} + + +public class ChatsHubTests : HubTestBase +{ + [Fact] + public async Task Send_ValidMessageToUser_CallsServiceAndNotifiesRecipientAndSender() + { + // Arrange + var recipientId = Guid.NewGuid(); + var messageContent = "Hello from integration test"; + var messageId = Guid.NewGuid(); + + MockMessageService.Setup(s => s.SendMessageAsync(It.Is(p => + p.FromUserId == TestUserId && + p.RecipientId == recipientId && + p.RecipientType == RecipientType.User))) + .ReturnsAsync(new SendMessageResult(true, null, messageId)); + + var messageReceivedTcs = new TaskCompletionSource(); + var messageSentTcs = new TaskCompletionSource(); + + // Simulate recipient client being connected and in their own group + // This is tricky without a second client connection. For now, we assume SignalR handles group delivery. + // A more thorough test would involve a second client. + ClientConnection.On("ReceiveMessage", (response) => + { + // This would ideally be for the recipient. For a single client test, this might be tricky. + // If sender also receives their own message via "ReceiveMessage" to their own user group: + if(response.SenderId == TestUserId && response.RecipientId == recipientId) + { + //This is if sender also gets ReceiveMessage, for simplicity in single client test + messageReceivedTcs.TrySetResult(response); + } + }); + ClientConnection.On("MessageSent", (response) => // Confirmation to sender + { + if(response.MessageId == messageId) messageSentTcs.TrySetResult(response); + }); + + var request = new MessageRequest + { + RecipientId = recipientId, + RecipientType = RecipientType.User, + EncryptedContent = messageContent, + MediaAttachments = new List() + }; + + // Act + await ClientConnection.InvokeAsync("Send", request); + + // Assert + var sentConfirmation = await Task.WhenAny(messageSentTcs.Task, Task.Delay(TimeSpan.FromSeconds(5))) == messageSentTcs.Task + ? messageSentTcs.Task.Result : null; + + // var receivedMessage = await Task.WhenAny(messageReceivedTcs.Task, Task.Delay(TimeSpan.FromSeconds(5))) == messageReceivedTcs.Task + // ? messageReceivedTcs.Task.Result : null; + + + Assert.NotNull(sentConfirmation); + Assert.Equal(messageId, sentConfirmation.MessageId); + Assert.Equal(messageContent, sentConfirmation.EncryptedContent); + + // Assert.NotNull(receivedMessage); // This part is harder to assert reliably with one client for recipient + // Assert.Equal(messageId, receivedMessage.MessageId); + + + MockMessageService.Verify(s => s.SendMessageAsync(It.Is(p => + p.FromUserId == TestUserId && + p.RecipientId == recipientId && + p.EncryptContent == messageContent)), Times.Once); + } + + [Fact] + public async Task Edit_ValidEditRequest_CallsServiceAndNotifies() + { + // Arrange + var originalSenderId = TestUserId; // Editor must be the sender + var messageToEditId = Guid.NewGuid(); + var newContent = "Updated content"; + var recipientId = Guid.NewGuid(); // Could be user or group + + var originalMessage = new Message { + Id = messageToEditId, + SenderId = originalSenderId, + RecipientId = recipientId, + RecipientType = RecipientType.User + }; + + MockMessageService.Setup(s => s.EditMessageAsync(It.Is(p => + p.EditorId == TestUserId && + p.MessageId == messageToEditId && + p.NewContent == newContent))) + .ReturnsAsync(new EditMessageResult(true, null, originalMessage)); + + var messageEditedTcs = new TaskCompletionSource(); + ClientConnection.On("MessageEdited", (response) => + { + if(response.MessageId == messageToEditId) messageEditedTcs.TrySetResult(response); + }); + + var request = new EditMessageRequest + { + MessageId = messageToEditId, + NewEncryptedContent = newContent + }; + + // Act + await ClientConnection.InvokeAsync("Edit", request); + + // Assert + var editedNotification = await Task.WhenAny(messageEditedTcs.Task, Task.Delay(TimeSpan.FromSeconds(5))) == messageEditedTcs.Task + ? messageEditedTcs.Task.Result : null; + + Assert.NotNull(editedNotification); + Assert.Equal(messageToEditId, editedNotification.MessageId); + Assert.Equal(newContent, editedNotification.NewEncryptedContent); + Assert.Equal(originalSenderId, editedNotification.SenderId); // Verify original sender is preserved + + MockMessageService.Verify(s => s.EditMessageAsync(It.Is(p => + p.EditorId == TestUserId && + p.MessageId == messageToEditId && + p.NewContent == newContent)), Times.Once); + } + + // TODO: Add tests for Remove method + // TODO: Add tests for OnConnectedAsync (e.g., joining groups - might require IUserService mock) + // TODO: Add tests for unauthorized scenarios (e.g., editing/deleting someone else's message) +} diff --git a/Govor.API.Tests/UnitTests/Services/MessageServiceTests.cs b/Govor.API.Tests/UnitTests/Services/MessageServiceTests.cs new file mode 100644 index 0000000..ee021a0 --- /dev/null +++ b/Govor.API.Tests/UnitTests/Services/MessageServiceTests.cs @@ -0,0 +1,276 @@ +using Moq; +using Microsoft.Extensions.Logging; +using Govor.Application.Services; +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Messages; +using Govor.Application.Interfaces.Messages.Parameters; +using Govor.Core.Models; +using Govor.Core.Repositories.Messages; +using Govor.Core.Repositories.Users; +using Govor.Core.Repositories.Groups; +using Xunit; +using System; +using System.Linq; +using System.Threading.Tasks; +using System.Collections.Generic; +using Govor.Application.Exceptions.VerifyFriendship; // Assuming this exception type + +namespace Govor.API.Tests.UnitTests.Services; + +public class MessageServiceTests +{ + private readonly Mock _mockMessagesRepo; + private readonly Mock _mockUsersRepo; + private readonly Mock _mockGroupsRepo; + private readonly Mock _mockVerifyFriendship; + private readonly Mock> _mockLogger; + private readonly MessageService _messageService; + + public MessageServiceTests() + { + _mockMessagesRepo = new Mock(); + _mockUsersRepo = new Mock(); + _mockGroupsRepo = new Mock(); + _mockVerifyFriendship = new Mock(); + _mockLogger = new Mock>(); + + _messageService = new MessageService( + _mockMessagesRepo.Object, + _mockUsersRepo.Object, + _mockGroupsRepo.Object, + _mockVerifyFriendship.Object, + _mockLogger.Object); + } + + // --- SendMessageAsync Tests --- + + [Fact] + public async Task SendMessageAsync_ToUser_Success() + { + // Arrange + var senderId = Guid.NewGuid(); + var recipientId = Guid.NewGuid(); + var sendMessageParams = new SendMessage("Hello", null, recipientId, RecipientType.User, senderId, DateTime.UtcNow, new List()); + + _mockUsersRepo.Setup(r => r.ExistsByIdAsync(recipientId)).ReturnsAsync(true); + _mockVerifyFriendship.Setup(v => v.VerifyAsync(senderId, recipientId)).Returns(Task.CompletedTask); + _mockMessagesRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask); + + // Act + var result = await _messageService.SendMessageAsync(sendMessageParams); + + // Assert + Assert.True(result.IsSuccess); + Assert.Null(result.Exception); + Assert.NotEqual(Guid.Empty, result.MessageId); + _mockMessagesRepo.Verify(r => r.AddAsync(It.Is(m => + m.SenderId == senderId && + m.RecipientId == recipientId && + m.RecipientType == RecipientType.User && + m.EncryptedContent == "Hello")), Times.Once); + } + + [Fact] + public async Task SendMessageAsync_ToGroup_Success() + { + // Arrange + var senderId = Guid.NewGuid(); + var groupId = Guid.NewGuid(); + var sendMessageParams = new SendMessage("Hello Group", null, groupId, RecipientType.Group, senderId, DateTime.UtcNow, new List()); + + _mockGroupsRepo.Setup(r => r.ExistsAsync(groupId)).ReturnsAsync(true); + // _mockGroupsRepo.Setup(r => r.IsUserMemberOfGroupAsync(senderId, groupId)).ReturnsAsync(true); // Assuming membership check + _mockMessagesRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask); + + // Act + var result = await _messageService.SendMessageAsync(sendMessageParams); + + // Assert + Assert.True(result.IsSuccess); + Assert.NotEqual(Guid.Empty, result.MessageId); + _mockMessagesRepo.Verify(r => r.AddAsync(It.Is(m => m.RecipientId == groupId && m.RecipientType == RecipientType.Group)), Times.Once); + } + + [Fact] + public async Task SendMessageAsync_ToUser_RecipientNotFound_ReturnsFailure() + { + // Arrange + var senderId = Guid.NewGuid(); + var recipientId = Guid.NewGuid(); + var sendMessageParams = new SendMessage("Hello", null, recipientId, RecipientType.User, senderId, DateTime.UtcNow, new List()); + + _mockUsersRepo.Setup(r => r.ExistsByIdAsync(recipientId)).ReturnsAsync(false); + + // Act + var result = await _messageService.SendMessageAsync(sendMessageParams); + + // Assert + Assert.False(result.IsSuccess); + Assert.NotNull(result.Exception); + Assert.IsType(result.Exception); + Assert.Equal(Guid.Empty, result.MessageId); + } + + [Fact] + public async Task SendMessageAsync_ToUser_FriendshipVerificationFails_ReturnsFailure() + { + // Arrange + var senderId = Guid.NewGuid(); + var recipientId = Guid.NewGuid(); + var sendMessageParams = new SendMessage("Hello", null, recipientId, RecipientType.User, senderId, DateTime.UtcNow, new List()); + + _mockUsersRepo.Setup(r => r.ExistsByIdAsync(recipientId)).ReturnsAsync(true); + _mockVerifyFriendship.Setup(v => v.VerifyAsync(senderId, recipientId)).ThrowsAsync(new FriendshipException("Not friends")); + + // Act + var result = await _messageService.SendMessageAsync(sendMessageParams); + + // Assert + Assert.False(result.IsSuccess); + Assert.NotNull(result.Exception); + Assert.IsType(result.Exception); + Assert.Equal(Guid.Empty, result.MessageId); + } + + + // --- EditMessageAsync Tests --- + + [Fact] + public async Task EditMessageAsync_Success() + { + // Arrange + var editorId = Guid.NewGuid(); + var messageId = Guid.NewGuid(); + var originalMessage = new Message { Id = messageId, SenderId = editorId, EncryptedContent = "Old", RecipientId = Guid.NewGuid(), RecipientType = RecipientType.User }; + var editParams = new EditMessage(editorId, messageId, "New Content", DateTime.UtcNow); + + _mockMessagesRepo.Setup(r => r.GetByIdAsync(messageId)).ReturnsAsync(originalMessage); + _mockMessagesRepo.Setup(r => r.UpdateAsync(It.IsAny())).Returns(Task.CompletedTask); + + // Act + var result = await _messageService.EditMessageAsync(editParams); + + // Assert + Assert.True(result.IsSuccess); + Assert.NotNull(result.OriginalMessage); + Assert.Equal(messageId, result.OriginalMessage!.Id); + _mockMessagesRepo.Verify(r => r.UpdateAsync(It.Is(m => + m.Id == messageId && + m.EncryptedContent == "New Content" && + m.IsEdited == true && + m.EditedAt == editParams.EditedAt)), Times.Once); + } + + [Fact] + public async Task EditMessageAsync_MessageNotFound_ReturnsFailure() + { + // Arrange + var editorId = Guid.NewGuid(); + var messageId = Guid.NewGuid(); + var editParams = new EditMessage(editorId, messageId, "New Content", DateTime.UtcNow); + + _mockMessagesRepo.Setup(r => r.GetByIdAsync(messageId)).ReturnsAsync((Message?)null); + + // Act + var result = await _messageService.EditMessageAsync(editParams); + + // Assert + Assert.False(result.IsSuccess); + Assert.IsType(result.Exception); + Assert.Null(result.OriginalMessage); + } + + [Fact] + public async Task EditMessageAsync_NotSender_ReturnsFailure() + { + // Arrange + var editorId = Guid.NewGuid(); + var senderId = Guid.NewGuid(); // Different from editorId + var messageId = Guid.NewGuid(); + var originalMessage = new Message { Id = messageId, SenderId = senderId, EncryptedContent = "Old" }; + var editParams = new EditMessage(editorId, messageId, "New Content", DateTime.UtcNow); + + _mockMessagesRepo.Setup(r => r.GetByIdAsync(messageId)).ReturnsAsync(originalMessage); + + // Act + var result = await _messageService.EditMessageAsync(editParams); + + // Assert + Assert.False(result.IsSuccess); + Assert.IsType(result.Exception); + Assert.Null(result.OriginalMessage); + } + + // --- DeleteMessageAsync Tests --- + + [Fact] + public async Task DeleteMessageAsync_Success() + { + // Arrange + var deleterId = Guid.NewGuid(); + var messageId = Guid.NewGuid(); + var originalMessage = new Message { Id = messageId, SenderId = deleterId, RecipientId = Guid.NewGuid(), RecipientType = RecipientType.User }; + var deleteParams = new DeleteMessage(deleterId, messageId); + + _mockMessagesRepo.Setup(r => r.GetByIdAsync(messageId)).ReturnsAsync(originalMessage); + // For soft delete, the repo's DeleteAsync would internally mark IsDeleted=true and save. + // If it were hard delete, it would be _mockMessagesRepo.Setup(r => r.DeleteAsync(messageId)).Returns(Task.CompletedTask); + // Assuming DeleteAsync in repo handles the soft delete logic (sets IsDeleted, DeletedAt, then calls UpdateAsync or similar) + // For this test, we verify that the service calls the repo's DeleteAsync method. + // The actual soft delete implementation is tested at the repository level. + // However, MessageService is responsible for *initiating* the delete. + // If MessageService directly manipulated IsDeleted, we'd mock repo.UpdateAsync. + // Since it calls repo.DeleteAsync, we mock that. + _mockMessagesRepo.Setup(r => r.DeleteAsync(messageId)).Returns(Task.CompletedTask); + + + // Act + var result = await _messageService.DeleteMessageAsync(deleteParams); + + // Assert + Assert.True(result.IsSuccess); + Assert.NotNull(result.OriginalMessage); + Assert.Equal(messageId, result.OriginalMessage!.Id); + _mockMessagesRepo.Verify(r => r.DeleteAsync(messageId), Times.Once); + } + + [Fact] + public async Task DeleteMessageAsync_MessageNotFound_ReturnsFailure() + { + // Arrange + var deleterId = Guid.NewGuid(); + var messageId = Guid.NewGuid(); + var deleteParams = new DeleteMessage(deleterId, messageId); + + _mockMessagesRepo.Setup(r => r.GetByIdAsync(messageId)).ReturnsAsync((Message?)null); + + // Act + var result = await _messageService.DeleteMessageAsync(deleteParams); + + // Assert + Assert.False(result.IsSuccess); + Assert.IsType(result.Exception); + Assert.Null(result.OriginalMessage); + } + + [Fact] + public async Task DeleteMessageAsync_NotSender_ReturnsFailure() + { + // Arrange + var deleterId = Guid.NewGuid(); + var senderId = Guid.NewGuid(); // Different + var messageId = Guid.NewGuid(); + var originalMessage = new Message { Id = messageId, SenderId = senderId }; + var deleteParams = new DeleteMessage(deleterId, messageId); + + _mockMessagesRepo.Setup(r => r.GetByIdAsync(messageId)).ReturnsAsync(originalMessage); + + // Act + var result = await _messageService.DeleteMessageAsync(deleteParams); + + // Assert + Assert.False(result.IsSuccess); + Assert.IsType(result.Exception); + Assert.Null(result.OriginalMessage); + } +} diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index b61dba0..f33a440 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -1,8 +1,6 @@ -using Govor.API.Services; using Govor.Application.Interfaces.Messages; using Govor.Application.Interfaces.Messages.Parameters; using Govor.Contracts.Requests.SignalR; -using Govor.Contracts.Responses.SignalR; using Govor.Core.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; @@ -13,125 +11,297 @@ namespace Govor.API.Hubs; public class ChatsHub : Hub { private readonly ILogger _logger; - private readonly IChatService _chatService; - private readonly IGroupService _groupService; - - public ChatsHub(ILogger logger, - IChatService chatService - ) + private readonly IMessageService _messageService; // Consolidated service + + public ChatsHub(ILogger logger, IMessageService messageService) { _logger = logger; - _chatService = chatService; + _messageService = messageService; } public override async Task OnConnectedAsync() { var userId = GetUserId(); - - if (userId != Guid.Empty) + if (userId == Guid.Empty) { - // Binding ConnectionId to UserId - await Groups.AddToGroupAsync(Context.ConnectionId, userId.ToString()); - _logger.LogInformation("User {UserId} connected with ConnectionId {ConnectionId}", userId, Context.ConnectionId); + _logger.LogWarning("User connected with invalid UserID claim."); + Context.Abort(); // Abort connection if userID is invalid + return; } - + + // Add user to their own group (for private messages and notifications) + await Groups.AddToGroupAsync(Context.ConnectionId, userId.ToString()); + _logger.LogInformation("User {UserId} connected with ConnectionId {ConnectionId} and added to their group", userId, Context.ConnectionId); + + // TODO: Add user to their chat groups - this might require fetching user's groups from a service + // var userGroups = await _userService.GetUserGroupsAsync(userId); + // foreach (var group in userGroups) + // { + // await Groups.AddToGroupAsync(Context.ConnectionId, $"group_{group.Id}"); + // } + await base.OnConnectedAsync(); } public override async Task OnDisconnectedAsync(Exception? exception) { - var userId = GetUserId(); + var userId = GetUserId(suppressException: true); // Suppress exception if userID is not found (e.g. connection aborted early) if (userId != Guid.Empty) { + // Remove user from their own group await Groups.RemoveFromGroupAsync(Context.ConnectionId, userId.ToString()); - _logger.LogInformation("User {UserId} disconnected", userId); + _logger.LogInformation("User {UserId} disconnected with ConnectionId {ConnectionId} and removed from their group", userId, Context.ConnectionId); + + // TODO: Remove user from their chat groups + // var userGroups = await _userService.GetUserGroupsAsync(userId); // This might be problematic if the service relies on the user being connected + // foreach (var group in userGroups) + // { + // await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"group_{group.Id}"); + // } } + else if (exception != null) + { + _logger.LogWarning(exception, "User disconnected with an exception and invalid UserID claim. ConnectionId: {ConnectionId}", Context.ConnectionId); + } + else + { + _logger.LogInformation("User disconnected with no exception and invalid UserID claim. ConnectionId: {ConnectionId}", Context.ConnectionId); + } + await base.OnDisconnectedAsync(exception); } - public async Task Remove(Guid recipientId, Guid messageId) + public async Task Send(MessageRequest request) { - - } - - public async Task Edit(string newMessage, Guid messageId) - { - - } - - public async Task SendGroup(GroupMessageRequest request) - { - var senderId = GetUserId(); + if (string.IsNullOrWhiteSpace(request.EncryptedContent) && (request.MediaAttachments == null || !request.MediaAttachments.Any())) + { + _logger.LogWarning("Empty message (no content and no attachments) received from user {UserId}", GetUserId()); + // Consider whether to throw an exception or just ignore + // For now, let's throw, as it's likely an issue with the client + throw new ArgumentException("Message cannot be empty (must have content or attachments)."); + } - // Создание сообщения - var message = new SendMessage( + var senderId = GetUserId(); + _logger.LogInformation("Message send initiated by {SenderId} to {RecipientId} of type {RecipientType}", senderId, request.RecipientId, request.RecipientType); + + var sendMessageParams = new SendMessage( EncryptContent: request.EncryptedContent, ReplyToMessageId: request.ReplyToMessageId, FromUserId: senderId, - RecipientId: request.GroupId, + RecipientId: request.RecipientId, + RecipientType: request.RecipientType, // Added RecipientType SendAt: DateTime.UtcNow, Media: request.MediaAttachments?.Select(f => new SendMedia( - f.MediaId, f.EncryptedKey, f.Type, f.MimeType)) ?? Array.Empty()); + f.MediaId, f.EncryptedKey, f.Type, f.MimeType)) ?? Array.Empty() + ); - var result = await _groupService.SendMessageAsync(message); - - if (!result.IsSuccess) - throw result.Exception!; - - // Шлём всем участникам группы, кроме отправителя - await Clients.GroupExcept($"group_{request.GroupId}", Context.ConnectionId) - .SendAsync("ReceiveGroupMessage", message); - - // Отправителю тоже - await Clients.Caller.SendAsync("ReceiveGroupMessage", message); - } - - public async Task Send(MessageRequest request) - { - if (string.IsNullOrWhiteSpace(request.EncryptedContent)) - { - _logger.LogWarning("Empty message received from user {UserId}", GetUserId()); - throw new ArgumentException("Message cannot be empty", nameof(request.EncryptedContent)); - } - - var senderId = GetUserId(); - try { - _logger.LogInformation("Message sent from {SenderId} to {RecipientId} at {UtcNow}", senderId, request.RecipientId, DateTime.UtcNow); - - var message = new SendMessage( - EncryptContent: request.EncryptedContent, - ReplyToMessageId: request.ReplyToMessageId, - FromUserId: senderId, - RecipientId: request.RecipientId, - SendAt: DateTime.UtcNow, - Media: request.MediaAttachments?.Select(f => new SendMedia( - f.MediaId, f.EncryptedKey, f.Type, f.MimeType)) ?? Array.Empty()); + var result = await _messageService.SendMessageAsync(sendMessageParams); + if (!result.IsSuccess || result.MessageId == Guid.Empty) + { + _logger.LogError(result.Exception, "Failed to send message from {SenderId} to {RecipientId}. Error: {ErrorMessage}", senderId, request.RecipientId, result.Exception?.Message ?? "Unknown error"); + // It might be better to send an error message back to the caller rather than throwing an exception that disconnects them. + // For now, rethrow to maintain previous behavior if an exception was present. + if (result.Exception != null) throw result.Exception; + throw new HubException("Failed to send message due to an internal error."); + } - Result result = await _chatService.SendMessageAsync(message); - if(result.IsSuccess == false) - throw result.Exception; - - // Sending a message to the sender and recipient - await Clients.Group(message.RecipientId.ToString()).SendAsync("Receive", message); - await Clients.Group(message.FromUserId.ToString()).SendAsync("Receive", message); - // TODO: Send to Group + var messageResponse = new UserMessageResponse // Assuming a response DTO + { + MessageId = result.MessageId, + SenderId = senderId, + RecipientId = request.RecipientId, + RecipientType = request.RecipientType, + EncryptedContent = request.EncryptedContent, + SentAt = sendMessageParams.SendAt, // Use the time from params + IsEdited = false, + MediaAttachments = request.MediaAttachments, + ReplyToMessageId = request.ReplyToMessageId + }; + + // Notify recipient (user or group) + if (request.RecipientType == RecipientType.User) + { + // Send to the recipient's personal group + await Clients.Group(request.RecipientId.ToString()).SendAsync("ReceiveMessage", messageResponse); + } + else if (request.RecipientType == RecipientType.Group) + { + // Send to all members of the group, including the sender if they are part of the group via a different connection + await Clients.Group($"group_{request.RecipientId}").SendAsync("ReceiveMessage", messageResponse); + } + + // Notify sender (confirmation) on their connection + await Clients.Caller.SendAsync("MessageSent", messageResponse); // Or use "ReceiveMessage" if the sender should also just get it like anyone else + + _logger.LogInformation("Message {MessageId} sent successfully from {SenderId} to {RecipientId} ({RecipientType})", result.MessageId, senderId, request.RecipientId, request.RecipientType); } catch (Exception ex) { _logger.LogError(ex, "Error sending message from {SenderId} to {RecipientId}", senderId, request.RecipientId); - throw; + // Consider sending a specific error message to the caller instead of a generic HubException or rethrowing. + // For example: await Clients.Caller.SendAsync("SendMessageFailed", new { Error = ex.Message }); + throw new HubException("An error occurred while sending the message.", ex); + } + } + + public async Task Edit(EditMessageRequest request) + { + var editorId = GetUserId(); + _logger.LogInformation("Message edit initiated by {EditorId} for message {MessageId}", editorId, request.MessageId); + + if (string.IsNullOrWhiteSpace(request.NewEncryptedContent)) + { + _logger.LogWarning("Edit request for message {MessageId} by user {EditorId} has empty new content.", request.MessageId, editorId); + throw new ArgumentException("New message content cannot be empty.", nameof(request.NewEncryptedContent)); + } + + var editParams = new EditMessage( + EditorId: editorId, + MessageId: request.MessageId, + NewContent: request.NewEncryptedContent, + EditedAt: DateTime.UtcNow + ); + + try + { + var result = await _messageService.EditMessageAsync(editParams); + if (!result.IsSuccess) + { + _logger.LogError(result.Exception, "Failed to edit message {MessageId} by {EditorId}. Error: {ErrorMessage}", request.MessageId, editorId, result.Exception?.Message ?? "Unknown error"); + if (result.Exception != null) throw result.Exception; // Or specific HubException + throw new HubException("Failed to edit message."); + } + + var originalMessage = result.OriginalMessage; // Assuming service returns this + if (originalMessage == null) + { + _logger.LogError("EditMessageAsync succeeded but did not return the original message details for message {MessageId}", request.MessageId); + throw new HubException("Failed to process message edit due to missing message details."); + } + + var editNotification = new MessageEditedResponse + { + MessageId = request.MessageId, + NewEncryptedContent = request.NewEncryptedContent, + EditedAt = editParams.EditedAt, + SenderId = originalMessage.SenderId, // Keep original sender + RecipientId = originalMessage.RecipientId, + RecipientType = originalMessage.RecipientType + }; + + // Notify relevant clients + if (originalMessage.RecipientType == RecipientType.User) + { + // Notify sender and recipient + await Clients.Group(originalMessage.SenderId.ToString()).SendAsync("MessageEdited", editNotification); + if (originalMessage.SenderId != originalMessage.RecipientId) // Avoid double sending if sender is recipient + { + await Clients.Group(originalMessage.RecipientId.ToString()).SendAsync("MessageEdited", editNotification); + } + } + else if (originalMessage.RecipientType == RecipientType.Group) + { + await Clients.Group($"group_{originalMessage.RecipientId}").SendAsync("MessageEdited", editNotification); + } + _logger.LogInformation("Message {MessageId} edited successfully by {EditorId}", request.MessageId, editorId); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Unauthorized attempt to edit message {MessageId} by user {EditorId}", request.MessageId, editorId); + throw new HubException("You are not authorized to edit this message.", ex); + } + catch (KeyNotFoundException ex) // Or a custom NotFoundException + { + _logger.LogWarning(ex, "Attempt to edit non-existent message {MessageId} by user {EditorId}", request.MessageId, editorId); + throw new HubException("Message not found.", ex); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error editing message {MessageId} by {EditorId}", request.MessageId, editorId); + throw new HubException("An error occurred while editing the message.", ex); + } + } + + public async Task Remove(RemoveMessageRequest request) + { + var removerId = GetUserId(); + _logger.LogInformation("Message removal initiated by {RemoverId} for message {MessageId}", removerId, request.MessageId); + + var removeParams = new DeleteMessage( + DeleterId: removerId, + MessageId: request.MessageId + ); + + try + { + var result = await _messageService.DeleteMessageAsync(removeParams); + if (!result.IsSuccess) + { + _logger.LogError(result.Exception, "Failed to remove message {MessageId} by {RemoverId}. Error: {ErrorMessage}", request.MessageId, removerId, result.Exception?.Message ?? "Unknown error"); + if (result.Exception != null) throw result.Exception; + throw new HubException("Failed to remove message."); + } + + var originalMessage = result.OriginalMessage; // Assuming service returns this + if (originalMessage == null) + { + _logger.LogError("DeleteMessageAsync succeeded but did not return the original message details for message {MessageId}", request.MessageId); + throw new HubException("Failed to process message deletion due to missing message details."); + } + + var removalNotification = new MessageRemovedResponse + { + MessageId = request.MessageId, + SenderId = originalMessage.SenderId, + RecipientId = originalMessage.RecipientId, + RecipientType = originalMessage.RecipientType + }; + + // Notify relevant clients + if (originalMessage.RecipientType == RecipientType.User) + { + await Clients.Group(originalMessage.SenderId.ToString()).SendAsync("MessageRemoved", removalNotification); + if (originalMessage.SenderId != originalMessage.RecipientId) + { + await Clients.Group(originalMessage.RecipientId.ToString()).SendAsync("MessageRemoved", removalNotification); + } + } + else if (originalMessage.RecipientType == RecipientType.Group) + { + await Clients.Group($"group_{originalMessage.RecipientId}").SendAsync("MessageRemoved", removalNotification); + } + _logger.LogInformation("Message {MessageId} removed successfully by {RemoverId}", request.MessageId, removerId); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Unauthorized attempt to remove message {MessageId} by user {RemoverId}", request.MessageId, removerId); + throw new HubException("You are not authorized to remove this message.", ex); + } + catch (KeyNotFoundException ex) // Or a custom NotFoundException + { + _logger.LogWarning(ex, "Attempt to remove non-existent message {MessageId} by user {RemoverId}", request.MessageId, removerId); + throw new HubException("Message not found.", ex); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error removing message {MessageId} by {RemoverId}", request.MessageId, removerId); + throw new HubException("An error occurred while removing the message.", ex); } } - private Guid GetUserId() + private Guid GetUserId(bool suppressException = false) { var userIdClaim = Context.User?.FindFirst("userID")?.Value; if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) { - _logger.LogError("Could not retrieve sender userId"); - throw new UnauthorizedAccessException("userID claim is missing or invalid"); + if (!suppressException) + { + _logger.LogError("Could not retrieve sender userId. Claim was: {UserIDClaim}", userIdClaim); + throw new UnauthorizedAccessException("userID claim is missing or invalid."); + } + return Guid.Empty; } return userId; } diff --git a/Govor.Application/Interfaces/IGroupService.cs b/Govor.Application/Interfaces/IGroupService.cs index 91ed51e..24f291d 100644 --- a/Govor.Application/Interfaces/IGroupService.cs +++ b/Govor.Application/Interfaces/IGroupService.cs @@ -1,9 +1,21 @@ -using Govor.Application.Interfaces.Messages; -using Govor.Core.Models; +using Govor.Core.Models; // For ChatGroup and User +using System; +using System.Collections.Generic; +using System.Threading.Tasks; -namespace Govor.API.Services; +namespace Govor.Application.Interfaces; // Corrected namespace -public interface IGroupService : IMessageSendingService, IMessageManagementService +public interface IGroupService { - ChatGroup GetGroupByInvite(string code); -} \ No newline at end of file + Task GetGroupByIdAsync(Guid groupId); // Added + Task CreateGroupAsync(string name, Guid creatorId, IEnumerable initialMemberIds); // Added + Task AddUserToGroupAsync(Guid groupId, Guid userId, Guid addedByUserId); // Added + Task RemoveUserFromGroupAsync(Guid groupId, Guid userId, Guid removedByUserId); // Added + Task DeleteGroupAsync(Guid groupId, Guid userId); // Added + Task> GetGroupMembersAsync(Guid groupId); // Added + Task>GetUserGroupsAsync(Guid userId); // Added to support ChatsHub group joining + + // Existing method, assuming it's still relevant for group operations (e.g., joining via invite link) + // Consider if this should return Task or throw if not found. + ChatGroup? GetGroupByInviteCode(string code); // Renamed parameter for clarity, made nullable +} diff --git a/Govor.Application/Interfaces/Messages/IChatService.cs b/Govor.Application/Interfaces/Messages/IChatService.cs deleted file mode 100644 index e44dc9b..0000000 --- a/Govor.Application/Interfaces/Messages/IChatService.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Govor.Application.Interfaces.Messages; - -public interface IChatService : IMessageSendingService, IMessageManagementService -{ - -} - - diff --git a/Govor.Application/Interfaces/Messages/IMessageService.cs b/Govor.Application/Interfaces/Messages/IMessageService.cs new file mode 100644 index 0000000..83c6a6e --- /dev/null +++ b/Govor.Application/Interfaces/Messages/IMessageService.cs @@ -0,0 +1,30 @@ +using Govor.Application.Interfaces.Messages.Parameters; +using Govor.Core.Models; // For Result + +namespace Govor.Application.Interfaces.Messages; + +// Combining IChatService and IGroupService functionalities relevant to messages +public interface IMessageService +{ + Task SendMessageAsync(SendMessage messageParameters); + Task EditMessageAsync(EditMessage messageParameters); + Task DeleteMessageAsync(DeleteMessage messageParameters); + + // Potentially other message-related methods like: + // Task GetMessagesAsync(Guid userId, Guid chatId, RecipientType chatType, int pageNumber, int pageSize); + // Task MarkMessageAsReadAsync(Guid userId, Guid messageId); +} + +// Define specific result types for clarity, including original message for notifications if needed + +public record SendMessageResult(bool IsSuccess, Exception? Exception, Guid MessageId) + : Result(IsSuccess, Exception, MessageId); + +public record EditMessageResult(bool IsSuccess, Exception? Exception, Message? OriginalMessage) + : Result(IsSuccess, Exception, OriginalMessage?.Id ?? Guid.Empty) +{ + // OriginalMessage can be useful for the Hub to know details like RecipientType, RecipientId for notifications +} + +public record DeleteMessageResult(bool IsSuccess, Exception? Exception, Message? OriginalMessage) + : Result(IsSuccess, Exception, OriginalMessage?.Id ?? Guid.Empty); diff --git a/Govor.Application/Interfaces/Messages/Parameters/DeleteMessage.cs b/Govor.Application/Interfaces/Messages/Parameters/DeleteMessage.cs new file mode 100644 index 0000000..3095059 --- /dev/null +++ b/Govor.Application/Interfaces/Messages/Parameters/DeleteMessage.cs @@ -0,0 +1,5 @@ +namespace Govor.Application.Interfaces.Messages.Parameters; + +public record DeleteMessage( + Guid DeleterId, + Guid MessageId); diff --git a/Govor.Application/Interfaces/Messages/Parameters/EditMessage.cs b/Govor.Application/Interfaces/Messages/Parameters/EditMessage.cs new file mode 100644 index 0000000..d186689 --- /dev/null +++ b/Govor.Application/Interfaces/Messages/Parameters/EditMessage.cs @@ -0,0 +1,7 @@ +namespace Govor.Application.Interfaces.Messages.Parameters; + +public record EditMessage( + Guid EditorId, + Guid MessageId, + string NewContent, + DateTime EditedAt); diff --git a/Govor.Application/Interfaces/Messages/Parameters/SendMessage.cs b/Govor.Application/Interfaces/Messages/Parameters/SendMessage.cs index 23c7cc8..6519eaf 100644 --- a/Govor.Application/Interfaces/Messages/Parameters/SendMessage.cs +++ b/Govor.Application/Interfaces/Messages/Parameters/SendMessage.cs @@ -1,9 +1,12 @@ +using Govor.Core.Models; // Added for RecipientType + namespace Govor.Application.Interfaces.Messages.Parameters; public record SendMessage( string EncryptContent, Guid? ReplyToMessageId, Guid RecipientId, + RecipientType RecipientType, // Added this field Guid FromUserId, DateTime SendAt, IEnumerable Media); \ No newline at end of file diff --git a/Govor.Application/Services/GroupService.cs b/Govor.Application/Services/GroupService.cs new file mode 100644 index 0000000..ffdc26a --- /dev/null +++ b/Govor.Application/Services/GroupService.cs @@ -0,0 +1,97 @@ +using Govor.Application.Interfaces; +using Govor.Core.Models; +using Govor.Core.Repositories.Groups; // Assuming IGroupsRepository will be used +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Govor.Application.Services; + +public class GroupService : IGroupService +{ + private readonly IGroupsRepository _groupsRepository; + private readonly ILogger _logger; + + public GroupService(IGroupsRepository groupsRepository, ILogger logger) + { + _groupsRepository = groupsRepository; + _logger = logger; + } + + public Task GetGroupByIdAsync(Guid groupId) + { + _logger.LogInformation("GetGroupByIdAsync called for {GroupId}", groupId); + // Implementation Note: Use _groupsRepository.GetByIdAsync(groupId); + throw new NotImplementedException(); + } + + public Task CreateGroupAsync(string name, Guid creatorId, IEnumerable initialMemberIds) + { + _logger.LogInformation("CreateGroupAsync called by {CreatorId} with name {Name}", creatorId, name); + // Implementation Note: + // 1. Create ChatGroup entity. + // 2. Create GroupMembership entities for creator and initial members. + // 3. Save to repository. + throw new NotImplementedException(); + } + + public Task AddUserToGroupAsync(Guid groupId, Guid userId, Guid addedByUserId) + { + _logger.LogInformation("AddUserToGroupAsync: User {UserId} to Group {GroupId} by {AddedByUserId}", userId, groupId, addedByUserId); + // Implementation Note: + // 1. Verify group exists. + // 2. Verify addedByUserId has permission (e.g., is admin or member, depending on rules). + // 3. Verify userId is not already a member. + // 4. Create GroupMembership entity. + // 5. Save to repository. + throw new NotImplementedException(); + } + + public Task RemoveUserFromGroupAsync(Guid groupId, Guid userId, Guid removedByUserId) + { + _logger.LogInformation("RemoveUserFromGroupAsync: User {UserId} from Group {GroupId} by {RemovedByUserId}", userId, groupId, removedByUserId); + // Implementation Note: + // 1. Verify group exists. + // 2. Verify removedByUserId has permission. + // 3. Verify userId is a member. + // 4. Remove GroupMembership entity. + // 5. Save to repository. + // 6. Consider what happens if the last admin/member is removed. + throw new NotImplementedException(); + } + + public Task DeleteGroupAsync(Guid groupId, Guid userId) + { + _logger.LogInformation("DeleteGroupAsync: Group {GroupId} by User {UserId}", groupId, userId); + // Implementation Note: + // 1. Verify group exists. + // 2. Verify userId has permission (e.g., is owner or admin). + // 3. Delete group and its memberships. + // 4. Save to repository. + throw new NotImplementedException(); + } + + public Task> GetGroupMembersAsync(Guid groupId) + { + _logger.LogInformation("GetGroupMembersAsync for Group {GroupId}", groupId); + // Implementation Note: Use _groupsRepository to fetch members. + throw new NotImplementedException(); + } + + public Task> GetUserGroupsAsync(Guid userId) + { + _logger.LogInformation("GetUserGroupsAsync for User {UserId}", userId); + // Implementation Note: Use _groupsRepository to fetch groups for the user. + // This is important for ChatsHub.OnConnectedAsync to subscribe user to their group channels. + throw new NotImplementedException(); + } + + public ChatGroup? GetGroupByInviteCode(string code) + { + _logger.LogInformation("GetGroupByInviteCode called with code {Code}", code); + // Implementation Note: + // 1. Find group by invite code (may require a specific table/field for invite codes). + throw new NotImplementedException(); + } +} diff --git a/Govor.Application/Services/MessageService.cs b/Govor.Application/Services/MessageService.cs new file mode 100644 index 0000000..95ab370 --- /dev/null +++ b/Govor.Application/Services/MessageService.cs @@ -0,0 +1,201 @@ +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Messages; +using Govor.Application.Interfaces.Messages.Parameters; +using Govor.Core.Models; +using Govor.Core.Repositories.Messages; +using Govor.Core.Repositories.Groups; // Assuming an IGroupsRepository exists for group validation +using Govor.Core.Repositories.Users; // Assuming an IUsersRepository exists for user validation +using Microsoft.Extensions.Logging; + +namespace Govor.Application.Services; + +public class MessageService : IMessageService +{ + private readonly IMessagesRepository _messagesRepository; + private readonly IUsersRepository _usersRepository; // For validating user recipients + private readonly IGroupsRepository _groupsRepository; // For validating group recipients and fetching members + private readonly IVerifyFriendship _verifyFriendship; // For private messages + private readonly ILogger _logger; + + public MessageService( + IMessagesRepository messagesRepository, + IUsersRepository usersRepository, + IGroupsRepository groupsRepository, + IVerifyFriendship verifyFriendship, + ILogger logger) + { + _messagesRepository = messagesRepository; + _usersRepository = usersRepository; + _groupsRepository = groupsRepository; + _verifyFriendship = verifyFriendship; + _logger = logger; + } + + public async Task SendMessageAsync(SendMessage sendParams) + { + try + { + // Validate recipient + if (sendParams.RecipientType == RecipientType.User) + { + if (!await _usersRepository.ExistsByIdAsync(sendParams.RecipientId)) // Changed to ExistsByIdAsync + { + _logger.LogWarning("Attempt to send message to non-existent user {RecipientId}", sendParams.RecipientId); + return new SendMessageResult(false, new KeyNotFoundException($"Recipient user {sendParams.RecipientId} not found."), Guid.Empty); + } + // Verify friendship for private messages + await _verifyFriendship.VerifyAsync(sendParams.FromUserId, sendParams.RecipientId); + } + else if (sendParams.RecipientType == RecipientType.Group) + { + if (!await _groupsRepository.ExistsAsync(sendParams.RecipientId)) + { + _logger.LogWarning("Attempt to send message to non-existent group {GroupId}", sendParams.RecipientId); + return new SendMessageResult(false, new KeyNotFoundException($"Recipient group {sendParams.RecipientId} not found."), Guid.Empty); + } + // TODO: Optionally, verify if sender is a member of the group + // bool isMember = await _groupsRepository.IsUserMemberOfGroupAsync(sendParams.FromUserId, sendParams.RecipientId); + // if (!isMember) + // { + // _logger.LogWarning("User {UserId} attempted to send message to group {GroupId} but is not a member", sendParams.FromUserId, sendParams.RecipientId); + // return new SendMessageResult(false, new UnauthorizedAccessException("Sender is not a member of the group."), Guid.Empty); + // } + } + else + { + _logger.LogError("Invalid recipient type specified: {RecipientType}", sendParams.RecipientType); + return new SendMessageResult(false, new ArgumentException("Invalid recipient type."), Guid.Empty); + } + + var messageId = Guid.NewGuid(); + var message = new Message + { + Id = messageId, + SenderId = sendParams.FromUserId, + RecipientId = sendParams.RecipientId, + RecipientType = sendParams.RecipientType, + EncryptedContent = sendParams.EncryptContent, + SentAt = sendParams.SendAt, + IsEdited = false, + ReplyToMessageId = sendParams.ReplyToMessageId, + MediaAttachments = sendParams.Media?.Select(m => new MediaAttachments + { + Id = m.Id, // Assuming SendMedia provides Id or it's generated here/by repo + MessageId = messageId, + Type = m.Type, + MimeType = m.MimeType, + EncryptedKey = m.EncryptedKey, + // FilePath will be set by storage service or handled by repository if media ID is pre-generated + }).ToList() ?? new List() + }; + + await _messagesRepository.AddAsync(message); + _logger.LogInformation("Message {MessageId} from {SenderId} to {RecipientId} ({RecipientType}) saved successfully.", messageId, sendParams.FromUserId, sendParams.RecipientId, sendParams.RecipientType); + return new SendMessageResult(true, null, messageId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending message from {SenderId} to {RecipientId} ({RecipientType})", sendParams.FromUserId, sendParams.RecipientId, sendParams.RecipientType); + return new SendMessageResult(false, ex, Guid.Empty); + } + } + + public async Task EditMessageAsync(EditMessage editParams) + { + try + { + var message = await _messagesRepository.GetByIdAsync(editParams.MessageId); + if (message == null) + { + _logger.LogWarning("Attempt to edit non-existent message {MessageId} by user {EditorId}", editParams.MessageId, editParams.EditorId); + return new EditMessageResult(false, new KeyNotFoundException($"Message {editParams.MessageId} not found."), null); + } + + if (message.SenderId != editParams.EditorId) + { + _logger.LogWarning("User {EditorId} attempted to edit message {MessageId} not sent by them (sender was {SenderId})", editParams.EditorId, editParams.MessageId, message.SenderId); + return new EditMessageResult(false, new UnauthorizedAccessException("User is not authorized to edit this message."), null); + } + + // TODO: Add a time limit for editing messages? e.g., if (message.SentAt < DateTime.UtcNow.AddMinutes(-15)) throw new Exception("Edit time limit exceeded"); + + // Keep a copy of the original message state for the result, if needed by Hub for notifications + var originalMessageForNotification = new Message + { + Id = message.Id, + SenderId = message.SenderId, + RecipientId = message.RecipientId, + RecipientType = message.RecipientType, + // Populate other fields if necessary for the notification + }; + + message.EncryptedContent = editParams.NewContent; + message.IsEdited = true; + message.EditedAt = editParams.EditedAt; + + await _messagesRepository.UpdateAsync(message); + _logger.LogInformation("Message {MessageId} edited successfully by user {EditorId}", editParams.MessageId, editParams.EditorId); + return new EditMessageResult(true, null, originalMessageForNotification); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error editing message {MessageId} by user {EditorId}", editParams.MessageId, editParams.EditorId); + return new EditMessageResult(false, ex, null); + } + } + + public async Task DeleteMessageAsync(DeleteMessage deleteParams) + { + try + { + var message = await _messagesRepository.GetByIdAsync(deleteParams.MessageId); + if (message == null) + { + _logger.LogWarning("Attempt to delete non-existent message {MessageId} by user {DeleterId}", deleteParams.MessageId, deleteParams.DeleterId); + return new DeleteMessageResult(false, new KeyNotFoundException($"Message {deleteParams.MessageId} not found."), null); + } + + if (message.SenderId != deleteParams.DeleterId) + { + // TODO: Allow group admins to delete messages in their groups? + // if (message.RecipientType == RecipientType.Group) { + // bool isAdmin = await _groupsRepository.IsUserAdminOfGroupAsync(deleteParams.DeleterId, message.RecipientId); + // if (!isAdmin) { + // _logger.LogWarning("User {DeleterId} (not sender or admin) attempted to delete group message {MessageId}", deleteParams.DeleterId, deleteParams.MessageId); + // return new DeleteMessageResult(false, new UnauthorizedAccessException("User is not authorized to delete this message."), null); + // } + // } else { + _logger.LogWarning("User {DeleterId} attempted to delete message {MessageId} not sent by them (sender was {SenderId})", deleteParams.DeleterId, deleteParams.MessageId, message.SenderId); + return new DeleteMessageResult(false, new UnauthorizedAccessException("User is not authorized to delete this message."), null); + // } + } + + // Keep a copy of the original message state for the result, if needed by Hub for notifications + var originalMessageForNotification = new Message + { + Id = message.Id, + SenderId = message.SenderId, + RecipientId = message.RecipientId, + RecipientType = message.RecipientType, + // Populate other fields if necessary for the notification + }; + + // Instead of physical delete, consider a soft delete: + // message.IsDeleted = true; + // message.DeletedAt = DateTime.UtcNow; + // await _messagesRepository.UpdateAsync(message); + // For now, doing a hard delete as per initial understanding. + await _messagesRepository.DeleteAsync(deleteParams.MessageId); + + // TODO: Delete associated media attachments from storage if they are no longer referenced. + + _logger.LogInformation("Message {MessageId} deleted successfully by user {DeleterId}", deleteParams.MessageId, deleteParams.DeleterId); + return new DeleteMessageResult(true, null, originalMessageForNotification); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting message {MessageId} by user {DeleterId}", deleteParams.MessageId, deleteParams.DeleterId); + return new DeleteMessageResult(false, ex, null); + } + } +} diff --git a/Govor.Application/Services/PrivateChatService.cs b/Govor.Application/Services/PrivateChatService.cs index 786ad27..e1c3e64 100644 --- a/Govor.Application/Services/PrivateChatService.cs +++ b/Govor.Application/Services/PrivateChatService.cs @@ -1,69 +1,48 @@ using Govor.Application.Interfaces; -using Govor.Application.Interfaces.Messages; -using Govor.Application.Interfaces.Messages.Parameters; -using Govor.Core.Models; -using Govor.Core.Repositories.Messages; -using Govor.Data; -using Microsoft.EntityFrameworkCore; +// using Govor.Application.Interfaces.Messages; // No longer needed +// using Govor.Application.Interfaces.Messages.Parameters; // No longer needed +// using Govor.Core.Models; // No longer needed unless other methods use Core models +// using Govor.Core.Repositories.Messages; // No longer needed +using Microsoft.Extensions.Logging; namespace Govor.Application.Services; -public class PrivateChatService : IChatService +// This service might handle creation/deletion of private chat sessions if that's +// more than just sending a message to a user (e.g., if private chats are explicit entities). +// For now, it primarily relies on IVerifyFriendship. +// If friendship verification is the only role, its methods could be absorbed elsewhere, +// or this service could be enhanced with other private chat management features. +public class PrivateChatService // No longer implements IChatService { private readonly IVerifyFriendship _verifyFriendship; - private readonly IMessagesRepository _messages; - - public PrivateChatService(IMessagesRepository messages, IVerifyFriendship verifyFriendship) + private readonly ILogger _logger; + + public PrivateChatService(IVerifyFriendship verifyFriendship, ILogger logger) { - _messages = messages; _verifyFriendship = verifyFriendship; - } - - public async Task SendMessageAsync(SendMessage newMessage) - { - try - { - await _verifyFriendship.VerifyAsync(newMessage.FromUserId, newMessage.RecipientId); - - var messageId = Guid.NewGuid(); - - var message = new Message() - { - Id = messageId, - EncryptedContent = newMessage.EncryptContent, - IsEdited = false, - SenderId = newMessage.FromUserId, - RecipientId = newMessage.RecipientId, - RecipientType = RecipientType.User, - ReplyToMessageId = newMessage.ReplyToMessageId, - - MediaAttachments = newMessage.Media.Select(m => new MediaAttachments() - { - Id = m.Id, - MessageId = messageId, - Type = m.Type, - MimeType = m.MimeType, - EncryptedKey = m.EncryptedKey, - }).ToList() - }; - - await _messages.AddAsync(message); - - return new Result(true, null, messageId); - } - catch (Exception ex) - { - return new Result(false, ex, Guid.Empty); - } + _logger = logger; } - public Task EditMessageAsync(Guid editorId, Guid messageId, string newContent) - { - throw new NotImplementedException(); - } + // Example of a method that might remain or be added: + // public async Task CanUserChatWithAsync(Guid userId1, Guid userId2) + // { + // try + // { + // await _verifyFriendship.VerifyAsync(userId1, userId2); + // return true; + // } + // catch (FriendshipException) // Or whatever exception VerifyAsync throws + // { + // return false; + // } + // } - public Task DeleteMessageAsync(Guid editorId, Guid messageId) + // All message sending, editing, deleting methods are removed as they are now in MessageService. + // If this service has no other responsibilities, it could be a candidate for removal, + // and IVerifyFriendship could be injected directly where needed (e.g. MessageService). + // However, keeping it allows for future expansion of private chat-specific logic. + public void PlaceholderMethod() { - throw new NotImplementedException(); + _logger.LogInformation("PrivateChatService is currently a placeholder after message management refactoring."); } } \ No newline at end of file diff --git a/Govor.Contracts/Requests/SignalR/EditMessageRequest.cs b/Govor.Contracts/Requests/SignalR/EditMessageRequest.cs new file mode 100644 index 0000000..d772483 --- /dev/null +++ b/Govor.Contracts/Requests/SignalR/EditMessageRequest.cs @@ -0,0 +1,7 @@ +namespace Govor.Contracts.Requests.SignalR; + +public class EditMessageRequest +{ + public Guid MessageId { get; set; } + public string NewEncryptedContent { get; set; } = string.Empty; +} diff --git a/Govor.Contracts/Requests/SignalR/MessageRequest.cs b/Govor.Contracts/Requests/SignalR/MessageRequest.cs index 09295d3..ad185b2 100644 --- a/Govor.Contracts/Requests/SignalR/MessageRequest.cs +++ b/Govor.Contracts/Requests/SignalR/MessageRequest.cs @@ -5,6 +5,7 @@ namespace Govor.Contracts.Requests.SignalR; public record MessageRequest { public Guid RecipientId { get; init; } + public RecipientType RecipientType { get; init; } // Added this field public string EncryptedContent { get; init; } = string.Empty; public Guid? ReplyToMessageId { get; set; } public List MediaAttachments { get; set; } = new(); diff --git a/Govor.Contracts/Requests/SignalR/RemoveMessageRequest.cs b/Govor.Contracts/Requests/SignalR/RemoveMessageRequest.cs new file mode 100644 index 0000000..9911814 --- /dev/null +++ b/Govor.Contracts/Requests/SignalR/RemoveMessageRequest.cs @@ -0,0 +1,6 @@ +namespace Govor.Contracts.Requests.SignalR; + +public class RemoveMessageRequest +{ + public Guid MessageId { get; set; } +} diff --git a/Govor.Contracts/Responses/SignalR/MessageEditedResponse.cs b/Govor.Contracts/Responses/SignalR/MessageEditedResponse.cs new file mode 100644 index 0000000..a420a69 --- /dev/null +++ b/Govor.Contracts/Responses/SignalR/MessageEditedResponse.cs @@ -0,0 +1,13 @@ +using Govor.Core.Models; // For RecipientType + +namespace Govor.Contracts.Responses.SignalR; + +public class MessageEditedResponse +{ + public Guid MessageId { get; set; } + public string NewEncryptedContent { get; set; } = string.Empty; + public DateTime EditedAt { get; set; } + public Guid SenderId { get; set; } // Original Sender + public Guid RecipientId { get; set; } + public RecipientType RecipientType { get; set; } +} diff --git a/Govor.Contracts/Responses/SignalR/MessageRemovedResponse.cs b/Govor.Contracts/Responses/SignalR/MessageRemovedResponse.cs new file mode 100644 index 0000000..1c6a21b --- /dev/null +++ b/Govor.Contracts/Responses/SignalR/MessageRemovedResponse.cs @@ -0,0 +1,11 @@ +using Govor.Core.Models; // For RecipientType + +namespace Govor.Contracts.Responses.SignalR; + +public class MessageRemovedResponse +{ + public Guid MessageId { get; set; } + public Guid SenderId { get; set; } // Original Sender + public Guid RecipientId { get; set; } + public RecipientType RecipientType { get; set; } +} diff --git a/Govor.Core/Repositories/Groups/IGroupsReader.cs b/Govor.Core/Repositories/Groups/IGroupsReader.cs index 41f1924..2e40192 100644 --- a/Govor.Core/Repositories/Groups/IGroupsReader.cs +++ b/Govor.Core/Repositories/Groups/IGroupsReader.cs @@ -1,6 +1,10 @@ namespace Govor.Core.Repositories.Groups; -public class IGroupsReader +public interface IGroupsReader // Changed to interface { - + Task ExistsAsync(Guid groupId); + Task IsUserMemberOfGroupAsync(Guid userId, Guid groupId); + // Potentially other read methods like: + // Task GetByIdAsync(Guid groupId); + // Task> GetGroupMembersAsync(Guid groupId); } \ No newline at end of file diff --git a/Govor.Core/Repositories/Groups/IGroupsRepository.cs b/Govor.Core/Repositories/Groups/IGroupsRepository.cs new file mode 100644 index 0000000..4c2b245 --- /dev/null +++ b/Govor.Core/Repositories/Groups/IGroupsRepository.cs @@ -0,0 +1,7 @@ +namespace Govor.Core.Repositories.Groups; + +public interface IGroupsRepository : IGroupsReader +{ + // This interface will combine IGroupsReader and IGroupsWriter (once created) + // For now, it only includes IGroupsReader. +} diff --git a/Govor.Data/Repositories/GroupRepository.cs b/Govor.Data/Repositories/GroupRepository.cs index 18b2dbb..eae8fd3 100644 --- a/Govor.Data/Repositories/GroupRepository.cs +++ b/Govor.Data/Repositories/GroupRepository.cs @@ -1,6 +1,56 @@ +using Govor.Core.Models; +using Govor.Core.Repositories.Groups; +using Microsoft.EntityFrameworkCore; +using System.Threading.Tasks; +using System; +using System.Linq; // Required for AnyAsync + namespace Govor.Data.Repositories; -public class GroupRepository +public class GroupRepository : IGroupsRepository { - + private readonly GovorDbContext _context; + + public GroupRepository(GovorDbContext context) + { + _context = context; + } + + public async Task ExistsAsync(Guid groupId) + { + return await _context.ChatGroups.AnyAsync(g => g.Id == groupId); + } + + public async Task IsUserMemberOfGroupAsync(Guid userId, Guid groupId) + { + // Assuming ChatGroup has a navigation property 'Members' or a 'GroupMemberships' table + // Adjust based on your actual data model for group membership + var group = await _context.ChatGroups + .Include(g => g.Memberships) // Assuming GroupMembership links User and Group + .FirstOrDefaultAsync(g => g.Id == groupId); + + if (group == null) + { + return false; // Group doesn't exist + } + // Check if any membership for this group includes the user + return group.Memberships.Any(m => m.UserId == userId); + } + + // Implement other IGroupsReader methods if any were defined beyond ExistsAsync and IsUserMemberOfGroupAsync + // For example: + // public async Task GetByIdAsync(Guid groupId) + // { + // return await _context.ChatGroups.FindAsync(groupId); + // } + + // public async Task> GetGroupMembersAsync(Guid groupId) + // { + // var group = await _context.ChatGroups + // .Include(g => g.Memberships) + // .ThenInclude(gm => gm.User) + // .FirstOrDefaultAsync(g => g.Id == groupId); + // if (group == null) return new List(); + // return group.Memberships.Select(gm => gm.User).ToList(); + // } } \ No newline at end of file diff --git a/Govor.Data/Repositories/MessagesRepository.cs b/Govor.Data/Repositories/MessagesRepository.cs index 2ef4755..6ce2edb 100644 --- a/Govor.Data/Repositories/MessagesRepository.cs +++ b/Govor.Data/Repositories/MessagesRepository.cs @@ -17,10 +17,14 @@ public class MessagesRepository : IMessagesRepository _validator = validator; } + private IQueryable GetBaseQuery() + { + return _context.Messages.AsNoTracking(); + } + public async Task> GetAllAsync() { - return await _context.Messages - .AsNoTracking() + return await GetBaseQuery() .Include(m => m.ReplyToMessage) .AsSplitQuery() .ToListOrThrowIfEmpty(new NotFoundException("No messages found in the database")); @@ -28,18 +32,17 @@ public class MessagesRepository : IMessagesRepository public async Task FindByIdAsync(Guid messageId) { - return await _context.Messages - .AsNoTracking() + return await GetBaseQuery() .Include(m => m.ReplyToMessage) + .Include(m => m.MediaAttachments) .AsSplitQuery() .FirstOrDefaultAsync(m => m.Id == messageId) - ?? throw new NotFoundByKeyException(messageId, "Message with given id does not exist"); + ?? throw new NotFoundByKeyException(messageId, "Message with given id does not exist."); } public async Task> FindBySenderIdAsync(Guid senderId) { - return await _context.Messages - .AsNoTracking() + return await GetBaseQuery() .Include(m => m.ReplyToMessage) .AsSplitQuery() .Where(m => m.SenderId == senderId) @@ -48,8 +51,7 @@ public class MessagesRepository : IMessagesRepository public async Task> FindByReceiverIdAsync(Guid receiverId) { - return await _context.Messages - .AsNoTracking() + return await GetBaseQuery() .Include(m => m.ReplyToMessage) .AsSplitQuery() .Where(m => m.RecipientId == receiverId) @@ -58,8 +60,7 @@ public class MessagesRepository : IMessagesRepository public async Task> FindBySenderAndReceiverIdAsync(Guid senderId, Guid receiverId, RecipientType recipientType = RecipientType.User) { - return await _context.Messages - .AsNoTracking() + return await GetBaseQuery() .Include(m => m.ReplyToMessage) .AsSplitQuery() .Where(m => m.SenderId == senderId @@ -70,8 +71,7 @@ public class MessagesRepository : IMessagesRepository public async Task> FindBySentAtAsync(DateTime date) { - return await _context.Messages - .AsNoTracking() + return await GetBaseQuery() .Include(m => m.ReplyToMessage) .AsSplitQuery() .Where(m => m.SentAt == date) @@ -107,45 +107,42 @@ public class MessagesRepository : IMessagesRepository var rowsAffected = await _context.Messages .Where(m => m.Id == message.Id) .ExecuteUpdateAsync(u => u - .SetProperty(m => m.SenderId, message.SenderId) - .SetProperty(m => m.RecipientId, message.RecipientId) - .SetProperty(m => m.SentAt, message.SentAt) - .SetProperty(m => m.RecipientType, message.RecipientType) .SetProperty(m => m.EncryptedContent, message.EncryptedContent) .SetProperty(m => m.EditedAt, message.EditedAt) .SetProperty(m => m.IsEdited, message.IsEdited) - .SetProperty(m => m.Reactions, message.Reactions) - .SetProperty(m => m.ReplyToMessageId, message.ReplyToMessageId) - .SetProperty(m => m.MessageViews, message.MessageViews) - .SetProperty(m => m.MediaAttachments, message.MediaAttachments) - ); if (rowsAffected == 0) - throw new UpdateException($"Not found message by given id {message.Id}"); + throw new UpdateException($"Message with ID {message.Id} not found or no changes detected."); + } + catch (InvalidObjectException ex) + { + throw new UpdateException($"Invalid message data for ID {message.Id}", ex); } catch (Exception ex) { - throw new UpdateException($"Error when updating the message {message.Id}", ex); + throw new UpdateException($"Error when updating message {message.Id}", ex); } } - public async Task RemoveAsync(Guid messageId) + public async Task RemoveAsync(Guid messageId) // Reverted to Hard Delete { try { - var result = await FindByIdAsync(messageId); + var message = await _context.Messages.FindAsync(messageId); // Find first, to ensure it exists + if (message == null) + throw new NotFoundByKeyException(messageId, "Message not found."); - _context.Messages.Remove(result); + _context.Messages.Remove(message); await _context.SaveChangesAsync(); } catch (NotFoundByKeyException ex) { - throw new RemoveException($"Not found message by given id {messageId}", ex); + throw new RemoveException($"Message with ID {messageId} not found.", ex); } - catch (Exception ex) + catch (Exception ex) // Catches DbUpdateException if there are FK constraints, etc. { - throw new RemoveException("Error when removing the message", ex); + throw new RemoveException($"Error when deleting message {messageId}", ex); } } @@ -153,13 +150,11 @@ public class MessagesRepository : IMessagesRepository { _validator.Validate(message); - return _context.Messages.Any( + return GetBaseQuery().Any( m => m.Id == message.Id && m.SenderId == message.SenderId && m.RecipientId == message.RecipientId && m.EncryptedContent == message.EncryptedContent && - m.EditedAt == message.EditedAt && - m.IsEdited == message.IsEdited && m.SentAt == message.SentAt && m.ReplyToMessageId == message.ReplyToMessageId ); @@ -167,6 +162,6 @@ public class MessagesRepository : IMessagesRepository public bool Exist(Guid messageId) { - return _context.Messages.Any(m => m.Id == messageId); + return GetBaseQuery().Any(m => m.Id == messageId); } }