Перепиливание под чистый DDD

This commit is contained in:
Халимов Рустам
2026-03-22 23:59:33 +03:00
parent 5da1a2f45d
commit 6e532b021d
302 changed files with 3595 additions and 3679 deletions

View File

@@ -0,0 +1,11 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Conversations.Application.Abstractions;
/// <summary>
/// Unit of Work специфичный для модуля Chats.
/// </summary>
public interface IChatsUnitOfWork : IUnitOfWork
{
}

View File

@@ -0,0 +1,130 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using MediatR;
using System.Linq;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
namespace Knot.Modules.Conversations.Application.Chats.Avatar;
public record UploadGroupAvatarCommand(Guid ChatId, Guid UserId, string FileName, string ContentType, Stream FileStream) : ICommand<Guid>;
internal sealed class UploadGroupAvatarCommandHandler : ICommandHandler<UploadGroupAvatarCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
private readonly IFileStorageService _fileStorage;
public UploadGroupAvatarCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow, IFileStorageService fileStorage)
{
_chatRepository = chatRepository;
_uow = uow;
_fileStorage = fileStorage;
}
public async Task<Result<Guid>> Handle(UploadGroupAvatarCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
}
var fileId = await _fileStorage.UploadFileAsync(request.FileStream, request.FileName, request.ContentType);
var url = $"/api/files/{fileId}";
chat.UpdateAvatar(url);
_chatRepository.Update(chat);
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(request.ChatId);
}
}
public record CropGroupAvatarCommand(Guid ChatId, Guid UserId, string FileName, string ContentType, Stream FileStream, int X, int Y, int Width, int Height) : ICommand<Guid>;
internal sealed class CropGroupAvatarCommandHandler : ICommandHandler<CropGroupAvatarCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
private readonly IFileStorageService _fileStorage;
public CropGroupAvatarCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow, IFileStorageService fileStorage)
{
_chatRepository = chatRepository;
_uow = uow;
_fileStorage = fileStorage;
}
public async Task<Result<Guid>> Handle(CropGroupAvatarCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
}
string url;
using (var image = await SixLabors.ImageSharp.Image.LoadAsync(request.FileStream))
{
int startX = Math.Max(0, Math.Min(request.X, image.Width - 1));
int startY = Math.Max(0, Math.Min(request.Y, image.Height - 1));
int rectWidth = Math.Max(1, Math.Min(request.Width, image.Width - startX));
int rectHeight = Math.Max(1, Math.Min(request.Height, image.Height - startY));
image.Mutate(ctx => ctx.Crop(new SixLabors.ImageSharp.Rectangle(startX, startY, rectWidth, rectHeight)));
image.Mutate(ctx => ctx.Resize(400, 400));
using var outStream = new MemoryStream();
await image.SaveAsJpegAsync(outStream, cancellationToken);
outStream.Position = 0;
var fileName = request.FileName ?? "avatar.jpg";
var fileId = await _fileStorage.UploadFileAsync(outStream, fileName, "image/jpeg");
url = $"/api/files/{fileId}";
}
chat.UpdateAvatar(url);
_chatRepository.Update(chat);
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(request.ChatId);
}
}
public record RemoveGroupAvatarCommand(Guid ChatId, Guid UserId) : ICommand<Guid>;
internal sealed class RemoveGroupAvatarCommandHandler : ICommandHandler<RemoveGroupAvatarCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
public RemoveGroupAvatarCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
{
_chatRepository = chatRepository;
_uow = uow;
}
public async Task<Result<Guid>> Handle(RemoveGroupAvatarCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
}
chat.UpdateAvatar(null);
_chatRepository.Update(chat);
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(request.ChatId);
}
}

View File

@@ -0,0 +1,35 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using MediatR;
using System.Linq;
namespace Knot.Modules.Conversations.Application.Chats.Clear;
public record ClearChatCommand(Guid ChatId, Guid UserId) : ICommand<MessageResponse>;
internal sealed class ClearChatCommandHandler : ICommandHandler<ClearChatCommand, MessageResponse>
{
private readonly IChatRepository _chatRepository;
public ClearChatCommandHandler(IChatRepository chatRepository)
{
_chatRepository = chatRepository;
}
public async Task<Result<MessageResponse>> Handle(ClearChatCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<MessageResponse>(ChatErrors.ChatNotFound);
}
// Currently a placeholder
return Result.Success(new MessageResponse("Cleared"));
}
}

View File

@@ -0,0 +1,44 @@
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Application.Chats.Create;
/// <summary>
/// Команда для создания чата.
/// </summary>
public sealed record CreateChatCommand(
string Name,
ChatType Type,
List<Guid> MemberIds) : ICommand<Guid>;
public sealed class CreateChatCommandHandler : ICommandHandler<CreateChatCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _unitOfWork;
public CreateChatCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork)
{
_chatRepository = chatRepository;
_unitOfWork = unitOfWork;
}
public async Task<Result<Guid>> Handle(CreateChatCommand request, CancellationToken cancellationToken)
{
var chat = Chat.Create(request.Name, request.Type);
for (int i = 0; i < request.MemberIds.Count; i++)
{
var userId = request.MemberIds[i];
var role = (i == 0) ? ChatRole.Owner : ChatRole.Member;
chat.AddMember(userId, role);
}
_chatRepository.Add(chat);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(chat.Id);
}
}

View File

