Перепиливание под чистый 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));
}
}

View File

@@ -0,0 +1,43 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations;
public static class DependencyInjection
{
/// <summary>
/// ╨а╨╡╨│╨╕╤Б╤В╤А╨░╤Ж╨╕╤П ╤Б╨╡╤А╨▓╨╕╤Б╨╛╨▓ ╨╝╨╛╨┤╤Г╨╗╤П Chats.
/// </summary>
public static IServiceCollection AddConversationsModule(
this IServiceCollection services,
IConfiguration configuration)
{
// ╨Э╨░╤Б╤В╤А╨╛╨╣╨║╨░ ╨▒╨░╨╖╤Л ╨┤╨░╨╜╨╜╤Л╤Е
string connectionString = configuration.GetConnectionString("DefaultConnection")!;
services.AddDbContext<ChatsDbContext>(options =>
options.UseNpgsql(connectionString));
// MongoDB Setup for Messages
var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017";
// Registration
services.AddScoped<IChatsUnitOfWork>(sp => sp.GetRequiredService<ChatsDbContext>());
services.AddScoped<IChatRepository, ChatRepository>();
// MediatR
services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
services.AddScoped<Knot.Modules.Messaging.Application.Abstractions.IChatAccessProvider, Knot.Modules.Conversations.Infrastructure.Services.ChatAccessProvider>();
services.AddScoped<Knot.Modules.Messaging.Application.Abstractions.IMessageNotifier, Knot.Modules.Conversations.Infrastructure.SignalR.MessageNotifier>();
return services;
}
}

View File

@@ -0,0 +1,157 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Conversations.Domain;
public sealed record ChatCreatedDomainEvent(Chat Chat) : IDomainEvent;
public sealed record ChatMemberAddedDomainEvent(Guid ChatId, Guid UserId) : IDomainEvent;
/// <summary>
/// Тип чата: личный или групповой.
/// </summary>
public enum ChatType
{
Personal,
Group,
Favorites
}
/// <summary>
/// Роль участника в чате.
/// </summary>
public static class ChatRole
{
public const string Owner = "owner";
public const string Admin = "admin";
public const string Member = "member";
}
/// <summary>
/// Сущность чата (Агрегат).
/// </summary>
public sealed class Chat : AggregateRoot<Guid>
{
public ChatType Type { get; private set; }
public string? Name { get; private set; }
public string? Description { get; private set; }
public string? Avatar { get; private set; }
public DateTime CreatedAt { get; private set; }
public long LastMessageSequenceId { get; private set; }
private readonly List<ChatMember> _members = new();
public IReadOnlyCollection<ChatMember> Members => _members.AsReadOnly();
private Chat(Guid id, ChatType type, string? name, string? avatar, string? description = null) : base(id)
{
Type = type;
Name = name;
Avatar = avatar;
Description = description;
CreatedAt = DateTime.UtcNow;
}
/// <summary>
/// Создает личный чат между двумя пользователями.
/// </summary>
public static Chat CreatePersonal()
{
var chat = new Chat(Guid.NewGuid(), ChatType.Personal, null, null);
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
return chat;
}
/// <summary>
/// Создает групповой чат.
/// </summary>
public static Chat CreateGroup(string name, string? avatar = null)
{
var chat = new Chat(Guid.NewGuid(), ChatType.Group, name, avatar);
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
return chat;
}
/// <summary>
/// Фабричный метод для создания чата.
/// </summary>
public static Chat Create(string? name, ChatType type, string? avatar = null, string? description = null)
{
var chat = new Chat(Guid.NewGuid(), type, name, avatar, description);
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
return chat;
}
public void AddMember(Guid userId, string role = "member")
{
if (_members.Any(m => m.UserId == userId))
{
return;
}
_members.Add(new ChatMember(Id, userId, role));
RaiseDomainEvent(new ChatMemberAddedDomainEvent(Id, userId));
}
public void RemoveMember(Guid userId)
{
var member = _members.FirstOrDefault(m => m.UserId == userId);
if (member != null)
{
_members.Remove(member);
}
}
public void UpdateName(string name) => Name = name;
public void UpdateDescription(string? description) => Description = description;
public void UpdateAvatar(string? avatarUrl) => Avatar = avatarUrl;
public long IncrementSequenceId()
{
return ++LastMessageSequenceId;
}
}
/// <summary>
/// Участник чата.
/// </summary>
public sealed class ChatMember : Entity<Guid>
{
public Guid ChatId { get; private set; }
public Guid UserId { get; private set; }
public string Role { get; private set; }
public DateTime JoinedAt { get; private set; }
public bool IsPinned { get; private set; }
public bool IsMuted { get; private set; }
public Guid? LastReadMessageId { get; private set; }
public long LastReadSequenceId { get; private set; }
public Guid? LastDeliveredMessageId { get; private set; }
// For EF Core
private ChatMember() : base(Guid.Empty) { Role = "member"; }
internal ChatMember(Guid chatId, Guid userId, string role) : base(Guid.NewGuid())
{
ChatId = chatId;
UserId = userId;
Role = role;
JoinedAt = DateTime.UtcNow;
}
public void TogglePin() => IsPinned = !IsPinned;
public void UpdateReadCursor(Guid messageId, long sequenceId)
{
if (sequenceId > LastReadSequenceId)
{
LastReadMessageId = messageId;
LastReadSequenceId = sequenceId;
}
}
public void UpdateDeliveredCursor(Guid messageId)
{
LastDeliveredMessageId = messageId;
}
}

View File

@@ -0,0 +1,10 @@
namespace Knot.Modules.Conversations.Domain;
public static class ChatConstants
{
public const int DefaultMessageQueryLimit = 100;
public const int MaxSharedMediaQueryLimit = 300;
public const int SearchMessagesLimit = 50;
public const int MaxFileUploadSizeMb = 50;
}

View File

