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
@@ -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.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<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;
_logger = logger;
_options = options.Value;
}
public async Task<EditMessageResult> 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);
}
}
}
@@ -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)
{
if (deleteParams.DeleterId == message.RecipientId)
if (deleteParams.DeleterId == message.SenderId)
{
return await ForceRemoveAsync(message);
}