@@ -0,0 +1,159 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Application.Chats.GetChatById;
public record GetChatByIdQuery(Guid UserId, Guid ChatId) : IQuery<ChatDto?>;
internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery, ChatDto?>
{
private readonly IChatRepository _chatRepository;
private readonly IUserDisplayNameProvider _userProvider;
private readonly IMessageRepository _messageRepository;
private readonly IMessageReactionRepository _reactionRepository;
public GetChatByIdQueryHandler(IChatRepository chatRepository, IUserDisplayNameProvider userProvider, IMessageRepository messageRepository, IMessageReactionRepository reactionRepository)
{
_chatRepository = chatRepository;
_userProvider = userProvider;
_messageRepository = messageRepository;
_reactionRepository = reactionRepository;
}
public async Task<Result<ChatDto?>> Handle(GetChatByIdQuery request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null)
{
return Result.Success<ChatDto?>(null);
}
if (!chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<ChatDto?>(ChatErrors.ChatsForbidden);
}
var userIdsToFetch = new HashSet<Guid>();
foreach (var member in chat.Members)
{
userIdsToFetch.Add(member.UserId);
}
var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken);
var latestReactions = latestMessage != null
? await _reactionRepository.GetReactionsForMessageAsync(latestMessage.Id, cancellationToken)
: new List<MessageReaction>();
if (latestMessage != null)
{
userIdsToFetch.Add(latestMessage.SenderId);
foreach (var reaction in latestReactions)
{
userIdsToFetch.Add(reaction.UserId);
}
}
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
var members = new List<ChatMemberDto>();
foreach (var member in chat.Members)
{
usersInfo.TryGetValue(member.UserId, out var user);
members.Add(new ChatMemberDto(
member.Id,
member.UserId,
member.Role,
member.IsPinned,
user != null ? new ChatUserDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
false,
DateTime.UtcNow
) : null
));
}
var messagesList = new List<ChatMessageDto>();
if (latestMessage != null)
{
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
var reactionsWithUser = new List<ReactionDto>();
foreach (var reaction in latestReactions)
{
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
reactionsWithUser.Add(new ReactionDto(
reaction.Id,
reaction.Emoji,
reaction.UserId,
reactionUser != null
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null)
));
}
var readByList = chat.Members
.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId)
.Select(m => new ReadByDto(m.UserId))
.ToList();
messagesList.Add(new ChatMessageDto(
latestMessage.Id,
latestMessage.ChatId,
latestMessage.SenderId,
latestMessage.Content,
latestMessage.Type,
latestMessage.ReplyToId,
latestMessage.Quote,
latestMessage.StoryId,
latestMessage.StoryMediaUrl,
latestMessage.StoryMediaType,
latestMessage.IsEdited,
latestMessage.IsDeleted,
latestMessage.CreatedAt,
latestMessage.SequenceId,
latestMessage.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
senderObj != null ? new MessageSenderDto(
senderObj.Id,
senderObj.Username,
senderObj.DisplayName,
senderObj.Avatar
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
reactionsWithUser,
readByList
));
}
var currentMember = chat.Members.First(m => m.UserId == request.UserId);
var unreadCount = (int)Math.Max(0, chat.LastMessageSequenceId - currentMember.LastReadSequenceId);
var dto = new ChatDto(
chat.Id,
chat.Type.ToString().ToLowerInvariant(),
chat.Type == ChatType.Favorites ? "Избранное" : (chat.Type == ChatType.Personal ? null : chat.Name),
chat.Description,
chat.Avatar,
chat.CreatedAt,
members,
messagesList,
unreadCount
);
return Result.Success<ChatDto?>(dto);
}
}

View File

@@ -0,0 +1,153 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Application.Chats.GetChats;
public record GetChatsQuery(Guid UserId) : IQuery<List<ChatDto>>;
internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<ChatDto>>
{
private readonly IChatRepository _chatRepository;
private readonly IUserDisplayNameProvider _userProvider;
private readonly IMessageRepository _messageRepository;
private readonly IMessageReactionRepository _reactionRepository;
public GetChatsQueryHandler(IChatRepository chatRepository, IUserDisplayNameProvider userProvider, IMessageRepository messageRepository, IMessageReactionRepository reactionRepository)
{
_chatRepository = chatRepository;
_userProvider = userProvider;
_messageRepository = messageRepository;
_reactionRepository = reactionRepository;
}
public async Task<Result<List<ChatDto>>> Handle(GetChatsQuery request, CancellationToken cancellationToken)
{
var userChats = await _chatRepository.GetUserChatsAsync(request.UserId, cancellationToken);
var dtos = new List<ChatDto>();
foreach (var chat in userChats)
{
var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken);
var latestReactions = latestMessage != null
? await _reactionRepository.GetReactionsForMessageAsync(latestMessage.Id, cancellationToken)
: new List<MessageReaction>();
var userIdsToFetch = new HashSet<Guid>();
foreach (var member in chat.Members)
{
userIdsToFetch.Add(member.UserId);
}
if (latestMessage != null)
{
userIdsToFetch.Add(latestMessage.SenderId);
foreach (var r in latestReactions)
{
userIdsToFetch.Add(r.UserId);
}
}
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
var members = new List<ChatMemberDto>();
foreach (var member in chat.Members)
{
usersInfo.TryGetValue(member.UserId, out var user);
members.Add(new ChatMemberDto(
member.Id,
member.UserId,
member.Role,
member.IsPinned,
user != null ? new ChatUserDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
false,
DateTime.UtcNow
) : null
));
}
var messagesList = new List<ChatMessageDto>();
if (latestMessage != null)
{
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
var reactionsWithUser = new List<ReactionDto>();
foreach (var reaction in latestReactions)
{
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
reactionsWithUser.Add(new ReactionDto(
reaction.Id,
reaction.Emoji,
reaction.UserId,
reactionUser != null
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null)
));
}
messagesList.Add(new ChatMessageDto(
latestMessage.Id,
latestMessage.ChatId,
latestMessage.SenderId,
latestMessage.Content,
latestMessage.Type,
latestMessage.ReplyToId,
latestMessage.Quote,
latestMessage.StoryId,
latestMessage.StoryMediaUrl,
latestMessage.StoryMediaType,
latestMessage.IsEdited,
latestMessage.IsDeleted,
latestMessage.CreatedAt,
latestMessage.SequenceId,
latestMessage.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
senderObj != null ? new MessageSenderDto(
senderObj.Id,
senderObj.Username,
senderObj.DisplayName,
senderObj.Avatar
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
reactionsWithUser,
chat.Members.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId).Select(m => new ReadByDto(m.UserId)).ToList()
));
}
var currentMember = chat.Members.FirstOrDefault(m => m.UserId == request.UserId);
var unreadCount = currentMember != null ? (int)Math.Max(0, chat.LastMessageSequenceId - currentMember.LastReadSequenceId) : 0;
dtos.Add(new ChatDto(
chat.Id,
chat.Type.ToString().ToLowerInvariant(),
chat.Type == ChatType.Favorites ? "Избранное" : (chat.Type == ChatType.Personal ? null : chat.Name),
chat.Description,
chat.Avatar,
chat.CreatedAt,
members,
messagesList,
unreadCount
));
}
var sorted = dtos.OrderByDescending(d => d.Messages.FirstOrDefault()?.CreatedAt ?? d.CreatedAt).ToList();
return Result.Success(sorted);
}
}