@@ -0,0 +1,22 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Conversations.Domain;
public static class ChatErrors
{
public static readonly Error FileEmpty = new Error("File.Empty", "No file uploaded");
public static readonly Error FileInvalidExtension = new Error("File.InvalidExtension", "Must be a ZIP archive");
public static readonly Error ImportExpired = new Error("Import.Expired", "Session not found or expired");
public static readonly Error ImportMissing = new Error("Import.Missing", "ZIP file lost");
public static readonly Error ChatNotFound = new Error("Chat.NotFound", "Chat not found or access denied");
public static readonly Error NotFound = new Error("Chat.NotFound", "Chat not found"); // Alias
public static readonly Error NotMember = new Error("Chat.NotMember", "You are not a member of this chat");
public static readonly Error ChatsForbidden = new Error("Chats.Forbidden", "Вы не являетесь участником этого чата.");
public static readonly Error MessagesNotFound = new Error("Messages.NotFound", "Message not found.");
public static readonly Error ChatsNotFound = new Error("Chats.NotFound", "Чат не найден.");
public static readonly Error Unauthorized = new Error("Chats.Unauthorized", "Access denied");
public static Error ImportCreateChatFailed(string msg) => new Error("Import.CreateChatFailed", msg);
public static Error FileTooLarge(int maxMb) => new Error("File.TooLarge", $"File exceeds the maximum allowed size of {maxMb}MB.");
}

View File

