diff --git a/Govor.API/Common/Extensions/AddOptionExtensions.cs b/Govor.API/Common/Extensions/AddOptionExtensions.cs index b31b5cf..0b08cae 100644 --- a/Govor.API/Common/Extensions/AddOptionExtensions.cs +++ b/Govor.API/Common/Extensions/AddOptionExtensions.cs @@ -1,4 +1,5 @@ using Govor.Application.Authentication.JWT; +using Govor.Application.Messages; namespace Govor.API.Common.Extensions; @@ -8,7 +9,7 @@ public static class AddOptionExtensions { services.Configure(configuration.GetSection(nameof(JwtAccessOption))); services.Configure(configuration.GetSection(nameof(JwtRefreshOption))); - + services.Configure(configuration.GetSection(nameof(MessageEditingOptions))); return services; } } \ No newline at end of file diff --git a/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs index 2a26bbf..10256d6 100644 --- a/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs @@ -6,6 +6,7 @@ using Govor.Application.Authentication.JWT; using Govor.Application.Friends; using Govor.Application.Groups; using Govor.Application.Infrastructure.AdminsStuff; +using Govor.Application.Infrastructure.Common; using Govor.Application.Infrastructure.Extensions; using Govor.Application.Infrastructure.Validators; using Govor.Application.Medias; @@ -41,6 +42,8 @@ public static class ConfigurationProgramExtensions services.AddScoped(); services.AddScoped(); + services.AddScoped(); + // Friends services services.AddScoped(); services.AddScoped(); @@ -64,6 +67,7 @@ public static class ConfigurationProgramExtensions //services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index 7de557e..1f72d68 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -1,6 +1,7 @@ using Govor.API.Common.SignalR.Helpers; using Govor.API.Hubs.Infrastructure; using Govor.Application.Exceptions.VerifyFriendship; +using Govor.Application.Infrastructure.Common; using Govor.Application.Messages; using Govor.Application.Messages.Parameters; using Govor.Contracts.Requests.SignalR; @@ -15,24 +16,31 @@ namespace Govor.API.Hubs; public class ChatsHub : Hub { private readonly ILogger _logger; + private readonly IMessageReadingService _messageReadingService; private readonly IMessageSendingService _messageSendingService; private readonly IMessageEditingService _messageEditingService; private readonly IMessageRemovingService _messageRemovingService; private readonly IHubUserAccessor _userAccessor; private readonly IChatNotificationService _notifier; private readonly IConnectionManager _connectionManager; - + private readonly INowDateTimeProvider _nowDateTimeProvider; public ChatsHub(ILogger logger, + IMessageReadingService messageReadingService, IMessageSendingService messageSendingService, IMessageEditingService messageEditingService, + IMessageRemovingService messageRemovingService, IHubUserAccessor userAccessor, IChatNotificationService notifier, + INowDateTimeProvider nowDateTimeProvider, IConnectionManager connectionManager) { _logger = logger; + _nowDateTimeProvider = nowDateTimeProvider; _messageSendingService = messageSendingService; _messageEditingService = messageEditingService; + _messageRemovingService = messageRemovingService; + _messageReadingService = messageReadingService; _userAccessor = userAccessor; _notifier = notifier; _connectionManager = connectionManager; @@ -85,14 +93,40 @@ public class ChatsHub : Hub return HubResult.Ok(response); }, request.RecipientId); } - + // --- Read --- + public async Task> Read(ReadMessageRequest request) + { + return await SafeExecute(async (userId) => + { + var result = await _messageReadingService.ReadMessageAsync(userId, request.MessageId); + if(!result.IsSuccess) + throw new InvalidOperationException(result.Error.ToString()); + + var message = result.Value; + + var msgv = message.MessageViews.First(v => v.UserId == userId); + + var response = new MessageReadResponse() + { + ViewId = msgv.Id, + MessageId = request.MessageId, + ReaderId = userId, + WhenWas = msgv.ViewedAt, + RecipientId = message.RecipientId, + RecipientType = message.RecipientType, + }; + + await _notifier.NotifyMessageWasReadAsync(response); + + return HubResult.Ok(response); + }, request.MessageId); + } // --- REMOVE --- public async Task> Remove(RemoveMessageRequest request) { return await SafeExecute(async (userId) => { - var result = await _messageRemovingService.DeleteMessageAsync( - new DeleteMessage( + var deletemessage = new DeleteMessage( userId, request.MessageId, ForceRemove: request.RequestType switch @@ -100,8 +134,10 @@ public class ChatsHub : Hub RemoveMessageRequestType.HideForMe => false, RemoveMessageRequestType.ForceRemove => true, _ => false - }) - ); + } + ); + + var result = await _messageRemovingService.DeleteMessageAsync(deletemessage); if (!result.IsSuccess) throw new InvalidOperationException(result.Error.ToString()); @@ -110,7 +146,8 @@ public class ChatsHub : Hub { MessageId = request.MessageId, SenderId = result.Value.SenderId, - RecipientId = result.Value.RecipientId, + RecipientId = result.Value.RecipientId, // private chat id or group id + RequestType = request.RequestType, RecipientType = result.Value.RecipientType }; @@ -125,7 +162,12 @@ public class ChatsHub : Hub { return await SafeExecute(async (userId) => { - var editParams = new EditMessage(userId, request.MessageId, request.NewEncryptedContent, DateTime.UtcNow); + var editParams = new EditMessage( + userId, + request.MessageId, + request.NewEncryptedContent, + _nowDateTimeProvider.Now); + var result = await _messageEditingService.EditMessageAsync(editParams); if (!result.IsSuccess || result.OriginalMessage == null) @@ -199,7 +241,7 @@ public class ChatsHub : Hub FromUserId: senderId, RecipientId: request.RecipientId, RecipientType: request.RecipientType, - SendAt: DateTime.UtcNow, + SendAt: _nowDateTimeProvider.Now, Media: request.MediaAttachments?.Select(f => new SendMedia(f.MediaId, f.EncryptedKey)) ?? Array.Empty() ); diff --git a/Govor.API/Hubs/Infrastructure/ChatHubConstants.cs b/Govor.API/Hubs/Infrastructure/ChatHubConstants.cs index dc8c8fd..945531f 100644 --- a/Govor.API/Hubs/Infrastructure/ChatHubConstants.cs +++ b/Govor.API/Hubs/Infrastructure/ChatHubConstants.cs @@ -5,8 +5,9 @@ public static class ChatHubConstants public const string ReceiveMessage = "ReceiveMessage"; public const string MessageSent = "MessageSent"; public const string MessageRemoved = "MessageRemoved"; - public const string MessageEdited = "MessageEdit"; - + public const string MessageEdited = "MessageEdited"; + public static string MessageRead = "MessageReaded"; + public static string GetUserGroup(Guid userId) => userId.ToString(); public static string GetChatGroup(Guid groupId) => $"group_{groupId}"; public static string GetPrivateChat(Guid groupId) => $"private_{groupId}"; diff --git a/Govor.API/Hubs/Infrastructure/ChatNotificationService.cs b/Govor.API/Hubs/Infrastructure/ChatNotificationService.cs index b9a2428..fd5c04f 100644 --- a/Govor.API/Hubs/Infrastructure/ChatNotificationService.cs +++ b/Govor.API/Hubs/Infrastructure/ChatNotificationService.cs @@ -45,6 +45,16 @@ public class ChatNotificationService : IChatNotificationService // .SendAsync(ChatHubConstants.MessageSent, message); } + public async Task NotifyMessageWasReadAsync(MessageReadResponse response) + { + await NotifyParticipantsAsync( + response.ReaderId, + response.RecipientId, + response.RecipientType, + ChatHubConstants.MessageRead, + response); + } + private async Task NotifyMessageReceivedInPrivateChatAsync(UserMessageResponse message) { diff --git a/Govor.API/Hubs/Infrastructure/IChatNotificationService.cs b/Govor.API/Hubs/Infrastructure/IChatNotificationService.cs index 410f039..49b8d78 100644 --- a/Govor.API/Hubs/Infrastructure/IChatNotificationService.cs +++ b/Govor.API/Hubs/Infrastructure/IChatNotificationService.cs @@ -5,6 +5,7 @@ namespace Govor.API.Hubs.Infrastructure; public interface IChatNotificationService { Task NotifyMessageSentAsync(UserMessageResponse message); + Task NotifyMessageWasReadAsync(MessageReadResponse response); Task NotifyMessageRemovedAsync(MessageRemovedResponse response); Task NotifyMessageEditedAsync(MessageEditResponse response); } \ No newline at end of file diff --git a/Govor.API/appsettings.json b/Govor.API/appsettings.json index 70d9f3e..35eb75f 100644 --- a/Govor.API/appsettings.json +++ b/Govor.API/appsettings.json @@ -19,5 +19,9 @@ }, "EncryptionOption": { "Secret": "8B2j9kkw9xP5m7nQwE2zY3A-=Q8zP7C4+TqLZpg" + }, + "MessageEditingOptions": { + "Enabled": true, + "MaxEditTimeMinutes": 15 } } diff --git a/Govor.Application/Infrastructure/Common/INowDateTimeProvider.cs b/Govor.Application/Infrastructure/Common/INowDateTimeProvider.cs new file mode 100644 index 0000000..d373442 --- /dev/null +++ b/Govor.Application/Infrastructure/Common/INowDateTimeProvider.cs @@ -0,0 +1,6 @@ +namespace Govor.Application.Infrastructure.Common; + +public interface INowDateTimeProvider +{ + DateTime Now { get; } +} \ No newline at end of file diff --git a/Govor.Application/Infrastructure/Common/NowDateTimeProvider.cs b/Govor.Application/Infrastructure/Common/NowDateTimeProvider.cs new file mode 100644 index 0000000..8db979a --- /dev/null +++ b/Govor.Application/Infrastructure/Common/NowDateTimeProvider.cs @@ -0,0 +1,6 @@ +namespace Govor.Application.Infrastructure.Common; + +public class NowDateTimeProvider : INowDateTimeProvider +{ + public DateTime Now => DateTime.UtcNow; +} \ No newline at end of file diff --git a/Govor.Application/Messages/IMessageReadingService.cs b/Govor.Application/Messages/IMessageReadingService.cs new file mode 100644 index 0000000..71a8ffd --- /dev/null +++ b/Govor.Application/Messages/IMessageReadingService.cs @@ -0,0 +1,10 @@ +using Govor.Domain.Common; +using Govor.Domain.Models.Messages; +using SmartRes; + +namespace Govor.Application.Messages; + +public interface IMessageReadingService +{ + Task> ReadMessageAsync(Guid readerId, Guid messageId); +} \ No newline at end of file diff --git a/Govor.Application/Messages/MessageEditingOptions.cs b/Govor.Application/Messages/MessageEditingOptions.cs new file mode 100644 index 0000000..f51230b --- /dev/null +++ b/Govor.Application/Messages/MessageEditingOptions.cs @@ -0,0 +1,7 @@ +namespace Govor.Application.Messages; + +public class MessageEditingOptions +{ + public bool Enabled { get; set; } + public int MaxEditTimeMinutes { get; set; } = 15; +} \ No newline at end of file diff --git a/Govor.Application/Messages/MessageEditingService.cs b/Govor.Application/Messages/MessageEditingService.cs index fce9b21..4dd6a98 100644 --- a/Govor.Application/Messages/MessageEditingService.cs +++ b/Govor.Application/Messages/MessageEditingService.cs @@ -3,18 +3,24 @@ using Microsoft.EntityFrameworkCore; using Govor.Domain; using Govor.Domain.Models.Messages; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Govor.Application.Messages; public class MessageEditingService : IMessageEditingService { - private readonly GovorDbContext _dbContext; + private readonly GovorDbContext _dbContext; private readonly ILogger _logger; + private readonly MessageEditingOptions _options; - public MessageEditingService(GovorDbContext dbContext, ILogger logger) + public MessageEditingService( + GovorDbContext dbContext, + ILogger logger, + IOptions options) { _dbContext = dbContext; _logger = logger; + _options = options.Value; } public async Task EditMessageAsync(EditMessage editParams) @@ -25,15 +31,47 @@ public class MessageEditingService : IMessageEditingService if (message == null) { - return new EditMessageResult(false, new KeyNotFoundException("Message not found."), null); + return new EditMessageResult( + false, + new KeyNotFoundException("Message not found."), + null); } - + if (message.SenderId != editParams.EditorId) { - _logger.LogWarning("User {EditorId} unauthorized to edit message {MessageId}", editParams.EditorId, editParams.MessageId); - return new EditMessageResult(false, new UnauthorizedAccessException("User is not authorized to edit this message."), null); + _logger.LogWarning( + "User {EditorId} unauthorized to edit message {MessageId}", + editParams.EditorId, + editParams.MessageId); + + return new EditMessageResult( + false, + new UnauthorizedAccessException( + "User is not authorized to edit this message."), + null); } - + + // Проверяем время, прошедшее с момента отправки + var now = DateTime.UtcNow; + var editDeadline = message.SentAt.AddMinutes( + _options.MaxEditTimeMinutes); + + if (now > editDeadline && _options.Enabled) + { + _logger.LogWarning( + "Message {MessageId} cannot be edited. " + + "Edit time limit of {MaxEditTimeMinutes} minutes has expired.", + message.Id, + _options.MaxEditTimeMinutes); + + return new EditMessageResult( + false, + new InvalidOperationException( + $"Message can only be edited within " + + $"{_options.MaxEditTimeMinutes} minutes after sending."), + null); + } + var originalMessageSnapshot = new Message { Id = message.Id, @@ -44,14 +82,20 @@ public class MessageEditingService : IMessageEditingService ReplyToMessageId = message.ReplyToMessageId, MediaAttachments = message.MediaAttachments?.ToList() ?? [] }; - + message.EncryptedContent = editParams.NewContent; message.IsEdited = true; message.EditedAt = editParams.EditedAt; await _dbContext.SaveChangesAsync(); - - _logger.LogInformation("Message {MessageId} edited successfully.", editParams.MessageId); - return new EditMessageResult(true, null, originalMessageSnapshot); + + _logger.LogInformation( + "Message {MessageId} edited successfully.", + editParams.MessageId); + + return new EditMessageResult( + true, + null, + originalMessageSnapshot); } -} +} \ No newline at end of file diff --git a/Govor.Application/Messages/MessageReadingService.cs b/Govor.Application/Messages/MessageReadingService.cs new file mode 100644 index 0000000..cd9d1c5 --- /dev/null +++ b/Govor.Application/Messages/MessageReadingService.cs @@ -0,0 +1,72 @@ +using Govor.Application.Infrastructure.Common; +using Govor.Domain; +using Govor.Domain.Common; +using Govor.Domain.Models.Messages; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SmartRes; + +namespace Govor.Application.Messages; + +public class MessageReadingService : IMessageReadingService +{ + private readonly GovorDbContext _dbContext; + private readonly ILogger _logger; + private readonly INowDateTimeProvider _dateTimeProvider; + + + public MessageReadingService( + GovorDbContext dbContext, + ILogger logger, + INowDateTimeProvider dateTimeProvider) + { + _dbContext = dbContext; + _logger = logger; + _dateTimeProvider = dateTimeProvider; + } + + public async Task> ReadMessageAsync(Guid readerId, Guid messageId) + { + var message = await _dbContext.Messages + .Include(msg => msg.MessageViews) + .FirstOrDefaultAsync(msg => msg.Id == messageId); + + if(message is null) + return Result.Failure(Error.NotFound( + "Message not found", + "Message with id: " + messageId + " was not found.") + ); + + if (CanReadMessage(readerId, message)) + { + var view = new MessageView() + { + Id = Guid.NewGuid(), + MessageId = messageId, + UserId = readerId, + ViewedAt = _dateTimeProvider.Now, + }; + + _dbContext.MessageViews.Add(view); + + await _dbContext.SaveChangesAsync(); + + _logger.LogInformation("Message with id: {Id} was read by user {user} at {time}", view.Id, view.UserId, view.ViewedAt); + } + + return message; + } + + private bool CanReadMessage(Guid readerId, Message message) + { + if (message.RecipientType == RecipientType.User) + { + return _dbContext.PrivateChats.Any(pr => pr.UserAId == readerId || pr.UserBId == readerId) && + message.MessageViews.All(mv => mv.UserId != readerId); + } + else + { + return false; + } + } +} \ No newline at end of file diff --git a/Govor.Application/Messages/MessageRemovingService.cs b/Govor.Application/Messages/MessageRemovingService.cs index 32314a9..c0d9a78 100644 --- a/Govor.Application/Messages/MessageRemovingService.cs +++ b/Govor.Application/Messages/MessageRemovingService.cs @@ -64,7 +64,7 @@ public class MessageRemovingService : IMessageRemovingService private async Task> ValidateUserRecipientAsync(Message message, DeleteMessage deleteParams) { - if (deleteParams.DeleterId == message.RecipientId) + if (deleteParams.DeleterId == message.SenderId) { return await ForceRemoveAsync(message); } diff --git a/Govor.Application/PushNotifications/PushTokenService.cs b/Govor.Application/PushNotifications/PushTokenService.cs index 1f6f66f..b03216f 100644 --- a/Govor.Application/PushNotifications/PushTokenService.cs +++ b/Govor.Application/PushNotifications/PushTokenService.cs @@ -1,3 +1,4 @@ +using Govor.Application.Infrastructure.Common; using Govor.Domain; using Govor.Domain.Common; using Govor.Domain.Models.Users; @@ -11,9 +12,13 @@ public class PushTokenService : IPushTokenService { private readonly GovorDbContext _context; private readonly ILogger _logger; + private readonly INowDateTimeProvider _nowDateTimeProvider; - public PushTokenService(GovorDbContext context, ILogger logger) + public PushTokenService(GovorDbContext context, + INowDateTimeProvider nowDateTimeProvider, + ILogger logger) { + _nowDateTimeProvider = nowDateTimeProvider; _context = context; _logger = logger; } @@ -139,7 +144,7 @@ public class PushTokenService : IPushTokenService } var existingToken = await _context.UserPushTokens - .FirstOrDefaultAsync(t => t.Token == token); + .FirstOrDefaultAsync(t => t.Platform == platform && t.UserId == userId && t.UserSessionId == sessionId); if (existingToken is null) { @@ -150,18 +155,18 @@ public class PushTokenService : IPushTokenService UserSessionId = sessionId, Token = token, Platform = platform, - CreatedAt = DateTime.UtcNow + CreatedAt = _nowDateTimeProvider.Now }; await _context.UserPushTokens.AddAsync(newToken); } else { - existingToken.UserId = userId; existingToken.UserSessionId = sessionId; existingToken.Platform = platform; - existingToken.UpdatedAt = DateTime.UtcNow; + existingToken.Token = token; + existingToken.UpdatedAt = _nowDateTimeProvider.Now; } await _context.SaveChangesAsync(); diff --git a/Govor.Contracts/Requests/SignalR/ReadMessageRequest.cs b/Govor.Contracts/Requests/SignalR/ReadMessageRequest.cs new file mode 100644 index 0000000..ec00fbd --- /dev/null +++ b/Govor.Contracts/Requests/SignalR/ReadMessageRequest.cs @@ -0,0 +1,6 @@ +namespace Govor.Contracts.Requests.SignalR; + +public class ReadMessageRequest +{ + public Guid MessageId { get; set; } +} \ No newline at end of file diff --git a/Govor.Contracts/Responses/SignalR/MessageReadResponse.cs b/Govor.Contracts/Responses/SignalR/MessageReadResponse.cs new file mode 100644 index 0000000..75ebab8 --- /dev/null +++ b/Govor.Contracts/Responses/SignalR/MessageReadResponse.cs @@ -0,0 +1,13 @@ +using Govor.Domain.Models.Messages; + +namespace Govor.Contracts.Responses.SignalR; + +public class MessageReadResponse +{ + public required Guid ViewId { get; set; } + public required Guid MessageId { get; set; } + public required Guid ReaderId { get; set; } + public required Guid RecipientId { get; set; } + public required DateTime WhenWas { get; set; } + public RecipientType RecipientType { get; set; } +} \ No newline at end of file