View File

@@ -0,0 +1,39 @@
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Application.Chats.GetOrCreateFavorites;
public sealed record GetOrCreateFavoritesCommand(Guid UserId) : ICommand<Guid>;
public sealed class GetOrCreateFavoritesCommandHandler : ICommandHandler<GetOrCreateFavoritesCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _unitOfWork;
public GetOrCreateFavoritesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork)
{
_chatRepository = chatRepository;
_unitOfWork = unitOfWork;
}
public async Task<Result<Guid>> Handle(GetOrCreateFavoritesCommand request, CancellationToken cancellationToken)
{
var favorites = await _chatRepository.GetFavoritesAsync(request.UserId, cancellationToken);
if (favorites != null)
{
return Result.Success(favorites.Id);
}
// Create new favorites chat
var chat = Chat.Create("Избранное", ChatType.Favorites);
chat.AddMember(request.UserId, ChatRole.Owner);
_chatRepository.Add(chat);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(chat.Id);
}
}

View File

@@ -0,0 +1,54 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using MediatR;
using System.Linq;
namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete;
public record LeaveOrDeleteChatCommand(Guid ChatId, Guid UserId) : ICommand<SuccessResponse>;
internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrDeleteChatCommand, SuccessResponse>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
public LeaveOrDeleteChatCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
{
_chatRepository = chatRepository;
_uow = uow;
}
public async Task<Result<SuccessResponse>> Handle(LeaveOrDeleteChatCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null)
{
return Result.Success(new SuccessResponse(true));
}
if (!chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<SuccessResponse>(ChatErrors.Unauthorized);
}
if (chat.Type == ChatType.Group)
{
chat.RemoveMember(request.UserId);
_chatRepository.Update(chat);
}
else
{
_chatRepository.Remove(chat);
}
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(new SuccessResponse(true));
}
}

View File

@@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using MediatR;
using System.Linq;
namespace Knot.Modules.Conversations.Application.Chats.Members;
public record AddMembersCommand(Guid ChatId, Guid UserId, List<Guid> UserIdsToAdd) : ICommand<Guid>;
internal sealed class AddMembersCommandHandler : ICommandHandler<AddMembersCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
public AddMembersCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
{
_chatRepository = chatRepository;
_uow = uow;
}
public async Task<Result<Guid>> Handle(AddMembersCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
}
foreach (var userId in request.UserIdsToAdd)
{
chat.AddMember(userId);
}
_chatRepository.Update(chat);
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(request.ChatId);
}
}
public record RemoveMemberCommand(Guid ChatId, Guid UserId, Guid UserIdToRemove) : ICommand<Guid>;
internal sealed class RemoveMemberCommandHandler : ICommandHandler<RemoveMemberCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
public RemoveMemberCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
{
_chatRepository = chatRepository;
_uow = uow;
}
public async Task<Result<Guid>> Handle(RemoveMemberCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
}
chat.RemoveMember(request.UserIdToRemove);
_chatRepository.Update(chat);
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(request.ChatId);
}
}

View File

@@ -0,0 +1,47 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.DTOs;
using MediatR;
using System.Linq;
namespace Knot.Modules.Conversations.Application.Chats.TogglePin;
public record TogglePinCommand(Guid ChatId, Guid UserId) : ICommand<TogglePinResponse>;
internal sealed class TogglePinCommandHandler : ICommandHandler<TogglePinCommand, TogglePinResponse>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
public TogglePinCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
{
_chatRepository = chatRepository;
_uow = uow;
}
public async Task<Result<TogglePinResponse>> Handle(TogglePinCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null)
{
return Result.Failure<TogglePinResponse>(ChatErrors.ChatNotFound);
}
var member = chat.Members.FirstOrDefault(m => m.UserId == request.UserId);
if (member == null)
{
return Result.Failure<TogglePinResponse>(ChatErrors.ChatNotFound);
}
member.TogglePin();
_chatRepository.Update(chat);
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(new TogglePinResponse(member.IsPinned));
}
}