@@ -0,0 +1,14 @@
using Knot.Modules.Conversations.Domain;
namespace Knot.Modules.Conversations.Domain;
public interface IChatRepository
{
void Add(Chat chat);
void Update(Chat chat);
void Remove(Chat chat);
Task<Chat?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
Task<Chat?> GetFavoritesAsync(Guid userId, CancellationToken cancellationToken);
Task<List<Chat>> GetUserChatsAsync(Guid userId, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,92 @@
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Infrastructure.SignalR;
using MediatR;
using Microsoft.AspNetCore.SignalR;
namespace Knot.Modules.Conversations.Infrastructure.Handlers;
/// <summary>
/// Обработчик доменного события создания чата.
/// </summary>
public sealed class ChatCreatedDomainEventHandler : INotificationHandler<ChatCreatedDomainEvent>, INotificationHandler<ChatMemberAddedDomainEvent>
{
private readonly IHubContext<ChatHub> _hubContext;
private readonly IChatRepository _chatRepository;
private readonly IUserDisplayNameProvider _displayNameProvider;
public ChatCreatedDomainEventHandler(
IHubContext<ChatHub> hubContext,
IChatRepository chatRepository,
IUserDisplayNameProvider displayNameProvider)
{
_hubContext = hubContext;
_chatRepository = chatRepository;
_displayNameProvider = displayNameProvider;
}
public async Task Handle(ChatCreatedDomainEvent notification, CancellationToken cancellationToken)
{
await NotifyNewChatAsync(notification.Chat, cancellationToken);
}
public async Task Handle(ChatMemberAddedDomainEvent notification, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(notification.ChatId, cancellationToken);
if (chat != null)
{
// Уведомляем всех участников, что кто-то добавлен (или пользователь сам видит новый чат)
await NotifyNewChatAsync(chat, cancellationToken);
}
}
private async Task NotifyNewChatAsync(Chat chat, CancellationToken cancellationToken, string? targetUserId = null)
{
var members = new List<object>();
foreach (var member in chat.Members)
{
var userInfo = await _displayNameProvider.GetUserInfoAsync(member.UserId, cancellationToken);
members.Add(new
{
member.Id,
member.UserId,
member.Role,
member.JoinedAt,
User = userInfo != null
? new { userInfo.Id, userInfo.Username, userInfo.DisplayName, userInfo.Avatar, isOnline = false }
: new { Id = member.UserId, Username = "unknown", DisplayName = "Unknown", Avatar = (string?)null, isOnline = false }
});
}
var payload = new
{
id = chat.Id,
type = chat.Type.ToString().ToLowerInvariant(),
name = chat.Type == ChatType.Favorites ? "Избранное" : (chat.Type == ChatType.Personal ? null : chat.Name),
avatar = chat.Avatar,
createdAt = chat.CreatedAt,
members = members,
messages = new List<object>(),
unreadCount = 0
};
if (targetUserId != null)
{
// Отправляем конкретному пользователю (по всем его соединениям)
// Мы можем использовать метод Hub или просто отправить в группу UserId (если она есть)
// В нашем ChatHub мы добавляем пользователя в группы чатов при подключении.
// Но для нового чата он еще не в группе.
// Поэтому отправляем через User ID (SignalR поддерживает Clients.User(id))
await _hubContext.Clients.User(targetUserId).SendAsync("new_chat", payload, cancellationToken);
}
else
{
// Уведомляем всех текущих участников
foreach (var member in chat.Members)
{
await _hubContext.Clients.User(member.UserId.ToString()).SendAsync("new_chat", payload, cancellationToken);
}
}
}
}

View File

@@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Conversations.Domain;
namespace Knot.Modules.Conversations.Infrastructure.Persistence;
public sealed class ChatRepository : IChatRepository
{
private readonly ChatsDbContext _dbContext;
public ChatRepository(ChatsDbContext dbContext)
{
_dbContext = dbContext;
}
public void Add(Chat chat)
{
_dbContext.Chats.Add(chat);
}
public void Update(Chat chat)
{
_dbContext.Chats.Update(chat);
}
public void Remove(Chat chat)
{
_dbContext.Chats.Remove(chat);
}
public async Task<Chat?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
{
return await _dbContext.Chats
.Include(c => c.Members)
.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
}
public async Task<Chat?> GetFavoritesAsync(Guid userId, CancellationToken cancellationToken)
{
return await _dbContext.Chats
.Include(c => c.Members)
.FirstOrDefaultAsync(c =>
c.Type == ChatType.Favorites &&
c.Members.Any(m => m.UserId == userId),
cancellationToken);
}
public async Task<List<Chat>> GetUserChatsAsync(Guid userId, CancellationToken cancellationToken)
{
return await _dbContext.Chats
.Include(c => c.Members)
.Where(c => c.Members.Any(m => m.UserId == userId))
.OrderByDescending(c => c.CreatedAt)
.ToListAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,88 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Security;
namespace Knot.Modules.Conversations.Infrastructure.Persistence;
/// <summary>
/// Контекст базы данных для модуля чатов.
/// </summary>
public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
{
private readonly IMediator _mediator;
private readonly IEncryptionService _encryptionService;
public ChatsDbContext(DbContextOptions<ChatsDbContext> options, IMediator mediator, IEncryptionService encryptionService)
: base(options)
{
_mediator = mediator;
_encryptionService = encryptionService;
}
public DbSet<Chat> Chats => Set<Chat>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
optionsBuilder.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning));
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("chats");
modelBuilder.Entity<Chat>(builder =>
{
builder.ToTable("Chats");
builder.HasKey(c => c.Id);
builder.Property(c => c.Type).HasConversion<string>();
builder.OwnsMany(c => c.Members, mb =>
{
mb.ToTable("ChatMembers");
mb.HasKey(m => m.Id);
mb.WithOwner().HasForeignKey(m => m.ChatId);
mb.HasIndex(m => new { m.ChatId, m.UserId }).IsUnique();
}).Navigation(c => c.Members).UsePropertyAccessMode(PropertyAccessMode.Field);
});
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
// 1. Получаем все события из агрегатов
var domainEvents = ChangeTracker
.Entries<IAggregateRoot>()
.SelectMany(x =>
{
if (x.Entity is AggregateRoot<Guid> root)
{
var events = root.GetDomainEvents().ToList();
root.ClearDomainEvents();
return events;
}
return Enumerable.Empty<IDomainEvent>();
})
.ToList();
// 2. Сохраняем изменения
int result = await base.SaveChangesAsync(cancellationToken);
// 3. Публикуем события через MediatR
foreach (var domainEvent in domainEvents)
{
await _mediator.Publish(domainEvent, cancellationToken);
}
return result;
}
}

View File

@@ -0,0 +1,11 @@
namespace Knot.Modules.Conversations.Infrastructure.Services;
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
public class ChatAccessProvider : IChatAccessProvider { private readonly ChatsDbContext _db; public ChatAccessProvider(ChatsDbContext db) { _db = db; } public Task<List<Guid>> GetValidChatIdsForUserAsync(Guid userId, CancellationToken ct) { return _db.Chats.Where(c => c.Members.Any(m => m.UserId == userId)).Select(c => c.Id).ToListAsync(ct); } }

View File

@@ -0,0 +1,664 @@
using System.Collections.Concurrent;
using System.Security.Claims;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Knot.Modules.Conversations.Application.Messages.Send;
using Knot.Modules.Conversations.Application.Messages.Read;
using Knot.Modules.Conversations.Application.Messages.Delete;
using Knot.Modules.Conversations.Application.Messages.React;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Microsoft.Extensions.Caching.Memory;
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
/// <summary>
/// Хаб SignalR для обработки сообщений и WebRTC сигналинга в реальном времени.
/// </summary>
[Authorize]
public sealed class ChatHub : Hub
{
// Маппинг userId → список connectionId
private static readonly ConcurrentDictionary<string, HashSet<string>> _userConnections = new();
// chatId → (userId → ParticipantInfo)
private static readonly ConcurrentDictionary<string, ConcurrentDictionary<string, ParticipantInfo>> _groupCallParticipants = new();
public static int OnlineUsersCount => _userConnections.Count;
public static bool IsUserOnline(string userId) => _userConnections.ContainsKey(userId);
private readonly ISender _sender;
private readonly IUserContext _userContext;
private readonly IChatRepository _chatRepository;
private readonly ILogger<ChatHub> _logger;
private readonly IMemoryCache _cache;
public ChatHub(ISender sender, IUserContext userContext, IChatRepository chatRepository, ILogger<ChatHub> logger, IMemoryCache cache)
{
_sender = sender;
_userContext = userContext;
_chatRepository = chatRepository;
_logger = logger;
_cache = cache;
}
public override async Task OnConnectedAsync()
{
if (_userContext.IsAuthenticated)
{
var userId = _userContext.UserId.ToString();
_userConnections.AddOrUpdate(
userId,
_ => new HashSet<string> { Context.ConnectionId },
(_, set) => { lock (set) { set.Add(Context.ConnectionId); } return set; }
);
_cache.Set("Global_OnlineUsersCount", _userConnections.Count);
var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted);
foreach (var chat in userChats)
{
await Groups.AddToGroupAsync(Context.ConnectionId, chat.Id.ToString());
}
_logger.LogInformation("User {UserId} connected with {ConnectionId}, added to {ChatCount} chats",
userId, Context.ConnectionId, userChats.Count);
await Clients.Others.SendAsync("user_online", new { userId });
}
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
if (_userContext.IsAuthenticated)
{
var userId = _userContext.UserId.ToString();
if (_userConnections.TryGetValue(userId, out var set))
{
lock (set) { set.Remove(Context.ConnectionId); }
if (set.Count == 0)
{
_userConnections.TryRemove(userId, out _);
await Clients.Others.SendAsync("user_offline", new { userId, lastSeen = DateTime.UtcNow });
}
}
_cache.Set("Global_OnlineUsersCount", _userConnections.Count);
_logger.LogInformation("User {UserId} disconnected", userId);
}
await base.OnDisconnectedAsync(exception);
}
// ────────────────────────────────────────────────────────────────
// Chat methods
// ────────────────────────────────────────────────────────────────
[HubMethodName("send_message")]
public async Task SendMessage(SendMessageHubRequest request)
{
var attachments = request.Attachments?.Select(a =>
new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList();
var command = new SendMessageCommand(
request.ChatId,
_userContext.UserId,
request.Content,
request.Type,
attachments,
request.ReplyToId,
request.Quote,
request.ForwardedFromId);
await _sender.Send(command);
}
[HubMethodName("read_messages")]
public async Task ReadMessages(ReadMessagesRequest request)
{
if (request.LastReadMessageId != Guid.Empty && request.LastReadSequenceId > 0)
{
var command = new ReadMessagesCommand(
request.ChatId, _userContext.UserId, request.LastReadMessageId, request.LastReadSequenceId);
await _sender.Send(command);
}
await Clients.Group(request.ChatId.ToString()).SendAsync("messages_read", new
{
ChatId = request.ChatId.ToString(),
UserId = _userContext.UserId,
LastReadMessageId = request.LastReadMessageId,
LastReadSequenceId = request.LastReadSequenceId
});
}
[HubMethodName("delete_messages")]
public async Task DeleteMessages(DeleteMessagesHubRequest request)
{
var parsedIds = request.MessageIds
.Select(id => Guid.TryParse(id, out var parsed) ? parsed : Guid.Empty)
.Where(id => id != Guid.Empty)
.ToList();
if (parsedIds.Any())
{
var command = new DeleteMessagesCommand(
request.ChatId, _userContext.UserId, parsedIds, request.DeleteForAll);
await _sender.Send(command);
}
}
[HubMethodName("typing_start")]
public async Task TypingStart(string chatId)
{
await Clients.Group(chatId).SendAsync("user_typing", new { ChatId = chatId, UserId = _userContext.UserId });
}
[HubMethodName("typing_stop")]
public async Task TypingStop(string chatId)
{
await Clients.Group(chatId).SendAsync("user_stopped_typing", new { ChatId = chatId, UserId = _userContext.UserId });
}
[HubMethodName("join_chat")]
public async Task JoinChat(string chatId)
{
// Simple security check: check if user is member of chat (optional but recommended)
if (Guid.TryParse(chatId, out var chatGuid))
{
var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted);
if (userChats.Any(c => c.Id == chatGuid))
{
await Groups.AddToGroupAsync(Context.ConnectionId, chatId);
}
}
}
[HubMethodName("add_reaction")]
public async Task AddReaction(AddReactionRequest request)
{
_logger.LogInformation("AddReaction called: MessageId={MessageId}, ChatId={ChatId}, Emoji={Emoji}, UserId={UserId}",
request.MessageId, request.ChatId, request.Emoji, _userContext.UserId);
var command = new AddReactionCommand(
request.MessageId, _userContext.UserId, request.Emoji, request.ChatId);
var result = await _sender.Send(command);
if (result.IsFailure)
{
_logger.LogWarning("AddReaction failed: {Error}", result.Error.Description);
throw new HubException(result.Error.Description);
}
_logger.LogInformation("AddReaction completed successfully");
}
[HubMethodName("remove_reaction")]
public async Task RemoveReaction(RemoveReactionRequest request)
{
_logger.LogInformation("RemoveReaction called: MessageId={MessageId}, ChatId={ChatId}, Emoji={Emoji}, UserId={UserId}",
request.MessageId, request.ChatId, request.Emoji, _userContext.UserId);
var command = new RemoveReactionCommand(
request.MessageId, _userContext.UserId, request.Emoji, request.ChatId);
var result = await _sender.Send(command);
if (result.IsFailure)
{
_logger.LogWarning("RemoveReaction failed: {Error}", result.Error.Description);
throw new HubException(result.Error.Description);
}
_logger.LogInformation("RemoveReaction completed successfully");
}
// ────────────────────────────────────────────────────────────────
// Friend signals (Proxy methods for real-time notification)
// ────────────────────────────────────────────────────────────────
[HubMethodName("friend_request")]
public async Task FriendRequest(FriendSignalRequest request)
{
await SendToUserAsync(request.FriendId, "friend_request_received", new { userId = _userContext.UserId });
}
[HubMethodName("friend_accepted")]
public async Task FriendAccepted(FriendSignalRequest request)
{
await SendToUserAsync(request.FriendId, "friend_request_accepted", new { userId = _userContext.UserId });
}
[HubMethodName("friend_removed")]
public async Task FriendRemoved(FriendSignalRequest request)
{
await SendToUserAsync(request.FriendId, "friend_removed_notify", new { userId = _userContext.UserId });
}
// ────────────────────────────────────────────────────────────────
// WebRTC signaling
// ────────────────────────────────────────────────────────────────
private async Task SendToUserAsync(string targetUserId, string method, object payload)
{
if (_userConnections.TryGetValue(targetUserId, out var connectionIds))
{
string[] ids;
lock (connectionIds) { ids = connectionIds.ToArray(); }
foreach (var connId in ids)
{
await Clients.Client(connId).SendAsync(method, payload);
}
}
}
[HubMethodName("call_offer")]
public async Task CallOffer(CallOfferRequest request)
{
// Try to get caller info from current user's claims
var displayName = Context.User?.FindFirstValue("name") ?? Context.User?.FindFirstValue(ClaimTypes.Name) ?? "User";
var avatar = Context.User?.FindFirstValue("avatar");
var username = Context.User?.FindFirstValue("unique_name") ?? Context.User?.Identity?.Name;
await SendToUserAsync(request.TargetUserId, "call_incoming", new
{
from = _userContext.UserId.ToString(),
offer = request.Offer,
callType = request.CallType,
chatId = request.ChatId,
callerInfo = new
{
id = _userContext.UserId.ToString(),
displayName = displayName,
avatar = avatar,
username = username
}
});
}
[HubMethodName("call_answer")]
public async Task CallAnswer(CallAnswerRequest request)
{
await SendToUserAsync(request.TargetUserId, "call_answered", new
{
from = _userContext.UserId.ToString(),
answer = request.Answer,
});
}
[HubMethodName("call_decline")]
public async Task CallDecline(TargetUserRequest request)
{
await SendToUserAsync(request.TargetUserId, "call_declined", new
{
from = _userContext.UserId.ToString(),
});
}
[HubMethodName("call_end")]
public async Task CallEnd(TargetUserRequest request)
{
await SendToUserAsync(request.TargetUserId, "call_ended", new
{
from = _userContext.UserId.ToString(),
});
}
[HubMethodName("ice_candidate")]
public async Task IceCandidate(IceCandidateRequest request)
{
await SendToUserAsync(request.TargetUserId, "ice_candidate", new
{
from = _userContext.UserId.ToString(),
candidate = request.Candidate,
});
}
[HubMethodName("renegotiate")]
public async Task Renegotiate(RenegotiateRequest request)
{
await SendToUserAsync(request.TargetUserId, "renegotiate", new
{
from = _userContext.UserId.ToString(),
offer = request.Offer,
});
}
[HubMethodName("renegotiate_answer")]
public async Task RenegotiateAnswer(RenegotiateAnswerRequest request)
{
await SendToUserAsync(request.TargetUserId, "renegotiate_answer", new
{
from = _userContext.UserId.ToString(),
answer = request.Answer,
});
}
[HubMethodName("call_type_changed")]
public async Task CallTypeChanged(CallTypeChangedRequest request)
{
await SendToUserAsync(request.TargetUserId, "call_type_changed", new
{
from = _userContext.UserId.ToString(),
callType = request.CallType,
isScreenSharing = request.IsScreenSharing
});
}
[HubMethodName("call_status")]
public async Task CallStatus(CallStatusRequest request)
{
await SendToUserAsync(request.TargetUserId, "call_status_updated", new
{
from = _userContext.UserId.ToString(),
isMuted = request.IsMuted,
isVideoOff = request.IsVideoOff
});
}
// ────────────────────────────────────────────────────────────────
// Group Call signals
// ────────────────────────────────────────────────────────────────
[HubMethodName("group_call_join")]
public async Task GroupCallJoin(GroupCallJoinRequest request)
{
var chatId = request.ChatId;
var userId = _userContext.UserId.ToString();
var displayName = Context.User?.FindFirstValue("name") ?? Context.User?.FindFirstValue(ClaimTypes.Name) ?? "User";
var avatar = Context.User?.FindFirstValue("avatar");
var username = Context.User?.FindFirstValue("unique_name") ?? Context.User?.Identity?.Name ?? "user";
var userInfo = new ParticipantInfo(userId, username, displayName, avatar);
var participants = _groupCallParticipants.GetOrAdd(chatId, _ => new ConcurrentDictionary<string, ParticipantInfo>());
var isFirst = participants.IsEmpty;
participants.TryAdd(userId, userInfo);
// Notify others
await Clients.Group(chatId).SendAsync("group_call_user_joined", new
{
chatId = chatId,
userId = userId,
userInfo = userInfo
});
// Send current participants to joiner (excluding self)
var others = participants.Values.Where(p => p.Id != userId).ToList();
await Clients.Caller.SendAsync("group_call_participants", new
{
chatId = chatId,
participants = others
});
// Broadcast active call participants to everyone in the chat
await Clients.Group(chatId).SendAsync("group_call_active", new
{
chatId = chatId,
participants = participants.Keys.ToList()
});
if (isFirst)
{
await Clients.Group(chatId).SendAsync("group_call_incoming", new
{
chatId = chatId,
from = userId,
callerInfo = userInfo,
callType = request.CallType
});
}
}
[HubMethodName("group_call_leave")]
public async Task GroupCallLeave(GroupLeaveRequest request)
{
var chatId = request.ChatId;
var userId = _userContext.UserId.ToString();
if (_groupCallParticipants.TryGetValue(chatId, out var participants))
{
participants.TryRemove(userId, out _);
// Broadcast updated participants list
await Clients.Group(chatId).SendAsync("group_call_active", new
{
chatId = chatId,
participants = participants.Keys.ToList()
});
if (participants.IsEmpty)
{
_groupCallParticipants.TryRemove(chatId, out _);
await Clients.Group(chatId).SendAsync("group_call_ended", new { chatId = chatId });
}
}
await Clients.Group(chatId).SendAsync("group_call_user_left", new
{
chatId = chatId,
userId = userId
});
}
[HubMethodName("group_call_offer")]
public async Task GroupCallOffer(GroupCallOfferRequest request)
{
await SendToUserAsync(request.TargetUserId, "group_call_offer", new
{
chatId = request.ChatId,
from = _userContext.UserId.ToString(),
offer = request.Offer
});
}
[HubMethodName("group_call_answer")]
public async Task GroupCallAnswer(GroupCallAnswerRequest request)
{
await SendToUserAsync(request.TargetUserId, "group_call_answer", new
{
chatId = request.ChatId,
from = _userContext.UserId.ToString(),
answer = request.Answer
});
}
[HubMethodName("group_ice_candidate")]
public async Task GroupIceCandidate(GroupIceCandidateRequest request)
{
await SendToUserAsync(request.TargetUserId, "group_ice_candidate", new
{
chatId = request.ChatId,
from = _userContext.UserId.ToString(),
candidate = request.Candidate
});
}
[HubMethodName("group_call_renegotiate")]
public async Task GroupRenegotiate(GroupRenegotiateRequest request)
{
await SendToUserAsync(request.TargetUserId, "group_call_renegotiate", new
{
chatId = request.ChatId,
from = _userContext.UserId.ToString(),
offer = request.Offer
});
}
[HubMethodName("group_call_renegotiate_answer")]
public async Task GroupRenegotiateAnswer(GroupRenegotiateAnswerRequest request)
{
await SendToUserAsync(request.TargetUserId, "group_call_renegotiate_answer", new
{
chatId = request.ChatId,
from = _userContext.UserId.ToString(),
answer = request.Answer
});
}
[HubMethodName("group_call_status")]
public async Task GroupCallStatus(GroupCallStatusRequest request)
{
var chatId = request.ChatId;
var userId = _userContext.UserId.ToString();
if (_groupCallParticipants.TryGetValue(chatId, out var participants))
{
if (participants.TryGetValue(userId, out var info))
{
// Update local state if needed (e.g. muted status)
participants[userId] = info with
{
IsMuted = request.IsMuted,
IsVideoOff = request.IsVideoOff
};
}
}
await Clients.Group(request.ChatId).SendAsync("group_call_status_updated", new
{
chatId = request.ChatId,
userId = Context.UserIdentifier,
isMuted = request.IsMuted,
isVideoOff = request.IsVideoOff
});
}
[HubMethodName("group_call_status_params")]
public async Task GroupCallStatusParams(string chatId, bool isMuted, bool isVideoOff)
{
var userId = _userContext.UserId.ToString();
if (_groupCallParticipants.TryGetValue(chatId, out var participants))
{
if (participants.TryGetValue(userId, out var info))
{
// Update local state if needed (e.g. muted status)
participants[userId] = info with
{
IsMuted = isMuted,
IsVideoOff = isVideoOff
};
}
}
await Clients.Group(chatId).SendAsync("group_call_status_updated", new
{
chatId = chatId,
userId = Context.UserIdentifier,
isMuted = isMuted,
isVideoOff = isVideoOff
});
}
[HubMethodName("get_group_call_status")]
public async Task GetGroupCallStatus(GetGroupCallStatusRequest request)
{
var chatId = request.ChatId;
if (_groupCallParticipants.TryGetValue(chatId, out var participants))
{
var others = participants.Values.ToList();
_logger.LogInformation("Found {Count} participants for chat {ChatId}", others.Count, chatId);
await Clients.Caller.SendAsync("group_call_active", new
{
chatId = chatId,
participants = others.Select(p => p.Id).ToList()
});
}
else
{
_logger.LogInformation("No active call for chat {ChatId}", chatId);
await Clients.Caller.SendAsync("group_call_active", new
{
chatId = chatId,
participants = new List<string>()
});
}
}
[HubMethodName("screen_share_started")]
public async Task ScreenShareStarted(string chatId)
{
var userId = _userContext.UserId.ToString();
if (_groupCallParticipants.TryGetValue(chatId, out var participants))
{
if (participants.TryGetValue(userId, out var info))
{
participants[userId] = info with { IsSharingScreen = true };
}
}
await Clients.Group(chatId).SendAsync("screen_share_started", new
{
chatId = chatId,
userId = userId
});
}
[HubMethodName("screen_share_stopped")]
public async Task ScreenShareStopped(string chatId)
{
var userId = _userContext.UserId.ToString();
if (_groupCallParticipants.TryGetValue(chatId, out var participants))
{
if (participants.TryGetValue(userId, out var info))
{
participants[userId] = info with { IsSharingScreen = false };
}
}
await Clients.Group(chatId).SendAsync("screen_share_stopped", new
{
chatId = chatId,
userId = userId
});
}
// ────────────────────────────────────────────────────────────────
// Records
// ────────────────────────────────────────────────────────────────
public record AttachmentHubRequest(string Type, string Url, string? FileName, long? FileSize);
public record SendMessageHubRequest(
Guid ChatId,
string? Content,
string Type,
List<AttachmentHubRequest>? Attachments = null,
Guid? ReplyToId = null,
string? Quote = null,
Guid? ForwardedFromId = null);
public record ReadMessagesRequest(Guid ChatId, Guid LastReadMessageId, long LastReadSequenceId);
public record CallOfferRequest(string TargetUserId, object Offer, string CallType, string? ChatId);
public record CallAnswerRequest(string TargetUserId, object Answer);
public record TargetUserRequest(string TargetUserId);
public record IceCandidateRequest(string TargetUserId, object Candidate);
public record RenegotiateRequest(string TargetUserId, object Offer);
public record RenegotiateAnswerRequest(string TargetUserId, object Answer);
public record CallTypeChangedRequest(string TargetUserId, string CallType, bool IsScreenSharing = false);
public record CallStatusRequest(string TargetUserId, bool IsMuted, bool IsVideoOff);
public record AddReactionRequest(Guid MessageId, Guid ChatId, string Emoji);
public record RemoveReactionRequest(Guid MessageId, Guid ChatId, string Emoji);
public record DeleteMessagesHubRequest(Guid ChatId, List<string> MessageIds, bool DeleteForAll);
public record GroupCallJoinRequest(string ChatId, string CallType);
public record ParticipantInfo(string Id, string Username, string DisplayName, string? Avatar, bool IsSharingScreen = false, bool IsMuted = false, bool IsVideoOff = false);
public record GroupLeaveRequest(string ChatId);
public record GetGroupCallStatusRequest(string ChatId);
public record GroupCallStatusRequest(string ChatId, bool IsMuted, bool IsVideoOff);
public record GroupCallOfferRequest(string ChatId, string TargetUserId, object Offer);
public record GroupCallAnswerRequest(string ChatId, string TargetUserId, object Answer);
public record GroupIceCandidateRequest(string ChatId, string TargetUserId, object Candidate);
public record GroupRenegotiateRequest(string ChatId, string TargetUserId, object Offer);
public record GroupRenegotiateAnswerRequest(string ChatId, string TargetUserId, object Answer);
public record FriendSignalRequest(string FriendId);
}

