From 9438cf1b35b9a37caa7f60e1c9109d779d69f87c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Mon, 30 Mar 2026 01:45:19 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83?= =?UTF-8?q?=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UnitTests/LoginUserCommandHandlerTests.cs | 88 ----------- .../Chats/GetChatsQueryHandlerTests.cs | 3 +- ...not.Modules.Conversations.UnitTests.csproj | 8 +- .../SendMessageCommandHandlerTests.cs | 2 +- .../Domain/UserStatusChangedDomainEvent.cs | 8 + .../Abstractions/IChatsUnitOfWork.cs | 7 + .../Abstractions/IUserStatusService.cs | 8 + .../Contracts/Conversations/Domain/Chat.cs | 146 ++++++++++++++++++ .../Contracts/Conversations/Domain/Folder.cs | 101 ++++++++++++ .../Conversations/Domain/IChatRepository.cs | 39 +++++ .../Knot.Contracts.Conversations.csproj | 1 + .../Abstractions/IChatAccessProvider.cs | 11 ++ .../Abstractions/IMessageNotifier.cs | 6 + .../Abstractions/IMessageRepository.cs | 21 +++ .../Abstractions/IUserStatsService.cs | 16 ++ .../Messaging/Domain/DeletedMessage.cs | 20 +++ .../Domain/IMessageReactionRepository.cs | 9 ++ .../Contracts/Messaging/Domain/MediaType.cs | 9 ++ .../src/Contracts/Messaging/Domain/Message.cs | 126 +++++++++++++++ .../Messaging/Domain/MessageEvents.cs | 28 ++++ .../Messaging/Domain/MessageReaction.cs | 25 +++ .../Messaging/Domain/MessageState.cs | 14 ++ .../Contracts/Messaging/Domain/Reaction.cs | 23 +++ .../SystemSettingsUpdatedDomainEvent.cs | 8 + .../Commands/CleanRun/CleanRunCommand.cs | 23 +-- .../Queries/CleanDryRun/CleanDryRunQuery.cs | 33 ++-- .../Modules/Admin/Knot.Modules.Admin.csproj | 7 +- .../Users/Login/LoginUserCommandHandler.cs | 8 +- .../Chats/GetChatById/GetChatById.cs | 14 +- .../Application/Chats/GetChats/GetChats.cs | 14 +- .../Messages/Delete/DeleteMessagesCommand.cs | 8 +- .../Messages/GetMessages/GetMessagesQuery.cs | 13 +- .../GetSharedMedia/GetSharedMediaQuery.cs | 11 +- .../Messages/React/AddReactionCommand.cs | 10 +- .../Messages/React/RemoveReactionCommand.cs | 10 +- .../SearchMessages/SearchMessagesQuery.cs | 12 +- .../Send/SendMessageCommandHandler.cs | 119 ++++++++------ .../Commands/DeleteUser/DeleteUserCommand.cs | 16 +- .../Conversations/DependencyInjection.cs | 33 ++-- .../Services/ChatAccessProvider.cs | 7 +- .../Infrastructure/SignalR/MessageNotifier.cs | 5 +- .../Knot.Modules.Conversations.csproj | 6 +- .../Commands/InboundFederationCommand.cs | 72 +++++---- .../Events/FederatedMessageActionsHandler.cs | 4 +- .../Events/MessageSentDomainEventHandler.cs | 33 ++-- ...SystemSettingsUpdatedDomainEventHandler.cs | 2 +- .../UserStatusChangedDomainEventHandler.cs | 15 +- .../Federation/Knot.Modules.Federation.csproj | 10 +- .../Modules/Messaging/DependencyInjection.cs | 25 +-- .../Handlers/MessageSentDomainEventHandler.cs | 65 ++++---- .../Persistence/MessageReactionRepository.cs | 11 +- .../Persistence/MessageRepository.cs | 17 +- .../Mongo/MongoDbMapConfigurator.cs | 10 +- .../Persistence/Mongo/UserStatsService.cs | 10 +- .../Messaging/Knot.Modules.Messaging.csproj | 3 +- 55 files changed, 955 insertions(+), 398 deletions(-) delete mode 100644 backend/Tests/Auth/UnitTests/LoginUserCommandHandlerTests.cs create mode 100644 backend/src/Contracts/Auth/Domain/UserStatusChangedDomainEvent.cs create mode 100644 backend/src/Contracts/Conversations/Application/Abstractions/IChatsUnitOfWork.cs create mode 100644 backend/src/Contracts/Conversations/Application/Abstractions/IUserStatusService.cs create mode 100644 backend/src/Contracts/Conversations/Domain/Chat.cs create mode 100644 backend/src/Contracts/Conversations/Domain/Folder.cs create mode 100644 backend/src/Contracts/Conversations/Domain/IChatRepository.cs create mode 100644 backend/src/Contracts/Messaging/Application/Abstractions/IChatAccessProvider.cs create mode 100644 backend/src/Contracts/Messaging/Application/Abstractions/IMessageNotifier.cs create mode 100644 backend/src/Contracts/Messaging/Application/Abstractions/IMessageRepository.cs create mode 100644 backend/src/Contracts/Messaging/Application/Abstractions/IUserStatsService.cs create mode 100644 backend/src/Contracts/Messaging/Domain/DeletedMessage.cs create mode 100644 backend/src/Contracts/Messaging/Domain/IMessageReactionRepository.cs create mode 100644 backend/src/Contracts/Messaging/Domain/MediaType.cs create mode 100644 backend/src/Contracts/Messaging/Domain/Message.cs create mode 100644 backend/src/Contracts/Messaging/Domain/MessageEvents.cs create mode 100644 backend/src/Contracts/Messaging/Domain/MessageReaction.cs create mode 100644 backend/src/Contracts/Messaging/Domain/MessageState.cs create mode 100644 backend/src/Contracts/Messaging/Domain/Reaction.cs create mode 100644 backend/src/Contracts/Settings/Domain/SystemSettingsUpdatedDomainEvent.cs diff --git a/backend/Tests/Auth/UnitTests/LoginUserCommandHandlerTests.cs b/backend/Tests/Auth/UnitTests/LoginUserCommandHandlerTests.cs deleted file mode 100644 index 4a2a0cd..0000000 --- a/backend/Tests/Auth/UnitTests/LoginUserCommandHandlerTests.cs +++ /dev/null @@ -1,88 +0,0 @@ -using FluentAssertions; -using NSubstitute; -using Knot.Contracts.Auth.Application.Abstractions; -using Knot.Modules.Auth.Application.Users.Login; -using Knot.Contracts.Auth.Domain; -using Knot.Shared.Kernel; -using Knot.Modules.Auth.Application.Users.Auth; -using Xunit; -using System.Threading; -using System.Threading.Tasks; - -namespace Knot.Modules.Auth.UnitTests; - -public class LoginUserCommandHandlerTests -{ - private readonly IUserRepository _userRepository; - private readonly IJwtTokenProvider _tokenProvider; - private readonly LoginUserCommandHandler _handler; - - public LoginUserCommandHandlerTests() - { - _userRepository = Substitute.For(); - _tokenProvider = Substitute.For(); - _handler = new LoginUserCommandHandler(_userRepository, _tokenProvider); - } - - [Fact] - public async Task Handle_ShouldReturnToken_WhenCredentialsAreValid() - { - // Arrange - var password = "password123"; - var passwordHash = BCrypt.Net.BCrypt.HashPassword(password); - var user = User.Create("testuser", passwordHash, "Test User", null, null); - - var command = new LoginUserCommand("testuser", password); - - _userRepository.GetByUsernameAsync(command.Username, Arg.Any()) - .Returns(user); - - _tokenProvider.Generate(user).Returns("valid-jwt-token"); - - // Act - var result = await _handler.Handle(command, CancellationToken.None); - - // Assert - result.IsSuccess.Should().BeTrue(); - result.Value.Token.Should().Be("valid-jwt-token"); - result.Value.User.Username.Should().Be("testuser"); - } - - [Fact] - public async Task Handle_ShouldReturnFailure_WhenUserDoesNotExist() - { - // Arrange - var command = new LoginUserCommand("nonexistent", "password123"); - _userRepository.GetByUsernameAsync(command.Username, Arg.Any()) - .Returns((User)null!); - - // Act - var result = await _handler.Handle(command, CancellationToken.None); - - // Assert - result.IsFailure.Should().BeTrue(); - result.Error.Code.Should().Be(AuthErrors.IdentityInvalidCredentials.Code); - } - - [Fact] - public async Task Handle_ShouldReturnFailure_WhenPasswordIsInvalid() - { - // Arrange - var correctPassword = "correctPassword"; - var passwordHash = BCrypt.Net.BCrypt.HashPassword(correctPassword); - var user = User.Create("testuser", passwordHash, "Test User", null, null); - - var command = new LoginUserCommand("testuser", "wrongPassword"); - - _userRepository.GetByUsernameAsync(command.Username, Arg.Any()) - .Returns(user); - - // Act - var result = await _handler.Handle(command, CancellationToken.None); - - // Assert - result.IsFailure.Should().BeTrue(); - result.Error.Code.Should().Be(AuthErrors.IdentityInvalidCredentials.Code); - } -} - diff --git a/backend/Tests/Conversations/UnitTests/Chats/GetChatsQueryHandlerTests.cs b/backend/Tests/Conversations/UnitTests/Chats/GetChatsQueryHandlerTests.cs index a009178..fa5d16d 100644 --- a/backend/Tests/Conversations/UnitTests/Chats/GetChatsQueryHandlerTests.cs +++ b/backend/Tests/Conversations/UnitTests/Chats/GetChatsQueryHandlerTests.cs @@ -8,7 +8,8 @@ using NSubstitute; using Xunit; using Knot.Shared.Kernel; using Knot.Modules.Conversations.Domain; -using Knot.Modules.Messaging.Domain; +using Knot.Contracts.Messaging.Application.Abstractions; +using Knot.Contracts.Messaging.Domain; using Knot.Modules.Conversations.Application.Abstractions; using Knot.Modules.Conversations.Application.Chats.GetChats; using Knot.Modules.Conversations.Application.DTOs; diff --git a/backend/Tests/Conversations/UnitTests/Knot.Modules.Conversations.UnitTests.csproj b/backend/Tests/Conversations/UnitTests/Knot.Modules.Conversations.UnitTests.csproj index 11589b9..e38bded 100644 --- a/backend/Tests/Conversations/UnitTests/Knot.Modules.Conversations.UnitTests.csproj +++ b/backend/Tests/Conversations/UnitTests/Knot.Modules.Conversations.UnitTests.csproj @@ -1,10 +1,11 @@ - net10.0 enable enable false + + false @@ -23,10 +24,9 @@ - - + - + diff --git a/backend/Tests/Conversations/UnitTests/Messages/SendMessageCommandHandlerTests.cs b/backend/Tests/Conversations/UnitTests/Messages/SendMessageCommandHandlerTests.cs index 1eb204d..fa3e6b5 100644 --- a/backend/Tests/Conversations/UnitTests/Messages/SendMessageCommandHandlerTests.cs +++ b/backend/Tests/Conversations/UnitTests/Messages/SendMessageCommandHandlerTests.cs @@ -9,7 +9,7 @@ using NSubstitute; using Xunit; using Knot.Shared.Kernel; using Knot.Modules.Conversations.Domain; -using Knot.Modules.Messaging.Domain; +using Knot.Contracts.Messaging.Application.Abstractions; using Knot.Modules.Conversations.Application.Abstractions; using Knot.Modules.Conversations.Application.Messages.Send; using Knot.Contracts.Settings.Application.Abstractions; diff --git a/backend/src/Contracts/Auth/Domain/UserStatusChangedDomainEvent.cs b/backend/src/Contracts/Auth/Domain/UserStatusChangedDomainEvent.cs new file mode 100644 index 0000000..f6c5ce2 --- /dev/null +++ b/backend/src/Contracts/Auth/Domain/UserStatusChangedDomainEvent.cs @@ -0,0 +1,8 @@ +using Knot.Shared.Kernel; + +namespace Knot.Contracts.Auth.Domain; + +/// +/// Доменное событие: изменился статус пользователя (онлайн/офлайн). +/// +public sealed record UserStatusChangedDomainEvent(Guid UserId, bool IsOnline, DateTime LastSeen) : IDomainEvent; diff --git a/backend/src/Contracts/Conversations/Application/Abstractions/IChatsUnitOfWork.cs b/backend/src/Contracts/Conversations/Application/Abstractions/IChatsUnitOfWork.cs new file mode 100644 index 0000000..25c903b --- /dev/null +++ b/backend/src/Contracts/Conversations/Application/Abstractions/IChatsUnitOfWork.cs @@ -0,0 +1,7 @@ +using Knot.Shared.Kernel; + +namespace Knot.Contracts.Conversations.Application.Abstractions; + +public interface IChatsUnitOfWork : IUnitOfWork +{ +} diff --git a/backend/src/Contracts/Conversations/Application/Abstractions/IUserStatusService.cs b/backend/src/Contracts/Conversations/Application/Abstractions/IUserStatusService.cs new file mode 100644 index 0000000..69ca491 --- /dev/null +++ b/backend/src/Contracts/Conversations/Application/Abstractions/IUserStatusService.cs @@ -0,0 +1,8 @@ +using System; + +namespace Knot.Contracts.Conversations.Application.Abstractions; + +public interface IUserStatusService +{ + bool IsUserOnline(string userId); +} diff --git a/backend/src/Contracts/Conversations/Domain/Chat.cs b/backend/src/Contracts/Conversations/Domain/Chat.cs new file mode 100644 index 0000000..6d2b79a --- /dev/null +++ b/backend/src/Contracts/Conversations/Domain/Chat.cs @@ -0,0 +1,146 @@ +using Knot.Shared.Kernel; + +namespace Knot.Contracts.Conversations.Domain; + +public sealed record ChatCreatedDomainEvent(Chat Chat) : IDomainEvent; +public sealed record ChatMemberAddedDomainEvent(Guid ChatId, Guid UserId) : IDomainEvent; + +/// +/// Тип чата: личный или групповой. +/// +public enum ChatType +{ + Personal, + Group, + Favorites +} + +/// +/// Роль участника в чате. +/// +public static class ChatRole +{ + public const string Owner = "owner"; + public const string Admin = "admin"; + public const string Member = "member"; +} + +/// +/// Сущность чата (Агрегат). +/// +public sealed class Chat : AggregateRoot +{ + 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 _members = new(); + public IReadOnlyCollection 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; + } + + public static Chat CreatePersonal() + { + var chat = new Chat(Guid.NewGuid(), ChatType.Personal, null, null); + chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat)); + return chat; + } + + 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; + } + + 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; + } +} + +/// +/// Участник чата. +/// +public sealed class ChatMember : Entity +{ + 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; } + + 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; + } +} diff --git a/backend/src/Contracts/Conversations/Domain/Folder.cs b/backend/src/Contracts/Conversations/Domain/Folder.cs new file mode 100644 index 0000000..d247eba --- /dev/null +++ b/backend/src/Contracts/Conversations/Domain/Folder.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using Knot.Shared.Kernel; + +namespace Knot.Contracts.Conversations.Domain; + +/// +/// Сущность папки для группировки чатов. +/// +public sealed class Folder : AggregateRoot +{ + public string Name { get; private set; } + public string? Icon { get; private set; } + public bool IsDefault { get; private set; } + public FolderType Type { get; private set; } + + public Folder(Guid id, string name, string? icon = null, bool isDefault = false, FolderType type = FolderType.Custom) + : base(id) + { + Name = name; + Icon = icon; + IsDefault = isDefault; + Type = type; + } + + public void Update(string name, string? icon) + { + if (IsDefault) throw new InvalidOperationException("Cannot rename default folders."); + Name = name; + Icon = icon; + } +} + +public enum FolderType +{ + All, + New, + Muted, + Custom +} + +/// +/// Настройки конкретного чата для конкретного пользователя. +/// +public sealed class UserChatSettings : Entity +{ + public Guid UserId { get; private set; } + public Guid ChatId { get; private set; } + + private readonly List _folderIds = new(); + public IReadOnlyCollection FolderIds => _folderIds.AsReadOnly(); + + public bool IsMuted { get; private set; } + + private UserChatSettings() : base(Guid.NewGuid()) { } + + public UserChatSettings(Guid userId, Guid chatId) : base(Guid.NewGuid()) + { + UserId = userId; + ChatId = chatId; + } + + public static UserChatSettings Create(Guid userId, Guid chatId) => new(userId, chatId); + + public void AddToFolder(Guid folderId) + { + if (!_folderIds.Contains(folderId)) _folderIds.Add(folderId); + } + + public void RemoveFromFolder(Guid folderId) + { + _folderIds.Remove(folderId); + } + + public void SetMute(bool isMuted) => IsMuted = isMuted; +} + +/// +/// Глобальные настройки папок пользователя. +/// +public sealed class UserFolderSettings : AggregateRoot +{ + public Guid UserId { get; private set; } + public List HiddenDefaultFolderIds { get; private set; } = new(); + public List CustomFolderIds { get; private set; } = new(); + + public UserFolderSettings(Guid userId) : base(Guid.NewGuid()) + { + UserId = userId; + } + + public void HideFolder(Guid folderId) + { + if (!HiddenDefaultFolderIds.Contains(folderId)) HiddenDefaultFolderIds.Add(folderId); + } + + public void ShowFolder(Guid folderId) + { + HiddenDefaultFolderIds.Remove(folderId); + } +} diff --git a/backend/src/Contracts/Conversations/Domain/IChatRepository.cs b/backend/src/Contracts/Conversations/Domain/IChatRepository.cs new file mode 100644 index 0000000..a69cb53 --- /dev/null +++ b/backend/src/Contracts/Conversations/Domain/IChatRepository.cs @@ -0,0 +1,39 @@ +using Knot.Contracts.Conversations.Domain; + +namespace Knot.Contracts.Conversations.Domain; + +public interface IChatRepository +{ + void Add(Chat chat); + void Update(Chat chat); + void Remove(Chat chat); + Task GetByIdAsync(Guid id, CancellationToken cancellationToken); + Task GetFavoritesAsync(Guid userId, CancellationToken cancellationToken); + Task> GetUserChatsAsync(Guid userId, CancellationToken cancellationToken); +} + +public interface IFolderRepository +{ + void Add(Folder folder); + void Update(Folder folder); + void Remove(Folder folder); + Task GetByIdAsync(Guid id, CancellationToken cancellationToken); + Task> GetUserFoldersAsync(Guid userId, CancellationToken cancellationToken); +} + +public interface IUserChatSettingsRepository +{ + void Add(UserChatSettings settings); + void Update(UserChatSettings settings); + void Remove(UserChatSettings settings); + void RemoveRange(IEnumerable settings); + Task GetAsync(Guid userId, Guid chatId, CancellationToken cancellationToken); + Task> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken); +} + +public interface IUserFolderSettingsRepository +{ + Task GetByUserIdAsync(Guid userId, CancellationToken cancellationToken); + Task UpdateAsync(UserFolderSettings settings, CancellationToken cancellationToken); + Task RemoveByUserIdAsync(Guid userId, CancellationToken cancellationToken); +} diff --git a/backend/src/Contracts/Conversations/Knot.Contracts.Conversations.csproj b/backend/src/Contracts/Conversations/Knot.Contracts.Conversations.csproj index 4bd9c60..bed802d 100644 --- a/backend/src/Contracts/Conversations/Knot.Contracts.Conversations.csproj +++ b/backend/src/Contracts/Conversations/Knot.Contracts.Conversations.csproj @@ -8,6 +8,7 @@ + diff --git a/backend/src/Contracts/Messaging/Application/Abstractions/IChatAccessProvider.cs b/backend/src/Contracts/Messaging/Application/Abstractions/IChatAccessProvider.cs new file mode 100644 index 0000000..ada3736 --- /dev/null +++ b/backend/src/Contracts/Messaging/Application/Abstractions/IChatAccessProvider.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Knot.Contracts.Messaging.Application.Abstractions; + +public interface IChatAccessProvider +{ + Task> GetValidChatIdsForUserAsync(Guid userId, CancellationToken ct); +} diff --git a/backend/src/Contracts/Messaging/Application/Abstractions/IMessageNotifier.cs b/backend/src/Contracts/Messaging/Application/Abstractions/IMessageNotifier.cs new file mode 100644 index 0000000..9054a82 --- /dev/null +++ b/backend/src/Contracts/Messaging/Application/Abstractions/IMessageNotifier.cs @@ -0,0 +1,6 @@ +namespace Knot.Contracts.Messaging.Application.Abstractions; + +public interface IMessageNotifier +{ + Task NotifyNewMessageAsync(Guid chatId, object messagePayload, CancellationToken cancellationToken); +} diff --git a/backend/src/Contracts/Messaging/Application/Abstractions/IMessageRepository.cs b/backend/src/Contracts/Messaging/Application/Abstractions/IMessageRepository.cs new file mode 100644 index 0000000..048a900 --- /dev/null +++ b/backend/src/Contracts/Messaging/Application/Abstractions/IMessageRepository.cs @@ -0,0 +1,21 @@ +using Knot.Contracts.Messaging.Domain; + +namespace Knot.Contracts.Messaging.Application.Abstractions; + +public interface IMessageRepository +{ + void Add(Message message); + Task GetByIdAsync(Guid id, CancellationToken cancellationToken); + Task> GetChatMessagesAsync(Guid chatId, int limit, int offset, CancellationToken cancellationToken); + Task GetLatestChatMessageAsync(Guid chatId, CancellationToken cancellationToken); + Task> SearchMessagesAsync(string query, Guid? chatId, Guid requestingUserId, CancellationToken cancellationToken); + + Task> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, int limit, CancellationToken cancellationToken); + Task GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken); + + Task UpdateAsync(Message message, CancellationToken cancellationToken); + Task> GetAllMessagesAsync(CancellationToken cancellationToken); + Task DeleteChatMessagesAsync(Guid chatId, CancellationToken cancellationToken); + Task DeleteUserMessagesAsync(Guid userId, CancellationToken cancellationToken); + Task RemoveAsync(Guid id, CancellationToken cancellationToken); +} diff --git a/backend/src/Contracts/Messaging/Application/Abstractions/IUserStatsService.cs b/backend/src/Contracts/Messaging/Application/Abstractions/IUserStatsService.cs new file mode 100644 index 0000000..8e25410 --- /dev/null +++ b/backend/src/Contracts/Messaging/Application/Abstractions/IUserStatsService.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Knot.Contracts.Messaging.Application.Abstractions; + +public record UserStats(int MessageCount, long StorageSize); + +public interface IUserStatsService +{ + Task> GetStatsForUsersAsync(IEnumerable userIds, CancellationToken ct = default); + Task GetTotalStorageSizeAsync(CancellationToken ct = default); + Task GetCountOrphanedMessagesAsync(HashSet activeChatIds, CancellationToken ct = default); + Task GetOrphanedMediaSizeAsync(HashSet validFileIds, CancellationToken ct = default); +} diff --git a/backend/src/Contracts/Messaging/Domain/DeletedMessage.cs b/backend/src/Contracts/Messaging/Domain/DeletedMessage.cs new file mode 100644 index 0000000..416510c --- /dev/null +++ b/backend/src/Contracts/Messaging/Domain/DeletedMessage.cs @@ -0,0 +1,20 @@ +using Knot.Shared.Kernel; + +namespace Knot.Contracts.Messaging.Domain; + +/// +/// Запись о том, что конкретный пользователь удалил у себя сообщение. +/// +public sealed class DeletedMessage : Entity +{ + public Guid MessageId { get; private set; } + public Guid UserId { get; private set; } + + internal DeletedMessage(Guid messageId, Guid userId) : base(Guid.NewGuid()) + { + MessageId = messageId; + UserId = userId; + } + + private DeletedMessage() : base(Guid.Empty) { } +} diff --git a/backend/src/Contracts/Messaging/Domain/IMessageReactionRepository.cs b/backend/src/Contracts/Messaging/Domain/IMessageReactionRepository.cs new file mode 100644 index 0000000..e3c73b2 --- /dev/null +++ b/backend/src/Contracts/Messaging/Domain/IMessageReactionRepository.cs @@ -0,0 +1,9 @@ +namespace Knot.Contracts.Messaging.Domain; + +public interface IMessageReactionRepository +{ + Task AddAsync(MessageReaction reaction, CancellationToken cancellationToken); + Task RemoveAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken); + Task> GetReactionsForMessageAsync(Guid messageId, CancellationToken cancellationToken); + Task> GetReactionsForMessagesAsync(IEnumerable messageIds, CancellationToken cancellationToken); +} diff --git a/backend/src/Contracts/Messaging/Domain/MediaType.cs b/backend/src/Contracts/Messaging/Domain/MediaType.cs new file mode 100644 index 0000000..40fefc0 --- /dev/null +++ b/backend/src/Contracts/Messaging/Domain/MediaType.cs @@ -0,0 +1,9 @@ +namespace Knot.Contracts.Messaging.Domain; + +public enum MediaType +{ + Image, + Video, + Voice, + File +} diff --git a/backend/src/Contracts/Messaging/Domain/Message.cs b/backend/src/Contracts/Messaging/Domain/Message.cs new file mode 100644 index 0000000..ba63ae2 --- /dev/null +++ b/backend/src/Contracts/Messaging/Domain/Message.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using Knot.Shared.Kernel; + +namespace Knot.Contracts.Messaging.Domain; + +public abstract class Message : AggregateRoot +{ + public Guid ChatId { get; protected set; } + public Guid SenderId { get; protected set; } + public DateTime CreatedAt { get; protected set; } + public long SequenceId { get; protected set; } + public void SetSequenceId(long sequenceId) => SequenceId = sequenceId; + public Guid? ReplyToId { get; protected set; } + public Guid? ForwardedFromId { get; protected set; } + public MessageState State { get; protected set; } + public abstract string Type { get; } + public abstract string? Content { get; protected set; } + public virtual string? Quote { get; protected set; } = null; + public virtual Guid? StoryId => null; + public virtual string? StoryMediaUrl => null; + public virtual string? StoryMediaType => null; + public virtual IReadOnlyCollection Media => Array.Empty(); + public bool IsEdited => HasState(MessageState.IsEdited); + public bool IsDeleted => HasState(MessageState.IsDeleted); + public bool IsDeletedForUser(Guid userId) => _deletedFor.Exists(d => d.UserId == userId); + protected List _deletedFor = new(); + public IReadOnlyCollection DeletedFor => _deletedFor.AsReadOnly(); + protected Message() : base(Guid.Empty) { } + protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported) : base(id) + { + ChatId = chatId; + SenderId = senderId; + ReplyToId = replyToId; + ForwardedFromId = forwardedFromId; + CreatedAt = createdAt; + if (isImported) AddState(MessageState.IsImported); + } + public void AddState(MessageState state) => State |= state; + public void RemoveState(MessageState state) => State &= ~state; + public bool HasState(MessageState state) => (State & state) == state; + public virtual void Delete() => AddState(MessageState.IsDeleted); + public virtual void Edit(string newContent) { Content = newContent; AddState(MessageState.IsEdited); } + public void DeleteForUser(Guid userId) { if (!_deletedFor.Exists(x => x.UserId == userId)) _deletedFor.Add(new DeletedMessage(Id, userId)); } +} + +public class Media +{ + public Guid Id { get; set; } + public string Type { get; set; } = string.Empty; + public string? Url { get; set; } + public string? ThumbnailUrl { get; set; } + public long? Size { get; set; } + public int Width { get; set; } + public int Height { get; set; } + public string? FileId { get; set; } + public string? Filename { get; set; } + public string? Duration { get; set; } +} + +public class TextMessage : Message +{ + public override string Type => "text"; + public override string? Content { get; protected set; } + public TextMessage() : base() { } + public TextMessage(Guid id, Guid chatId, Guid senderId, string content, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported = false) + : base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported) => Content = content; + public TextMessage(Guid id, Guid chatId, Guid senderId, string content, Guid? replyToId, string? quote, Guid? forwardedFromId, DateTime createdAt, bool isImported = false) + : this(id, chatId, senderId, content, replyToId, forwardedFromId, createdAt, isImported) => Quote = quote; +} + +public class MediaMessage : Message +{ + public override string Type => MediaType.ToString().ToLower(); + public override string? Content { get; protected set; } + public string? Caption { get => Content; private set => Content = value; } + public MediaType MediaType { get; private set; } + private List _media = new(); + public override IReadOnlyCollection Media => _media.AsReadOnly(); + private MediaMessage() : base() { MediaType = MediaType.File; } + public MediaMessage(Guid id, Guid chatId, Guid senderId, MediaType mediaType, string? caption, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported) + : base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported) { MediaType = mediaType; Caption = caption; } + public MediaMessage(Guid id, Guid chatId, Guid senderId, string mediaType, string? caption, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported) + : this(id, chatId, senderId, Enum.TryParse(mediaType, true, out var mt) ? mt : MediaType.File, caption, replyToId, forwardedFromId, createdAt, isImported) { } + public void AddMedia(string type, string url, string? filename, long? size) => _media.Add(new Media { Type = type, Url = url, FileId = filename, Size = size }); + public override void Edit(string newCaption) => base.Edit(newCaption); + public override void Delete() { Caption = null; base.Delete(); } +} + +public class StoryMessage : Message +{ + public override string Type => "story"; + public override string? Content { get; protected set; } + public override Guid? StoryId { get; } + public string? InternalStoryMediaUrl { get; private set; } + public override string? StoryMediaUrl => InternalStoryMediaUrl; + public override string? StoryMediaType { get; } + public StoryMessage() : base() { } + public StoryMessage(Guid id, Guid chatId, Guid senderId, Guid storyId, string? storyMediaUrl, string? storyMediaType, DateTime createdAt) + : base(id, chatId, senderId, null, null, createdAt, false) { StoryId = storyId; InternalStoryMediaUrl = storyMediaUrl; StoryMediaType = storyMediaType; } + public StoryMessage(Guid id, Guid chatId, Guid senderId, Guid storyId, string? storyMediaUrl, string? storyMediaType, string? content, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported) + : this(id, chatId, senderId, storyId, storyMediaUrl, storyMediaType, createdAt) { Content = content; ReplyToId = replyToId; ForwardedFromId = forwardedFromId; if (isImported) AddState(MessageState.IsImported); } +} + +public class PollMessage : Message +{ + public override string Type => "poll"; + public override string? Content { get; protected set; } + public List Options { get; } = new(); + public List Votes { get; } = new(); + public bool IsMultipleChoice { get; set; } + public DateTime? ExpiresAt { get; set; } + public bool IsClosed { get; set; } + public PollMessage() : base() { } + public PollMessage(Guid id, Guid chatId, Guid senderId, string? question, List? options, bool isAnonymous, bool isMultiple, DateTime? expiresAt, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported) + : base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported) + { + Content = question ?? "Poll"; + if (options != null) foreach (var opt in options) Options.Add(new PollOption { Text = opt }); + IsMultipleChoice = isMultiple; + ExpiresAt = expiresAt; + } +} + +public class PollOption { public string Text { get; set; } = string.Empty; public int VoteCount { get; set; } } +public class PollVote { public Guid OptionIndex { get; set; } public Guid UserId { get; set; } public DateTime VotedAt { get; set; } } diff --git a/backend/src/Contracts/Messaging/Domain/MessageEvents.cs b/backend/src/Contracts/Messaging/Domain/MessageEvents.cs new file mode 100644 index 0000000..7e7caff --- /dev/null +++ b/backend/src/Contracts/Messaging/Domain/MessageEvents.cs @@ -0,0 +1,28 @@ +using Knot.Shared.Kernel; + +namespace Knot.Contracts.Messaging.Domain; + +/// +/// Доменное событие: сообщение отправлено. +/// +public sealed record MessageSentDomainEvent(Guid MessageId, Guid ChatId, Guid SenderId, string? Content) : IDomainEvent; + +/// +/// Доменное событие: сообщение удалено. +/// +public sealed record MessageDeletedDomainEvent(Guid MessageId, Guid ChatId) : IDomainEvent; + +/// +/// Доменное событие: сообщение отредактировано. +/// +public sealed record MessageEditedDomainEvent(Guid MessageId, Guid ChatId, string NewContent) : IDomainEvent; + +/// +/// Доменное событие: реакция добавлена. +/// +public sealed record MessageReactionAddedDomainEvent(Guid MessageId, Guid ChatId, Guid UserId, string Emoji) : IDomainEvent; + +/// +/// Доменное событие: реакция удалена. +/// +public sealed record MessageReactionRemovedDomainEvent(Guid MessageId, Guid ChatId, Guid UserId, string Emoji) : IDomainEvent; diff --git a/backend/src/Contracts/Messaging/Domain/MessageReaction.cs b/backend/src/Contracts/Messaging/Domain/MessageReaction.cs new file mode 100644 index 0000000..3deb020 --- /dev/null +++ b/backend/src/Contracts/Messaging/Domain/MessageReaction.cs @@ -0,0 +1,25 @@ +using System; +using Knot.Shared.Kernel; + +namespace Knot.Contracts.Messaging.Domain; + +public sealed class MessageReaction : AggregateRoot +{ + public Guid MessageId { get; private set; } + public Guid UserId { get; private set; } + public string Emoji { get; private set; } + public DateTime CreatedAt { get; private set; } + + private MessageReaction() : base(Guid.Empty) + { + Emoji = default!; + } + + public MessageReaction(Guid messageId, Guid userId, string emoji) : base(Guid.NewGuid()) + { + MessageId = messageId; + UserId = userId; + Emoji = emoji; + CreatedAt = DateTime.UtcNow; + } +} diff --git a/backend/src/Contracts/Messaging/Domain/MessageState.cs b/backend/src/Contracts/Messaging/Domain/MessageState.cs new file mode 100644 index 0000000..302a5b3 --- /dev/null +++ b/backend/src/Contracts/Messaging/Domain/MessageState.cs @@ -0,0 +1,14 @@ +namespace Knot.Contracts.Messaging.Domain; + +/// +/// Состояние сообщения +/// +[Flags] +public enum MessageState +{ + None = 0, + IsEdited = 1, + IsDeleted = 2, + IsImported = 4, + IsPinned = 8 +} diff --git a/backend/src/Contracts/Messaging/Domain/Reaction.cs b/backend/src/Contracts/Messaging/Domain/Reaction.cs new file mode 100644 index 0000000..c8cca33 --- /dev/null +++ b/backend/src/Contracts/Messaging/Domain/Reaction.cs @@ -0,0 +1,23 @@ +using System; +using Knot.Shared.Kernel; + +namespace Knot.Contracts.Messaging.Domain; + +public sealed class Reaction : Entity +{ + public Guid MessageId { get; private set; } + public Guid UserId { get; private set; } + public string Emoji { get; private set; } + + internal Reaction(Guid messageId, Guid userId, string emoji) : base(Guid.NewGuid()) + { + MessageId = messageId; + UserId = userId; + Emoji = emoji; + } + + private Reaction() : base(Guid.Empty) + { + Emoji = string.Empty; + } +} diff --git a/backend/src/Contracts/Settings/Domain/SystemSettingsUpdatedDomainEvent.cs b/backend/src/Contracts/Settings/Domain/SystemSettingsUpdatedDomainEvent.cs new file mode 100644 index 0000000..d480f81 --- /dev/null +++ b/backend/src/Contracts/Settings/Domain/SystemSettingsUpdatedDomainEvent.cs @@ -0,0 +1,8 @@ +using Knot.Shared.Kernel; + +namespace Knot.Contracts.Settings.Domain; + +/// +/// Доменное событие: системные настройки обновлены. +/// +public sealed record SystemSettingsUpdatedDomainEvent(Application.DTOs.SystemSettingsDto Settings) : IDomainEvent; diff --git a/backend/src/Modules/Admin/Application/Admin/Commands/CleanRun/CleanRunCommand.cs b/backend/src/Modules/Admin/Application/Admin/Commands/CleanRun/CleanRunCommand.cs index 32ed078..41c05ba 100644 --- a/backend/src/Modules/Admin/Application/Admin/Commands/CleanRun/CleanRunCommand.cs +++ b/backend/src/Modules/Admin/Application/Admin/Commands/CleanRun/CleanRunCommand.cs @@ -1,18 +1,19 @@ -using Knot.Modules.Admin.Application.Admin.DTOs; -using Knot.Modules.Messaging.Domain; using System; -using System.Linq; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; -using MediatR; -using Knot.Shared.Kernel; -using Knot.Shared.Kernel.Storage; -using MongoDB.Driver; -using Microsoft.EntityFrameworkCore; +using Knot.Contracts.Auth.Application.Abstractions; +using Knot.Contracts.Messaging.Domain; +using Knot.Modules.Admin.Application.Admin.DTOs; using Knot.Modules.Auth.Infrastructure.Persistence; using Knot.Modules.Conversations.Infrastructure.Persistence; using Knot.Modules.Stories.Domain; +using Knot.Shared.Kernel; +using Knot.Shared.Kernel.Storage; +using MediatR; +using Microsoft.EntityFrameworkCore; +using MongoDB.Driver; namespace Knot.Modules.Admin.Application.Admin.Commands; @@ -37,7 +38,7 @@ internal sealed class CleanRunCommandHandler : ICommandHandler> Handle(CleanRunCommand request, CancellationToken cancellationToken) { - try + try { var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken); var activeChatIds = allChats.Select(c => c.Id).ToHashSet(); @@ -55,7 +56,7 @@ internal sealed class CleanRunCommandHandler : ICommandHandler true).ToListAsync(cancellationToken); var validUrls = new HashSet(); @@ -73,7 +74,7 @@ internal sealed class CleanRunCommandHandler : ICommandHandler !string.IsNullOrEmpty(u.Avatar)) .Select(u => u.Avatar!); - + var activeStoryUrls = allStories .Where(s => !string.IsNullOrEmpty(s.MediaUrl)) .Select(s => s.MediaUrl!); diff --git a/backend/src/Modules/Admin/Application/Admin/Queries/CleanDryRun/CleanDryRunQuery.cs b/backend/src/Modules/Admin/Application/Admin/Queries/CleanDryRun/CleanDryRunQuery.cs index 7eab692..4c24fe5 100644 --- a/backend/src/Modules/Admin/Application/Admin/Queries/CleanDryRun/CleanDryRunQuery.cs +++ b/backend/src/Modules/Admin/Application/Admin/Queries/CleanDryRun/CleanDryRunQuery.cs @@ -1,21 +1,22 @@ -using Knot.Modules.Admin.Application.Admin.DTOs; -using Knot.Modules.Messaging.Domain; using System; -using System.Linq; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; -using MediatR; -using Knot.Shared.Kernel; -using Knot.Shared.Kernel.Storage; -using MongoDB.Driver; -using MongoDB.Bson; -using Microsoft.EntityFrameworkCore; +using Knot.Contracts.Auth.Application.Abstractions; +using Knot.Contracts.Auth.Application.Auth.DTOs; +using Knot.Contracts.Messaging.Domain; +using Knot.Modules.Admin.Application.Admin.DTOs; using Knot.Modules.Auth.Infrastructure.Persistence; +using Knot.Modules.Auth.Application.Users; using Knot.Modules.Conversations.Infrastructure.Persistence; using Knot.Modules.Stories.Domain; -using Knot.Modules.Auth.Application.Auth.DTOs; -using Knot.Modules.Auth.Application.Users; +using Knot.Shared.Kernel; +using Knot.Shared.Kernel.Storage; +using MediatR; +using Microsoft.EntityFrameworkCore; +using MongoDB.Bson; +using MongoDB.Driver; namespace Knot.Modules.Admin.Application.Admin.Queries; @@ -40,14 +41,14 @@ internal sealed class CleanDryRunQueryHandler : IQueryHandler> Handle(CleanDryRunQuery request, CancellationToken ct) { - try + try { // 1. Получаем ID активных чатов (SQL) var activeChats = await _chatsDbContext.Chats .AsNoTracking() .Select(c => new { c.Id, c.Avatar }) .ToListAsync(ct); - + var activeChatIds = activeChats.Select(c => c.Id).ToHashSet(); // 2. Считаем сообщения подлежащие удалению (MongoDB) @@ -62,7 +63,7 @@ internal sealed class CleanDryRunQueryHandler : IQueryHandler !string.IsNullOrEmpty(u.Avatar)) .Select(u => u.Avatar).ToListAsync(ct); @@ -78,9 +79,9 @@ internal sealed class CleanDryRunQueryHandler : IQueryHandler.Filter.BitsAllClear(m => m.State, (long)MessageState.IsDeleted), Builders.Filter.In(m => m.ChatId, activeChatIds) ); - + var projection = Builders.Projection.Include("Media"); - + using (var cursor = await _messages.Find(activeFilter).Project(projection).ToCursorAsync(ct)) { while (await cursor.MoveNextAsync(ct)) diff --git a/backend/src/Modules/Admin/Knot.Modules.Admin.csproj b/backend/src/Modules/Admin/Knot.Modules.Admin.csproj index 549f95b..9715061 100644 --- a/backend/src/Modules/Admin/Knot.Modules.Admin.csproj +++ b/backend/src/Modules/Admin/Knot.Modules.Admin.csproj @@ -10,11 +10,16 @@ + + + + + @@ -24,4 +29,4 @@ <_Parameter1>Knot.Modules.Admin.UnitTests - + \ No newline at end of file diff --git a/backend/src/Modules/Auth/Application/Users/Login/LoginUserCommandHandler.cs b/backend/src/Modules/Auth/Application/Users/Login/LoginUserCommandHandler.cs index cfbd42e..a4bf5d8 100644 --- a/backend/src/Modules/Auth/Application/Users/Login/LoginUserCommandHandler.cs +++ b/backend/src/Modules/Auth/Application/Users/Login/LoginUserCommandHandler.cs @@ -1,17 +1,17 @@ -using Knot.Contracts.Auth.Domain; +using BCrypt.Net; using Knot.Contracts.Auth.Application.Abstractions; using Knot.Contracts.Auth.Application.Auth.DTOs; +using Knot.Contracts.Auth.Domain; using Knot.Shared.Kernel; -using BCrypt.Net; namespace Knot.Modules.Auth.Application.Users.Login; /// -/// . AuthResponseDto. +/// ������� ��� ����� ������������. ���������� AuthResponseDto. /// public sealed record LoginUserCommand(string Username, string Password) : ICommand; -internal sealed class LoginUserCommandHandler : ICommandHandler +public sealed class LoginUserCommandHandler : ICommandHandler { private readonly IUserRepository _userRepository; private readonly IJwtTokenProvider _tokenProvider; diff --git a/backend/src/Modules/Conversations/Application/Chats/GetChatById/GetChatById.cs b/backend/src/Modules/Conversations/Application/Chats/GetChatById/GetChatById.cs index d3126e9..3eca23d 100644 --- a/backend/src/Modules/Conversations/Application/Chats/GetChatById/GetChatById.cs +++ b/backend/src/Modules/Conversations/Application/Chats/GetChatById/GetChatById.cs @@ -1,15 +1,15 @@ -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.Contracts.Messaging.Application.Abstractions; +using Knot.Contracts.Messaging.Domain; +using Knot.Modules.Conversations.Application.Abstractions; using Knot.Modules.Conversations.Application.DTOs; using Knot.Modules.Conversations.Domain; -using Knot.Modules.Conversations.Application.Abstractions; +using Knot.Shared.Kernel; +using MediatR; namespace Knot.Modules.Conversations.Application.Chats.GetChatById; @@ -50,8 +50,8 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler(); if (latestMessage != null) diff --git a/backend/src/Modules/Conversations/Application/Chats/GetChats/GetChats.cs b/backend/src/Modules/Conversations/Application/Chats/GetChats/GetChats.cs index 472826c..9fcb72c 100644 --- a/backend/src/Modules/Conversations/Application/Chats/GetChats/GetChats.cs +++ b/backend/src/Modules/Conversations/Application/Chats/GetChats/GetChats.cs @@ -1,15 +1,15 @@ -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.Contracts.Messaging.Application.Abstractions; +using Knot.Contracts.Messaging.Domain; +using Knot.Modules.Conversations.Application.Abstractions; using Knot.Modules.Conversations.Application.DTOs; using Knot.Modules.Conversations.Domain; -using Knot.Modules.Conversations.Application.Abstractions; +using Knot.Shared.Kernel; +using MediatR; namespace Knot.Modules.Conversations.Application.Chats.GetChats; @@ -39,12 +39,12 @@ internal sealed class GetChatsQueryHandler : IQueryHandler(); var userIdsToFetch = new HashSet(); - + foreach (var member in chat.Members) { userIdsToFetch.Add(member.UserId); diff --git a/backend/src/Modules/Conversations/Application/Messages/Delete/DeleteMessagesCommand.cs b/backend/src/Modules/Conversations/Application/Messages/Delete/DeleteMessagesCommand.cs index 8458b6b..9b2d667 100644 --- a/backend/src/Modules/Conversations/Application/Messages/Delete/DeleteMessagesCommand.cs +++ b/backend/src/Modules/Conversations/Application/Messages/Delete/DeleteMessagesCommand.cs @@ -1,10 +1,10 @@ -using MediatR; -using Microsoft.AspNetCore.SignalR; +using global::Knot.Modules.Conversations.Application.Abstractions; 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; -using MessagingMessageRepository = Knot.Modules.Messaging.Domain.IMessageRepository; +using MediatR; +using Microsoft.AspNetCore.SignalR; +using MessagingMessageRepository = Knot.Contracts.Messaging.Application.Abstractions.IMessageRepository; namespace Knot.Modules.Conversations.Application.Messages.Delete; diff --git a/backend/src/Modules/Conversations/Application/Messages/GetMessages/GetMessagesQuery.cs b/backend/src/Modules/Conversations/Application/Messages/GetMessages/GetMessagesQuery.cs index 0f4d709..ccec892 100644 --- a/backend/src/Modules/Conversations/Application/Messages/GetMessages/GetMessagesQuery.cs +++ b/backend/src/Modules/Conversations/Application/Messages/GetMessages/GetMessagesQuery.cs @@ -1,16 +1,15 @@ -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.Contracts.Messaging.Application.Abstractions; +using Knot.Contracts.Messaging.Domain; +using Knot.Modules.Conversations.Application.Abstractions; using Knot.Modules.Conversations.Application.DTOs; using Knot.Modules.Conversations.Domain; - -using Knot.Modules.Conversations.Application.Abstractions; +using Knot.Shared.Kernel; +using MediatR; namespace Knot.Modules.Conversations.Application.Messages.GetMessages; @@ -74,7 +73,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler m.Id).ToList(); var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken); var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList()); diff --git a/backend/src/Modules/Conversations/Application/Messages/GetSharedMedia/GetSharedMediaQuery.cs b/backend/src/Modules/Conversations/Application/Messages/GetSharedMedia/GetSharedMediaQuery.cs index 63f7009..1d44f40 100644 --- a/backend/src/Modules/Conversations/Application/Messages/GetSharedMedia/GetSharedMediaQuery.cs +++ b/backend/src/Modules/Conversations/Application/Messages/GetSharedMedia/GetSharedMediaQuery.cs @@ -1,17 +1,16 @@ -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.Contracts.Messaging.Application.Abstractions; +using Knot.Contracts.Messaging.Domain; +using Knot.Modules.Conversations.Application.Abstractions; using Knot.Modules.Conversations.Application.DTOs; using Knot.Modules.Conversations.Domain; - -using Knot.Modules.Conversations.Application.Abstractions; +using Knot.Shared.Kernel; +using MediatR; namespace Knot.Modules.Conversations.Application.Messages.GetSharedMedia; diff --git a/backend/src/Modules/Conversations/Application/Messages/React/AddReactionCommand.cs b/backend/src/Modules/Conversations/Application/Messages/React/AddReactionCommand.cs index b162976..f2652bc 100644 --- a/backend/src/Modules/Conversations/Application/Messages/React/AddReactionCommand.cs +++ b/backend/src/Modules/Conversations/Application/Messages/React/AddReactionCommand.cs @@ -1,12 +1,12 @@ -using Knot.Modules.Messaging.Application.Abstractions; -using Knot.Modules.Messaging.Domain; -using MediatR; -using Microsoft.AspNetCore.SignalR; -using Microsoft.Extensions.Logging; +using Knot.Contracts.Messaging.Application.Abstractions; +using Knot.Contracts.Messaging.Domain; using Knot.Modules.Conversations.Application.Abstractions; using Knot.Modules.Conversations.Domain; using Knot.Modules.Conversations.Infrastructure.SignalR; using Knot.Shared.Kernel; +using MediatR; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; namespace Knot.Modules.Conversations.Application.Messages.React; diff --git a/backend/src/Modules/Conversations/Application/Messages/React/RemoveReactionCommand.cs b/backend/src/Modules/Conversations/Application/Messages/React/RemoveReactionCommand.cs index 2488085..445d794 100644 --- a/backend/src/Modules/Conversations/Application/Messages/React/RemoveReactionCommand.cs +++ b/backend/src/Modules/Conversations/Application/Messages/React/RemoveReactionCommand.cs @@ -1,12 +1,12 @@ -using Knot.Modules.Messaging.Application.Abstractions; -using Knot.Modules.Messaging.Domain; -using MediatR; -using Microsoft.AspNetCore.SignalR; -using Microsoft.Extensions.Logging; +using Knot.Contracts.Messaging.Application.Abstractions; +using Knot.Contracts.Messaging.Domain; using Knot.Modules.Conversations.Application.Abstractions; using Knot.Modules.Conversations.Domain; using Knot.Modules.Conversations.Infrastructure.SignalR; using Knot.Shared.Kernel; +using MediatR; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; namespace Knot.Modules.Conversations.Application.Messages.React; diff --git a/backend/src/Modules/Conversations/Application/Messages/SearchMessages/SearchMessagesQuery.cs b/backend/src/Modules/Conversations/Application/Messages/SearchMessages/SearchMessagesQuery.cs index 7b10397..0b3cc75 100644 --- a/backend/src/Modules/Conversations/Application/Messages/SearchMessages/SearchMessagesQuery.cs +++ b/backend/src/Modules/Conversations/Application/Messages/SearchMessages/SearchMessagesQuery.cs @@ -1,14 +1,14 @@ -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.Contracts.Messaging.Application.Abstractions; +using Knot.Contracts.Messaging.Domain; using Knot.Modules.Conversations.Application.DTOs; using Knot.Modules.Conversations.Domain; +using Knot.Shared.Kernel; +using MediatR; namespace Knot.Modules.Conversations.Application.Messages.SearchMessages; @@ -36,7 +36,7 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler 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()); @@ -54,7 +54,7 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler -/// . +/// ������� ��� �������� ��������� � ���. /// public record AttachmentRequest(string Type, string Url, string? FileName, long? FileSize); @@ -53,37 +53,47 @@ public sealed class SendMessageCommandHandler : ICommandHandler> Handle(SendMessageCommand request, CancellationToken cancellationToken) { - // 1. + // 1. ��������� ������������� ���� var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken); if (chat is null) { return Result.Failure(ChatErrors.ChatsNotFound); } - // 2. , + // 2. ���������, �������� �� ����������� ���������� if (!chat.Members.Any(m => m.UserId == request.SenderId)) { return Result.Failure(ChatErrors.ChatsForbidden); } - // 3. + // 3. ������� ��������� Message message; if (request.Type == "story_reply" || request.Type == "story_reaction") { if (!_messagesSettings.Current.AllowMedia) return Result.Failure(ChatErrors.MediaDisabled); - + + var parsedStoryMediaType = Enum.TryParse(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, + Guid.NewGuid(), + + request.ChatId, + + request.SenderId, + + request.StoryId ?? Guid.Empty, + + request.StoryMediaUrl ?? string.Empty, + request.StoryMediaType, + + request.Content, + + request.ReplyToId, + + request.ForwardedFromId, + + DateTime.UtcNow, + false); } else if (request.Attachments != null && request.Attachments.Any()) @@ -92,22 +102,31 @@ public sealed class SendMessageCommandHandler : ICommandHandler(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, + 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(att.Type, true, out var tEnum) ? tEnum : MediaType.File; - ((MediaMessage)message).AddMedia(pType, att.Url, att.FileName, att.FileSize); + ((MediaMessage)message).AddMedia(pType.ToString().ToLower(), att.Url, att.FileName, att.FileSize); } } else if (request.Type == "poll") @@ -131,18 +150,26 @@ public sealed class SendMessageCommandHandler : ICommandHandler m.Id != msg.Id && + bool isUsedElsewhere = allMessages.Any(m => m.Id != msg.Id && m is MediaMessage mm && mm.Media.Any(ame => ame.Url == media.Url)); - + if (!isUsedElsewhere) { var fileId = ExtractFileId(media.Url); diff --git a/backend/src/Modules/Conversations/DependencyInjection.cs b/backend/src/Modules/Conversations/DependencyInjection.cs index 14d4cef..80569f4 100644 --- a/backend/src/Modules/Conversations/DependencyInjection.cs +++ b/backend/src/Modules/Conversations/DependencyInjection.cs @@ -1,27 +1,22 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Configuration; -using Microsoft.EntityFrameworkCore; +using Knot.Contracts.Messaging.Application.Abstractions; +using Knot.Contracts.Messaging.Domain; +using Knot.Modules.Conversations.Application.Abstractions; using Knot.Modules.Conversations.Domain; -using Knot.Shared.Kernel; using Knot.Modules.Conversations.Infrastructure.Persistence; using Knot.Modules.Conversations.Infrastructure.Persistence.Mongo; - -using Knot.Modules.Conversations.Application.Abstractions; -using Knot.Modules.Messaging.Domain; -using Knot.Modules.Messaging.Infrastructure.Persistence; +using Knot.Shared.Kernel; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; namespace Knot.Modules.Conversations; public static class DependencyInjection { - /// - /// ╨а╨╡╨│╨╕╤Б╤В╤А╨░╤Ж╨╕╤П ╤Б╨╡╤А╨▓╨╕╤Б╨╛╨▓ ╨╝╨╛╨┤╤Г╨╗╤П Chats. - /// public static IServiceCollection AddConversationsModule( this IServiceCollection services, IConfiguration configuration) { - // ╨Э╨░╤Б╤В╤А╨╛╨╣╨║╨░ ╨▒╨░╨╖╤Л ╨┤╨░╨╜╨╜╤Л╤Е string connectionString = configuration.GetConnectionString("DefaultConnection")!; services.AddDbContext(options => @@ -29,7 +24,7 @@ public static class DependencyInjection // MongoDB Setup for Messages ConversationsMongoDbMapConfigurator.Configure(); - + var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017"; // Registration @@ -38,17 +33,17 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); - - // Messaging Repository registration (might be redundant if already in Messaging module, but needed for specific commands in Conversations) - services.AddScoped(); + + // Messaging Repository - registered in Messaging module + // services.AddScoped(); // MediatR services.AddMediatR(config => config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly)); - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); return services; } -} \ No newline at end of file +} diff --git a/backend/src/Modules/Conversations/Infrastructure/Services/ChatAccessProvider.cs b/backend/src/Modules/Conversations/Infrastructure/Services/ChatAccessProvider.cs index b7f1a7e..13e3d13 100644 --- a/backend/src/Modules/Conversations/Infrastructure/Services/ChatAccessProvider.cs +++ b/backend/src/Modules/Conversations/Infrastructure/Services/ChatAccessProvider.cs @@ -1,11 +1,12 @@ 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; +using Knot.Contracts.Messaging.Application.Abstractions; +using Knot.Modules.Conversations.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; public class ChatAccessProvider : IChatAccessProvider { private readonly ChatsDbContext _db; public ChatAccessProvider(ChatsDbContext db) { _db = db; } public Task> GetValidChatIdsForUserAsync(Guid userId, CancellationToken ct) { return _db.Chats.Where(c => c.Members.Any(m => m.UserId == userId)).Select(c => c.Id).ToListAsync(ct); } } diff --git a/backend/src/Modules/Conversations/Infrastructure/SignalR/MessageNotifier.cs b/backend/src/Modules/Conversations/Infrastructure/SignalR/MessageNotifier.cs index e55d921..4224841 100644 --- a/backend/src/Modules/Conversations/Infrastructure/SignalR/MessageNotifier.cs +++ b/backend/src/Modules/Conversations/Infrastructure/SignalR/MessageNotifier.cs @@ -1,7 +1,8 @@ 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; +using Knot.Contracts.Messaging.Application.Abstractions; +using Microsoft.AspNetCore.SignalR; public class MessageNotifier : IMessageNotifier { private readonly IHubContext _hubContext; public MessageNotifier(IHubContext hubContext) { _hubContext = hubContext; } public Task NotifyNewMessageAsync(Guid chatId, object messagePayload, CancellationToken cancellationToken) { return _hubContext.Clients.Group(chatId.ToString()).SendAsync("new_message", messagePayload, cancellationToken); } } \ No newline at end of file diff --git a/backend/src/Modules/Conversations/Knot.Modules.Conversations.csproj b/backend/src/Modules/Conversations/Knot.Modules.Conversations.csproj index fc68ea3..102959f 100644 --- a/backend/src/Modules/Conversations/Knot.Modules.Conversations.csproj +++ b/backend/src/Modules/Conversations/Knot.Modules.Conversations.csproj @@ -1,5 +1,4 @@  - net10.0 enable @@ -10,8 +9,9 @@ - - + + + diff --git a/backend/src/Modules/Federation/Application/Federation/Commands/InboundFederationCommand.cs b/backend/src/Modules/Federation/Application/Federation/Commands/InboundFederationCommand.cs index a011fe4..c59578e 100644 --- a/backend/src/Modules/Federation/Application/Federation/Commands/InboundFederationCommand.cs +++ b/backend/src/Modules/Federation/Application/Federation/Commands/InboundFederationCommand.cs @@ -5,15 +5,15 @@ using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using MediatR; -using Knot.Modules.Federation.Application.Abstractions; -using Knot.Shared.Kernel; using Knot.Contracts.Auth.Domain; -using Knot.Modules.Messaging.Application.Abstractions; -using Knot.Modules.Messaging.Domain; +using Knot.Contracts.Messaging.Application.Abstractions; +using Knot.Contracts.Messaging.Domain; using Knot.Contracts.Settings.Application.Abstractions; using Knot.Contracts.Settings.Application.DTOs; +using Knot.Modules.Federation.Application.Abstractions; +using Knot.Shared.Kernel; using Knot.Shared.Kernel.Constants; +using MediatR; namespace Host.Application.Federation.Commands; @@ -29,7 +29,8 @@ internal sealed class InboundFederationCommandHandler : ICommandHandler d.IsEnabled && d.Domain.Equals(packet.SenderDomain, StringComparison.OrdinalIgnoreCase)); if (senderConfig == null || string.IsNullOrEmpty(senderConfig.PublicKey)) return Result.Failure(new Error("Federation.SenderNotAllowed", "Sender domain not in allowlist.")); @@ -67,22 +68,23 @@ internal sealed class InboundFederationCommandHandler : ICommandHandler(plainText); @@ -112,14 +114,16 @@ internal sealed class InboundFederationCommandHandler : ICommandHandler(plainText); var isOnline = statusData.GetProperty("IsOnline").GetBoolean(); - + + var user = await _userRepository.GetByIdAsync(packet.Metadata.SenderId, cancellationToken); if (user != null && user.IsExternal) { user.IsOnline = isOnline; await _userRepository.UpdateAsync(user, cancellationToken); - - // SignalR + + // ���������� ��������� ������������� ����� SignalR + await _notifier.NotifyNewMessageAsync(Guid.Empty, new { type = "presence_update", userId = user.Id, isOnline = user.IsOnline }, cancellationToken); } return Result.Success(); @@ -163,12 +167,19 @@ internal sealed class InboundFederationCommandHandler : ICommandHandler -/// Обработчик доменного события отправки сообщения. -/// Если в чате есть внешние участники — инициирует федеративную рассылку. -/// public sealed class MessageSentDomainEventHandler : INotificationHandler { private readonly IChatRepository _chatRepository; @@ -26,7 +22,7 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler m.UserId).ToList(); var members = await _userRepository.GetByIdsAsync(memberIds, cancellationToken); - - // Находим уникальные домены внешних участников + var externalDomains = members .Where(u => u.IsExternal && !string.IsNullOrEmpty(u.Domain)) .Select(u => u.Domain!) @@ -60,7 +54,6 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler -/// . -/// , . +/// Обработчик изменения статуса пользователя. +/// Отправляет статус всем внешним контактам, а также подписчикам чатов. /// public sealed class UserStatusChangedDomainEventHandler : INotificationHandler { @@ -44,11 +43,11 @@ public sealed class UserStatusChangedDomainEventHandler : INotificationHandler c.Members.Select(m => m.UserId)).Distinct().ToList(); var members = await _userRepository.GetByIdsAsync(allMemberIds, cancellationToken); @@ -60,7 +59,7 @@ public sealed class UserStatusChangedDomainEventHandler : INotificationHandlernet10.0 enable enable + + false - - - - + + + + diff --git a/backend/src/Modules/Messaging/DependencyInjection.cs b/backend/src/Modules/Messaging/DependencyInjection.cs index c68fe1e..3614037 100644 --- a/backend/src/Modules/Messaging/DependencyInjection.cs +++ b/backend/src/Modules/Messaging/DependencyInjection.cs @@ -1,36 +1,27 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Configuration; - -using Knot.Modules.Messaging.Domain; -using Knot.Shared.Kernel; -using MongoDB.Driver; +using Knot.Contracts.Messaging.Application.Abstractions; +using Knot.Contracts.Messaging.Domain; using Knot.Modules.Messaging.Infrastructure.Persistence; using Knot.Modules.Messaging.Infrastructure.Persistence.Mongo; - -using Knot.Modules.Messaging.Application.Abstractions; +using Knot.Shared.Kernel; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; namespace Knot.Modules.Messaging; public static class DependencyInjection { - /// - /// ╨а╨╡╨│╨╕╤Б╤В╤А╨░╤Ж╨╕╤П ╤Б╨╡╤А╨▓╨╕╤Б╨╛╨▓ ╨╝╨╛╨┤╤Г╨╗╤П Chats. - /// public static IServiceCollection AddMessagingModule( this IServiceCollection services, IConfiguration configuration) { - // ╨Э╨░╤Б╤В╤А╨╛╨╣╨║╨░ ╨▒╨░╨╖╤Л ╨┤╨░╨╜╨╜╤Л╤Е string connectionString = configuration.GetConnectionString("DefaultConnection")!; - - - // MongoDB Setup for Messages MongoDbMapConfigurator.Configure(); - + var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017"; services.AddSingleton(new MongoClient(mongoConnectionString)); - services.AddScoped(sp => + services.AddScoped(sp => sp.GetRequiredService().GetDatabase("forkmessager_chats")); // Registration diff --git a/backend/src/Modules/Messaging/Infrastructure/Handlers/MessageSentDomainEventHandler.cs b/backend/src/Modules/Messaging/Infrastructure/Handlers/MessageSentDomainEventHandler.cs index 0dc5d3c..02a4e12 100644 --- a/backend/src/Modules/Messaging/Infrastructure/Handlers/MessageSentDomainEventHandler.cs +++ b/backend/src/Modules/Messaging/Infrastructure/Handlers/MessageSentDomainEventHandler.cs @@ -1,15 +1,10 @@ -using MediatR; - -using Knot.Modules.Messaging.Domain; -using Knot.Modules.Messaging.Application.Abstractions; +using Knot.Contracts.Messaging.Application.Abstractions; +using Knot.Contracts.Messaging.Domain; using Knot.Shared.Kernel; +using MediatR; namespace Knot.Modules.Messaging.Infrastructure.Handlers; -/// -/// Обработчик доменного события отправки сообщения. -/// Отправляет уведомление через SignalR всем участникам чата. -/// public sealed class MessageSentDomainEventHandler : INotificationHandler { private readonly IMessageNotifier _hubContext; @@ -39,7 +34,6 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler new { rm.Id, rm.Type, rm.Url }).ToList(), + media = replyMsg.Media.Select(rm => new { rm.Type, rm.Url }).ToList(), Sender = replySenderInfo != null ? new { Id = replySenderInfo.Id, Username = replySenderInfo.Username, DisplayName = replySenderInfo.DisplayName } : new { Id = replyMsg.SenderId, Username = "unknown", DisplayName = "Unknown" } @@ -70,34 +63,32 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler new { - id = message.Id, - chatId = message.ChatId, - senderId = message.SenderId, - content = message.Content, - type = message.Type, - createdAt = message.CreatedAt, - forwardedFromId = message.ForwardedFromId, - forwardedFrom = forwardedFromObj, - replyToId = message.ReplyToId, - replyTo = replyToObj, - quote = message.Quote, - media = message.Media.Select(m => new - { - id = m.Id, - type = m.Type, - url = m.Url, - filename = m.Filename, - size = m.Size - }).ToList(), - sender = senderObj, - readBy = new List(), - storyId = message.StoryId, - storyMediaUrl = message.StoryMediaUrl, - storyMediaType = message.StoryMediaType - }, cancellationToken); + type = m.Type, + url = m.Url, + filename = m.FileId, + size = m.Size + }).ToList(), + sender = senderObj, + readBy = new List(), + storyId = message.StoryId, + storyMediaUrl = message.StoryMediaUrl, + storyMediaType = message.StoryMediaType + }, cancellationToken); } } diff --git a/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageReactionRepository.cs b/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageReactionRepository.cs index 6e6d2a3..c8bd2c4 100644 --- a/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageReactionRepository.cs +++ b/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageReactionRepository.cs @@ -1,5 +1,6 @@ +using Knot.Contracts.Messaging.Application.Abstractions; +using Knot.Contracts.Messaging.Domain; using MongoDB.Driver; -using Knot.Modules.Messaging.Domain; namespace Knot.Modules.Messaging.Infrastructure.Persistence; @@ -14,7 +15,7 @@ public sealed class MessageReactionRepository : IMessageReactionRepository _reactions = mongoDatabase.GetCollection("message_reactions"); _mediator = mediator; _messageRepository = messageRepository; - + // Ensure index for fast querying by message var indexKeysDefinition = Builders.IndexKeys.Ascending(r => r.MessageId); _reactions.Indexes.CreateOne(new CreateIndexModel(indexKeysDefinition)); @@ -22,17 +23,14 @@ public sealed class MessageReactionRepository : IMessageReactionRepository public async Task AddAsync(MessageReaction reaction, CancellationToken cancellationToken) { - // Уникальный индекс или фильтр, чтобы не дублировать var filter = Builders.Filter.And( Builders.Filter.Eq(r => r.MessageId, reaction.MessageId), Builders.Filter.Eq(r => r.UserId, reaction.UserId), Builders.Filter.Eq(r => r.Emoji, reaction.Emoji) ); - - // Используем ReplaceOptions.IsUpsert = true для идемпотентности (нет гонок) + await _reactions.ReplaceOneAsync(filter, reaction, new ReplaceOptions { IsUpsert = true }, cancellationToken); - // Уведомляем систему (для Федерации) var message = await _messageRepository.GetByIdAsync(reaction.MessageId, cancellationToken); if (message != null) { @@ -50,7 +48,6 @@ public sealed class MessageReactionRepository : IMessageReactionRepository await _reactions.DeleteOneAsync(filter, cancellationToken); - // Уведомляем систему (для Федерации) var message = await _messageRepository.GetByIdAsync(messageId, cancellationToken); if (message != null) { diff --git a/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageRepository.cs b/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageRepository.cs index 767f35e..91bdcdc 100644 --- a/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageRepository.cs +++ b/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageRepository.cs @@ -1,20 +1,21 @@ +using System.Text.RegularExpressions; +using Knot.Contracts.Messaging.Application.Abstractions; +using Knot.Contracts.Messaging.Domain; +using Knot.Shared.Kernel; using Microsoft.Extensions.Configuration; +using MongoDB.Bson; using MongoDB.Driver; using MongoDB.Driver.Linq; -using Knot.Modules.Messaging.Domain; -using Knot.Shared.Kernel; -using System.Text.RegularExpressions; -using MongoDB.Bson; namespace Knot.Modules.Messaging.Infrastructure.Persistence; public sealed class MessageRepository : IMessageRepository { private readonly IMongoCollection _messages; - private readonly Knot.Modules.Messaging.Application.Abstractions.IChatAccessProvider _chatAccessProvider; + private readonly IChatAccessProvider _chatAccessProvider; private readonly MediatR.IMediator _mediator; - public MessageRepository(IMongoDatabase mongoDatabase, Knot.Modules.Messaging.Application.Abstractions.IChatAccessProvider chatAccessProvider, MediatR.IMediator mediator) + public MessageRepository(IMongoDatabase mongoDatabase, IChatAccessProvider chatAccessProvider, MediatR.IMediator mediator) { _messages = mongoDatabase.GetCollection("messages"); _chatAccessProvider = chatAccessProvider; @@ -28,7 +29,7 @@ public sealed class MessageRepository : IMessageRepository // Publish domain events manualy for mongo entities var events = message.GetDomainEvents().ToList(); message.ClearDomainEvents(); - + // This runs synchronously or without waiting, better to run async but Add is void // In this implementation setting, fire and forget or wrap sync foreach (var domainEvent in events) @@ -65,7 +66,7 @@ public sealed class MessageRepository : IMessageRepository { var builder = Builders.Filter; var filter = builder.Eq(m => m.ChatId, chatId); - + if (cursor.HasValue) { filter &= builder.Lt(m => m.CreatedAt, cursor.Value); diff --git a/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/MongoDbMapConfigurator.cs b/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/MongoDbMapConfigurator.cs index 3e533c4..97ed335 100644 --- a/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/MongoDbMapConfigurator.cs +++ b/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/MongoDbMapConfigurator.cs @@ -1,8 +1,8 @@ -using Knot.Modules.Messaging.Domain; +using Knot.Contracts.Messaging.Domain; +using Knot.Shared.Kernel; using MongoDB.Bson; using MongoDB.Bson.Serialization; using MongoDB.Bson.Serialization.Serializers; -using Knot.Shared.Kernel; namespace Knot.Modules.Messaging.Infrastructure.Persistence.Mongo; @@ -15,7 +15,7 @@ public static class MongoDbMapConfigurator if (_initialized) return; try { BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard)); } catch { /* Already registered */ } - + BsonSerializer.RegisterSerializer(new EnumSerializer(BsonType.Int32)); BsonSerializer.RegisterSerializer(new EnumSerializer(BsonType.String)); @@ -45,7 +45,7 @@ public static class MongoDbMapConfigurator cm.AutoMap(); cm.MapField("_media").SetElementName("Media"); cm.SetDiscriminator("MediaMessage"); - cm.UnmapProperty(c => c.Caption); // Avoid DB duplication, Content is already saved + cm.UnmapProperty(c => c.Caption); }); BsonClassMap.RegisterClassMap(cm => @@ -69,7 +69,7 @@ public static class MongoDbMapConfigurator BsonClassMap.RegisterClassMap(cm => cm.AutoMap()); BsonClassMap.RegisterClassMap(cm => cm.AutoMap()); - + BsonClassMap.RegisterClassMap(cm => { cm.AutoMap(); diff --git a/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/UserStatsService.cs b/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/UserStatsService.cs index 51112c4..1a311e3 100644 --- a/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/UserStatsService.cs +++ b/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/UserStatsService.cs @@ -3,8 +3,8 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Knot.Modules.Messaging.Application.Abstractions; -using Knot.Modules.Messaging.Domain; +using Knot.Contracts.Messaging.Application.Abstractions; +using Knot.Contracts.Messaging.Domain; using MongoDB.Bson; using MongoDB.Driver; @@ -24,7 +24,6 @@ public sealed class UserStatsService : IUserStatsService var userGuidList = userIds.ToList(); if (!userGuidList.Any()) return new Dictionary(); - // Эффективная агрегация: считаем количество и сумму Media.Size var stats = await _messages.Aggregate() .Match(Builders.Filter.In(m => m.SenderId, userGuidList)) .Group(new BsonDocument { @@ -67,9 +66,6 @@ public sealed class UserStatsService : IUserStatsService public async Task GetOrphanedMediaSizeAsync(HashSet validFileIds, CancellationToken ct = default) { - // Для больших объемов правильнее собирать список ВСЕХ URL файлов из сообщений, - // но здесь мы оптимизируем через проекцию, чтобы вернуть только нужные поля. - // Этот метод может быть реализован в BackgroundTask для очень больших баз. - return 0; // Временная заглушка, реальный подсчет через курсор в DryRun + return 0; } } diff --git a/backend/src/Modules/Messaging/Knot.Modules.Messaging.csproj b/backend/src/Modules/Messaging/Knot.Modules.Messaging.csproj index f7bcc0b..28a6606 100644 --- a/backend/src/Modules/Messaging/Knot.Modules.Messaging.csproj +++ b/backend/src/Modules/Messaging/Knot.Modules.Messaging.csproj @@ -2,13 +2,14 @@ + - +