View File

@@ -0,0 +1,49 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using MediatR;
using System.Linq;
namespace Knot.Modules.Conversations.Application.Chats.Update;
public record UpdateChatCommand(Guid ChatId, Guid UserId, string? Name, string? Description) : ICommand<Guid>;
internal sealed class UpdateChatCommandHandler : ICommandHandler<UpdateChatCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
public UpdateChatCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
{
_chatRepository = chatRepository;
_uow = uow;
}
public async Task<Result<Guid>> Handle(UpdateChatCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
}
if (request.Name != null)
{
chat.UpdateName(request.Name);
}
if (request.Description != null)
{
chat.UpdateDescription(request.Description);
}
_chatRepository.Update(chat);
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(chat.Id);
}
}

View File

@@ -0,0 +1,7 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Conversations.Application.DTOs;
public sealed record AddMembersRequest(List<Guid> UserIds);

View File

@@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Conversations.Application.DTOs;
public record ChatDto(
Guid Id,
string Type,
string? Name,
string? Description,
string? Avatar,
DateTime CreatedAt,
List<ChatMemberDto> Members,
List<ChatMessageDto> Messages,
int UnreadCount
);

View File

@@ -0,0 +1,13 @@
using Knot.Modules.Conversations.Domain;
using System;
namespace Knot.Modules.Conversations.Application.DTOs;
public record ChatMemberDto(
Guid Id,
Guid UserId,
string Role,
bool IsPinned,
ChatUserDto? User
);

View File

@@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Conversations.Application.DTOs;
public record ChatMessageDto(
Guid Id,
Guid ChatId,
Guid SenderId,
string? Content,
string Type,
Guid? ReplyToId,
string? Quote,
Guid? StoryId,
string? StoryMediaUrl,
string? StoryMediaType,
bool IsEdited,
bool IsDeleted,
DateTime CreatedAt,
long SequenceId,
List<MediaDto> Media,
MessageSenderDto Sender,
List<ReactionDto> Reactions,
List<ReadByDto> ReadBy
);

View File

@@ -0,0 +1,13 @@
using System;
namespace Knot.Modules.Conversations.Application.DTOs;
public record ChatUserDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar,
bool IsOnline,
DateTime LastSeen
);

View File

@@ -0,0 +1,8 @@
using System;
using System.Collections.Generic;
using Knot.Modules.Conversations.Domain;
namespace Knot.Modules.Conversations.Application.DTOs;
public sealed record CreateChatRequest(string Name, ChatType Type, List<Guid> MemberIds);

View File

@@ -0,0 +1,7 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Conversations.Application.DTOs;
public sealed record CreateGroupChatRequest(string Name, List<Guid> MemberIds);

View File

@@ -0,0 +1,4 @@
namespace Knot.Modules.Conversations.Application.DTOs;
using System;
public record CreatePersonalChatRequest(Guid UserId);

View File

@@ -0,0 +1,12 @@
using System;
namespace Knot.Modules.Conversations.Application.DTOs;
public record MediaDto(
Guid Id,
string Type,
string Url,
string? Filename,
long? Size
);

View File

@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Conversations.Application.DTOs;
public record MessageDetailDto(
Guid Id,
Guid ChatId,
Guid SenderId,
string? Content,
string Type,
Guid? ReplyToId,
ReplyToMessageDto? ReplyTo,
string? Quote,
bool IsEdited,
bool IsDeleted,
DateTime CreatedAt,
long SequenceId,
Guid? ForwardedFromId,
MessageSenderDto? ForwardedFrom,
Guid? StoryId,
string? StoryMediaUrl,
string? StoryMediaType,
List<MediaDto> Media,
MessageSenderDto? Sender,
List<ReadByDto> ReadBy,
List<MessageReactionDto> Reactions
);
public record ReplyToMessageDto(
Guid Id,
string? Content,
bool IsDeleted,
List<MediaDto> Media,
MessageSenderDto? Sender
);
public record MessageReactionDto(
Guid Id,
string Emoji,
Guid UserId,
MessageSenderDto? User
);

View File

@@ -0,0 +1,11 @@
using System;
namespace Knot.Modules.Conversations.Application.DTOs;
public record MessageSenderDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar
);

View File

@@ -0,0 +1,11 @@
using System;
namespace Knot.Modules.Conversations.Application.DTOs;
public record ReactionDto(
Guid Id,
string Emoji,
Guid UserId,
MessageSenderDto User
);

View File

@@ -0,0 +1,8 @@
using System;
namespace Knot.Modules.Conversations.Application.DTOs;
public record ReadByDto(
Guid UserId
);

View File

@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Conversations.Application.DTOs;
public record SearchMessageDto(
Guid Id,
Guid ChatId,
Guid SenderId,
string? Content,
string Type,
Guid? ReplyToId,
string? Quote,
bool IsEdited,
bool IsDeleted,
DateTime CreatedAt,
long SequenceId,
Guid? ForwardedFromId,
MessageSenderDto? ForwardedFrom,
Guid? StoryId,
string? StoryMediaUrl,
string? StoryMediaType,
List<MediaDto> Media,
MessageSenderDto Sender,
List<SimpleReactionDto> Reactions,
List<ReadByDto> ReadBy
);
public record SimpleReactionDto(
Guid UserId,
string Emoji
);

View File

@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Conversations.Application.DTOs;
public sealed record SendMessageRequest(
string? Content,
string Type,
List<AttachmentDto>? Attachments = null,
Guid? ReplyToId = null,
string? Quote = null,
Guid? ForwardedFromId = null);
public sealed record AttachmentDto(string Type, string Url, string? FileName, long? FileSize);