View File

@@ -0,0 +1,7 @@
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Modules.Messaging.Application.Abstractions;
using Microsoft.AspNetCore.SignalR;
using System;
using System.Threading;
using System.Threading.Tasks;
public class MessageNotifier : IMessageNotifier { private readonly IHubContext<ChatHub> _hubContext; public MessageNotifier(IHubContext<ChatHub> hubContext) { _hubContext = hubContext; } public Task NotifyNewMessageAsync(Guid chatId, object messagePayload, CancellationToken cancellationToken) { return _hubContext.Clients.Group(chatId.ToString()).SendAsync("new_message", messagePayload, cancellationToken); } }

View File

@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\Messaging\Knot.Modules.Messaging.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.4.0" />
<PackageReference Include="Carter" Version="10.0.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="MediatR" Version="12.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.4" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="MongoDB.Driver" Version="3.7.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Knot.Modules.Chats.UnitTests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,111 @@
// <auto-generated />
using System;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Modules.Conversations.Migrations
{
[DbContext(typeof(ChatsDbContext))]
[Migration("20260322191932_InitialConversations")]
partial class InitialConversations
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("chats")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Conversations.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<long>("LastMessageSequenceId")
.HasColumnType("bigint");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Knot.Modules.Conversations.Domain.Chat", b =>
{
b.OwnsMany("Knot.Modules.Conversations.Domain.ChatMember", "Members", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("ChatId")
.HasColumnType("uuid");
b1.Property<bool>("IsMuted")
.HasColumnType("boolean");
b1.Property<bool>("IsPinned")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b1.Property<Guid?>("LastDeliveredMessageId")
.HasColumnType("uuid");
b1.Property<Guid?>("LastReadMessageId")
.HasColumnType("uuid");
b1.Property<long>("LastReadSequenceId")
.HasColumnType("bigint");
b1.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.HasKey("Id");
b1.HasIndex("ChatId", "UserId")
.IsUnique();
b1.ToTable("ChatMembers", "chats");
b1.WithOwner()
.HasForeignKey("ChatId");
});
b.Navigation("Members");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,83 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Conversations.Migrations
{
/// <inheritdoc />
public partial class InitialConversations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "chats");
migrationBuilder.CreateTable(
name: "Chats",
schema: "chats",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: true),
Description = table.Column<string>(type: "text", nullable: true),
Avatar = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
LastMessageSequenceId = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Chats", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ChatMembers",
schema: "chats",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChatId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Role = table.Column<string>(type: "text", nullable: false),
JoinedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
IsPinned = table.Column<bool>(type: "boolean", nullable: false),
IsMuted = table.Column<bool>(type: "boolean", nullable: false),
LastReadMessageId = table.Column<Guid>(type: "uuid", nullable: true),
LastReadSequenceId = table.Column<long>(type: "bigint", nullable: false),
LastDeliveredMessageId = table.Column<Guid>(type: "uuid", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ChatMembers", x => x.Id);
table.ForeignKey(
name: "FK_ChatMembers_Chats_ChatId",
column: x => x.ChatId,
principalSchema: "chats",
principalTable: "Chats",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ChatMembers_ChatId_UserId",
schema: "chats",
table: "ChatMembers",
columns: new[] { "ChatId", "UserId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ChatMembers",
schema: "chats");
migrationBuilder.DropTable(
name: "Chats",
schema: "chats");
}
}
}

