Add read receipts and editing limits

Introduced message read tracking with a dedicated MessageReadingService, SignalR read request/response contracts, and hub notification flow for read receipts. Added configurable message edit constraints through MessageEditingOptions and a shared INowDateTimeProvider so timestamps are consistent across the app. Also fixed recipient validation in message removal and improved push token upsert behavior by matching the correct user/session record.
This commit is contained in:
Artemy
2026-08-27 18:02:47 +07:00
parent 3ec61fa2c0
commit 4603be9f71
17 changed files with 262 additions and 30 deletions
@@ -1,4 +1,5 @@
using Govor.Application.Authentication.JWT; using Govor.Application.Authentication.JWT;
using Govor.Application.Messages;
namespace Govor.API.Common.Extensions; namespace Govor.API.Common.Extensions;
@@ -8,7 +9,7 @@ public static class AddOptionExtensions
{ {
services.Configure<JwtAccessOption>(configuration.GetSection(nameof(JwtAccessOption))); services.Configure<JwtAccessOption>(configuration.GetSection(nameof(JwtAccessOption)));
services.Configure<JwtRefreshOption>(configuration.GetSection(nameof(JwtRefreshOption))); services.Configure<JwtRefreshOption>(configuration.GetSection(nameof(JwtRefreshOption)));
services.Configure<MessageEditingOptions>(configuration.GetSection(nameof(MessageEditingOptions)));
return services; return services;
} }
} }
@@ -6,6 +6,7 @@ using Govor.Application.Authentication.JWT;
using Govor.Application.Friends; using Govor.Application.Friends;
using Govor.Application.Groups; using Govor.Application.Groups;
using Govor.Application.Infrastructure.AdminsStuff; using Govor.Application.Infrastructure.AdminsStuff;
using Govor.Application.Infrastructure.Common;
using Govor.Application.Infrastructure.Extensions; using Govor.Application.Infrastructure.Extensions;
using Govor.Application.Infrastructure.Validators; using Govor.Application.Infrastructure.Validators;
using Govor.Application.Medias; using Govor.Application.Medias;
@@ -41,6 +42,8 @@ public static class ConfigurationProgramExtensions
services.AddScoped<IInvitationGenerator, InvitationGenerator>(); services.AddScoped<IInvitationGenerator, InvitationGenerator>();
services.AddScoped<ISynchingService, SynchingService>(); services.AddScoped<ISynchingService, SynchingService>();
services.AddScoped<INowDateTimeProvider, NowDateTimeProvider>();
// Friends services // Friends services
services.AddScoped<IFriendshipService, FriendshipService>(); services.AddScoped<IFriendshipService, FriendshipService>();
services.AddScoped<IFriendRequestCommandService, FriendRequestCommandService>(); services.AddScoped<IFriendRequestCommandService, FriendRequestCommandService>();
@@ -64,6 +67,7 @@ public static class ConfigurationProgramExtensions
//services.AddScoped<IMessageCommandService, MessageCommandService>(); //services.AddScoped<IMessageCommandService, MessageCommandService>();
services.AddScoped<IMessageSendingService, MessageSendingService>(); services.AddScoped<IMessageSendingService, MessageSendingService>();
services.AddScoped<IMessageReadingService, MessageReadingService>();
services.AddScoped<IMessageEditingService, MessageEditingService>(); services.AddScoped<IMessageEditingService, MessageEditingService>();
services.AddScoped<IMessageRemovingService, MessageRemovingService>(); services.AddScoped<IMessageRemovingService, MessageRemovingService>();
services.AddScoped<IVerifyFriendship, VerifyFriendship>(); services.AddScoped<IVerifyFriendship, VerifyFriendship>();
+51 -9
View File
@@ -1,6 +1,7 @@
using Govor.API.Common.SignalR.Helpers; using Govor.API.Common.SignalR.Helpers;
using Govor.API.Hubs.Infrastructure; using Govor.API.Hubs.Infrastructure;
using Govor.Application.Exceptions.VerifyFriendship; using Govor.Application.Exceptions.VerifyFriendship;
using Govor.Application.Infrastructure.Common;
using Govor.Application.Messages; using Govor.Application.Messages;
using Govor.Application.Messages.Parameters; using Govor.Application.Messages.Parameters;
using Govor.Contracts.Requests.SignalR; using Govor.Contracts.Requests.SignalR;
@@ -15,24 +16,31 @@ namespace Govor.API.Hubs;
public class ChatsHub : Hub public class ChatsHub : Hub
{ {
private readonly ILogger<ChatsHub> _logger; private readonly ILogger<ChatsHub> _logger;
private readonly IMessageReadingService _messageReadingService;
private readonly IMessageSendingService _messageSendingService; private readonly IMessageSendingService _messageSendingService;
private readonly IMessageEditingService _messageEditingService; private readonly IMessageEditingService _messageEditingService;
private readonly IMessageRemovingService _messageRemovingService; private readonly IMessageRemovingService _messageRemovingService;
private readonly IHubUserAccessor _userAccessor; private readonly IHubUserAccessor _userAccessor;
private readonly IChatNotificationService _notifier; private readonly IChatNotificationService _notifier;
private readonly IConnectionManager _connectionManager; private readonly IConnectionManager _connectionManager;
private readonly INowDateTimeProvider _nowDateTimeProvider;
public ChatsHub(ILogger<ChatsHub> logger, public ChatsHub(ILogger<ChatsHub> logger,
IMessageReadingService messageReadingService,
IMessageSendingService messageSendingService, IMessageSendingService messageSendingService,
IMessageEditingService messageEditingService, IMessageEditingService messageEditingService,
IMessageRemovingService messageRemovingService,
IHubUserAccessor userAccessor, IHubUserAccessor userAccessor,
IChatNotificationService notifier, IChatNotificationService notifier,
INowDateTimeProvider nowDateTimeProvider,
IConnectionManager connectionManager) IConnectionManager connectionManager)
{ {
_logger = logger; _logger = logger;
_nowDateTimeProvider = nowDateTimeProvider;
_messageSendingService = messageSendingService; _messageSendingService = messageSendingService;
_messageEditingService = messageEditingService; _messageEditingService = messageEditingService;
_messageRemovingService = messageRemovingService;
_messageReadingService = messageReadingService;
_userAccessor = userAccessor; _userAccessor = userAccessor;
_notifier = notifier; _notifier = notifier;
_connectionManager = connectionManager; _connectionManager = connectionManager;
@@ -85,14 +93,40 @@ public class ChatsHub : Hub
return HubResult<UserMessageResponse>.Ok(response); return HubResult<UserMessageResponse>.Ok(response);
}, request.RecipientId); }, request.RecipientId);
} }
// --- Read ---
public async Task<HubResult<MessageReadResponse>> 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<MessageReadResponse>.Ok(response);
}, request.MessageId);
}
// --- REMOVE --- // --- REMOVE ---
public async Task<HubResult<MessageRemovedResponse>> Remove(RemoveMessageRequest request) public async Task<HubResult<MessageRemovedResponse>> Remove(RemoveMessageRequest request)
{ {
return await SafeExecute(async (userId) => return await SafeExecute(async (userId) =>
{ {
var result = await _messageRemovingService.DeleteMessageAsync( var deletemessage = new DeleteMessage(
new DeleteMessage(
userId, userId,
request.MessageId, request.MessageId,
ForceRemove: request.RequestType switch ForceRemove: request.RequestType switch
@@ -100,8 +134,10 @@ public class ChatsHub : Hub
RemoveMessageRequestType.HideForMe => false, RemoveMessageRequestType.HideForMe => false,
RemoveMessageRequestType.ForceRemove => true, RemoveMessageRequestType.ForceRemove => true,
_ => false _ => false
}) }
); );
var result = await _messageRemovingService.DeleteMessageAsync(deletemessage);
if (!result.IsSuccess) if (!result.IsSuccess)
throw new InvalidOperationException(result.Error.ToString()); throw new InvalidOperationException(result.Error.ToString());
@@ -110,7 +146,8 @@ public class ChatsHub : Hub
{ {
MessageId = request.MessageId, MessageId = request.MessageId,
SenderId = result.Value.SenderId, 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 RecipientType = result.Value.RecipientType
}; };
@@ -125,7 +162,12 @@ public class ChatsHub : Hub
{ {
return await SafeExecute(async (userId) => 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); var result = await _messageEditingService.EditMessageAsync(editParams);
if (!result.IsSuccess || result.OriginalMessage == null) if (!result.IsSuccess || result.OriginalMessage == null)
@@ -199,7 +241,7 @@ public class ChatsHub : Hub
FromUserId: senderId, FromUserId: senderId,
RecipientId: request.RecipientId, RecipientId: request.RecipientId,
RecipientType: request.RecipientType, RecipientType: request.RecipientType,
SendAt: DateTime.UtcNow, SendAt: _nowDateTimeProvider.Now,
Media: request.MediaAttachments?.Select(f => new SendMedia(f.MediaId, f.EncryptedKey)) Media: request.MediaAttachments?.Select(f => new SendMedia(f.MediaId, f.EncryptedKey))
?? Array.Empty<SendMedia>() ?? Array.Empty<SendMedia>()
); );
@@ -5,8 +5,9 @@ public static class ChatHubConstants
public const string ReceiveMessage = "ReceiveMessage"; public const string ReceiveMessage = "ReceiveMessage";
public const string MessageSent = "MessageSent"; public const string MessageSent = "MessageSent";
public const string MessageRemoved = "MessageRemoved"; 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 GetUserGroup(Guid userId) => userId.ToString();
public static string GetChatGroup(Guid groupId) => $"group_{groupId}"; public static string GetChatGroup(Guid groupId) => $"group_{groupId}";
public static string GetPrivateChat(Guid groupId) => $"private_{groupId}"; public static string GetPrivateChat(Guid groupId) => $"private_{groupId}";
@@ -45,6 +45,16 @@ public class ChatNotificationService : IChatNotificationService
// .SendAsync(ChatHubConstants.MessageSent, message); // .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) private async Task NotifyMessageReceivedInPrivateChatAsync(UserMessageResponse message)
{ {
@@ -5,6 +5,7 @@ namespace Govor.API.Hubs.Infrastructure;
public interface IChatNotificationService public interface IChatNotificationService
{ {
Task NotifyMessageSentAsync(UserMessageResponse message); Task NotifyMessageSentAsync(UserMessageResponse message);
Task NotifyMessageWasReadAsync(MessageReadResponse response);
Task NotifyMessageRemovedAsync(MessageRemovedResponse response); Task NotifyMessageRemovedAsync(MessageRemovedResponse response);
Task NotifyMessageEditedAsync(MessageEditResponse response); Task NotifyMessageEditedAsync(MessageEditResponse response);
} }
+4
View File
@@ -19,5 +19,9 @@
}, },
"EncryptionOption": { "EncryptionOption": {
"Secret": "8B2j9kkw9xP5m7nQwE2zY3A-=Q8zP7C4+TqLZpg" "Secret": "8B2j9kkw9xP5m7nQwE2zY3A-=Q8zP7C4+TqLZpg"
},
"MessageEditingOptions": {
"Enabled": true,
"MaxEditTimeMinutes": 15
} }
} }
@@ -0,0 +1,6 @@
namespace Govor.Application.Infrastructure.Common;
public interface INowDateTimeProvider
{
DateTime Now { get; }
}
@@ -0,0 +1,6 @@
namespace Govor.Application.Infrastructure.Common;
public class NowDateTimeProvider : INowDateTimeProvider
{
public DateTime Now => DateTime.UtcNow;
}
@@ -0,0 +1,10 @@
using Govor.Domain.Common;
using Govor.Domain.Models.Messages;
using SmartRes;
namespace Govor.Application.Messages;
public interface IMessageReadingService
{
Task<Result<Message, Error>> ReadMessageAsync(Guid readerId, Guid messageId);
}
@@ -0,0 +1,7 @@
namespace Govor.Application.Messages;
public class MessageEditingOptions
{
public bool Enabled { get; set; }
public int MaxEditTimeMinutes { get; set; } = 15;
}
@@ -3,18 +3,24 @@ using Microsoft.EntityFrameworkCore;
using Govor.Domain; using Govor.Domain;
using Govor.Domain.Models.Messages; using Govor.Domain.Models.Messages;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Govor.Application.Messages; namespace Govor.Application.Messages;
public class MessageEditingService : IMessageEditingService public class MessageEditingService : IMessageEditingService
{ {
private readonly GovorDbContext _dbContext; private readonly GovorDbContext _dbContext;
private readonly ILogger<MessageEditingService> _logger; private readonly ILogger<MessageEditingService> _logger;
private readonly MessageEditingOptions _options;
public MessageEditingService(GovorDbContext dbContext, ILogger<MessageEditingService> logger) public MessageEditingService(
GovorDbContext dbContext,
ILogger<MessageEditingService> logger,
IOptions<MessageEditingOptions> options)
{ {
_dbContext = dbContext; _dbContext = dbContext;
_logger = logger; _logger = logger;
_options = options.Value;
} }
public async Task<EditMessageResult> EditMessageAsync(EditMessage editParams) public async Task<EditMessageResult> EditMessageAsync(EditMessage editParams)
@@ -25,15 +31,47 @@ public class MessageEditingService : IMessageEditingService
if (message == null) 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) if (message.SenderId != editParams.EditorId)
{ {
_logger.LogWarning("User {EditorId} unauthorized to edit message {MessageId}", editParams.EditorId, editParams.MessageId); _logger.LogWarning(
return new EditMessageResult(false, new UnauthorizedAccessException("User is not authorized to edit this message."), null); "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 var originalMessageSnapshot = new Message
{ {
Id = message.Id, Id = message.Id,
@@ -44,14 +82,20 @@ public class MessageEditingService : IMessageEditingService
ReplyToMessageId = message.ReplyToMessageId, ReplyToMessageId = message.ReplyToMessageId,
MediaAttachments = message.MediaAttachments?.ToList() ?? [] MediaAttachments = message.MediaAttachments?.ToList() ?? []
}; };
message.EncryptedContent = editParams.NewContent; message.EncryptedContent = editParams.NewContent;
message.IsEdited = true; message.IsEdited = true;
message.EditedAt = editParams.EditedAt; message.EditedAt = editParams.EditedAt;
await _dbContext.SaveChangesAsync(); await _dbContext.SaveChangesAsync();
_logger.LogInformation("Message {MessageId} edited successfully.", editParams.MessageId); _logger.LogInformation(
return new EditMessageResult(true, null, originalMessageSnapshot); "Message {MessageId} edited successfully.",
editParams.MessageId);
return new EditMessageResult(
true,
null,
originalMessageSnapshot);
} }
} }
@@ -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<MessageReadingService> _logger;
private readonly INowDateTimeProvider _dateTimeProvider;
public MessageReadingService(
GovorDbContext dbContext,
ILogger<MessageReadingService> logger,
INowDateTimeProvider dateTimeProvider)
{
_dbContext = dbContext;
_logger = logger;
_dateTimeProvider = dateTimeProvider;
}
public async Task<Result<Message, Error>> 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<Message, Error>.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;
}
}
}
@@ -64,7 +64,7 @@ public class MessageRemovingService : IMessageRemovingService
private async Task<Result<Message,Error>> ValidateUserRecipientAsync(Message message, DeleteMessage deleteParams) private async Task<Result<Message,Error>> ValidateUserRecipientAsync(Message message, DeleteMessage deleteParams)
{ {
if (deleteParams.DeleterId == message.RecipientId) if (deleteParams.DeleterId == message.SenderId)
{ {
return await ForceRemoveAsync(message); return await ForceRemoveAsync(message);
} }
@@ -1,3 +1,4 @@
using Govor.Application.Infrastructure.Common;
using Govor.Domain; using Govor.Domain;
using Govor.Domain.Common; using Govor.Domain.Common;
using Govor.Domain.Models.Users; using Govor.Domain.Models.Users;
@@ -11,9 +12,13 @@ public class PushTokenService : IPushTokenService
{ {
private readonly GovorDbContext _context; private readonly GovorDbContext _context;
private readonly ILogger<PushTokenService> _logger; private readonly ILogger<PushTokenService> _logger;
private readonly INowDateTimeProvider _nowDateTimeProvider;
public PushTokenService(GovorDbContext context, ILogger<PushTokenService> logger) public PushTokenService(GovorDbContext context,
INowDateTimeProvider nowDateTimeProvider,
ILogger<PushTokenService> logger)
{ {
_nowDateTimeProvider = nowDateTimeProvider;
_context = context; _context = context;
_logger = logger; _logger = logger;
} }
@@ -139,7 +144,7 @@ public class PushTokenService : IPushTokenService
} }
var existingToken = await _context.UserPushTokens 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) if (existingToken is null)
{ {
@@ -150,18 +155,18 @@ public class PushTokenService : IPushTokenService
UserSessionId = sessionId, UserSessionId = sessionId,
Token = token, Token = token,
Platform = platform, Platform = platform,
CreatedAt = DateTime.UtcNow CreatedAt = _nowDateTimeProvider.Now
}; };
await _context.UserPushTokens.AddAsync(newToken); await _context.UserPushTokens.AddAsync(newToken);
} }
else else
{ {
existingToken.UserId = userId; existingToken.UserId = userId;
existingToken.UserSessionId = sessionId; existingToken.UserSessionId = sessionId;
existingToken.Platform = platform; existingToken.Platform = platform;
existingToken.UpdatedAt = DateTime.UtcNow; existingToken.Token = token;
existingToken.UpdatedAt = _nowDateTimeProvider.Now;
} }
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
@@ -0,0 +1,6 @@
namespace Govor.Contracts.Requests.SignalR;
public class ReadMessageRequest
{
public Guid MessageId { get; set; }
}
@@ -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; }
}