View File

@@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Conversations.Application.DTOs;
public record SharedMediaDto(
Guid Id,
string? Content,
DateTime CreatedAt,
List<string>? Links,
MessageSenderDto? Sender,
Guid? ReplyToId,
string? Quote,
Guid? StoryId,
string? StoryMediaUrl,
string? StoryMediaType,
bool? IsEdited,
string? Type,
List<MediaDto>? Media
);

View File

@@ -0,0 +1,4 @@
namespace Knot.Modules.Conversations.Application.DTOs;
public record TogglePinResponse(bool IsPinned);

View File

@@ -0,0 +1,4 @@
namespace Knot.Modules.Conversations.Application.DTOs;
public sealed record UpdateChatRequest(string? Name, string? Description);

View File

@@ -0,0 +1,11 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Conversations.Application.DTOs;
public record UploadFileResponseDto(
string Url,
string Filename,
long Size
);

View File

@@ -0,0 +1,83 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using MediatR;
using Microsoft.AspNetCore.SignalR;
using global::Knot.Modules.Conversations.Domain;
using global::Knot.Modules.Conversations.Infrastructure.SignalR;
using global::Knot.Shared.Kernel;
using global::Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Application.Messages.Delete;
public sealed record DeleteMessagesCommand(
Guid ChatId,
Guid UserId,
List<Guid> MessageIds,
bool DeleteForAll) : ICommand;
public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessagesCommand>
{
private readonly IMessageRepository _messageRepository;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly IHubContext<ChatHub> _hubContext;
public DeleteMessagesCommandHandler(
IMessageRepository messageRepository,
IChatsUnitOfWork unitOfWork,
IHubContext<ChatHub> hubContext)
{
_messageRepository = messageRepository;
_unitOfWork = unitOfWork;
_hubContext = hubContext;
}
public async Task<global::Knot.Shared.Kernel.Result> Handle(DeleteMessagesCommand request, CancellationToken cancellationToken)
{
foreach (var id in request.MessageIds)
{
var message = await _messageRepository.GetByIdAsync(id, cancellationToken);
if (message is null || message.ChatId != request.ChatId)
{
continue;
}
if (request.DeleteForAll)
{
if (message.SenderId == request.UserId)
{
message.Delete();
}
}
else
{
message.DeleteForUser(request.UserId);
}
await _messageRepository.UpdateAsync(message, cancellationToken);
}
if (request.DeleteForAll)
{
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new
{
chatId = request.ChatId,
messageIds = request.MessageIds,
deleteForAll = true
});
}
else
{
// Уведомляем только самого пользователя (все его текущие сессии)
await _hubContext.Clients.User(request.UserId.ToString()).SendAsync("messages_deleted", new
{
chatId = request.ChatId,
messageIds = request.MessageIds,
deleteForAll = false
});
}
return global::Knot.Shared.Kernel.Result.Success();
}
}

View File

@@ -0,0 +1,151 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Application.Messages.GetMessages;
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor) : IQuery<List<MessageDetailDto>>;
internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>>
{
private readonly IMessageRepository _messageRepository;
private readonly IUserDisplayNameProvider _userProvider;
private readonly IChatRepository _chatRepository;
private readonly IMessageReactionRepository _reactionRepository;
public GetMessagesQueryHandler(IMessageRepository messageRepository, IUserDisplayNameProvider userProvider, IChatRepository chatRepository, IMessageReactionRepository reactionRepository)
{
_messageRepository = messageRepository;
_userProvider = userProvider;
_chatRepository = chatRepository;
_reactionRepository = reactionRepository;
}
public async Task<Result<List<MessageDetailDto>>> Handle(GetMessagesQuery request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<List<MessageDetailDto>>(ChatErrors.ChatsForbidden);
}
DateTime? cursorDate = null;
if (!string.IsNullOrEmpty(request.Cursor) && DateTime.TryParse(request.Cursor, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
{
cursorDate = parsed.ToUniversalTime();
}
var messages = await _messageRepository.GetChatMessagesCursorAsync(request.ChatId, cursorDate, ChatConstants.DefaultMessageQueryLimit, cancellationToken);
var result = new List<MessageDetailDto>();
var userIdsToFetch = new HashSet<Guid>();
var replyMessages = new Dictionary<Guid, Message>();
// Filter messages that are deleted for the current user before processing
var filteredMessages = messages.Where(m => !m.IsDeletedForUser(request.UserId)).ToList();
foreach (var m in filteredMessages)
{
userIdsToFetch.Add(m.SenderId);
if (!m.ReplyToId.HasValue)
{
continue;
}
var replyMsg = await _messageRepository.GetByIdAsync(m.ReplyToId.Value, cancellationToken);
if (replyMsg == null)
{
continue;
}
replyMessages[replyMsg.Id] = replyMsg;
userIdsToFetch.Add(replyMsg.SenderId);
}
var senders = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
var messageIds = filteredMessages.Select(m => m.Id).ToList();
var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken);
var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList());
foreach (var message in messages)
{
if (message.IsDeletedForUser(request.UserId))
{
continue;
}
ReplyToMessageDto? replyToObj = null;
if (message.ReplyToId.HasValue && replyMessages.TryGetValue(message.ReplyToId.Value, out var replyMsg))
{
var senderObj = senders.TryGetValue(replyMsg.SenderId, out var rs)
? new MessageSenderDto(rs.Id, rs.Username, rs.DisplayName, rs.Avatar)
: null;
replyToObj = new ReplyToMessageDto(
replyMsg.Id,
replyMsg.Content,
replyMsg.IsDeleted,
replyMsg.Media.Select(rm => new MediaDto(rm.Id, rm.Type, rm.Url, rm.Filename, rm.Size)).ToList(),
senderObj
);
}
var reactionsWithUser = new List<MessageReactionDto>();
var messageReactions = reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr : new List<MessageReaction>();
foreach (var reaction in messageReactions)
{
var userObj = senders.TryGetValue(reaction.UserId, out var reactionUser)
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null);
reactionsWithUser.Add(new MessageReactionDto(
reaction.Id,
reaction.Emoji,
reaction.UserId,
userObj
));
}
result.Add(new MessageDetailDto(
message.Id,
message.ChatId,
message.SenderId,
message.Content,
message.Type,
message.ReplyToId,
replyToObj,
message.Quote,
message.IsEdited,
message.IsDeleted,
message.CreatedAt,
message.SequenceId,
message.ForwardedFromId,
message.ForwardedFromId.HasValue && senders.TryGetValue(message.ForwardedFromId.Value, out var fwdUser) ? new MessageSenderDto(fwdUser.Id, fwdUser.Username, fwdUser.DisplayName, fwdUser.Avatar) : null,
message.StoryId,
message.StoryMediaUrl,
message.StoryMediaType,
message.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : null,
chat.Members.Where(m => m.LastReadSequenceId >= message.SequenceId && m.UserId != message.SenderId).Select(m => new ReadByDto(m.UserId)).ToList(),
reactionsWithUser
));
}
return Result.Success(result);
}
}