View File

@@ -0,0 +1,108 @@
// <auto-generated />
using System;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Modules.Conversations.Migrations
{
[DbContext(typeof(ChatsDbContext))]
partial class ChatsDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("chats")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Conversations.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<long>("LastMessageSequenceId")
.HasColumnType("bigint");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Knot.Modules.Conversations.Domain.Chat", b =>
{
b.OwnsMany("Knot.Modules.Conversations.Domain.ChatMember", "Members", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("ChatId")
.HasColumnType("uuid");
b1.Property<bool>("IsMuted")
.HasColumnType("boolean");
b1.Property<bool>("IsPinned")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b1.Property<Guid?>("LastDeliveredMessageId")
.HasColumnType("uuid");
b1.Property<Guid?>("LastReadMessageId")
.HasColumnType("uuid");
b1.Property<long>("LastReadSequenceId")
.HasColumnType("bigint");
b1.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.HasKey("Id");
b1.HasIndex("ChatId", "UserId")
.IsUnique();
b1.ToTable("ChatMembers", "chats");
b1.WithOwner()
.HasForeignKey("ChatId");
});
b.Navigation("Members");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,174 @@
using Carter;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Application.Chats.GetChats;
using Knot.Modules.Conversations.Application.Chats.GetChatById;
using Knot.Modules.Conversations.Application.Chats.Create;
using Knot.Modules.Conversations.Application.Chats.GetOrCreateFavorites;
using Knot.Modules.Conversations.Application.Chats.Update;
using Knot.Modules.Conversations.Application.Chats.LeaveOrDelete;
using Knot.Modules.Conversations.Application.Chats.Clear;
using Knot.Modules.Conversations.Application.Chats.TogglePin;
using Knot.Modules.Conversations.Application.Chats.Members;
using Knot.Modules.Conversations.Application.Chats.Avatar;
using Knot.Modules.Conversations.Domain;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc;
namespace Knot.Modules.Conversations.Presentation.Endpoints;
public sealed class ChatsEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup("api/chats").RequireAuthorization();
group.MapGet("", async (ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new GetChatsQuery(userContext.UserId), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
});
group.MapPost("", async ([FromBody] CreateChatRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var command = new CreateChatCommand(request.Name, request.Type, request.MemberIds);
var result = await sender.Send(command, ct);
if (result.IsFailure) return Results.BadRequest(result.Error.Description);
var chatResult = await sender.Send(new GetChatByIdQuery(userContext.UserId, result.Value), ct);
return Results.Ok(chatResult.Value);
});
group.MapPost("personal", async ([FromBody] CreatePersonalChatRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var command = new CreateChatCommand(string.Empty, ChatType.Personal, new List<Guid> { userContext.UserId, request.UserId });
var result = await sender.Send(command, ct);
if (result.IsFailure) return Results.BadRequest(result.Error.Description);
var chatResult = await sender.Send(new GetChatByIdQuery(userContext.UserId, result.Value), ct);
return Results.Ok(chatResult.Value);
});
group.MapPost("group", async ([FromBody] CreateGroupChatRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var memberIds = request.MemberIds.ToList();
if (memberIds.Contains(userContext.UserId)) memberIds.Remove(userContext.UserId);
memberIds.Insert(0, userContext.UserId);
var command = new CreateChatCommand(request.Name, ChatType.Group, memberIds);
var result = await sender.Send(command, ct);
if (result.IsFailure) return Results.BadRequest(result.Error.Description);
var chatResult = await sender.Send(new GetChatByIdQuery(userContext.UserId, result.Value), ct);
return Results.Ok(chatResult.Value);
});
group.MapPost("favorites", async (ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new GetOrCreateFavoritesCommand(userContext.UserId), ct);
if (result.IsFailure) return Results.BadRequest(result.Error.Description);
var chatResult = await sender.Send(new GetChatByIdQuery(userContext.UserId, result.Value), ct);
return Results.Ok(chatResult.Value);
});
group.MapPut("{id:guid}", async (Guid id, [FromBody] UpdateChatRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new UpdateChatCommand(id, userContext.UserId, request.Name, request.Description), ct);
if (result.IsFailure) return Results.NotFound();
var chatResult = await sender.Send(new GetChatByIdQuery(userContext.UserId, result.Value), ct);
return Results.Ok(chatResult.Value);
});
group.MapDelete("{id:guid}", async (Guid id, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new LeaveOrDeleteChatCommand(id, userContext.UserId), ct);
if (result.IsFailure)
{
if (result.Error.Code == "Unauthorized") return Results.Forbid();
return Results.NotFound();
}
return Results.Ok(result.Value);
});
group.MapPost("{id:guid}/clear", async (Guid id, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new ClearChatCommand(id, userContext.UserId), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound();
});
group.MapPost("{id:guid}/pin", async (Guid id, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new TogglePinCommand(id, userContext.UserId), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound();
});
group.MapPost("{id:guid}/members", async (Guid id, [FromBody] AddMembersRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new AddMembersCommand(id, userContext.UserId, request.UserIds.ToList()), ct);
if (result.IsFailure) return Results.NotFound();
var chatResult = await sender.Send(new GetChatByIdQuery(userContext.UserId, result.Value), ct);
return Results.Ok(chatResult.Value);
});
group.MapDelete("{id:guid}/members/{userId:guid}", async (Guid id, Guid userId, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new RemoveMemberCommand(id, userContext.UserId, userId), ct);
if (result.IsFailure) return Results.NotFound();
var chatResult = await sender.Send(new GetChatByIdQuery(userContext.UserId, result.Value), ct);
return Results.Ok(chatResult.Value);
});
group.MapPost("{id:guid}/avatar", async (Guid id, HttpRequest req, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
if (!req.HasFormContentType) return Results.BadRequest("No file");
var form = await req.ReadFormAsync(ct);
var avatar = form.Files.FirstOrDefault();
if (avatar == null || avatar.Length == 0) return Results.BadRequest("No file");
using var stream = avatar.OpenReadStream();
var result = await sender.Send(new UploadGroupAvatarCommand(id, userContext.UserId, avatar.FileName, avatar.ContentType, stream), ct);
if (result.IsFailure) return Results.NotFound();
var chatResult = await sender.Send(new GetChatByIdQuery(userContext.UserId, result.Value), ct);
return Results.Ok(chatResult.Value);
}).DisableAntiforgery();
group.MapPost("{id:guid}/avatar/crop", async (Guid id, HttpRequest req, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
if (!req.HasFormContentType) return Results.BadRequest("No file");
var form = await req.ReadFormAsync(ct);
var avatar = form.Files.FirstOrDefault();
if (avatar == null || avatar.Length == 0) return Results.BadRequest("No file");
int.TryParse(form["x"], out int x);
int.TryParse(form["y"], out int y);
int.TryParse(form["width"], out int width);
int.TryParse(form["height"], out int height);
using var stream = avatar.OpenReadStream();
var result = await sender.Send(new CropGroupAvatarCommand(id, userContext.UserId, avatar.FileName ?? "avatar.jpg", avatar.ContentType, stream, x, y, width, height), ct);
if (result.IsFailure) return Results.NotFound();
var chatResult = await sender.Send(new GetChatByIdQuery(userContext.UserId, result.Value), ct);
return Results.Ok(chatResult.Value);
}).DisableAntiforgery();
group.MapDelete("{id:guid}/avatar", async (Guid id, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new RemoveGroupAvatarCommand(id, userContext.UserId), ct);
if (result.IsFailure) return Results.NotFound();
var chatResult = await sender.Send(new GetChatByIdQuery(userContext.UserId, result.Value), ct);
return Results.Ok(chatResult.Value);
});
}
}