View File

@@ -0,0 +1,131 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Application.Messages.GetSharedMedia;
public record GetSharedMediaQuery(Guid UserId, Guid ChatId, string? Type) : IQuery<List<SharedMediaDto>>;
internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQuery, List<SharedMediaDto>>
{
private readonly IMessageRepository _messageRepository;
private readonly IUserDisplayNameProvider _userProvider;
private readonly IChatRepository _chatRepository;
public GetSharedMediaQueryHandler(IMessageRepository messageRepository, IUserDisplayNameProvider userProvider, IChatRepository chatRepository)
{
_messageRepository = messageRepository;
_userProvider = userProvider;
_chatRepository = chatRepository;
}
public async Task<Result<List<SharedMediaDto>>> Handle(GetSharedMediaQuery request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(member => member.UserId == request.UserId))
{
return Result.Failure<List<SharedMediaDto>>(ChatErrors.ChatsForbidden);
}
var messages = await _messageRepository.GetChatMessagesAsync(request.ChatId, ChatConstants.MaxSharedMediaQueryLimit, 0, cancellationToken);
messages = messages.Where(message => !message.IsDeletedForUser(request.UserId)).ToList();
var result = new List<SharedMediaDto>();
var filterType = request.Type?.ToLower();
var userIds = messages.Select(message => message.SenderId).Distinct();
var senders = await _userProvider.GetUsersInfoAsync(userIds, cancellationToken);
foreach (var message in messages)
{
if (filterType == "links")
{
var messageContent = message.Content;
var linkRegex = new Regex(@"https?://[^\s]+", RegexOptions.IgnoreCase);
var contentLinks = !string.IsNullOrEmpty(messageContent) ? linkRegex.Matches(messageContent).Select(match => match.Value).ToList() : new List<string>();
var messageMediaColl = message.Media;
var mediaLinks = (messageMediaColl ?? Enumerable.Empty<Media>()).Where(media => media.Type?.ToString().ToLower() == "link").Select(media => media.Url).ToList();
var allLinks = contentLinks.Concat(mediaLinks).Distinct().ToList();
if (allLinks.Any())
{
senders.TryGetValue(message.SenderId, out var sender);
result.Add(new SharedMediaDto(
message.Id,
messageContent,
message.CreatedAt,
allLinks,
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : null,
null, null, null, null, null, null, null, null
));
}
continue;
}
var messageMedia = message.Media;
if (messageMedia == null || !messageMedia.Any())
{
continue;
}
var filteredMedia = messageMedia.Where(media =>
{
var mediaType = media.Type?.ToLower() ?? "file";
var isGif = mediaType == "image" && (media.Url.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) || media.Url.EndsWith(".gif", StringComparison.OrdinalIgnoreCase));
if (filterType == "gifs")
{
return isGif;
}
if (filterType == "files")
{
return mediaType != "image" && mediaType != "video" && mediaType != "link";
}
if (filterType == "media")
{
return mediaType == "image" || mediaType == "video";
}
return true;
}).ToList();
if (filteredMedia.Any())
{
senders.TryGetValue(message.SenderId, out var sender);
result.Add(new SharedMediaDto(
message.Id,
message.Content,
message.CreatedAt,
null,
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : null,
message.ReplyToId,
message.Quote,
message.StoryId,
message.StoryMediaUrl,
message.StoryMediaType,
message.IsEdited,
message.Type,
filteredMedia.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList()
));
}
}
return Result.Success(result.OrderByDescending(x => x.CreatedAt).ToList());
}
}

View File

@@ -0,0 +1,76 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using MediatR;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Shared.Kernel;
namespace Knot.Modules.Conversations.Application.Messages.React;
public sealed record AddReactionCommand(
Guid MessageId,
Guid UserId,
string Emoji,
Guid ChatId) : ICommand;
public sealed class AddReactionCommandHandler : ICommandHandler<AddReactionCommand>
{
private readonly IMessageReactionRepository _reactionRepository;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly IHubContext<ChatHub> _hubContext;
private readonly IUserDisplayNameProvider _displayNameProvider;
private readonly ILogger<AddReactionCommandHandler> _logger;
public AddReactionCommandHandler(
IMessageReactionRepository reactionRepository,
IChatsUnitOfWork unitOfWork,
IHubContext<ChatHub> hubContext,
IUserDisplayNameProvider displayNameProvider,
ILogger<AddReactionCommandHandler> logger)
{
_reactionRepository = reactionRepository;
_unitOfWork = unitOfWork;
_hubContext = hubContext;
_displayNameProvider = displayNameProvider;
_logger = logger;
}
public async Task<Result> Handle(AddReactionCommand request, CancellationToken cancellationToken)
{
_logger.LogInformation("AddReaction: MessageId={MessageId}, UserId={UserId}, Emoji={Emoji}, ChatId={ChatId}",
request.MessageId, request.UserId, request.Emoji, request.ChatId);
var reaction = new MessageReaction(request.MessageId, request.UserId, request.Emoji);
await _reactionRepository.AddAsync(reaction, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
_logger.LogInformation("AddReaction: Reaction saved to database");
var username = await _displayNameProvider.GetDisplayNameAsync(request.UserId, cancellationToken);
_logger.LogInformation("AddReaction: Sending reaction_added to group {ChatId}", request.ChatId);
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("reaction_added", new
{
messageId = request.MessageId,
chatId = request.ChatId,
userId = request.UserId,
username = username,
emoji = request.Emoji
});
_logger.LogInformation("AddReaction: reaction_added sent successfully");
return Result.Success();
}
}

View File

@@ -0,0 +1,67 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using MediatR;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Shared.Kernel;
namespace Knot.Modules.Conversations.Application.Messages.React;
public sealed record RemoveReactionCommand(
Guid MessageId,
Guid UserId,
string Emoji,
Guid ChatId) : ICommand;
public sealed class RemoveReactionCommandHandler : ICommandHandler<RemoveReactionCommand>
{
private readonly IMessageReactionRepository _reactionRepository;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly IHubContext<ChatHub> _hubContext;
private readonly ILogger<RemoveReactionCommandHandler> _logger;
public RemoveReactionCommandHandler(
IMessageReactionRepository reactionRepository,
IChatsUnitOfWork unitOfWork,
IHubContext<ChatHub> hubContext,
ILogger<RemoveReactionCommandHandler> logger)
{
_reactionRepository = reactionRepository;
_unitOfWork = unitOfWork;
_hubContext = hubContext;
_logger = logger;
}
public async Task<Result> Handle(RemoveReactionCommand request, CancellationToken cancellationToken)
{
_logger.LogInformation("RemoveReaction: MessageId={MessageId}, UserId={UserId}, Emoji={Emoji}, ChatId={ChatId}",
request.MessageId, request.UserId, request.Emoji, request.ChatId);
await _reactionRepository.RemoveAsync(
request.MessageId,
request.UserId,
request.Emoji,
cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
_logger.LogInformation("RemoveReaction: Reaction removed from database");
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("reaction_removed", new
{
messageId = request.MessageId,
chatId = request.ChatId,
userId = request.UserId,
emoji = request.Emoji
});
_logger.LogInformation("RemoveReaction: reaction_removed sent successfully");
return Result.Success();
}
}

View File

@@ -0,0 +1,36 @@
using MediatR;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Application.Messages.Read;
public sealed record ReadMessagesCommand(Guid ChatId, Guid UserId, Guid LastReadMessageId, long LastReadSequenceId) : ICommand;
public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCommand>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _unitOfWork;
public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork)
{
_chatRepository = chatRepository;
_unitOfWork = unitOfWork;
}
public async Task<Result> Handle(ReadMessagesCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null) return Result.Failure(ChatErrors.NotFound);
var member = chat.Members.FirstOrDefault(m => m.UserId == request.UserId);
if (member == null) return Result.Failure(ChatErrors.NotMember);
member.UpdateReadCursor(request.LastReadMessageId, request.LastReadSequenceId);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}

View File

@@ -0,0 +1,72 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain;
namespace Knot.Modules.Conversations.Application.Messages.SearchMessages;
public record SearchMessagesQuery(Guid UserId, string Query, Guid? ChatId) : IQuery<List<SearchMessageDto>>;
internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQuery, List<SearchMessageDto>>
{
private readonly IMessageRepository _messageRepository;
private readonly IUserDisplayNameProvider _userProvider;
private readonly IMessageReactionRepository _reactionRepository;
public SearchMessagesQueryHandler(IMessageRepository messageRepository, IUserDisplayNameProvider userProvider, IMessageReactionRepository reactionRepository)
{
_messageRepository = messageRepository;
_userProvider = userProvider;
_reactionRepository = reactionRepository;
}
public async Task<Result<List<SearchMessageDto>>> Handle(SearchMessagesQuery request, CancellationToken cancellationToken)
{
var messages = await _messageRepository.SearchMessagesAsync(request.Query, request.ChatId, request.UserId, cancellationToken);
messages = messages.Where(message => !message.IsDeletedForUser(request.UserId)).ToList();
var userIds = messages.Select(message => message.SenderId).ToList();
userIds.AddRange(messages.Where(message => message.ForwardedFromId.HasValue).Select(message => message.ForwardedFromId!.Value));
var senders = await _userProvider.GetUsersInfoAsync(userIds.Distinct(), cancellationToken);
var messageIds = messages.Select(m => m.Id).ToList();
var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken);
var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList());
var result = messages.Select(message => new SearchMessageDto(
message.Id,
message.ChatId,
message.SenderId,
message.Content,
message.Type,
message.ReplyToId,
message.Quote,
message.IsEdited,
message.IsDeleted,
message.CreatedAt,
message.SequenceId,
message.ForwardedFromId,
null,
message.StoryId,
message.StoryMediaUrl,
message.StoryMediaType,
message.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList() : new List<SimpleReactionDto>(),
new List<ReadByDto>()
)).ToList();
return Result.Success(result);
}
}