View File

@@ -0,0 +1,79 @@
using Carter;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Application.Messages.GetMessages;
using Knot.Modules.Conversations.Application.Messages.SearchMessages;
using Knot.Modules.Conversations.Application.Messages.UploadFile;
using Knot.Modules.Conversations.Application.Messages.GetSharedMedia;
using Knot.Modules.Conversations.Application.Messages.Send;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc;
namespace Knot.Modules.Conversations.Presentation.Endpoints;
public sealed class MessagesEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup("api/messages").RequireAuthorization();
group.MapGet("chat/{chatId:guid}", async (Guid chatId, [FromQuery] string? cursor, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new GetMessagesQuery(userContext.UserId, chatId, cursor), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
});
group.MapGet("search", async ([FromQuery] string q, [FromQuery] Guid? chatId, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new SearchMessagesQuery(userContext.UserId, q, chatId), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
});
group.MapPost("upload", async (HttpRequest req, ISender sender, CancellationToken ct) =>
{
if (!req.HasFormContentType) return Results.BadRequest("No file uploaded");
var form = await req.ReadFormAsync(ct);
var file = form.Files.FirstOrDefault();
if (file == null || file.Length == 0) return Results.BadRequest("No file uploaded");
using var stream = file.OpenReadStream();
var result = await sender.Send(new UploadFileCommand(file.FileName, file.ContentType, file.Length, stream), ct);
if (result.IsFailure)
{
if (result.Error.Code == "File.TooLarge") return Results.StatusCode(413);
return Results.BadRequest(result.Error.Description);
}
return Results.Ok(result.Value);
}).DisableAntiforgery();
group.MapGet("chat/{chatId:guid}/shared", async (Guid chatId, [FromQuery] string? type, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new GetSharedMediaQuery(userContext.UserId, chatId, type), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
});
group.MapPost("chat/{chatId:guid}", async (Guid chatId, [FromBody] SendMessageRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var attachments = request.Attachments?.Select(a =>
new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList();
var command = new SendMessageCommand(
chatId,
userContext.UserId,
request.Content,
request.Type,
attachments,
request.ReplyToId,
request.Quote,
request.ForwardedFromId);
var result = await sender.Send(command, ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
});
}
}