View File

@@ -0,0 +1,139 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Application.Messages.Send;
/// <summary>
/// Команда для отправки сообщения в чат.
/// </summary>
public record AttachmentRequest(string Type, string Url, string? FileName, long? FileSize);
public sealed record SendMessageCommand(
Guid ChatId,
Guid SenderId,
string? Content,
string Type,
List<AttachmentRequest>? Attachments = null,
Guid? ReplyToId = null,
string? Quote = null,
Guid? ForwardedFromId = null,
Guid? StoryId = null,
string? StoryMediaUrl = null,
string? StoryMediaType = null) : ICommand<Guid>;
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IMessageRepository _messageRepository;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly MediatR.IMediator _mediator;
public SendMessageCommandHandler(
IChatRepository chatRepository,
IMessageRepository messageRepository,
IChatsUnitOfWork unitOfWork,
MediatR.IMediator mediator)
{
_chatRepository = chatRepository;
_messageRepository = messageRepository;
_unitOfWork = unitOfWork;
_mediator = mediator;
}
public async Task<Result<Guid>> Handle(SendMessageCommand request, CancellationToken cancellationToken)
{
// 1. Проверяем существование чата
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat is null)
{
return Result.Failure<Guid>(ChatErrors.ChatsNotFound);
}
// 2. Проверяем, является ли отправитель участником
if (!chat.Members.Any(m => m.UserId == request.SenderId))
{
return Result.Failure<Guid>(ChatErrors.ChatsForbidden);
}
// 3. Создаем сообщение
Message message;
if (request.Type == "story_reply" || request.Type == "story_reaction")
{
var parsedStoryMediaType = Enum.TryParse<MediaType>(request.StoryMediaType, true, out var sTypeEnum) ? sTypeEnum : MediaType.Image;
message = new StoryMessage(
Guid.NewGuid(),
request.ChatId,
request.SenderId,
request.StoryId ?? Guid.Empty,
request.StoryMediaUrl ?? string.Empty,
parsedStoryMediaType,
request.Content,
request.ReplyToId,
request.ForwardedFromId,
DateTime.UtcNow,
false);
}
else if (request.Attachments != null && request.Attachments.Any())
{
var firstAtt = request.Attachments.First();
var parsedType = Enum.TryParse<MediaType>(firstAtt.Type, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
message = new MediaMessage(
Guid.NewGuid(),
request.ChatId,
request.SenderId,
parsedType,
request.Content,
request.ReplyToId,
request.ForwardedFromId,
DateTime.UtcNow,
false);
foreach (var att in request.Attachments)
{
var pType = Enum.TryParse<MediaType>(att.Type, true, out var tEnum) ? tEnum : MediaType.File;
((MediaMessage)message).AddMedia(pType, att.Url, att.FileName, att.FileSize);
}
}
else
{
message = new TextMessage(
Guid.NewGuid(),
request.ChatId,
request.SenderId,
request.Content ?? string.Empty,
request.ReplyToId,
request.Quote,
request.ForwardedFromId,
DateTime.UtcNow,
false);
}
// 4. Последовательность сообщений High-Water Mark
chat.IncrementSequenceId();
message.SetSequenceId(chat.LastMessageSequenceId);
var senderMember = chat.Members.First(m => m.UserId == request.SenderId);
senderMember.UpdateReadCursor(message.Id, message.SequenceId);
senderMember.UpdateDeliveredCursor(message.Id);
// 5. Сохраняем
_messageRepository.Add(message);
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _mediator.Publish(new MessageSentDomainEvent(
message.Id,
message.ChatId,
message.SenderId,
message.Content),
cancellationToken);
return Result.Success(message.Id);
}
}

View File

@@ -0,0 +1,45 @@
using Knot.Modules.Conversations.Domain;
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Shared.Kernel.Configuration;
using Knot.Modules.Conversations.Application.DTOs;
namespace Knot.Modules.Conversations.Application.Messages.UploadFile;
public record UploadFileCommand(string FileName, string ContentType, long Length, Stream FileStream) : ICommand<UploadFileResponseDto>;
internal sealed class UploadFileCommandHandler : ICommandHandler<UploadFileCommand, UploadFileResponseDto>
{
private readonly IFileStorageService _fileStorage;
private readonly ISettingsService _settingsService;
public UploadFileCommandHandler(IFileStorageService fileStorage, ISettingsService settingsService)
{
_fileStorage = fileStorage;
_settingsService = settingsService;
}
public async Task<Result<UploadFileResponseDto>> Handle(UploadFileCommand request, CancellationToken cancellationToken)
{
if (request.Length == 0)
{
return Result.Failure<UploadFileResponseDto>(ChatErrors.FileEmpty);
}
var maxMb = _settingsService.Current.MaxFileSizeMb;
if (request.Length > maxMb * 1024 * 1024)
{
return Result.Failure<UploadFileResponseDto>(ChatErrors.FileTooLarge(maxMb));
}
var fileId = await _fileStorage.UploadFileAsync(request.FileStream, request.FileName, request.ContentType);
return Result.Success(new UploadFileResponseDto("/api/files/" + fileId, request.FileName, request.Length));
}
}