From 59e0cf76675e68d65c0cbf2d50a4863005511608 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: Thu, 19 Mar 2026 00:59:01 +0300 Subject: [PATCH] DDD --- .../Commands/CleanRun/CleanRunCommand.cs | 85 +++ .../ResetUserPasswordCommand.cs | 40 ++ .../UpdateSettings/UpdateSettingsCommand.cs | 26 + .../Queries/CleanDryRun/CleanDryRunQuery.cs | 87 +++ .../GetDashboardStatsQuery.cs | 25 + .../Queries/GetSettings/GetSettingsQuery.cs | 26 + .../GetUserDetails/GetUserDetailsQuery.cs | 72 +++ .../Queries/SearchUsers/SearchUsersQuery.cs | 43 ++ .../Config/Queries/GetPublicConfigQuery.cs | 25 + .../Commands/HandshakeFederationCommand.cs | 51 ++ .../Klipy/Queries/GetTrendingGifsQuery.cs | 63 +++ .../Klipy/Queries/SearchGifsQuery.cs | 67 +++ .../AddStoryReactionCommand.cs | 128 +++++ .../AddStoryReply/AddStoryReplyCommand.cs | 114 ++++ .../CreateStory/CreateStoryCommand.cs | 36 ++ .../DeleteStory/DeleteStoryCommand.cs | 33 ++ .../RemoveStoryReactionCommand.cs | 36 ++ .../Commands/ViewStory/ViewStoryCommand.cs | 73 +++ .../Queries/GetStories/GetStoriesQuery.cs | 90 +++ .../GetStoryReplies/GetStoryRepliesQuery.cs | 56 ++ .../GetStoryViewers/GetStoryViewersQuery.cs | 58 ++ .../GetUserStories/GetUserStoriesQuery.cs | 62 +++ .../WebRtc/Queries/GetIceServersQuery.cs | 73 +++ .../src/Host/Controllers/AdminController.cs | 286 ++-------- .../src/Host/Controllers/AuthController.cs | 111 +--- .../src/Host/Controllers/ChatsController.cs | 429 +++----------- .../src/Host/Controllers/FriendsController.cs | 178 ++---- .../Host/Controllers/MessagesController.cs | 320 ++--------- .../src/Host/Controllers/StoriesController.cs | 523 ++---------------- .../Controllers/TelegramImportController.cs | 467 +--------------- .../src/Host/Controllers/UsersController.cs | 211 ++----- .../src/Host/Endpoints/ConfigEndpoints.cs | 10 +- .../src/Host/Endpoints/FederationEndpoints.cs | 34 +- .../src/Host/Endpoints/KlipyEndpoints.cs | 40 +- .../src/Host/Endpoints/WebRtcEndpoints.cs | 12 +- apps/server-net/src/Host/Host.csproj | 2 +- .../Host/Models/Admin/AdminUserDetailsDto.cs | 15 + .../src/Host/Models/Admin/AdminUserDto.cs | 14 + .../Host/Models/Admin/AdminUserStatsDto.cs | 9 + .../Models/Admin/CleanupDryRunResultDto.cs | 7 + .../src/Host/Models/Auth/AuthResponseDto.cs | 20 + .../src/Host/Models/Auth/ResetPasswordDto.cs | 6 + .../src/Host/Models/Config/PublicConfigDto.cs | 26 + .../src/Host/Models/Friends/FriendDto.cs | 13 + .../Host/Models/Friends/FriendRequestDto.cs | 16 + .../Host/Models/Friends/SendFriendRequest.cs | 5 + .../Models/Import/AnalyzeImportResponseDto.cs | 9 + .../Models/Import/ExecuteImportRequest.cs | 10 + .../Models/Import/ExecuteImportResponseDto.cs | 9 + .../Models/Stories/AddStoryReactionRequest.cs | 5 + .../Models/Stories/AddStoryReplyRequest.cs | 5 + .../Host/Models/Stories/CreateStoryRequest.cs | 5 + .../Stories/RemoveStoryReactionRequest.cs | 5 + .../src/Host/Models/Stories/StoriesDtos.cs | 10 + .../src/Host/Models/Stories/StoryDto.cs | 18 + .../Host/Models/Stories/StoryReactionDto.cs | 10 + .../src/Host/Models/Stories/StoryReplyDto.cs | 13 + .../src/Host/Models/Stories/StoryUserDto.cs | 10 + .../src/Host/Models/Stories/StoryViewerDto.cs | 11 + .../Host/Models/Users/UpdateProfileRequest.cs | 5 + .../Models/Users/UpdateSettingsRequest.cs | 3 + .../src/Host/Models/Users/UserDto.cs | 12 + .../src/Host/Models/Users/UserProfileDto.cs | 16 + apps/server-net/src/Host/Program.cs | 10 +- .../src/Host/Services/ConfigService.cs | 18 - .../src/Host/Services/FederationService.cs | 44 -- .../src/Host/Services/IConfigService.cs | 11 - .../src/Host/Services/IFederationService.cs | 24 - .../src/Host/Services/IKlipyService.cs | 14 - .../src/Host/Services/IPublicConfig.cs | 14 - .../src/Host/Services/IWebRtcService.cs | 17 - .../src/Host/Services/KlipyService.cs | 102 ---- .../src/Host/Services/PublicConfig.cs | 16 - .../src/Host/Services/WebRtcService.cs | 71 --- .../Chats/Application/Chats/Avatar/Avatar.cs | 123 ++++ .../Application/Chats/Clear/ClearChat.cs | 32 ++ .../Chats/GetChatById/GetChatById.cs | 116 ++++ .../Application/Chats/GetChats/GetChats.cs | 136 +++++ .../Chats/LeaveOrDelete/LeaveOrDeleteChat.cs | 47 ++ .../Application/Chats/Members/Members.cs | 68 +++ .../Application/Chats/TogglePin/TogglePin.cs | 42 ++ .../Application/Chats/Update/UpdateChat.cs | 39 ++ .../Application/DTOs/AddMembersRequest.cs | 6 + .../Modules/Chats/Application/DTOs/ChatDto.cs | 16 + .../Chats/Application/DTOs/ChatMemberDto.cs | 12 + .../Chats/Application/DTOs/ChatMessageDto.cs | 24 + .../Chats/Application/DTOs/ChatUserDto.cs | 12 + .../Application/DTOs/CreateChatRequest.cs | 7 + .../DTOs/CreateGroupChatRequest.cs | 6 + .../DTOs/CreatePersonalChatRequest.cs | 5 + .../Chats/Application/DTOs/MediaDto.cs | 11 + .../Application/DTOs/MessageDetailDto.cs | 42 ++ .../Application/DTOs/MessageSenderDto.cs | 10 + .../Chats/Application/DTOs/ReactionDto.cs | 10 + .../Chats/Application/DTOs/ReadByDto.cs | 7 + .../Application/DTOs/SearchMessageDto.cs | 31 ++ .../Application/DTOs/SendMessageRequest.cs | 14 + .../Chats/Application/DTOs/SharedMediaDto.cs | 20 + .../Application/DTOs/TogglePinResponse.cs | 3 + .../Application/DTOs/UpdateChatRequest.cs | 3 + .../Application/DTOs/UploadFileResponseDto.cs | 10 + .../Messages/GetMessages/GetMessagesQuery.cs | 126 +++++ .../GetSharedMedia/GetSharedMediaQuery.cs | 95 ++++ .../SearchMessages/SearchMessagesQuery.cs | 66 +++ .../Messages/UploadFile/UploadFileCommand.cs | 38 ++ .../TelegramImport/AnalyzeImportCommand.cs | 82 +++ .../TelegramImport/ExecuteImportCommand.cs | 397 +++++++++++++ .../Chats/Domain/IMessageRepository.cs | 1 + .../Persistence/MessageRepository.cs | 7 + .../Modules/Chats/Knot.Modules.Chats.csproj | 3 + .../Abstractions/IIdentityDbContext.cs | 14 + .../Friends/AcceptFriendRequest.cs | 33 ++ .../Friends/DeclineFriendRequest.cs | 33 ++ .../Identity/Application/Friends/FriendDto.cs | 14 + .../Application/Friends/FriendRequestDto.cs | 11 + .../Friends/FriendshipStatusResponse.cs | 9 + .../Application/Friends/GetFriends.cs | 56 ++ .../Friends/GetFriendshipStatus.cs | 39 ++ .../Friends/GetIncomingRequests.cs | 46 ++ .../Friends/GetOutgoingRequests.cs | 46 ++ .../Application/Friends/RemoveFriend.cs | 33 ++ .../Application/Friends/SendFriendRequest.cs | 42 ++ .../Application/Users/Auth/AuthResponseDto.cs | 20 + .../Application/Users/Avatar/CropAvatar.cs | 65 +++ .../Application/Users/Avatar/DeleteAvatar.cs | 40 ++ .../Application/Users/Avatar/UploadAvatar.cs | 46 ++ .../Identity/Application/Users/GetMe/GetMe.cs | 41 ++ .../Application/Users/GetUser/GetUser.cs | 38 ++ .../Users/Login/LoginUserCommandHandler.cs | 26 +- .../Register/RegisterUserCommandHandler.cs | 42 +- .../Application/Users/Search/SearchUsers.cs | 33 ++ .../Users/UpdateProfile/UpdateProfile.cs | 40 ++ .../Users/UpdateSettings/UpdateSettings.cs | 41 ++ .../Identity/Application/Users/UserDto.cs | 12 + .../Application/Users/UserProfileDto.cs | 16 + .../Modules/Identity/DependencyInjection.cs | 1 + .../Persistence/IdentityDbContext.cs | 2 +- .../Identity/Knot.Modules.Identity.csproj | 1 + .../Storage/S3FileStorageService.cs | 4 +- .../src/Shared/Knot.Shared.Kernel/IQuery.cs | 13 + .../Knot.Shared.Kernel/MessageResponse.cs | 3 + .../Knot.Shared.Kernel/SuccessResponse.cs | 3 + 142 files changed, 4557 insertions(+), 2593 deletions(-) create mode 100644 apps/server-net/src/Host/Application/Admin/Commands/CleanRun/CleanRunCommand.cs create mode 100644 apps/server-net/src/Host/Application/Admin/Commands/ResetUserPassword/ResetUserPasswordCommand.cs create mode 100644 apps/server-net/src/Host/Application/Admin/Commands/UpdateSettings/UpdateSettingsCommand.cs create mode 100644 apps/server-net/src/Host/Application/Admin/Queries/CleanDryRun/CleanDryRunQuery.cs create mode 100644 apps/server-net/src/Host/Application/Admin/Queries/GetDashboardStats/GetDashboardStatsQuery.cs create mode 100644 apps/server-net/src/Host/Application/Admin/Queries/GetSettings/GetSettingsQuery.cs create mode 100644 apps/server-net/src/Host/Application/Admin/Queries/GetUserDetails/GetUserDetailsQuery.cs create mode 100644 apps/server-net/src/Host/Application/Admin/Queries/SearchUsers/SearchUsersQuery.cs create mode 100644 apps/server-net/src/Host/Application/Config/Queries/GetPublicConfigQuery.cs create mode 100644 apps/server-net/src/Host/Application/Federation/Commands/HandshakeFederationCommand.cs create mode 100644 apps/server-net/src/Host/Application/Klipy/Queries/GetTrendingGifsQuery.cs create mode 100644 apps/server-net/src/Host/Application/Klipy/Queries/SearchGifsQuery.cs create mode 100644 apps/server-net/src/Host/Application/Stories/Commands/AddStoryReaction/AddStoryReactionCommand.cs create mode 100644 apps/server-net/src/Host/Application/Stories/Commands/AddStoryReply/AddStoryReplyCommand.cs create mode 100644 apps/server-net/src/Host/Application/Stories/Commands/CreateStory/CreateStoryCommand.cs create mode 100644 apps/server-net/src/Host/Application/Stories/Commands/DeleteStory/DeleteStoryCommand.cs create mode 100644 apps/server-net/src/Host/Application/Stories/Commands/RemoveStoryReaction/RemoveStoryReactionCommand.cs create mode 100644 apps/server-net/src/Host/Application/Stories/Commands/ViewStory/ViewStoryCommand.cs create mode 100644 apps/server-net/src/Host/Application/Stories/Queries/GetStories/GetStoriesQuery.cs create mode 100644 apps/server-net/src/Host/Application/Stories/Queries/GetStoryReplies/GetStoryRepliesQuery.cs create mode 100644 apps/server-net/src/Host/Application/Stories/Queries/GetStoryViewers/GetStoryViewersQuery.cs create mode 100644 apps/server-net/src/Host/Application/Stories/Queries/GetUserStories/GetUserStoriesQuery.cs create mode 100644 apps/server-net/src/Host/Application/WebRtc/Queries/GetIceServersQuery.cs create mode 100644 apps/server-net/src/Host/Models/Admin/AdminUserDetailsDto.cs create mode 100644 apps/server-net/src/Host/Models/Admin/AdminUserDto.cs create mode 100644 apps/server-net/src/Host/Models/Admin/AdminUserStatsDto.cs create mode 100644 apps/server-net/src/Host/Models/Admin/CleanupDryRunResultDto.cs create mode 100644 apps/server-net/src/Host/Models/Auth/AuthResponseDto.cs create mode 100644 apps/server-net/src/Host/Models/Auth/ResetPasswordDto.cs create mode 100644 apps/server-net/src/Host/Models/Config/PublicConfigDto.cs create mode 100644 apps/server-net/src/Host/Models/Friends/FriendDto.cs create mode 100644 apps/server-net/src/Host/Models/Friends/FriendRequestDto.cs create mode 100644 apps/server-net/src/Host/Models/Friends/SendFriendRequest.cs create mode 100644 apps/server-net/src/Host/Models/Import/AnalyzeImportResponseDto.cs create mode 100644 apps/server-net/src/Host/Models/Import/ExecuteImportRequest.cs create mode 100644 apps/server-net/src/Host/Models/Import/ExecuteImportResponseDto.cs create mode 100644 apps/server-net/src/Host/Models/Stories/AddStoryReactionRequest.cs create mode 100644 apps/server-net/src/Host/Models/Stories/AddStoryReplyRequest.cs create mode 100644 apps/server-net/src/Host/Models/Stories/CreateStoryRequest.cs create mode 100644 apps/server-net/src/Host/Models/Stories/RemoveStoryReactionRequest.cs create mode 100644 apps/server-net/src/Host/Models/Stories/StoriesDtos.cs create mode 100644 apps/server-net/src/Host/Models/Stories/StoryDto.cs create mode 100644 apps/server-net/src/Host/Models/Stories/StoryReactionDto.cs create mode 100644 apps/server-net/src/Host/Models/Stories/StoryReplyDto.cs create mode 100644 apps/server-net/src/Host/Models/Stories/StoryUserDto.cs create mode 100644 apps/server-net/src/Host/Models/Stories/StoryViewerDto.cs create mode 100644 apps/server-net/src/Host/Models/Users/UpdateProfileRequest.cs create mode 100644 apps/server-net/src/Host/Models/Users/UpdateSettingsRequest.cs create mode 100644 apps/server-net/src/Host/Models/Users/UserDto.cs create mode 100644 apps/server-net/src/Host/Models/Users/UserProfileDto.cs delete mode 100644 apps/server-net/src/Host/Services/ConfigService.cs delete mode 100644 apps/server-net/src/Host/Services/FederationService.cs delete mode 100644 apps/server-net/src/Host/Services/IConfigService.cs delete mode 100644 apps/server-net/src/Host/Services/IFederationService.cs delete mode 100644 apps/server-net/src/Host/Services/IKlipyService.cs delete mode 100644 apps/server-net/src/Host/Services/IPublicConfig.cs delete mode 100644 apps/server-net/src/Host/Services/IWebRtcService.cs delete mode 100644 apps/server-net/src/Host/Services/KlipyService.cs delete mode 100644 apps/server-net/src/Host/Services/PublicConfig.cs delete mode 100644 apps/server-net/src/Host/Services/WebRtcService.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/Chats/Avatar/Avatar.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/Chats/Clear/ClearChat.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/Chats/GetChatById/GetChatById.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/Chats/GetChats/GetChats.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/Chats/LeaveOrDelete/LeaveOrDeleteChat.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/Chats/Members/Members.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/Chats/TogglePin/TogglePin.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/Chats/Update/UpdateChat.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/DTOs/AddMembersRequest.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/DTOs/ChatDto.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/DTOs/ChatMemberDto.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/DTOs/ChatMessageDto.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/DTOs/ChatUserDto.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/DTOs/CreateChatRequest.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/DTOs/CreateGroupChatRequest.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/DTOs/CreatePersonalChatRequest.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/DTOs/MediaDto.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/DTOs/MessageDetailDto.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/DTOs/MessageSenderDto.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/DTOs/ReactionDto.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/DTOs/ReadByDto.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/DTOs/SearchMessageDto.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/DTOs/SendMessageRequest.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/DTOs/SharedMediaDto.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/DTOs/TogglePinResponse.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/DTOs/UpdateChatRequest.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/DTOs/UploadFileResponseDto.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/Messages/GetMessages/GetMessagesQuery.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/Messages/GetSharedMedia/GetSharedMediaQuery.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/Messages/SearchMessages/SearchMessagesQuery.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/Messages/UploadFile/UploadFileCommand.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/TelegramImport/AnalyzeImportCommand.cs create mode 100644 apps/server-net/src/Modules/Chats/Application/TelegramImport/ExecuteImportCommand.cs create mode 100644 apps/server-net/src/Modules/Identity/Application/Abstractions/IIdentityDbContext.cs create mode 100644 apps/server-net/src/Modules/Identity/Application/Friends/AcceptFriendRequest.cs create mode 100644 apps/server-net/src/Modules/Identity/Application/Friends/DeclineFriendRequest.cs create mode 100644 apps/server-net/src/Modules/Identity/Application/Friends/FriendDto.cs create mode 100644 apps/server-net/src/Modules/Identity/Application/Friends/FriendRequestDto.cs create mode 100644 apps/server-net/src/Modules/Identity/Application/Friends/FriendshipStatusResponse.cs create mode 100644 apps/server-net/src/Modules/Identity/Application/Friends/GetFriends.cs create mode 100644 apps/server-net/src/Modules/Identity/Application/Friends/GetFriendshipStatus.cs create mode 100644 apps/server-net/src/Modules/Identity/Application/Friends/GetIncomingRequests.cs create mode 100644 apps/server-net/src/Modules/Identity/Application/Friends/GetOutgoingRequests.cs create mode 100644 apps/server-net/src/Modules/Identity/Application/Friends/RemoveFriend.cs create mode 100644 apps/server-net/src/Modules/Identity/Application/Friends/SendFriendRequest.cs create mode 100644 apps/server-net/src/Modules/Identity/Application/Users/Auth/AuthResponseDto.cs create mode 100644 apps/server-net/src/Modules/Identity/Application/Users/Avatar/CropAvatar.cs create mode 100644 apps/server-net/src/Modules/Identity/Application/Users/Avatar/DeleteAvatar.cs create mode 100644 apps/server-net/src/Modules/Identity/Application/Users/Avatar/UploadAvatar.cs create mode 100644 apps/server-net/src/Modules/Identity/Application/Users/GetMe/GetMe.cs create mode 100644 apps/server-net/src/Modules/Identity/Application/Users/GetUser/GetUser.cs create mode 100644 apps/server-net/src/Modules/Identity/Application/Users/Search/SearchUsers.cs create mode 100644 apps/server-net/src/Modules/Identity/Application/Users/UpdateProfile/UpdateProfile.cs create mode 100644 apps/server-net/src/Modules/Identity/Application/Users/UpdateSettings/UpdateSettings.cs create mode 100644 apps/server-net/src/Modules/Identity/Application/Users/UserDto.cs create mode 100644 apps/server-net/src/Modules/Identity/Application/Users/UserProfileDto.cs create mode 100644 apps/server-net/src/Shared/Knot.Shared.Kernel/IQuery.cs create mode 100644 apps/server-net/src/Shared/Knot.Shared.Kernel/MessageResponse.cs create mode 100644 apps/server-net/src/Shared/Knot.Shared.Kernel/SuccessResponse.cs diff --git a/apps/server-net/src/Host/Application/Admin/Commands/CleanRun/CleanRunCommand.cs b/apps/server-net/src/Host/Application/Admin/Commands/CleanRun/CleanRunCommand.cs new file mode 100644 index 0000000..cdb026e --- /dev/null +++ b/apps/server-net/src/Host/Application/Admin/Commands/CleanRun/CleanRunCommand.cs @@ -0,0 +1,85 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Knot.Shared.Kernel; +using Knot.Shared.Kernel.Storage; +using Knot.Modules.Identity.Infrastructure.Persistence; +using Knot.Modules.Chats.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Host.Application.Admin.Commands; + +public record CleanRunCommand(IFileStorageService FileStorage, IdentityDbContext IdentityDb) : ICommand; + +internal sealed class CleanRunCommandHandler : ICommandHandler +{ + private readonly ChatsDbContext _chatsDbContext; + + public CleanRunCommandHandler(ChatsDbContext chatsDbContext) + { + _chatsDbContext = chatsDbContext; + } + + public async Task> Handle(CleanRunCommand request, CancellationToken cancellationToken) + { + var orphanMessages = await _chatsDbContext.Messages + .Include(m => m.Media) + .Where(m => m.IsDeleted || !_chatsDbContext.Chats.Any(c => c.Id == m.ChatId)) + .ToListAsync(cancellationToken); + + var allMinioFiles = (await request.FileStorage.ListFilesAsync()).ToList(); + + var keptMessages = await _chatsDbContext.Messages + .AsNoTracking() + .Include(m => m.Media) + .Where(m => !m.IsDeleted && _chatsDbContext.Chats.Any(c => c.Id == m.ChatId)) + .ToListAsync(cancellationToken); + + var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken); + var allUsers = await request.IdentityDb.Users.AsNoTracking().ToListAsync(cancellationToken); + + var validUrls = new HashSet(); + + var activeMessageUrls = keptMessages + .Where(m => m.Media != null) + .SelectMany(m => m.Media) + .Select(me => me.Url) + .Where(u => !string.IsNullOrEmpty(u)); + + var activeChatUrls = allChats + .Where(c => !string.IsNullOrEmpty(c.Avatar)) + .Select(c => c.Avatar!); + + var activeUserUrls = allUsers + .Where(u => !string.IsNullOrEmpty(u.Avatar)) + .Select(u => u.Avatar!); + + foreach (var u in activeMessageUrls) validUrls.Add(u!); + foreach (var u in activeChatUrls) validUrls.Add(u); + foreach (var u in activeUserUrls) validUrls.Add(u); + + var validFileIds = validUrls + .Where(u => u.Contains("/api/files/")) + .Select(u => u.Split('/').Last()) + .ToHashSet(); + + foreach (var file in allMinioFiles) + { + if (!validFileIds.Contains(file.FileId)) + { + await request.FileStorage.DeleteFileAsync(file.FileId); + } + } + + if (orphanMessages.Any()) + { + _chatsDbContext.Messages.RemoveRange(orphanMessages); + await _chatsDbContext.SaveChangesAsync(cancellationToken); + } + + return Result.Success(new MessageResponse("Cleanup completed successfully")); + } +} diff --git a/apps/server-net/src/Host/Application/Admin/Commands/ResetUserPassword/ResetUserPasswordCommand.cs b/apps/server-net/src/Host/Application/Admin/Commands/ResetUserPassword/ResetUserPasswordCommand.cs new file mode 100644 index 0000000..08b68e7 --- /dev/null +++ b/apps/server-net/src/Host/Application/Admin/Commands/ResetUserPassword/ResetUserPasswordCommand.cs @@ -0,0 +1,40 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Knot.Shared.Kernel; +using Host.Models; +using Knot.Modules.Identity.Domain; +using Knot.Modules.Identity.Infrastructure.Persistence; +using Knot.Modules.Identity.Application.Abstractions; + +namespace Host.Application.Admin.Commands; + +public record ResetUserPasswordCommand(Guid UserId, string NewPassword) : ICommand; + +internal sealed class ResetUserPasswordCommandHandler : ICommandHandler +{ + private readonly IUserRepository _userRepository; + private readonly IIdentityUnitOfWork _identityUnitOfWork; + + public ResetUserPasswordCommandHandler(IUserRepository userRepository, IIdentityUnitOfWork identityUnitOfWork) + { + _userRepository = userRepository; + _identityUnitOfWork = identityUnitOfWork; + } + + public async Task> Handle(ResetUserPasswordCommand request, CancellationToken cancellationToken) + { + var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken); + if (user == null) return Result.Failure(new Error("User.NotFound", "User not found")); + + if (string.IsNullOrWhiteSpace(request.NewPassword)) + return Result.Failure(new Error("InvalidPassword", "Password cannot be empty")); + + var hash = BCrypt.Net.BCrypt.HashPassword(request.NewPassword); + user.ChangePassword(hash); + + await _identityUnitOfWork.SaveChangesAsync(cancellationToken); + return Result.Success(new SuccessResponse(true)); + } +} diff --git a/apps/server-net/src/Host/Application/Admin/Commands/UpdateSettings/UpdateSettingsCommand.cs b/apps/server-net/src/Host/Application/Admin/Commands/UpdateSettings/UpdateSettingsCommand.cs new file mode 100644 index 0000000..adf3863 --- /dev/null +++ b/apps/server-net/src/Host/Application/Admin/Commands/UpdateSettings/UpdateSettingsCommand.cs @@ -0,0 +1,26 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Knot.Shared.Kernel; +using Knot.Shared.Kernel.Configuration; + +namespace Host.Application.Admin.Commands; + +public record UpdateSettingsCommand(SystemSettingsDto Settings) : ICommand; + +internal sealed class UpdateSettingsCommandHandler : ICommandHandler +{ + private readonly ISettingsService _settingsService; + + public UpdateSettingsCommandHandler(ISettingsService settingsService) + { + _settingsService = settingsService; + } + + public async Task> Handle(UpdateSettingsCommand request, CancellationToken cancellationToken) + { + await _settingsService.UpdateSettingsAsync(request.Settings, cancellationToken); + return Result.Success(request.Settings); + } +} diff --git a/apps/server-net/src/Host/Application/Admin/Queries/CleanDryRun/CleanDryRunQuery.cs b/apps/server-net/src/Host/Application/Admin/Queries/CleanDryRun/CleanDryRunQuery.cs new file mode 100644 index 0000000..e48b60a --- /dev/null +++ b/apps/server-net/src/Host/Application/Admin/Queries/CleanDryRun/CleanDryRunQuery.cs @@ -0,0 +1,87 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Knot.Shared.Kernel; +using Knot.Shared.Kernel.Storage; +using Knot.Modules.Identity.Infrastructure.Persistence; +using Knot.Modules.Chats.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Host.Models; + +namespace Host.Application.Admin.Queries; + +public record CleanDryRunQuery(IFileStorageService FileStorage, IdentityDbContext IdentityDb) : IQuery; + +internal sealed class CleanDryRunQueryHandler : IQueryHandler +{ + private readonly ChatsDbContext _chatsDbContext; + + public CleanDryRunQueryHandler(ChatsDbContext chatsDbContext) + { + _chatsDbContext = chatsDbContext; + } + + public async Task> Handle(CleanDryRunQuery request, CancellationToken cancellationToken) + { + var orphanedMessages = await _chatsDbContext.Messages + .Include(m => m.Media) + .Where(m => m.IsDeleted || !_chatsDbContext.Chats.Any(c => c.Id == m.ChatId)) + .ToListAsync(cancellationToken); + + var orphanedMessagesCount = orphanedMessages.Count; + + var allMinioFiles = (await request.FileStorage.ListFilesAsync()).ToList(); + + var keptMessages = await _chatsDbContext.Messages + .AsNoTracking() + .Include(m => m.Media) + .Where(m => !m.IsDeleted && _chatsDbContext.Chats.Any(c => c.Id == m.ChatId)) + .ToListAsync(cancellationToken); + + var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken); + var allUsers = await request.IdentityDb.Users.AsNoTracking().ToListAsync(cancellationToken); + + var validUrls = new HashSet(); + + var activeMessageUrls = keptMessages + .Where(m => m.Media != null) + .SelectMany(m => m.Media) + .Select(me => me.Url) + .Where(u => !string.IsNullOrEmpty(u)); + + var activeChatUrls = allChats + .Where(c => !string.IsNullOrEmpty(c.Avatar)) + .Select(c => c.Avatar!); + + var activeUserUrls = allUsers + .Where(u => !string.IsNullOrEmpty(u.Avatar)) + .Select(u => u.Avatar!); + + foreach (var u in activeMessageUrls) validUrls.Add(u!); + foreach (var u in activeChatUrls) validUrls.Add(u); + foreach (var u in activeUserUrls) validUrls.Add(u); + + var validFileIds = validUrls + .Where(u => u.Contains("/api/files/")) + .Select(u => u.Split('/').Last()) + .ToHashSet(); + + long safeBytes = 0; + foreach (var file in allMinioFiles) + { + if (!validFileIds.Contains(file.FileId)) + { + safeBytes += file.Size; + } + } + + return Result.Success(new CleanupDryRunResultDto + { + OrphanedMessagesCount = orphanedMessagesCount, + OrphanedMediaBytes = safeBytes + }); + } +} diff --git a/apps/server-net/src/Host/Application/Admin/Queries/GetDashboardStats/GetDashboardStatsQuery.cs b/apps/server-net/src/Host/Application/Admin/Queries/GetDashboardStats/GetDashboardStatsQuery.cs new file mode 100644 index 0000000..bcb82d2 --- /dev/null +++ b/apps/server-net/src/Host/Application/Admin/Queries/GetDashboardStats/GetDashboardStatsQuery.cs @@ -0,0 +1,25 @@ +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Knot.Shared.Kernel; +using Knot.Shared.Kernel.Services; + +namespace Host.Application.Admin.Queries; + +public record GetDashboardStatsQuery() : IQuery; + +internal sealed class GetDashboardStatsQueryHandler : IQueryHandler +{ + private readonly IStatisticsService _statisticsService; + + public GetDashboardStatsQueryHandler(IStatisticsService statisticsService) + { + _statisticsService = statisticsService; + } + + public async Task> Handle(GetDashboardStatsQuery request, CancellationToken cancellationToken) + { + var stats = await _statisticsService.GetDashboardStatsAsync(cancellationToken); + return Result.Success(stats); + } +} diff --git a/apps/server-net/src/Host/Application/Admin/Queries/GetSettings/GetSettingsQuery.cs b/apps/server-net/src/Host/Application/Admin/Queries/GetSettings/GetSettingsQuery.cs new file mode 100644 index 0000000..20dad9e --- /dev/null +++ b/apps/server-net/src/Host/Application/Admin/Queries/GetSettings/GetSettingsQuery.cs @@ -0,0 +1,26 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Knot.Shared.Kernel; +using Knot.Shared.Kernel.Configuration; + +namespace Host.Application.Admin.Queries; + +public record GetSettingsQuery() : IQuery; + +internal sealed class GetSettingsQueryHandler : IQueryHandler +{ + private readonly ISettingsService _settingsService; + + public GetSettingsQueryHandler(ISettingsService settingsService) + { + _settingsService = settingsService; + } + + public async Task> Handle(GetSettingsQuery request, CancellationToken cancellationToken) + { + var settings = await _settingsService.GetSettingsAsync(cancellationToken); + return Result.Success(settings); + } +} diff --git a/apps/server-net/src/Host/Application/Admin/Queries/GetUserDetails/GetUserDetailsQuery.cs b/apps/server-net/src/Host/Application/Admin/Queries/GetUserDetails/GetUserDetailsQuery.cs new file mode 100644 index 0000000..a463d0d --- /dev/null +++ b/apps/server-net/src/Host/Application/Admin/Queries/GetUserDetails/GetUserDetailsQuery.cs @@ -0,0 +1,72 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Knot.Shared.Kernel; +using Host.Models; +using Knot.Modules.Identity.Domain; +using Knot.Modules.Chats.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Host.Application.Admin.Queries; + +public record GetUserDetailsQuery(Guid UserId) : IQuery; + +internal sealed class GetUserDetailsQueryHandler : IQueryHandler +{ + private readonly IUserRepository _userRepository; + private readonly ChatsDbContext _chatsDbContext; + + public GetUserDetailsQueryHandler(IUserRepository userRepository, ChatsDbContext chatsDbContext) + { + _userRepository = userRepository; + _chatsDbContext = chatsDbContext; + } + + public async Task> Handle(GetUserDetailsQuery request, CancellationToken cancellationToken) + { + var targetUser = await _userRepository.GetByIdAsync(request.UserId, cancellationToken); + if (targetUser == null) return Result.Failure(new Error("User.NotFound", "User not found")); + + var messagesCount = await _chatsDbContext.Messages.CountAsync(m => m.SenderId == request.UserId, cancellationToken); + + var allUserMedia = await _chatsDbContext.Messages + .AsNoTracking() + .Where(m => m.SenderId == request.UserId) + .SelectMany(m => m.Media) + .ToListAsync(cancellationToken); + + var mediaCount = allUserMedia.Count(m => m.Type == "image" || m.Type == "video"); + var filesCount = allUserMedia.Count(m => m.Type == "file" || m.Type == "audio"); + var storageUsed = allUserMedia.Sum(m => m.Size ?? 0); + + var userContents = await _chatsDbContext.Messages + .AsNoTracking() + .Where(m => m.SenderId == request.UserId) + .Select(m => m.Content) + .ToListAsync(cancellationToken); + + var linksCount = userContents.Count(c => !string.IsNullOrEmpty(c) && c.Contains("http")); + + var result = new AdminUserDetailsDto( + targetUser.Id, + targetUser.Username, + targetUser.DisplayName, + targetUser.Bio, + targetUser.Avatar, + targetUser.CreatedAt, + Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()), + Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()) ? DateTime.UtcNow : targetUser.CreatedAt, + new AdminUserStatsDto( + messagesCount, + mediaCount, + filesCount, + linksCount, + storageUsed + ) + ); + + return Result.Success(result); + } +} diff --git a/apps/server-net/src/Host/Application/Admin/Queries/SearchUsers/SearchUsersQuery.cs b/apps/server-net/src/Host/Application/Admin/Queries/SearchUsers/SearchUsersQuery.cs new file mode 100644 index 0000000..d8f6095 --- /dev/null +++ b/apps/server-net/src/Host/Application/Admin/Queries/SearchUsers/SearchUsersQuery.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Knot.Shared.Kernel; +using Host.Models; +using Knot.Modules.Identity.Domain; + +namespace Host.Application.Admin.Queries; + +public record SearchUsersQuery(string Query) : IQuery>; + +internal sealed class SearchUsersQueryHandler : IQueryHandler> +{ + private readonly IUserRepository _userRepository; + + public SearchUsersQueryHandler(IUserRepository userRepository) + { + _userRepository = userRepository; + } + + public async Task>> Handle(SearchUsersQuery request, CancellationToken cancellationToken) + { + var users = string.IsNullOrWhiteSpace(request.Query) + ? await _userRepository.SearchUsersAsync("", cancellationToken) + : await _userRepository.SearchUsersAsync(request.Query, cancellationToken); + + var result = users.Select(u => new AdminUserDto( + u.Id, + u.Username, + u.DisplayName, + u.Email, + u.Avatar, + u.CreatedAt, + Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()), + Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()) ? DateTime.UtcNow : u.CreatedAt + )).ToList(); + + return Result.Success(result); + } +} diff --git a/apps/server-net/src/Host/Application/Config/Queries/GetPublicConfigQuery.cs b/apps/server-net/src/Host/Application/Config/Queries/GetPublicConfigQuery.cs new file mode 100644 index 0000000..2f405ea --- /dev/null +++ b/apps/server-net/src/Host/Application/Config/Queries/GetPublicConfigQuery.cs @@ -0,0 +1,25 @@ +using Knot.Shared.Kernel; +using Knot.Shared.Kernel.Configuration; +using MediatR; +using Host.Models; +using System.Threading; +using System.Threading.Tasks; + +namespace Host.Application.Config.Queries; + +public record GetPublicConfigQuery : IQuery; + +internal sealed class GetPublicConfigQueryHandler : IQueryHandler +{ + private readonly ISettingsService _settings; + + public GetPublicConfigQueryHandler(ISettingsService settings) + { + _settings = settings; + } + + public Task> Handle(GetPublicConfigQuery request, CancellationToken cancellationToken) + { + return Task.FromResult(Result.Success(PublicConfigDto.FromSettings(_settings.Current))); + } +} diff --git a/apps/server-net/src/Host/Application/Federation/Commands/HandshakeFederationCommand.cs b/apps/server-net/src/Host/Application/Federation/Commands/HandshakeFederationCommand.cs new file mode 100644 index 0000000..70135ff --- /dev/null +++ b/apps/server-net/src/Host/Application/Federation/Commands/HandshakeFederationCommand.cs @@ -0,0 +1,51 @@ +using Knot.Shared.Kernel; +using Knot.Shared.Kernel.Configuration; +using Knot.Shared.Kernel.Constants; +using System; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using MediatR; + +namespace Host.Application.Federation.Commands; + +public record HandshakeRequest(string Domain, string PublicKey); +public record HandshakeResponse(string Domain, string PublicKey, string Status); + +public record HandshakeFederationCommand(HandshakeRequest Request) : ICommand; + +internal sealed class HandshakeFederationCommandHandler : ICommandHandler +{ + private readonly ISettingsService _settings; + + public HandshakeFederationCommandHandler(ISettingsService settings) + { + _settings = settings; + } + + public Task> Handle(HandshakeFederationCommand request, CancellationToken cancellationToken) + { + var conf = _settings.Current; + if (!conf.EnableConfederation) + return Task.FromResult(Result.Failure(new Error(Errors.DisabledByAdmin, "Federation is disabled"))); + + if (string.IsNullOrEmpty(request.Request.Domain) || string.IsNullOrEmpty(request.Request.PublicKey)) + return Task.FromResult(Result.Failure(new Error("Request.Invalid", "Domain and PublicKey are required"))); + + var allowedList = conf.AllowedDomains?.Select(d => d.Trim().ToLower()).ToList() ?? new System.Collections.Generic.List(); + if (!allowedList.Contains(request.Request.Domain.ToLowerInvariant())) + return Task.FromResult(Result.Failure(new Error("Unauthorized", "Domain is not in the whitelist"))); + + using var rsa = RSA.Create(2048); + var selfPublicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey()); + + var response = new HandshakeResponse( + Environment.GetEnvironmentVariable("DOMAIN") ?? "knot.local", + selfPublicKey, + "Accepted" + ); + + return Task.FromResult(Result.Success(response)); + } +} diff --git a/apps/server-net/src/Host/Application/Klipy/Queries/GetTrendingGifsQuery.cs b/apps/server-net/src/Host/Application/Klipy/Queries/GetTrendingGifsQuery.cs new file mode 100644 index 0000000..862acc6 --- /dev/null +++ b/apps/server-net/src/Host/Application/Klipy/Queries/GetTrendingGifsQuery.cs @@ -0,0 +1,63 @@ +using Knot.Shared.Kernel; +using Knot.Shared.Kernel.Configuration; +using Knot.Shared.Kernel.Constants; +using Microsoft.Extensions.Caching.Memory; +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using MediatR; + +namespace Host.Application.Klipy.Queries; + +public record GetTrendingGifsQuery : IQuery; + +internal sealed class GetTrendingGifsQueryHandler : IQueryHandler +{ + private readonly ISettingsService _settings; + private readonly IMemoryCache _cache; + private readonly IHttpClientFactory _httpClientFactory; + + public GetTrendingGifsQueryHandler(ISettingsService settings, IMemoryCache cache, IHttpClientFactory httpClientFactory) + { + _settings = settings; + _cache = cache; + _httpClientFactory = httpClientFactory; + } + + public async Task> Handle(GetTrendingGifsQuery request, CancellationToken cancellationToken) + { + var conf = _settings.Current; + if (!conf.EnableKlipy || string.IsNullOrEmpty(conf.KlipyApiKey)) + return Result.Failure(new Error(Errors.KlipyNotConfigured, "Klipy is not configured")); + + var cacheKeyTrending = $"klipy_trending_{conf.KlipyApiKey}"; + if (_cache.TryGetValue(cacheKeyTrending, out JsonElement cachedResult)) + return Result.Success(cachedResult); + + var customerId = string.IsNullOrWhiteSpace(conf.KlipyCustomerId) ? Knot.Shared.Kernel.Constants.Klipy.DefaultCustomerId : conf.KlipyCustomerId; + var client = _httpClientFactory.CreateClient(); + + var urlCo = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCo, conf.KlipyApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceTrending, "", customerId); + + var response = await client.GetAsync(urlCo, cancellationToken); + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + var urlCom = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCom, conf.KlipyApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceTrending, "", customerId); + response = await client.GetAsync(urlCom, cancellationToken); + } + + if (!response.IsSuccessStatusCode) + return Result.Failure(new Error(Errors.KlipyApiError, "Klipy API Error")); + } + + var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + _cache.Set(cacheKeyTrending, result, TimeSpan.FromMinutes(Knot.Shared.Kernel.Constants.Klipy.TrendingCacheMinutes)); + + return Result.Success(result); + } +} diff --git a/apps/server-net/src/Host/Application/Klipy/Queries/SearchGifsQuery.cs b/apps/server-net/src/Host/Application/Klipy/Queries/SearchGifsQuery.cs new file mode 100644 index 0000000..bbb8bd8 --- /dev/null +++ b/apps/server-net/src/Host/Application/Klipy/Queries/SearchGifsQuery.cs @@ -0,0 +1,67 @@ +using Knot.Shared.Kernel; +using Knot.Shared.Kernel.Configuration; +using Knot.Shared.Kernel.Constants; +using Microsoft.Extensions.Caching.Memory; +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using MediatR; + +namespace Host.Application.Klipy.Queries; + +public record SearchGifsQuery(string Query) : IQuery; + +internal sealed class SearchGifsQueryHandler : IQueryHandler +{ + private readonly ISettingsService _settings; + private readonly IMemoryCache _cache; + private readonly IHttpClientFactory _httpClientFactory; + + public SearchGifsQueryHandler(ISettingsService settings, IMemoryCache cache, IHttpClientFactory httpClientFactory) + { + _settings = settings; + _cache = cache; + _httpClientFactory = httpClientFactory; + } + + public async Task> Handle(SearchGifsQuery request, CancellationToken cancellationToken) + { + var conf = _settings.Current; + if (!conf.EnableKlipy || string.IsNullOrEmpty(conf.KlipyApiKey)) + return Result.Failure(new Error(Errors.KlipyNotConfigured, "Klipy is not configured")); + + if (string.IsNullOrWhiteSpace(request.Query)) + return Result.Failure(new Error(Errors.InvalidQuery, "Invalid query parameter")); + + var cacheKey = $"klipy_search_{conf.KlipyApiKey}_{request.Query.ToLowerInvariant()}"; + if (_cache.TryGetValue(cacheKey, out JsonElement cachedResult)) + return Result.Success(cachedResult); + + var customerId = string.IsNullOrWhiteSpace(conf.KlipyCustomerId) ? Knot.Shared.Kernel.Constants.Klipy.DefaultCustomerId : conf.KlipyCustomerId; + var client = _httpClientFactory.CreateClient(); + + var queryParam = $"q={Uri.EscapeDataString(request.Query)}"; + var urlCo = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCo, conf.KlipyApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceSearch, queryParam, customerId); + + var response = await client.GetAsync(urlCo, cancellationToken); + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + var urlCom = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCom, conf.KlipyApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceSearch, queryParam, customerId); + response = await client.GetAsync(urlCom, cancellationToken); + } + + if (!response.IsSuccessStatusCode) + return Result.Failure(new Error(Errors.KlipySearchError, "Klipy Search API Error")); + } + + var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + _cache.Set(cacheKey, result, TimeSpan.FromMinutes(Knot.Shared.Kernel.Constants.Klipy.SearchCacheMinutes)); + + return Result.Success(result); + } +} diff --git a/apps/server-net/src/Host/Application/Stories/Commands/AddStoryReaction/AddStoryReactionCommand.cs b/apps/server-net/src/Host/Application/Stories/Commands/AddStoryReaction/AddStoryReactionCommand.cs new file mode 100644 index 0000000..b7eb7a6 --- /dev/null +++ b/apps/server-net/src/Host/Application/Stories/Commands/AddStoryReaction/AddStoryReactionCommand.cs @@ -0,0 +1,128 @@ +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.Identity.Domain; +using Knot.Modules.Identity.Infrastructure.Persistence; +using Knot.Modules.Identity.Application.Abstractions; +using Knot.Modules.Chats.Domain; +using Knot.Modules.Chats.Application.Messages.Send; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.SignalR; +using Knot.Modules.Chats.Infrastructure.SignalR; +using Host.Models; + +namespace Host.Application.Stories.Commands.AddStoryReaction; + +public record AddStoryReactionCommand(Guid UserId, Guid StoryId, string Emoji) : ICommand; + +internal sealed class AddStoryReactionCommandHandler : ICommandHandler +{ + private readonly IdentityDbContext _context; + private readonly IUserRepository _userRepository; + private readonly IChatRepository _chatRepository; + private readonly IMessageRepository _messageRepository; + private readonly ISender _sender; + private readonly IHubContext _hubContext; + + public AddStoryReactionCommandHandler( + IdentityDbContext context, + IUserRepository userRepository, + IChatRepository chatRepository, + IMessageRepository messageRepository, + ISender sender, + IHubContext hubContext) + { + _context = context; + _userRepository = userRepository; + _chatRepository = chatRepository; + _messageRepository = messageRepository; + _sender = sender; + _hubContext = hubContext; + } + + public async Task> Handle(AddStoryReactionCommand request, CancellationToken cancellationToken) + { + var story = await _context.Stories.FindAsync(new object[] { request.StoryId }, cancellationToken); + if (story == null) return Result.Failure(new Error("Story.NotFound", "Story not found")); + + var existing = await _context.StoryReactions + .FirstOrDefaultAsync(r => r.StoryId == request.StoryId && r.UserId == request.UserId && r.Emoji == request.Emoji, cancellationToken); + + if (existing != null) return Result.Success(new MessageResponse("Reaction already exists")); + + var reaction = new StoryReaction(request.StoryId, request.UserId, request.Emoji); + _context.StoryReactions.Add(reaction); + await _context.SaveChangesAsync(cancellationToken); + + var chatId = await GetOrCreatePersonalChatIdAsync(request.UserId, story.UserId, cancellationToken); + var lastStoryMessage = await _messageRepository.GetLastStoryMessageAsync(chatId, story.Id, cancellationToken); + + if (lastStoryMessage != null) + { + var addReactionCommand = new Knot.Modules.Chats.Application.Messages.React.AddReactionCommand( + lastStoryMessage.Id, request.UserId, request.Emoji, chatId); + await _sender.Send(addReactionCommand, cancellationToken); + } + else + { + var storyQuote = GetStoryQuote(story); + var messageCommand = new SendMessageCommand( + ChatId: chatId, + SenderId: request.UserId, + Content: request.Emoji, + Type: "text", + Quote: storyQuote, + StoryId: story.Id, + StoryMediaUrl: story.MediaUrl, + StoryMediaType: story.Type, + Attachments: null, + ReplyToId: null, + ForwardedFromId: null); + await _sender.Send(messageCommand, cancellationToken); + } + + var reactor = await _userRepository.GetByIdAsync(request.UserId, cancellationToken); + await _hubContext.Clients.All.SendAsync("story_reaction", new + { + storyId = story.Id, + userId = request.UserId, + username = reactor?.Username, + displayName = reactor?.DisplayName, + avatar = reactor?.Avatar, + emoji = request.Emoji, + createdAt = DateTime.UtcNow, + ownerId = story.UserId + }, cancellationToken); + + return Result.Success(new MessageResponse("Reaction added")); + } + + private string GetStoryQuote(Story story) + { + if (!string.IsNullOrEmpty(story.Content)) return story.Content; + return story.Type.ToLower() switch + { + "image" => "🖼 Фото", + "video" => "🎬 Видео", + _ => "История" + }; + } + + private async Task GetOrCreatePersonalChatIdAsync(Guid userId1, Guid userId2, CancellationToken cancellationToken) + { + var userChats = await _chatRepository.GetUserChatsAsync(userId1, cancellationToken); + var personalChat = userChats.FirstOrDefault(c => + c.Type == ChatType.Personal && + c.Members.Any(m => m.UserId == userId2)); + + if (personalChat != null) return personalChat.Id; + + var command = new Knot.Modules.Chats.Application.Chats.Create.CreateChatCommand(string.Empty, ChatType.Personal, new List { userId1, userId2 }); + var result = await _sender.Send(command, cancellationToken); + return result.Value; + } +} diff --git a/apps/server-net/src/Host/Application/Stories/Commands/AddStoryReply/AddStoryReplyCommand.cs b/apps/server-net/src/Host/Application/Stories/Commands/AddStoryReply/AddStoryReplyCommand.cs new file mode 100644 index 0000000..88656a4 --- /dev/null +++ b/apps/server-net/src/Host/Application/Stories/Commands/AddStoryReply/AddStoryReplyCommand.cs @@ -0,0 +1,114 @@ +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.Identity.Domain; +using Knot.Modules.Identity.Infrastructure.Persistence; +using Knot.Modules.Identity.Application.Abstractions; +using Knot.Modules.Chats.Domain; +using Knot.Modules.Chats.Application.Messages.Send; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.SignalR; +using Knot.Modules.Chats.Infrastructure.SignalR; +using Host.Models; + +namespace Host.Application.Stories.Commands.AddStoryReply; + +public record AddStoryReplyCommand(Guid UserId, Guid StoryId, string Content) : ICommand; + +internal sealed class AddStoryReplyCommandHandler : ICommandHandler +{ + private readonly IdentityDbContext _context; + private readonly IUserRepository _userRepository; + private readonly IChatRepository _chatRepository; + private readonly IMessageRepository _messageRepository; + private readonly ISender _sender; + private readonly IHubContext _hubContext; + + public AddStoryReplyCommandHandler( + IdentityDbContext context, + IUserRepository userRepository, + IChatRepository chatRepository, + IMessageRepository messageRepository, + ISender sender, + IHubContext hubContext) + { + _context = context; + _userRepository = userRepository; + _chatRepository = chatRepository; + _messageRepository = messageRepository; + _sender = sender; + _hubContext = hubContext; + } + + public async Task> Handle(AddStoryReplyCommand request, CancellationToken cancellationToken) + { + var story = await _context.Stories.FindAsync(new object[] { request.StoryId }, cancellationToken); + if (story == null) return Result.Failure(new Error("Story.NotFound", "Story not found")); + + var reply = new StoryReply(request.StoryId, request.UserId, request.Content); + _context.StoryReplies.Add(reply); + await _context.SaveChangesAsync(cancellationToken); + + var chatId = await GetOrCreatePersonalChatIdAsync(request.UserId, story.UserId, cancellationToken); + var lastStoryMessage = await _messageRepository.GetLastStoryMessageAsync(chatId, story.Id, cancellationToken); + + var storyQuote = GetStoryQuote(story); + var messageCommand = new SendMessageCommand( + ChatId: chatId, + SenderId: request.UserId, + Content: request.Content, + Type: "text", + Quote: storyQuote, + ReplyToId: lastStoryMessage?.Id, + StoryId: story.Id, + StoryMediaUrl: story.MediaUrl, + StoryMediaType: story.Type, + Attachments: null, + ForwardedFromId: null); + await _sender.Send(messageCommand, cancellationToken); + + var replier = await _userRepository.GetByIdAsync(request.UserId, cancellationToken); + await _hubContext.Clients.All.SendAsync("story_reply", new + { + storyId = story.Id, + userId = request.UserId, + username = replier?.Username, + displayName = replier?.DisplayName, + avatar = replier?.Avatar, + content = request.Content, + createdAt = DateTime.UtcNow, + ownerId = story.UserId + }, cancellationToken); + + return Result.Success(new MessageResponse("Reply added")); + } + + private string GetStoryQuote(Story story) + { + if (!string.IsNullOrEmpty(story.Content)) return story.Content; + return story.Type.ToLower() switch + { + "image" => "�� Фото", + "video" => "🎬 Видео", + _ => "История" + }; + } + + private async Task GetOrCreatePersonalChatIdAsync(Guid userId1, Guid userId2, CancellationToken cancellationToken) + { + var userChats = await _chatRepository.GetUserChatsAsync(userId1, cancellationToken); + var personalChat = userChats.FirstOrDefault(c => + c.Type == ChatType.Personal && + c.Members.Any(m => m.UserId == userId2)); + + if (personalChat != null) return personalChat.Id; + + var command = new Knot.Modules.Chats.Application.Chats.Create.CreateChatCommand(string.Empty, ChatType.Personal, new List { userId1, userId2 }); + var result = await _sender.Send(command, cancellationToken); + return result.Value; + } +} diff --git a/apps/server-net/src/Host/Application/Stories/Commands/CreateStory/CreateStoryCommand.cs b/apps/server-net/src/Host/Application/Stories/Commands/CreateStory/CreateStoryCommand.cs new file mode 100644 index 0000000..99028fa --- /dev/null +++ b/apps/server-net/src/Host/Application/Stories/Commands/CreateStory/CreateStoryCommand.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Knot.Shared.Kernel; +using Knot.Modules.Identity.Infrastructure.Persistence; +using Knot.Modules.Identity.Domain; + +namespace Host.Application.Stories.Commands.CreateStory; + +public record CreateStoryCommand(Guid UserId, string Type, string? MediaUrl, string? Content, string? BgColor) : ICommand; + +internal sealed class CreateStoryCommandHandler : ICommandHandler +{ + private readonly IdentityDbContext _context; + + public CreateStoryCommandHandler(IdentityDbContext context) + { + _context = context; + } + + public async Task> Handle(CreateStoryCommand request, CancellationToken cancellationToken) + { + var story = Story.Create( + request.UserId, + request.Type, + request.MediaUrl, + request.Content, + request.BgColor); + + _context.Stories.Add(story); + await _context.SaveChangesAsync(cancellationToken); + + return Result.Success(story.Id); + } +} diff --git a/apps/server-net/src/Host/Application/Stories/Commands/DeleteStory/DeleteStoryCommand.cs b/apps/server-net/src/Host/Application/Stories/Commands/DeleteStory/DeleteStoryCommand.cs new file mode 100644 index 0000000..04de990 --- /dev/null +++ b/apps/server-net/src/Host/Application/Stories/Commands/DeleteStory/DeleteStoryCommand.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Knot.Shared.Kernel; +using Knot.Modules.Identity.Infrastructure.Persistence; +using Host.Models; + +namespace Host.Application.Stories.Commands.DeleteStory; + +public record DeleteStoryCommand(Guid UserId, Guid StoryId) : ICommand; + +internal sealed class DeleteStoryCommandHandler : ICommandHandler +{ + private readonly IdentityDbContext _context; + + public DeleteStoryCommandHandler(IdentityDbContext context) + { + _context = context; + } + + public async Task> Handle(DeleteStoryCommand request, CancellationToken cancellationToken) + { + var story = await _context.Stories.FindAsync(new object[] { request.StoryId }, cancellationToken); + if (story == null) return Result.Failure(new Error("Story.NotFound", "Story not found")); + if (story.UserId != request.UserId) return Result.Failure(new Error("Unauthorized", "Unauthorized")); + + _context.Stories.Remove(story); + await _context.SaveChangesAsync(cancellationToken); + + return Result.Success(new MessageResponse("Story deleted")); + } +} diff --git a/apps/server-net/src/Host/Application/Stories/Commands/RemoveStoryReaction/RemoveStoryReactionCommand.cs b/apps/server-net/src/Host/Application/Stories/Commands/RemoveStoryReaction/RemoveStoryReactionCommand.cs new file mode 100644 index 0000000..5f8416b --- /dev/null +++ b/apps/server-net/src/Host/Application/Stories/Commands/RemoveStoryReaction/RemoveStoryReactionCommand.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Knot.Shared.Kernel; +using Knot.Modules.Identity.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Host.Models; + +namespace Host.Application.Stories.Commands.RemoveStoryReaction; + +public record RemoveStoryReactionCommand(Guid UserId, Guid StoryId, string Emoji) : ICommand; + +internal sealed class RemoveStoryReactionCommandHandler : ICommandHandler +{ + private readonly IdentityDbContext _context; + + public RemoveStoryReactionCommandHandler(IdentityDbContext context) + { + _context = context; + } + + public async Task> Handle(RemoveStoryReactionCommand request, CancellationToken cancellationToken) + { + var reaction = await _context.StoryReactions + .FirstOrDefaultAsync(r => r.StoryId == request.StoryId && r.UserId == request.UserId && r.Emoji == request.Emoji, cancellationToken); + + if (reaction == null) return Result.Success(new MessageResponse("Reaction not found")); + + _context.StoryReactions.Remove(reaction); + await _context.SaveChangesAsync(cancellationToken); + + return Result.Success(new MessageResponse("Reaction removed")); + } +} diff --git a/apps/server-net/src/Host/Application/Stories/Commands/ViewStory/ViewStoryCommand.cs b/apps/server-net/src/Host/Application/Stories/Commands/ViewStory/ViewStoryCommand.cs new file mode 100644 index 0000000..73a4945 --- /dev/null +++ b/apps/server-net/src/Host/Application/Stories/Commands/ViewStory/ViewStoryCommand.cs @@ -0,0 +1,73 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Knot.Shared.Kernel; +using Knot.Modules.Identity.Domain; +using Knot.Modules.Identity.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.SignalR; +using Knot.Modules.Chats.Infrastructure.SignalR; +using Host.Models; + +namespace Host.Application.Stories.Commands.ViewStory; + +public record ViewStoryCommand(Guid UserId, Guid StoryId) : ICommand; + +internal sealed class ViewStoryCommandHandler : ICommandHandler +{ + private readonly IdentityDbContext _context; + private readonly IUserRepository _userRepository; + private readonly IHubContext _hubContext; + + public ViewStoryCommandHandler(IdentityDbContext context, IUserRepository userRepository, IHubContext hubContext) + { + _context = context; + _userRepository = userRepository; + _hubContext = hubContext; + } + + public async Task> Handle(ViewStoryCommand request, CancellationToken cancellationToken) + { + try + { + var story = await _context.Stories + .Include(s => s.Viewers) + .FirstOrDefaultAsync(s => s.Id == request.StoryId, cancellationToken); + + if (story == null) return Result.Failure(new Error("Story.NotFound", "Story not found")); + + if (story.UserId == request.UserId) + { + return Result.Success(new MessageResponse("Owner view")); + } + + if (!story.Viewers.Any(v => v.UserId == request.UserId)) + { + story.AddViewer(request.UserId); + await _context.SaveChangesAsync(cancellationToken); + + var viewer = await _userRepository.GetByIdAsync(request.UserId, cancellationToken); + var updatedStory = await _context.Stories.Include(s => s.Viewers).FirstAsync(s => s.Id == story.Id, cancellationToken); + + await _hubContext.Clients.All.SendAsync("story_viewed", new + { + storyId = story.Id, + userId = request.UserId, + username = viewer?.Username, + displayName = viewer?.DisplayName, + avatar = viewer?.Avatar, + viewedAt = DateTime.UtcNow, + viewCount = updatedStory.Viewers.Count, + ownerId = story.UserId + }, cancellationToken); + } + return Result.Success(new MessageResponse("Story viewed")); + } + catch (DbUpdateException) + { + return Result.Success(new MessageResponse("Story already viewed")); + } + } +} diff --git a/apps/server-net/src/Host/Application/Stories/Queries/GetStories/GetStoriesQuery.cs b/apps/server-net/src/Host/Application/Stories/Queries/GetStories/GetStoriesQuery.cs new file mode 100644 index 0000000..eb15f2a --- /dev/null +++ b/apps/server-net/src/Host/Application/Stories/Queries/GetStories/GetStoriesQuery.cs @@ -0,0 +1,90 @@ +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.Identity.Domain; +using Knot.Modules.Identity.Infrastructure.Persistence; +using Knot.Modules.Identity.Application.Abstractions; +using Knot.Modules.Chats.Domain; +using Knot.Modules.Chats.Application.Messages.Send; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.SignalR; +using Knot.Modules.Chats.Infrastructure.SignalR; +using Host.Models; + +namespace Host.Application.Stories.Queries.GetStories; + +public record GetStoriesQuery(Guid UserId) : IQuery>; + +internal sealed class GetStoriesQueryHandler : IQueryHandler> +{ + private readonly IdentityDbContext _context; + private readonly IUserRepository _userRepository; + + public GetStoriesQueryHandler(IdentityDbContext context, IUserRepository userRepository) + { + _context = context; + _userRepository = userRepository; + } + + public async Task>> Handle(GetStoriesQuery request, CancellationToken cancellationToken) + { + var friendships = await _context.Set() + .Where(f => f.Status == FriendshipStatus.Accepted && (f.UserId == request.UserId || f.FriendId == request.UserId)) + .ToListAsync(cancellationToken); + + var friendIds = friendships.Select(f => f.UserId == request.UserId ? f.FriendId : f.UserId).ToList(); + friendIds.Add(request.UserId); + + var stories = await _context.Stories + .Include(s => s.Viewers) + .Include(s => s.Reactions) + .Include(s => s.Replies) + .Where(s => s.ExpiresAt > DateTime.UtcNow && friendIds.Contains(s.UserId)) + .OrderByDescending(s => s.CreatedAt) + .ToListAsync(cancellationToken); + + var groups = stories.GroupBy(s => s.UserId).ToList(); + var result = new List(); + + var userIds = groups.Select(g => g.Key).ToList(); + var userMap = new Dictionary(); + foreach (var uid in userIds) + { + var u = await _userRepository.GetByIdAsync(uid, cancellationToken); + if (u != null) userMap[uid] = u; + } + + foreach (var group in groups) + { + if (!userMap.TryGetValue(group.Key, out var user)) continue; + + result.Add(new StoryGroupDto( + new StoryUserDto(user.Id, user.Username, user.DisplayName, user.Avatar), + group.Select(s => new StoryDto( + s.Id, + s.Type, + s.MediaUrl, + s.Content, + s.BgColor, + s.CreatedAt, + s.ExpiresAt, + s.Viewers.Count, + s.Viewers.Any(v => v.UserId == request.UserId), + s.Reactions.Select(r => new StoryReactionDto(r.Id, r.UserId, r.Emoji, r.CreatedAt)).ToList(), + s.Replies.Count + )).OrderBy(s => s.CreatedAt).ToList(), + group.Any(s => !s.Viewers.Any(v => v.UserId == request.UserId)) + )); + } + + return Result.Success(result.OrderBy(r => + { + if (r.User.Id == request.UserId) return 0; + return 1; + }).ToList()); + } +} diff --git a/apps/server-net/src/Host/Application/Stories/Queries/GetStoryReplies/GetStoryRepliesQuery.cs b/apps/server-net/src/Host/Application/Stories/Queries/GetStoryReplies/GetStoryRepliesQuery.cs new file mode 100644 index 0000000..4f9f6ef --- /dev/null +++ b/apps/server-net/src/Host/Application/Stories/Queries/GetStoryReplies/GetStoryRepliesQuery.cs @@ -0,0 +1,56 @@ +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.Identity.Domain; +using Knot.Modules.Identity.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Host.Models; + +namespace Host.Application.Stories.Queries.GetStoryReplies; + +public record GetStoryRepliesQuery(Guid UserId, Guid StoryId) : IQuery>; + +internal sealed class GetStoryRepliesQueryHandler : IQueryHandler> +{ + private readonly IdentityDbContext _context; + private readonly IUserRepository _userRepository; + + public GetStoryRepliesQueryHandler(IdentityDbContext context, IUserRepository userRepository) + { + _context = context; + _userRepository = userRepository; + } + + public async Task>> Handle(GetStoryRepliesQuery request, CancellationToken cancellationToken) + { + var story = await _context.Stories + .Include(s => s.Replies) + .FirstOrDefaultAsync(s => s.Id == request.StoryId, cancellationToken); + + if (story == null) return Result.Failure>(new Error("Story.NotFound", "Story not found")); + if (story.UserId != request.UserId) return Result.Failure>(new Error("Unauthorized", "Unauthorized")); + + var replies = new List(); + foreach (var reply in story.Replies.OrderBy(r => r.CreatedAt)) + { + var user = await _userRepository.GetByIdAsync(reply.UserId, cancellationToken); + if (user == null) continue; + + replies.Add(new StoryReplyDto( + reply.Id, + user.Id, + user.Username, + user.DisplayName, + user.Avatar, + reply.Content, + reply.CreatedAt + )); + } + + return Result.Success(replies); + } +} diff --git a/apps/server-net/src/Host/Application/Stories/Queries/GetStoryViewers/GetStoryViewersQuery.cs b/apps/server-net/src/Host/Application/Stories/Queries/GetStoryViewers/GetStoryViewersQuery.cs new file mode 100644 index 0000000..be02493 --- /dev/null +++ b/apps/server-net/src/Host/Application/Stories/Queries/GetStoryViewers/GetStoryViewersQuery.cs @@ -0,0 +1,58 @@ +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.Identity.Domain; +using Knot.Modules.Identity.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Host.Models; + +namespace Host.Application.Stories.Queries.GetStoryViewers; + +public record GetStoryViewersQuery(Guid UserId, Guid StoryId) : IQuery>; + +internal sealed class GetStoryViewersQueryHandler : IQueryHandler> +{ + private readonly IdentityDbContext _context; + private readonly IUserRepository _userRepository; + + public GetStoryViewersQueryHandler(IdentityDbContext context, IUserRepository userRepository) + { + _context = context; + _userRepository = userRepository; + } + + public async Task>> Handle(GetStoryViewersQuery request, CancellationToken cancellationToken) + { + var story = await _context.Stories + .Include(s => s.Viewers) + .FirstOrDefaultAsync(s => s.Id == request.StoryId, cancellationToken); + + if (story == null) return Result.Failure>(new Error("Story.NotFound", "Story not found")); + if (story.UserId != request.UserId) return Result.Failure>(new Error("Unauthorized", "Unauthorized")); + + var viewerIds = story.Viewers.Select(v => v.UserId).ToList(); + var viewers = new List(); + + foreach (var viewerId in viewerIds) + { + var user = await _userRepository.GetByIdAsync(viewerId, cancellationToken); + if (user == null) continue; + + var viewerRecord = story.Viewers.First(v => v.UserId == viewerId); + + viewers.Add(new StoryViewerDto( + user.Id, + user.Username, + user.DisplayName, + user.Avatar, + viewerRecord.ViewedAt + )); + } + + return Result.Success(viewers); + } +} diff --git a/apps/server-net/src/Host/Application/Stories/Queries/GetUserStories/GetUserStoriesQuery.cs b/apps/server-net/src/Host/Application/Stories/Queries/GetUserStories/GetUserStoriesQuery.cs new file mode 100644 index 0000000..1bba775 --- /dev/null +++ b/apps/server-net/src/Host/Application/Stories/Queries/GetUserStories/GetUserStoriesQuery.cs @@ -0,0 +1,62 @@ +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.Identity.Domain; +using Knot.Modules.Identity.Infrastructure.Persistence; +using Knot.Modules.Identity.Application.Abstractions; +using Microsoft.EntityFrameworkCore; +using Host.Models; + +namespace Host.Application.Stories.Queries.GetUserStories; + +public record GetUserStoriesQuery(Guid CurrentUserId, Guid TargetUserId) : IQuery; + +internal sealed class GetUserStoriesQueryHandler : IQueryHandler +{ + private readonly IdentityDbContext _context; + private readonly IUserRepository _userRepository; + + public GetUserStoriesQueryHandler(IdentityDbContext context, IUserRepository userRepository) + { + _context = context; + _userRepository = userRepository; + } + + public async Task> Handle(GetUserStoriesQuery request, CancellationToken cancellationToken) + { + var stories = await _context.Stories + .Include(s => s.Viewers) + .Include(s => s.Reactions) + .Include(s => s.Replies) + .Where(s => s.UserId == request.TargetUserId) + .OrderByDescending(s => s.CreatedAt) + .ToListAsync(cancellationToken); + + var user = await _userRepository.GetByIdAsync(request.TargetUserId, cancellationToken); + if (user == null) return Result.Failure(new Error("User.NotFound", "User not found")); + + var result = new StoryGroupDto( + new StoryUserDto(user.Id, user.Username, user.DisplayName, user.Avatar), + stories.Select(s => new StoryDto( + s.Id, + s.Type, + s.MediaUrl, + s.Content, + s.BgColor, + s.CreatedAt, + s.ExpiresAt, + s.Viewers.Count, + s.Viewers.Any(v => v.UserId == request.CurrentUserId), + s.Reactions.Select(r => new StoryReactionDto(r.Id, r.UserId, r.Emoji, r.CreatedAt)).ToList(), + s.Replies.Count + )).ToList(), + stories.Any(s => !s.Viewers.Any(v => v.UserId == request.CurrentUserId)) + ); + + return Result.Success(result); + } +} diff --git a/apps/server-net/src/Host/Application/WebRtc/Queries/GetIceServersQuery.cs b/apps/server-net/src/Host/Application/WebRtc/Queries/GetIceServersQuery.cs new file mode 100644 index 0000000..22ccb5e --- /dev/null +++ b/apps/server-net/src/Host/Application/WebRtc/Queries/GetIceServersQuery.cs @@ -0,0 +1,73 @@ +using Knot.Shared.Kernel; +using Knot.Shared.Kernel.Configuration; +using Microsoft.Extensions.Configuration; +using MediatR; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Host.Application.WebRtc.Queries; + +public record IceServerDto(string[] Urls, string? Username = null, string? Credential = null); +public record IceServersResultDto(List IceServers); + +public record GetIceServersQuery : IQuery; + +internal sealed class GetIceServersQueryHandler : IQueryHandler +{ + private readonly IConfiguration _configuration; + private readonly ISettingsService _settingsService; + + public GetIceServersQueryHandler(IConfiguration configuration, ISettingsService settingsService) + { + _configuration = configuration; + _settingsService = settingsService; + } + + public Task> Handle(GetIceServersQuery request, CancellationToken cancellationToken) + { + var settings = _settingsService.Current; + if (!settings.EnableCalls) + { + return Task.FromResult(Result.Failure(new Error( + Knot.Shared.Kernel.Constants.Errors.DisabledByAdmin, + "Сервис отключен администратором." + ))); + } + + var turnUrl = !string.IsNullOrEmpty(settings.TurnHost) + ? $"turn:{settings.TurnHost}:{settings.TurnPort}" + : _configuration["WebRtc:TurnUrl"]; + + var turnUsername = !string.IsNullOrEmpty(settings.TurnUser) + ? settings.TurnUser + : _configuration["WebRtc:TurnUsername"]; + + var turnSecret = !string.IsNullOrEmpty(settings.TurnSecret) + ? settings.TurnSecret + : _configuration["WebRtc:TurnPassword"]; + + var iceServers = new List(); + + if (!string.IsNullOrEmpty(turnUrl)) + { + var stunUrl = turnUrl.Replace("turn:", "stun:"); + iceServers.Add(new IceServerDto(new[] { stunUrl })); + + if (!string.IsNullOrEmpty(turnUsername)) + { + iceServers.Add(new IceServerDto( + new[] { turnUrl, turnUrl + "?transport=tcp" }, + turnUsername, + !string.IsNullOrEmpty(turnSecret) ? turnSecret : turnUsername + )); + } + else + { + iceServers.Add(new IceServerDto(new[] { turnUrl, turnUrl + "?transport=tcp" })); + } + } + + return Task.FromResult(Result.Success(new IceServersResultDto(iceServers))); + } +} diff --git a/apps/server-net/src/Host/Controllers/AdminController.cs b/apps/server-net/src/Host/Controllers/AdminController.cs index dad95c2..e547116 100644 --- a/apps/server-net/src/Host/Controllers/AdminController.cs +++ b/apps/server-net/src/Host/Controllers/AdminController.cs @@ -1,11 +1,15 @@ -using Knot.Modules.Identity.Domain; -using Knot.Shared.Kernel.Configuration; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Knot.Modules.Chats.Infrastructure.Persistence; -using MediatR; +using Host.Models; +using Knot.Shared.Kernel.Configuration; using Knot.Modules.Identity.Application.Users.Register; -using Knot.Modules.Identity.Application.Abstractions; +using Knot.Shared.Kernel.Storage; +using Knot.Modules.Identity.Infrastructure.Persistence; +using System.Threading; +using System.Threading.Tasks; +using System; +using MediatR; +using Host.Application.Admin.Queries; +using Host.Application.Admin.Commands; namespace Host.Controllers; @@ -13,294 +17,88 @@ namespace Host.Controllers; [Route("api/[controller]")] public class AdminController : ControllerBase { - private readonly ISettingsService _settingsService; - private readonly Knot.Shared.Kernel.Services.IStatisticsService _statisticsService; - private readonly IUserRepository _userRepository; - private readonly ChatsDbContext _chatsDbContext; private readonly ISender _sender; - private readonly IIdentityUnitOfWork _identityUnitOfWork; - public AdminController( - ISettingsService settingsService, - Knot.Shared.Kernel.Services.IStatisticsService statisticsService, - IUserRepository userRepository, - ChatsDbContext chatsDbContext, - ISender sender, - IIdentityUnitOfWork identityUnitOfWork) + public AdminController(ISender sender) { - _settingsService = settingsService; - _statisticsService = statisticsService; - _userRepository = userRepository; - _chatsDbContext = chatsDbContext; _sender = sender; - _identityUnitOfWork = identityUnitOfWork; } [HttpGet("settings")] public async Task GetSettings(CancellationToken ct) { - var settings = await _settingsService.GetSettingsAsync(ct); - return Ok(settings); + var result = await _sender.Send(new GetSettingsQuery(), ct); + return Ok(result.Value); } [HttpPut("settings")] public async Task UpdateSettings([FromBody] SystemSettingsDto settings, CancellationToken ct) { - await _settingsService.UpdateSettingsAsync(settings, ct); - return Ok(settings); + var result = await _sender.Send(new UpdateSettingsCommand(settings), ct); + return Ok(result.Value); } [HttpGet("dashboard")] public async Task GetDashboardStats(CancellationToken ct) { - var stats = await _statisticsService.GetDashboardStatsAsync(ct); - return Ok(stats); + var result = await _sender.Send(new GetDashboardStatsQuery(), ct); + return Ok(result.Value); } [HttpPost("users")] - public async Task CreateUser([FromBody] RegisterUserCommand command) + public async Task CreateUser([FromBody] RegisterUserCommand command, CancellationToken ct) { - var result = await _sender.Send(command); - if (result.IsFailure) - { - return BadRequest(new { error = result.Error.Description }); - } + var result = await _sender.Send(command, ct); + if (result.IsFailure) return BadRequest(new { error = result.Error.Description }); - var user = await _userRepository.GetByIdAsync(result.Value, default); - return Ok(new { - user.Id, - user.Username, - user.DisplayName, - user.Email, - user.CreatedAt - }); + var userDetails = await _sender.Send(new GetUserDetailsQuery(result.Value.User.Id), ct); + return Ok(userDetails.Value); } [HttpPost("users/{userId:guid}/reset-password")] public async Task ResetUserPassword(Guid userId, [FromBody] ResetPasswordDto dto, CancellationToken ct) { - var user = await _userRepository.GetByIdAsync(userId, ct); - if (user == null) return NotFound(new { error = "User not found" }); - - if (string.IsNullOrWhiteSpace(dto.NewPassword)) return BadRequest(new { error = "Password cannot be empty" }); - - var hash = BCrypt.Net.BCrypt.HashPassword(dto.NewPassword); - user.ChangePassword(hash); - - await _identityUnitOfWork.SaveChangesAsync(ct); - return Ok(new { success = true }); + var result = await _sender.Send(new ResetUserPasswordCommand(userId, dto.NewPassword), ct); + if (result.IsFailure) + { + if (result.Error.Code == "User.NotFound") return NotFound(new { error = "User not found" }); + return BadRequest(new { error = result.Error.Description }); + } + return Ok(result.Value); } [HttpGet("users")] public async Task SearchUsers([FromQuery] string query = "", CancellationToken ct = default) { - var users = string.IsNullOrWhiteSpace(query) - ? await _userRepository.SearchUsersAsync("", ct) - : await _userRepository.SearchUsersAsync(query, ct); - - return Ok(users.Select(u => new - { - Id = u.Id, - Username = u.Username, - DisplayName = u.DisplayName, - Avatar = u.Avatar, - CreatedAt = u.CreatedAt, - IsOnline = Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()), - LastOnlineAt = Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()) ? DateTime.UtcNow : u.CreatedAt - })); + var result = await _sender.Send(new SearchUsersQuery(query), ct); + return Ok(result.Value); } [HttpGet("users/{id:guid}")] public async Task GetUserDetails(Guid id, CancellationToken ct) { - var targetUser = await _userRepository.GetByIdAsync(id, ct); - if (targetUser == null) return NotFound("User not found"); - - var messagesCount = await _chatsDbContext.Messages.CountAsync(m => m.SenderId == id, ct); - - var allUserMedia = await _chatsDbContext.Messages - .AsNoTracking() - .Where(m => m.SenderId == id) - .SelectMany(m => m.Media) - .ToListAsync(ct); - - var mediaCount = allUserMedia.Count(m => m.Type == "image" || m.Type == "video"); - var filesCount = allUserMedia.Count(m => m.Type == "file" || m.Type == "audio"); - var storageUsed = allUserMedia.Sum(m => m.Size ?? 0); - - // Fetch contents first, then count links to avoid EF Core ValueConverter translation error - var userContents = await _chatsDbContext.Messages - .AsNoTracking() - .Where(m => m.SenderId == id) - .Select(m => m.Content) - .ToListAsync(ct); - - var linksCount = userContents.Count(c => !string.IsNullOrEmpty(c) && c.Contains("http")); - - return Ok(new - { - Id = targetUser.Id, - Username = targetUser.Username, - DisplayName = targetUser.DisplayName, - Bio = targetUser.Bio, - Avatar = targetUser.Avatar, - CreatedAt = targetUser.CreatedAt, - IsOnline = Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()), - LastOnlineAt = Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()) ? DateTime.UtcNow : targetUser.CreatedAt, - Stats = new - { - MessagesCount = messagesCount, - MediaCount = mediaCount, - FilesCount = filesCount, - LinksCount = linksCount, - StorageUsedBytes = storageUsed - } - }); + var result = await _sender.Send(new GetUserDetailsQuery(id), ct); + if (result.IsFailure) return NotFound("User not found"); + return Ok(result.Value); } [HttpGet("clean/dry-run")] public async Task CleanDryRun( - [FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage, - [FromServices] Knot.Modules.Identity.Infrastructure.Persistence.IdentityDbContext identityDb, + [FromServices] IFileStorageService fileStorage, + [FromServices] IdentityDbContext identityDb, CancellationToken ct) { - var orphanedMessages = await _chatsDbContext.Messages - .Include(m => m.Media) - .Where(m => m.IsDeleted || !_chatsDbContext.Chats.Any(c => c.Id == m.ChatId)) - .ToListAsync(ct); - - var orphanedMessagesCount = orphanedMessages.Count; - - // Fetch all current physical files from MinIO - var allMinioFiles = (await fileStorage.ListFilesAsync()).ToList(); - - // Collect ALL active URLs that we must preserve - var keptMessages = await _chatsDbContext.Messages - .AsNoTracking() - .Include(m => m.Media) - .Where(m => !m.IsDeleted && _chatsDbContext.Chats.Any(c => c.Id == m.ChatId)) - .ToListAsync(ct); - - var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(ct); - var allUsers = await identityDb.Users.AsNoTracking().ToListAsync(ct); - - var validUrls = new HashSet(); - - var activeMessageUrls = keptMessages - .Where(m => m.Media != null) - .SelectMany(m => m.Media) - .Select(me => me.Url) - .Where(u => !string.IsNullOrEmpty(u)); - - var activeChatUrls = allChats - .Where(c => !string.IsNullOrEmpty(c.Avatar)) - .Select(c => c.Avatar!); - - var activeUserUrls = allUsers - .Where(u => !string.IsNullOrEmpty(u.Avatar)) - .Select(u => u.Avatar!); - - foreach(var u in activeMessageUrls) validUrls.Add(u!); - foreach(var u in activeChatUrls) validUrls.Add(u); - foreach(var u in activeUserUrls) validUrls.Add(u); - - var validFileIds = validUrls - .Where(u => u.Contains("/api/files/")) - .Select(u => u.Split('/').Last()) - .ToHashSet(); - - long safeBytes = 0; - foreach(var file in allMinioFiles) - { - if (!validFileIds.Contains(file.FileId)) - { - safeBytes += file.Size; - } - } - - return Ok(new CleanupDryRunResultDto - { - OrphanedMessagesCount = orphanedMessagesCount, - OrphanedMediaBytes = safeBytes - }); + var result = await _sender.Send(new CleanDryRunQuery(fileStorage, identityDb), ct); + return Ok(result.Value); } [HttpPost("clean/run")] public async Task CleanRun( - [FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage, - [FromServices] Knot.Modules.Identity.Infrastructure.Persistence.IdentityDbContext identityDb, + [FromServices] IFileStorageService fileStorage, + [FromServices] IdentityDbContext identityDb, CancellationToken ct) { - var orphanMessages = await _chatsDbContext.Messages - .Include(m => m.Media) - .Where(m => m.IsDeleted || !_chatsDbContext.Chats.Any(c => c.Id == m.ChatId)) - .ToListAsync(ct); - - // Fetch all current physical files from MinIO - var allMinioFiles = (await fileStorage.ListFilesAsync()).ToList(); - - // Collect ALL active URLs that we must preserve - var keptMessages = await _chatsDbContext.Messages - .AsNoTracking() - .Include(m => m.Media) - .Where(m => !m.IsDeleted && _chatsDbContext.Chats.Any(c => c.Id == m.ChatId)) - .ToListAsync(ct); - - var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(ct); - var allUsers = await identityDb.Users.AsNoTracking().ToListAsync(ct); - - var validUrls = new HashSet(); - - var activeMessageUrls = keptMessages - .Where(m => m.Media != null) - .SelectMany(m => m.Media) - .Select(me => me.Url) - .Where(u => !string.IsNullOrEmpty(u)); - - var activeChatUrls = allChats - .Where(c => !string.IsNullOrEmpty(c.Avatar)) - .Select(c => c.Avatar!); - - var activeUserUrls = allUsers - .Where(u => !string.IsNullOrEmpty(u.Avatar)) - .Select(u => u.Avatar!); - - foreach(var u in activeMessageUrls) validUrls.Add(u!); - foreach(var u in activeChatUrls) validUrls.Add(u); - foreach(var u in activeUserUrls) validUrls.Add(u); - - var validFileIds = validUrls - .Where(u => u.Contains("/api/files/")) - .Select(u => u.Split('/').Last()) - .ToHashSet(); - - // Physically delete from Storage - foreach (var file in allMinioFiles) - { - if (!validFileIds.Contains(file.FileId)) - { - await fileStorage.DeleteFileAsync(file.FileId); - } - } - - if(orphanMessages.Any()) { - _chatsDbContext.Messages.RemoveRange(orphanMessages); - await _chatsDbContext.SaveChangesAsync(ct); - } - - return Ok(new { message = "Cleanup completed successfully" }); + var result = await _sender.Send(new CleanRunCommand(fileStorage, identityDb), ct); + return Ok(result.Value); } } - -public class ResetPasswordDto -{ - public string NewPassword { get; set; } = string.Empty; -} - -public class CleanupDryRunResultDto -{ - public int OrphanedMessagesCount { get; set; } - public long OrphanedMediaBytes { get; set; } -} - - diff --git a/apps/server-net/src/Host/Controllers/AuthController.cs b/apps/server-net/src/Host/Controllers/AuthController.cs index d7432f5..7d48ae4 100644 --- a/apps/server-net/src/Host/Controllers/AuthController.cs +++ b/apps/server-net/src/Host/Controllers/AuthController.cs @@ -1,13 +1,11 @@ -using MediatR; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization; +using Knot.Shared.Kernel; +using Knot.Modules.Identity.Application.Users.Auth; using Knot.Modules.Identity.Application.Users.Register; using Knot.Modules.Identity.Application.Users.Login; -using Knot.Modules.Identity.Application.Abstractions; -using Knot.Modules.Identity.Domain; -using Knot.Modules.Identity.Domain; -using Knot.Shared.Kernel; -using Knot.Shared.Kernel.Configuration; +using Knot.Modules.Identity.Application.Users.GetMe; +using MediatR; namespace Host.Controllers; @@ -16,109 +14,34 @@ namespace Host.Controllers; public sealed class AuthController : ControllerBase { private readonly ISender _sender; - private readonly IJwtTokenProvider _tokenProvider; - private readonly IUserRepository _userRepository; - private readonly ISettingsService _settings; - public AuthController(ISender sender, IJwtTokenProvider tokenProvider, IUserRepository userRepository, ISettingsService settings) + public AuthController(ISender sender) { _sender = sender; - _tokenProvider = tokenProvider; - _userRepository = userRepository; - _settings = settings; } [HttpPost("register")] - public async Task Register([FromBody] RegisterUserCommand command) + public async Task Register([FromBody] RegisterUserCommand command, CancellationToken ct) { - if (!_settings.Current.EnableRegistration) - { - return BadRequest(new { error = "Registration is disabled by the administrator." }); - } - Result result = await _sender.Send(command); - - if (result.IsFailure) - { - return BadRequest(new { error = result.Error.Description, code = result.Error.Code }); - } - - // Получаем созданного пользователя для генерации токена и возврата данных - var user = await _userRepository.GetByIdAsync(result.Value, default); - if (user is null) return BadRequest(new { error = "Не удалось получить созданного пользователя" }); - - string token = _tokenProvider.Generate(user); - - return Ok(new - { - Token = token, - User = new - { - user.Id, - user.Username, - user.DisplayName, - user.Email, - user.Bio, - user.Avatar, - user.Birthday, - IsOnline = true, - user.CreatedAt - } - }); + var result = await _sender.Send(command, ct); + if (result.IsFailure) return BadRequest(new { error = result.Error.Code ?? result.Error.Description }); + return Ok(result.Value); } [HttpGet("me")] [Authorize] - public async Task GetMe([FromServices] IUserContext userContext) + public async Task GetMe([FromServices] IUserContext userContext, CancellationToken ct) { - var user = await _userRepository.GetByIdAsync(userContext.UserId, default); - if (user is null) return NotFound(); - - return Ok(new - { - User = new - { - user.Id, - user.Username, - user.DisplayName, - user.Email, - user.Bio, - user.Avatar, - user.Birthday, - IsOnline = true, - user.CreatedAt - } - }); + var result = await _sender.Send(new GetMeQuery(userContext.UserId), ct); + if (result.IsFailure) return NotFound(); + return Ok(new { User = result.Value.User }); } [HttpPost("login")] - public async Task Login([FromBody] LoginUserCommand command) + public async Task Login([FromBody] LoginUserCommand command, CancellationToken ct) { - Result result = await _sender.Send(command); - - if (result.IsFailure) - { - return Unauthorized(new { error = result.Error.Description, code = result.Error.Code }); - } - - // Получаем данные пользователя - var user = await _userRepository.GetByUsernameAsync(command.Username, default); - if (user is null) return Unauthorized(); - - return Ok(new - { - Token = result.Value, - User = new - { - user.Id, - user.Username, - user.DisplayName, - user.Email, - user.Bio, - user.Avatar, - user.Birthday, - IsOnline = true, - user.CreatedAt - } - }); + var result = await _sender.Send(command, ct); + if (result.IsFailure) return Unauthorized(new { error = result.Error.Code ?? result.Error.Description }); + return Ok(result.Value); } } diff --git a/apps/server-net/src/Host/Controllers/ChatsController.cs b/apps/server-net/src/Host/Controllers/ChatsController.cs index 03c3fad..582ebb8 100644 --- a/apps/server-net/src/Host/Controllers/ChatsController.cs +++ b/apps/server-net/src/Host/Controllers/ChatsController.cs @@ -1,19 +1,25 @@ -using MediatR; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization; -using Microsoft.EntityFrameworkCore; -using Knot.Modules.Chats.Application.Abstractions; -using Knot.Modules.Chats.Application.Chats.Create; -using Knot.Modules.Chats.Application.Messages.Send; -using Knot.Modules.Chats.Application.Chats.GetOrCreateFavorites; -using Knot.Modules.Chats.Domain; using Knot.Shared.Kernel; - -using Knot.Modules.Identity.Domain; -using Knot.Modules.Identity.Application.Abstractions; -using Knot.Modules.Chats.Infrastructure.Persistence; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Processing; +using Knot.Modules.Chats.Application.DTOs; +using Knot.Shared.Kernel.Storage; +using Knot.Modules.Chats.Application.Chats.GetChats; +using Knot.Modules.Chats.Application.Chats.GetChatById; +using Knot.Modules.Chats.Application.Chats.Create; +using Knot.Modules.Chats.Application.Chats.GetOrCreateFavorites; +using Knot.Modules.Chats.Application.Chats.Update; +using Knot.Modules.Chats.Application.Chats.LeaveOrDelete; +using Knot.Modules.Chats.Application.Chats.Clear; +using Knot.Modules.Chats.Application.Chats.TogglePin; +using Knot.Modules.Chats.Application.Chats.Members; +using Knot.Modules.Chats.Application.Chats.Avatar; +using Knot.Modules.Chats.Domain; +using MediatR; +using System; +using System.Linq; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; namespace Host.Controllers; @@ -24,151 +30,41 @@ public sealed class ChatsController : ControllerBase { private readonly ISender _sender; private readonly IUserContext _userContext; - private readonly IUserRepository _userRepository; - public ChatsController(ISender sender, IUserContext userContext, IUserRepository userRepository) + public ChatsController(ISender sender, IUserContext userContext) { _sender = sender; _userContext = userContext; - _userRepository = userRepository; } [HttpGet] - public async Task GetChats([FromServices] IChatRepository chatRepository, [FromServices] IMessageRepository messageRepository, CancellationToken ct) + public async Task GetChats(CancellationToken ct) { - var chats = await chatRepository.GetUserChatsAsync(_userContext.UserId, ct); - - var result = new List(); - bool hasFavorites = false; - - foreach (var c in chats) - { - if (c.Type == ChatType.Favorites) - { - if (hasFavorites) continue; - hasFavorites = true; - } - - var members = new List(); - foreach (var m in c.Members) - { - var user = await _userRepository.GetByIdAsync(m.UserId, ct); - members.Add(new - { - id = m.Id, - userId = m.UserId, - role = m.Role, - isPinned = m.IsPinned, - user = user != null ? new { - id = user.Id, - username = user.Username, - displayName = user.DisplayName, - avatar = user.Avatar, - isOnline = false, - lastSeen = DateTime.UtcNow - } : null - }); - } - - var chatMessages = await messageRepository.GetChatMessagesAsync(c.Id, 1, 0, ct); - var messagesList = new List(); - - if (chatMessages.Any()) - { - var m = chatMessages.First(); - var senderObj = await _userRepository.GetByIdAsync(m.SenderId, ct); - - var reactionsWithUser = new List(); - foreach (var r in m.Reactions) - { - var rUser = await _userRepository.GetByIdAsync(r.UserId, ct); - reactionsWithUser.Add(new - { - id = r.Id, - emoji = r.Emoji, - userId = r.UserId, - user = rUser != null - ? new { id = rUser.Id, username = rUser.Username, displayName = rUser.DisplayName } - : new { id = r.UserId, username = "unknown", displayName = "Unknown" } - }); - } - - messagesList.Add(new - { - m.Id, - m.ChatId, - m.SenderId, - m.Content, - m.Type, - m.ReplyToId, - m.Quote, - m.StoryId, - m.StoryMediaUrl, - m.StoryMediaType, - m.IsEdited, - m.IsDeleted, - m.CreatedAt, - Media = m.Media.ToList(), - Sender = senderObj != null ? new { - id = senderObj.Id, - username = senderObj.Username, - displayName = senderObj.DisplayName, - avatar = senderObj.Avatar - } : new { id = m.SenderId, username = "unknown", displayName = "Unknown", avatar = (string?)null }, - reactions = reactionsWithUser, - ReadBy = m.ReadBy.Select(r => new { userId = r.UserId }).ToList() - }); - } - - if (c.Type == ChatType.Personal && messagesList.Count == 0) - { - var currentMember = c.Members.FirstOrDefault(m => m.UserId == _userContext.UserId); - if (currentMember == null || currentMember.Role != ChatRole.Owner) - { - continue; // Skip returning this empty personal chat to the non-initiator - } - } - - result.Add(new - { - id = c.Id, - type = c.Type.ToString().ToLowerInvariant(), - name = c.Type == ChatType.Favorites ? "Избранное" : (c.Type == ChatType.Personal ? null : c.Name), - description = c.Description, - avatar = c.Avatar, - createdAt = c.CreatedAt, - members = members, - messages = messagesList, - unreadCount = 0 - }); - } - - return Ok(result); + var result = await _sender.Send(new GetChatsQuery(_userContext.UserId), ct); + if (result.IsFailure) return BadRequest(result.Error.Description); + return Ok(result.Value); } [HttpPost] - public async Task Create([FromBody] CreateChatRequest request) + public async Task Create([FromBody] CreateChatRequest request, CancellationToken ct) { var command = new CreateChatCommand(request.Name, request.Type, request.MemberIds); - Result result = await _sender.Send(command); + var result = await _sender.Send(command, ct); + if (result.IsFailure) return BadRequest(result.Error.Description); - if (result.IsFailure) - { - return BadRequest(result.Error); - } - - return Ok(result.Value); + var chatResult = await _sender.Send(new GetChatByIdQuery(result.Value), ct); + return Ok(chatResult.Value); } [HttpPost("personal")] public async Task CreatePersonal([FromBody] CreatePersonalChatRequest request, CancellationToken ct) { var command = new CreateChatCommand(string.Empty, ChatType.Personal, new List { _userContext.UserId, request.UserId }); - Result result = await _sender.Send(command, ct); + var result = await _sender.Send(command, ct); + if (result.IsFailure) return BadRequest(result.Error.Description); - if (result.IsFailure) return BadRequest(result.Error); - - return Ok(await MapChatAsync(result.Value, ct)); + var chatResult = await _sender.Send(new GetChatByIdQuery(result.Value), ct); + return Ok(chatResult.Value); } [HttpPost("group")] @@ -182,265 +78,116 @@ public sealed class ChatsController : ControllerBase memberIds.Insert(0, _userContext.UserId); var command = new CreateChatCommand(request.Name, ChatType.Group, memberIds); - Result result = await _sender.Send(command, ct); + var result = await _sender.Send(command, ct); + if (result.IsFailure) return BadRequest(result.Error.Description); - if (result.IsFailure) return BadRequest(result.Error); - - return Ok(await MapChatAsync(result.Value, ct)); + var chatResult = await _sender.Send(new GetChatByIdQuery(result.Value), ct); + return Ok(chatResult.Value); } [HttpPost("favorites")] public async Task GetOrCreateFavorites(CancellationToken ct) { - var command = new GetOrCreateFavoritesCommand(_userContext.UserId); - Result result = await _sender.Send(command, ct); + var result = await _sender.Send(new GetOrCreateFavoritesCommand(_userContext.UserId), ct); + if (result.IsFailure) return BadRequest(result.Error.Description); - if (result.IsFailure) return BadRequest(result.Error); - - return Ok(await MapChatAsync(result.Value, ct)); + var chatResult = await _sender.Send(new GetChatByIdQuery(result.Value), ct); + return Ok(chatResult.Value); } [HttpPut("{id:guid}")] - public async Task UpdateChat(Guid id, [FromBody] UpdateChatRequest request, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct) + public async Task UpdateChat(Guid id, [FromBody] UpdateChatRequest request, CancellationToken ct) { - var chat = await chatRepository.GetByIdAsync(id, ct); - if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound(); + var result = await _sender.Send(new UpdateChatCommand(id, _userContext.UserId, request.Name, request.Description), ct); + if (result.IsFailure) return NotFound(); - if (request.Name != null) chat.UpdateName(request.Name); - if (request.Description != null) chat.UpdateDescription(request.Description); - chatRepository.Update(chat); - await uow.SaveChangesAsync(ct); - - return Ok(await MapChatAsync(id, ct)); + var chatResult = await _sender.Send(new GetChatByIdQuery(result.Value), ct); + return Ok(chatResult.Value); } [HttpDelete("{id:guid}")] - public async Task LeaveOrDeleteChat(Guid id, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct) + public async Task LeaveOrDeleteChat(Guid id, CancellationToken ct) { - var chat = await chatRepository.GetByIdAsync(id, ct); - if (chat == null) return Ok(new { success = true }); // Already physically deleted, idempotent - if (!chat.Members.Any(m => m.UserId == _userContext.UserId)) return Forbid(); - - if (chat.Type == ChatType.Group) + var result = await _sender.Send(new LeaveOrDeleteChatCommand(id, _userContext.UserId), ct); + if (result.IsFailure) { - chat.RemoveMember(_userContext.UserId); - chatRepository.Update(chat); + if (result.Error.Code == "Unauthorized") return Forbid(); + return NotFound(); } - else - { - chatRepository.Remove(chat); - } - await uow.SaveChangesAsync(ct); - - return Ok(new { success = true }); + return Ok(result.Value); } [HttpPost("{id:guid}/clear")] - public async Task ClearChat(Guid id, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct) + public async Task ClearChat(Guid id, CancellationToken ct) { - var chat = await chatRepository.GetByIdAsync(id, ct); - if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound(); - // Mark clear timestamp per member — simplified: just return success - return Ok(new { message = "Cleared" }); + var result = await _sender.Send(new ClearChatCommand(id, _userContext.UserId), ct); + if (result.IsFailure) return NotFound(); + return Ok(result.Value); } [HttpPost("{id:guid}/pin")] - public async Task TogglePin(Guid id, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct) + public async Task TogglePin(Guid id, CancellationToken ct) { - var chat = await chatRepository.GetByIdAsync(id, ct); - if (chat == null) return NotFound(); - - var member = chat.Members.FirstOrDefault(m => m.UserId == _userContext.UserId); - if (member == null) return NotFound(); - - member.TogglePin(); - chatRepository.Update(chat); - await uow.SaveChangesAsync(ct); - - return Ok(new { isPinned = member.IsPinned }); + var result = await _sender.Send(new TogglePinCommand(id, _userContext.UserId), ct); + if (result.IsFailure) return NotFound(); + return Ok(result.Value); } [HttpPost("{id:guid}/members")] - public async Task AddMembers(Guid id, [FromBody] AddMembersRequest request, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct) + public async Task AddMembers(Guid id, [FromBody] AddMembersRequest request, CancellationToken ct) { - var chat = await chatRepository.GetByIdAsync(id, ct); - if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound(); + var result = await _sender.Send(new AddMembersCommand(id, _userContext.UserId, request.UserIds.ToList()), ct); + if (result.IsFailure) return NotFound(); - foreach (var userId in request.UserIds) - { - chat.AddMember(userId); - } - chatRepository.Update(chat); - await uow.SaveChangesAsync(ct); - - return Ok(await MapChatAsync(id, ct)); + var chatResult = await _sender.Send(new GetChatByIdQuery(result.Value), ct); + return Ok(chatResult.Value); } [HttpDelete("{id:guid}/members/{userId:guid}")] - public async Task RemoveMember(Guid id, Guid userId, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct) + public async Task RemoveMember(Guid id, Guid userId, CancellationToken ct) { - var chat = await chatRepository.GetByIdAsync(id, ct); - if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound(); + var result = await _sender.Send(new RemoveMemberCommand(id, _userContext.UserId, userId), ct); + if (result.IsFailure) return NotFound(); - chat.RemoveMember(userId); - chatRepository.Update(chat); - await uow.SaveChangesAsync(ct); - - return Ok(await MapChatAsync(id, ct)); + var chatResult = await _sender.Send(new GetChatByIdQuery(result.Value), ct); + return Ok(chatResult.Value); } [HttpPost("{id:guid}/avatar")] - public async Task UploadGroupAvatar(Guid id, IFormFile avatar, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, [FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage, CancellationToken ct) + public async Task UploadGroupAvatar(Guid id, Microsoft.AspNetCore.Http.IFormFile avatar, CancellationToken ct) { - var chat = await chatRepository.GetByIdAsync(id, ct); - if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound(); if (avatar == null || avatar.Length == 0) return BadRequest("No file"); using var stream = avatar.OpenReadStream(); - var fileId = await fileStorage.UploadFileAsync(stream, avatar.FileName, avatar.ContentType); - var url = $"/api/files/{fileId}"; + var result = await _sender.Send(new UploadGroupAvatarCommand(id, _userContext.UserId, avatar.FileName, avatar.ContentType, stream), ct); + + if (result.IsFailure) return NotFound(); - chat.UpdateAvatar(url); - chatRepository.Update(chat); - await uow.SaveChangesAsync(ct); - - return Ok(await MapChatAsync(id, ct)); + var chatResult = await _sender.Send(new GetChatByIdQuery(result.Value), ct); + return Ok(chatResult.Value); } [HttpPost("{id:guid}/avatar/crop")] - public async Task CropGroupAvatar(Guid id, [FromForm] IFormFile avatar, [FromForm] int x, [FromForm] int y, [FromForm] int width, [FromForm] int height, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, [FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage, CancellationToken ct) + public async Task CropGroupAvatar(Guid id, [FromForm] Microsoft.AspNetCore.Http.IFormFile avatar, [FromForm] int x, [FromForm] int y, [FromForm] int width, [FromForm] int height, CancellationToken ct) { - var chat = await chatRepository.GetByIdAsync(id, ct); - if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound(); if (avatar == null || avatar.Length == 0) return BadRequest("No file"); - string url; + using var stream = avatar.OpenReadStream(); + var result = await _sender.Send(new CropGroupAvatarCommand(id, _userContext.UserId, avatar.FileName, avatar.ContentType, stream, x, y, width, height), ct); + + if (result.IsFailure) return NotFound(); - try - { - using (var inputStream = avatar.OpenReadStream()) - using (var image = await SixLabors.ImageSharp.Image.LoadAsync(inputStream)) - { - int startX = Math.Max(0, Math.Min(x, image.Width - 1)); - int startY = Math.Max(0, Math.Min(y, image.Height - 1)); - int rectWidth = Math.Max(1, Math.Min(width, image.Width - startX)); - int rectHeight = Math.Max(1, Math.Min(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, ct); - outStream.Position = 0; - - var fileName = avatar.FileName ?? "avatar.jpg"; - var fileId = await fileStorage.UploadFileAsync(outStream, fileName, "image/jpeg"); - url = $"/api/files/{fileId}"; - } - } - catch (Exception ex) - { - return StatusCode(500, "Error processing image: " + ex.Message); - } - - chat.UpdateAvatar(url); - chatRepository.Update(chat); - await uow.SaveChangesAsync(ct); - - return Ok(await MapChatAsync(id, ct)); + var chatResult = await _sender.Send(new GetChatByIdQuery(result.Value), ct); + return Ok(chatResult.Value); } [HttpDelete("{id:guid}/avatar")] - public async Task RemoveGroupAvatar(Guid id, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct) + public async Task RemoveGroupAvatar(Guid id, CancellationToken ct) { - var chat = await chatRepository.GetByIdAsync(id, ct); - if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound(); + var result = await _sender.Send(new RemoveGroupAvatarCommand(id, _userContext.UserId), ct); + if (result.IsFailure) return NotFound(); - chat.UpdateAvatar(null); - chatRepository.Update(chat); - await uow.SaveChangesAsync(ct); - - return Ok(await MapChatAsync(id, ct)); - } - - private async Task MapChatAsync(Guid chatId, CancellationToken ct) - { - var chatRepository = HttpContext.RequestServices.GetRequiredService(); - var messageRepository = HttpContext.RequestServices.GetRequiredService(); - var chat = await chatRepository.GetByIdAsync(chatId, ct); - if (chat == null) return new { }; - - var members = new List(); - foreach (var m in chat.Members) - { - var user = await _userRepository.GetByIdAsync(m.UserId, ct); - members.Add(new - { - id = m.Id, - userId = m.UserId, - role = m.Role, - isPinned = m.IsPinned, - user = user != null ? new { - id = user.Id, - username = user.Username, - displayName = user.DisplayName, - avatar = user.Avatar, - isOnline = false, - lastSeen = DateTime.UtcNow - } : null - }); - } - - var chatMessages = await messageRepository.GetChatMessagesAsync(chatId, 1, 0, ct); - var messagesList = new List(); - if (chatMessages.Any()) - { - var m = chatMessages.First(); - var senderObj = await _userRepository.GetByIdAsync(m.SenderId, ct); - messagesList.Add(new - { - m.Id, - m.ChatId, - m.SenderId, - m.Content, - m.Type, - m.ReplyToId, - m.Quote, - m.StoryId, - m.StoryMediaUrl, - m.StoryMediaType, - m.IsEdited, - m.IsDeleted, - m.CreatedAt, - Media = m.Media.ToList(), - Sender = senderObj != null ? new { - id = senderObj.Id, - username = senderObj.Username, - displayName = senderObj.DisplayName, - avatar = senderObj.Avatar - } : new { id = m.SenderId, username = "unknown", displayName = "Unknown", avatar = (string?)null }, - ReadBy = m.ReadBy.Select(r => new { userId = r.UserId }).ToList() - }); - } - - return new - { - id = chat.Id, - type = chat.Type.ToString().ToLowerInvariant(), - name = chat.Type == ChatType.Favorites ? "Избранное" : (chat.Type == ChatType.Personal ? null : chat.Name), - description = chat.Description, - avatar = chat.Avatar, - createdAt = chat.CreatedAt, - members = members, - messages = messagesList, - unreadCount = 0 - }; + var chatResult = await _sender.Send(new GetChatByIdQuery(result.Value), ct); + return Ok(chatResult.Value); } } - -public sealed record CreateChatRequest(string Name, ChatType Type, List MemberIds); -public sealed record CreatePersonalChatRequest(Guid UserId); -public sealed record CreateGroupChatRequest(string Name, List MemberIds); -public sealed record UpdateChatRequest(string? Name, string? Description); -public sealed record AddMembersRequest(List UserIds); - diff --git a/apps/server-net/src/Host/Controllers/FriendsController.cs b/apps/server-net/src/Host/Controllers/FriendsController.cs index d75ca10..ee45036 100644 --- a/apps/server-net/src/Host/Controllers/FriendsController.cs +++ b/apps/server-net/src/Host/Controllers/FriendsController.cs @@ -1,10 +1,11 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Authorization; -using Microsoft.EntityFrameworkCore; -using Knot.Modules.Identity.Domain; -using Knot.Modules.Identity.Infrastructure.Persistence; +using System; +using System.Threading; +using System.Threading.Tasks; +using Knot.Modules.Identity.Application.Friends; using Knot.Shared.Kernel; -using Knot.Modules.Identity.Application.Abstractions; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; namespace Host.Controllers; @@ -13,187 +14,76 @@ namespace Host.Controllers; [Route("api/friends")] public sealed class FriendsController : ControllerBase { - private readonly IdentityDbContext _context; + private readonly ISender _sender; private readonly IUserContext _userContext; - private readonly IUserRepository _userRepository; - public FriendsController(IdentityDbContext context, IUserContext userContext, IUserRepository userRepository) + public FriendsController(ISender sender, IUserContext userContext) { - _context = context; + _sender = sender; _userContext = userContext; - _userRepository = userRepository; } [HttpGet] public async Task GetFriends(CancellationToken ct) { - var currentUserId = _userContext.UserId; - var friendships = await _context.Friendships - .Where(f => (f.UserId == currentUserId || f.FriendId == currentUserId) && f.Status == FriendshipStatus.Accepted) - .ToListAsync(ct); - - var friendIds = friendships.Select(f => f.UserId == currentUserId ? f.FriendId : f.UserId).ToList(); - var friends = new List(); - - foreach (var id in friendIds) - { - var user = await _userRepository.GetByIdAsync(id, ct); - if (user == null) continue; - - var fs = friendships.First(f => f.UserId == id || f.FriendId == id); - - friends.Add(new - { - id = user.Id, - username = user.Username, - displayName = user.DisplayName, - avatar = user.Avatar, - isOnline = false, - lastSeen = DateTime.UtcNow, - friendshipId = fs.Id - }); - } - - return Ok(friends); + var result = await _sender.Send(new GetFriendsQuery(_userContext.UserId), ct); + if (result.IsFailure) return BadRequest(result.Error); + return Ok(result.Value); } [HttpGet("requests")] public async Task GetRequests(CancellationToken ct) { - var currentUserId = _userContext.UserId; - var friendships = await _context.Friendships - .Where(f => f.FriendId == currentUserId && f.Status == FriendshipStatus.Pending) - .ToListAsync(ct); - - var requests = new List(); - foreach (var fs in friendships) - { - var user = await _userRepository.GetByIdAsync(fs.UserId, ct); - if (user == null) continue; - - requests.Add(new - { - id = fs.Id, - user = new - { - id = user.Id, - username = user.Username, - displayName = user.DisplayName, - avatar = user.Avatar - }, - createdAt = fs.CreatedAt - }); - } - return Ok(requests); + var result = await _sender.Send(new GetIncomingRequestsQuery(_userContext.UserId), ct); + if (result.IsFailure) return BadRequest(result.Error); + return Ok(result.Value); } [HttpGet("outgoing")] public async Task GetOutgoingRequests(CancellationToken ct) { - var currentUserId = _userContext.UserId; - var friendships = await _context.Friendships - .Where(f => f.UserId == currentUserId && f.Status == FriendshipStatus.Pending) - .ToListAsync(ct); - - var requests = new List(); - foreach (var fs in friendships) - { - var user = await _userRepository.GetByIdAsync(fs.FriendId, ct); - if (user == null) continue; - - requests.Add(new - { - id = fs.Id, - user = new - { - id = user.Id, - username = user.Username, - displayName = user.DisplayName, - avatar = user.Avatar - }, - createdAt = fs.CreatedAt - }); - } - return Ok(requests); + var result = await _sender.Send(new GetOutgoingRequestsQuery(_userContext.UserId), ct); + if (result.IsFailure) return BadRequest(result.Error); + return Ok(result.Value); } [HttpPost("request")] - public async Task SendRequest([FromBody] SendFriendRequest request, CancellationToken ct) + public async Task SendRequest([FromBody] Host.Models.SendFriendRequest request, CancellationToken ct) { - var currentUserId = _userContext.UserId; - if (currentUserId == request.FriendId) return BadRequest("Cannot add yourself"); - - var existing = await _context.Friendships - .FirstOrDefaultAsync(f => (f.UserId == currentUserId && f.FriendId == request.FriendId) || - (f.UserId == request.FriendId && f.FriendId == currentUserId), ct); - - if (existing != null) return BadRequest("Friendship already exists"); - - var friendship = Friendship.Create(currentUserId, request.FriendId); - _context.Friendships.Add(friendship); - await _context.SaveChangesAsync(ct); - + var result = await _sender.Send(new SendFriendRequestCommand(_userContext.UserId, request.FriendId), ct); + if (result.IsFailure) return BadRequest(result.Error.Description); return Ok(new { status = "pending" }); } [HttpPost("{id:guid}/accept")] public async Task AcceptRequest(Guid id, CancellationToken ct) { - var friendship = await _context.Friendships.FindAsync(new object[] { id }, ct); - if (friendship == null || friendship.FriendId != _userContext.UserId) return NotFound(); - - friendship.Accept(); - await _context.SaveChangesAsync(ct); - - return Ok(new { id = friendship.Id }); + var result = await _sender.Send(new AcceptFriendRequestCommand(_userContext.UserId, id), ct); + if (result.IsFailure) return NotFound(result.Error.Description); + return Ok(new { id = result.Value }); } [HttpPost("{id:guid}/decline")] public async Task DeclineRequest(Guid id, CancellationToken ct) { - var friendship = await _context.Friendships.FindAsync(new object[] { id }, ct); - if (friendship == null || friendship.FriendId != _userContext.UserId) return NotFound(); - - friendship.Decline(); - await _context.SaveChangesAsync(ct); - - return Ok(new { success = true }); + var result = await _sender.Send(new DeclineFriendRequestCommand(_userContext.UserId, id), ct); + if (result.IsFailure) return NotFound(result.Error.Description); + return Ok(new Knot.Shared.Kernel.SuccessResponse(true)); } [HttpGet("status/{userId:guid}")] public async Task GetStatus(Guid userId, CancellationToken ct) { - var currentUserId = _userContext.UserId; - if (currentUserId == userId) return Ok(new { status = "self" }); - - var fs = await _context.Friendships - .FirstOrDefaultAsync(f => (f.UserId == currentUserId && f.FriendId == userId) || - (f.UserId == userId && f.FriendId == currentUserId), ct); - - if (fs == null) return Ok(new { status = "none" }); - - return Ok(new - { - status = fs.Status.ToString().ToLower(), - friendshipId = fs.Id, - direction = fs.UserId == currentUserId ? "outgoing" : "incoming" - }); + var result = await _sender.Send(new GetFriendshipStatusQuery(_userContext.UserId, userId), ct); + if (result.IsFailure) return BadRequest(result.Error); + return Ok(result.Value); } - [HttpDelete("{id:guid}")] public async Task RemoveFriend(Guid id, CancellationToken ct) { - var currentUserId = _userContext.UserId; - var friendship = await _context.Friendships.FindAsync(new object[] { id }, ct); - if (friendship == null || (friendship.UserId != currentUserId && friendship.FriendId != currentUserId)) - return NotFound(); - - _context.Friendships.Remove(friendship); - await _context.SaveChangesAsync(ct); - - return Ok(new { success = true }); + var result = await _sender.Send(new RemoveFriendCommand(_userContext.UserId, id), ct); + if (result.IsFailure) return NotFound(result.Error.Description); + return Ok(new Knot.Shared.Kernel.SuccessResponse(true)); } } - -public sealed record SendFriendRequest(Guid FriendId); diff --git a/apps/server-net/src/Host/Controllers/MessagesController.cs b/apps/server-net/src/Host/Controllers/MessagesController.cs index adaf211..9ff06ec 100644 --- a/apps/server-net/src/Host/Controllers/MessagesController.cs +++ b/apps/server-net/src/Host/Controllers/MessagesController.cs @@ -1,15 +1,18 @@ -using MediatR; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization; -using Knot.Modules.Chats.Domain; +using Knot.Modules.Chats.Application.DTOs; using Knot.Shared.Kernel; +using System.Threading; +using System.Threading.Tasks; +using System; +using Microsoft.AspNetCore.Http; +using MediatR; +using Knot.Modules.Chats.Application.Messages.GetMessages; +using Knot.Modules.Chats.Application.Messages.SearchMessages; +using Knot.Modules.Chats.Application.Messages.UploadFile; +using Knot.Modules.Chats.Application.Messages.GetSharedMedia; using Knot.Modules.Chats.Application.Messages.Send; -using Knot.Modules.Identity.Domain; -using Knot.Modules.Identity.Application.Abstractions; using System.Linq; -using System.Text.RegularExpressions; -using Knot.Shared.Kernel.Configuration; -using System.Runtime.CompilerServices; namespace Host.Controllers; @@ -20,295 +23,59 @@ public sealed class MessagesController : ControllerBase { private readonly ISender _sender; private readonly IUserContext _userContext; - private readonly IUserRepository _userRepository; - public MessagesController(ISender sender, IUserContext userContext, IUserRepository userRepository) + public MessagesController(ISender sender, IUserContext userContext) { _sender = sender; _userContext = userContext; - _userRepository = userRepository; } [HttpGet("chat/{chatId:guid}")] - public async Task GetMessages(Guid chatId, [FromServices] IMessageRepository messageRepository, [FromQuery] string? cursor, CancellationToken ct = default) + public async Task GetMessages(Guid chatId, [FromQuery] string? cursor, CancellationToken ct = default) { - DateTime? cursorDate = null; - if (!string.IsNullOrEmpty(cursor) && DateTime.TryParse(cursor, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed)) - { - cursorDate = parsed.ToUniversalTime(); - } - - var messages = await messageRepository.GetChatMessagesCursorAsync(chatId, cursorDate, 100, ct); - var senders = new Dictionary(); - var result = new List(); - - foreach (var m in messages) - { - if (m.DeletedByUsers.Contains(_userContext.UserId)) continue; - - if (!senders.ContainsKey(m.SenderId)) - { - var user = await _userRepository.GetByIdAsync(m.SenderId, ct); - if (user != null) senders[m.SenderId] = user; - } - - if (m.ForwardedFromId.HasValue && !senders.ContainsKey(m.ForwardedFromId.Value)) - { - var fuser = await _userRepository.GetByIdAsync(m.ForwardedFromId.Value, ct); - if (fuser != null) senders[m.ForwardedFromId.Value] = fuser; - } - - object? replyToObj = null; - if (m.ReplyToId.HasValue) - { - var replyMsg = await messageRepository.GetByIdAsync(m.ReplyToId.Value, ct); - if (replyMsg != null) - { - if (!senders.ContainsKey(replyMsg.SenderId)) - { - var replySender = await _userRepository.GetByIdAsync(replyMsg.SenderId, ct); - if (replySender != null) senders[replyMsg.SenderId] = replySender; - } - var senderObj = senders.TryGetValue(replyMsg.SenderId, out var rs) - ? new { id = rs.Id, username = rs.Username, displayName = rs.DisplayName, avatar = rs.Avatar } - : null; - - replyToObj = new - { - id = replyMsg.Id, - content = replyMsg.Content, - isDeleted = replyMsg.IsDeleted, - media = replyMsg.Media.Select(rm => new { rm.Id, rm.Type, rm.Url }).ToList(), - sender = senderObj - }; - } - } - - var reactionsWithUser = new List(); - foreach (var r in m.Reactions) - { - if (!senders.ContainsKey(r.UserId)) - { - var rUser = await _userRepository.GetByIdAsync(r.UserId, ct); - if (rUser != null) senders[r.UserId] = rUser; - } - var userObj = senders.TryGetValue(r.UserId, out var ru) - ? new { id = ru.Id, username = ru.Username, displayName = ru.DisplayName, avatar = ru.Avatar } - : new { id = r.UserId, username = "unknown", displayName = "Unknown", avatar = (string?)null }; - - reactionsWithUser.Add(new - { - id = r.Id, - emoji = r.Emoji, - userId = r.UserId, - user = userObj - }); - } - - result.Add(new - { - m.Id, - m.ChatId, - m.SenderId, - m.Content, - m.Type, - m.ReplyToId, - replyTo = replyToObj, - m.Quote, - m.IsEdited, - m.IsDeleted, - m.CreatedAt, - forwardedFromId = m.ForwardedFromId, - forwardedFrom = m.ForwardedFromId.HasValue && senders.TryGetValue(m.ForwardedFromId.Value, out var fwd) ? new { - id = fwd.Id, - username = fwd.Username, - displayName = fwd.DisplayName, - avatar = fwd.Avatar - } : null, - storyId = m.StoryId, - storyMediaUrl = m.StoryMediaUrl, - storyMediaType = m.StoryMediaType, - media = m.Media.Select(media => new { - media.Id, - media.Type, - media.Url, - filename = media.Filename, - size = media.Size - }).ToList(), - sender = senders.TryGetValue(m.SenderId, out var s) ? new { - id = s.Id, - username = s.Username, - displayName = s.DisplayName, - avatar = s.Avatar - } : null, - readBy = m.ReadBy.Select(r => new { userId = r.UserId }).ToList(), - reactions = reactionsWithUser - }); - } - - return Ok(result); + var result = await _sender.Send(new GetMessagesQuery(_userContext.UserId, chatId, cursor), ct); + if (result.IsFailure) return BadRequest(result.Error.Description); + return Ok(result.Value); } [HttpGet("search")] - public async Task GetSearch([FromServices] IMessageRepository messageRepository, [FromQuery] string q, [FromQuery] Guid? chatId, CancellationToken ct) + public async Task GetSearch([FromQuery] string q, [FromQuery] Guid? chatId, CancellationToken ct) { - var messages = await messageRepository.SearchMessagesAsync(q, chatId, ct); - - // Filter out messages deleted for this user - messages = messages.Where(m => !m.DeletedByUsers.Contains(_userContext.UserId)).ToList(); - - var userIds = messages.Select(m => m.SenderId).ToList(); - userIds.AddRange(messages.Where(m => m.ForwardedFromId.HasValue).Select(m => m.ForwardedFromId!.Value)); - - var senders = new Dictionary(); - foreach (var id in userIds.Distinct()) - { - var user = await _userRepository.GetByIdAsync(id, ct); - if (user != null) senders[id] = user; - } - - var result = messages.Select(m => new - { - m.Id, - m.ChatId, - m.SenderId, - m.Content, - m.Type, - m.ReplyToId, - m.Quote, - m.IsEdited, - m.IsDeleted, - m.CreatedAt, - forwardedFromId = m.ForwardedFromId, - forwardedFrom = m.ForwardedFromId.HasValue && senders.TryGetValue(m.ForwardedFromId.Value, out var fwd) ? new { - id = fwd.Id, - username = fwd.Username, - displayName = fwd.DisplayName, - avatar = fwd.Avatar - } : null, - storyId = m.StoryId, - storyMediaUrl = m.StoryMediaUrl, - storyMediaType = m.StoryMediaType, - media = m.Media.Select(media => new { - media.Id, - media.Type, - media.Url, - filename = media.Filename, - size = media.Size - }).ToList(), - sender = senders.TryGetValue(m.SenderId, out var s) ? new { - id = s.Id, - username = s.Username, - displayName = s.DisplayName, - avatar = s.Avatar - } : new { id = m.SenderId, username = "unknown", displayName = "Unknown", avatar = (string?)null }, - reactions = m.Reactions.Select(r => new { r.UserId, r.Emoji }).ToList(), - readBy = m.ReadBy.Select(r => new { userId = r.UserId }).ToList() - }); - - return Ok(result); + var result = await _sender.Send(new SearchMessagesQuery(_userContext.UserId, q, chatId), ct); + if (result.IsFailure) return BadRequest(result.Error.Description); + return Ok(result.Value); } [HttpPost("upload")] - public async Task UploadFile( - IFormFile file, - [FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage, - [FromServices] ISettingsService settingsService) + public async Task UploadFile(IFormFile file, CancellationToken ct) { if (file == null || file.Length == 0) return BadRequest("No file uploaded"); - var maxMb = settingsService.Current.MaxFileSizeMb; - if (file.Length > maxMb * 1024 * 1024) - return StatusCode(413, $"File exceeds the maximum allowed size of {maxMb}MB."); - using var stream = file.OpenReadStream(); - var fileId = await fileStorage.UploadFileAsync(stream, file.FileName, file.ContentType); + 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 StatusCode(413, result.Error.Description); + return BadRequest(result.Error.Description); + } - return Ok(new { url = $"/api/files/{fileId}", filename = file.FileName, size = file.Length }); + return Ok(result.Value); } [HttpGet("chat/{chatId:guid}/shared")] - public async Task GetSharedMedia(Guid chatId, [FromServices] IMessageRepository messageRepository, [FromQuery] string? type, CancellationToken ct) + public async Task GetSharedMedia(Guid chatId, [FromQuery] string? type, CancellationToken ct) { - var messages = await messageRepository.GetChatMessagesAsync(chatId, 300, 0, ct); - - // Filter out deleted messages - messages = messages.Where(m => !m.IsDeleted && !m.DeletedByUsers.Contains(_userContext.UserId)).ToList(); - - var result = new List(); - var filterType = type?.ToLower(); - - foreach (var m in messages) - { - if (filterType == "links") - { - var linkRegex = new Regex(@"https?://[^\s]+", RegexOptions.IgnoreCase); - var contentLinks = !string.IsNullOrEmpty(m.Content) ? linkRegex.Matches(m.Content).Select(match => match.Value).ToList() : new List(); - var mediaLinks = (m.Media ?? Enumerable.Empty()).Where(media => media.Type?.ToLower() == "link").Select(media => media.Url).ToList(); - var allLinks = contentLinks.Concat(mediaLinks).Distinct().ToList(); - - if (allLinks.Any()) - { - var sender = await _userRepository.GetByIdAsync(m.SenderId, ct); - result.Add(new - { - m.Id, - m.Content, - m.CreatedAt, - links = allLinks, - sender = sender != null ? new { sender.Id, sender.Username, sender.DisplayName, sender.Avatar } : null - }); - } - continue; - } - - if (m.Media == null || !m.Media.Any()) continue; - - var filteredMedia = m.Media.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 == "media") return (mediaType == "image" || mediaType == "video") && !isGif; - if (filterType == "gifs") return isGif; - if (filterType == "files") return mediaType != "image" && mediaType != "video" && mediaType != "link"; - return true; - }).ToList(); - - if (filteredMedia.Any()) - { - var sender = await _userRepository.GetByIdAsync(m.SenderId, ct); - result.Add(new - { - m.Id, - m.ReplyToId, - m.Quote, - m.StoryId, - m.StoryMediaUrl, - m.StoryMediaType, - m.IsEdited, - m.Content, - m.Type, - m.CreatedAt, - media = filteredMedia.Select(media => new { - media.Id, - media.Type, - media.Url, - filename = media.Filename, - size = media.Size - }).ToList(), - sender = sender != null ? new { sender.Id, sender.Username, sender.DisplayName, sender.Avatar } : null - }); - } - } - - return Ok(result.OrderByDescending(x => ((dynamic)x).CreatedAt).ToList()); + var result = await _sender.Send(new GetSharedMediaQuery(_userContext.UserId, chatId, type), ct); + if (result.IsFailure) return BadRequest(result.Error.Description); + return Ok(result.Value); } [HttpPost("chat/{chatId:guid}")] - public async Task SendMessage(Guid chatId, [FromBody] SendMessageRequest request) + public async Task SendMessage(Guid chatId, [FromBody] SendMessageRequest request, CancellationToken ct) { var attachments = request.Attachments?.Select(a => - new Knot.Modules.Chats.Application.Messages.Send.AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList(); + new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList(); var command = new SendMessageCommand( chatId, @@ -320,23 +87,10 @@ public sealed class MessagesController : ControllerBase request.Quote, request.ForwardedFromId); - Result result = await _sender.Send(command); - - if (result.IsFailure) - { - return BadRequest(result.Error); - } + var result = await _sender.Send(command, ct); + if (result.IsFailure) return BadRequest(result.Error.Description); + return Ok(result.Value); } } - -public sealed record SendMessageRequest( - string? Content, - string Type, - List? Attachments = null, - Guid? ReplyToId = null, - string? Quote = null, - Guid? ForwardedFromId = null); - -public sealed record AttachmentDto(string Type, string Url, string? FileName, long? FileSize); diff --git a/apps/server-net/src/Host/Controllers/StoriesController.cs b/apps/server-net/src/Host/Controllers/StoriesController.cs index 81aad0b..f2e9644 100644 --- a/apps/server-net/src/Host/Controllers/StoriesController.cs +++ b/apps/server-net/src/Host/Controllers/StoriesController.cs @@ -1,15 +1,21 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.SignalR; -using Microsoft.EntityFrameworkCore; -using Knot.Modules.Identity.Domain; -using Knot.Modules.Identity.Infrastructure.Persistence; +using Host.Models; using Knot.Shared.Kernel; -using Knot.Modules.Identity.Application.Abstractions; -using Microsoft.Extensions.Logging; +using System; +using System.Threading; +using System.Threading.Tasks; using MediatR; -using Knot.Modules.Chats.Domain; -using Knot.Modules.Chats.Application.Messages.Send; +using Host.Application.Stories.Queries.GetStories; +using Host.Application.Stories.Commands.CreateStory; +using Host.Application.Stories.Queries.GetUserStories; +using Host.Application.Stories.Commands.ViewStory; +using Host.Application.Stories.Queries.GetStoryViewers; +using Host.Application.Stories.Commands.AddStoryReaction; +using Host.Application.Stories.Commands.RemoveStoryReaction; +using Host.Application.Stories.Commands.AddStoryReply; +using Host.Application.Stories.Queries.GetStoryReplies; +using Host.Application.Stories.Commands.DeleteStory; namespace Host.Controllers; @@ -18,527 +24,102 @@ namespace Host.Controllers; [Route("api/stories")] public sealed class StoriesController : ControllerBase { - private readonly IdentityDbContext _context; - private readonly IUserContext _userContext; - private readonly IUserRepository _userRepository; - private readonly IChatRepository _chatRepository; private readonly ISender _sender; - private readonly IHubContext _hubContext; - private readonly IMessageRepository _messageRepository; - private readonly ILogger _logger; + private readonly IUserContext _userContext; - public StoriesController( - IdentityDbContext context, - IUserContext userContext, - IUserRepository userRepository, - IChatRepository chatRepository, - IMessageRepository messageRepository, - ISender sender, - IHubContext hubContext, - ILogger logger) + public StoriesController(ISender sender, IUserContext userContext) { - _context = context; - _userContext = userContext; - _userRepository = userRepository; - _chatRepository = chatRepository; - _messageRepository = messageRepository; _sender = sender; - _hubContext = hubContext; - _logger = logger; + _userContext = userContext; } [HttpGet] public async Task GetStories(CancellationToken ct) { - var currentUserId = _userContext.UserId; - - var friendships = await _context.Set() - .Where(f => f.Status == FriendshipStatus.Accepted && (f.UserId == currentUserId || f.FriendId == currentUserId)) - .ToListAsync(ct); - - var friendIds = friendships.Select(f => f.UserId == currentUserId ? f.FriendId : f.UserId).ToList(); - friendIds.Add(currentUserId); - - var stories = await _context.Stories - .Include(s => s.Viewers) - .Include(s => s.Reactions) - .Include(s => s.Replies) - .Where(s => s.ExpiresAt > DateTime.UtcNow && friendIds.Contains(s.UserId)) - .OrderByDescending(s => s.CreatedAt) - .ToListAsync(ct); - - var groups = stories.GroupBy(s => s.UserId).ToList(); - var result = new List(); - - var userIds = groups.Select(g => g.Key).ToList(); - var userMap = new Dictionary(); - foreach (var uid in userIds) - { - var u = await _userRepository.GetByIdAsync(uid, ct); - if (u != null) userMap[uid] = u; - } - - foreach (var group in groups) - { - if (!userMap.TryGetValue(group.Key, out var user)) continue; - - result.Add(new - { - user = new - { - id = user.Id, - username = user.Username, - displayName = user.DisplayName, - avatar = user.Avatar - }, - stories = group.Select(s => new - { - id = s.Id, - type = s.Type, - mediaUrl = s.MediaUrl, - content = s.Content, - bgColor = s.BgColor, - createdAt = s.CreatedAt, - expiresAt = s.ExpiresAt, - viewCount = s.Viewers.Count, - viewed = s.Viewers.Any(v => v.UserId == currentUserId), - reactions = s.Reactions.Select(r => new - { - id = r.Id, - userId = r.UserId, - emoji = r.Emoji, - createdAt = r.CreatedAt - }).ToList(), - replyCount = s.Replies.Count - }).OrderBy(s => s.createdAt).ToList(), - hasUnviewed = group.Any(s => !s.Viewers.Any(v => v.UserId == currentUserId)) - }); - } - - var finalResult = result.OrderBy(r => - { - var rDynamic = (dynamic)r; - if ((Guid)rDynamic.user.id == currentUserId) return 0; - return 1; - }).ToList(); - - return Ok(finalResult); + var result = await _sender.Send(new GetStoriesQuery(_userContext.UserId), ct); + return Ok(result.Value); } [HttpPost] public async Task CreateStory([FromBody] CreateStoryRequest request, CancellationToken ct) { - var story = Story.Create( - _userContext.UserId, - request.Type, - request.MediaUrl, - request.Content, - request.BgColor); - - _context.Stories.Add(story); - await _context.SaveChangesAsync(ct); - - return Ok(new { id = story.Id }); + var result = await _sender.Send(new CreateStoryCommand(_userContext.UserId, request.Type, request.MediaUrl, request.Content, request.BgColor), ct); + return Ok(new { id = result.Value }); } [HttpGet("user/{userId}")] public async Task GetUserStories(Guid userId, CancellationToken ct) { - var currentUserId = _userContext.UserId; - var stories = await _context.Stories - .Include(s => s.Viewers) - .Include(s => s.Reactions) - .Include(s => s.Replies) - .Where(s => s.UserId == userId) - .OrderByDescending(s => s.CreatedAt) - .ToListAsync(ct); - - var user = await _userRepository.GetByIdAsync(userId, ct); - if (user == null) return NotFound(); - - var result = new - { - user = new - { - id = user.Id, - username = user.Username, - displayName = user.DisplayName, - avatar = user.Avatar - }, - stories = stories.Select(s => new - { - id = s.Id, - type = s.Type, - mediaUrl = s.MediaUrl, - content = s.Content, - bgColor = s.BgColor, - createdAt = s.CreatedAt, - expiresAt = s.ExpiresAt, - viewCount = s.Viewers.Count, - viewed = s.Viewers.Any(v => v.UserId == currentUserId), - reactions = s.Reactions.Select(r => new - { - id = r.Id, - userId = r.UserId, - emoji = r.Emoji, - createdAt = r.CreatedAt - }).ToList(), - replyCount = s.Replies.Count - }).ToList() - }; - - return Ok(result); + var result = await _sender.Send(new GetUserStoriesQuery(_userContext.UserId, userId), ct); + if (result.IsFailure) return NotFound(); + return Ok(result.Value); } [HttpPost("{id}/view")] public async Task ViewStory(Guid id, CancellationToken ct) { - _logger.LogInformation("ViewStory called: StoryId={StoryId}, UserId={UserId}", id, _userContext.UserId); - - try - { - var story = await _context.Stories - .Include(s => s.Viewers) - .FirstOrDefaultAsync(s => s.Id == id, ct); - - if (story == null) - { - _logger.LogWarning("Story not found: {StoryId}", id); - return NotFound(); - } - - if (story.UserId == _userContext.UserId) - { - _logger.LogInformation("Owner view, skipping"); - return Ok(new { message = "Owner view" }); - } - - if (!story.Viewers.Any(v => v.UserId == _userContext.UserId)) - { - story.AddViewer(_userContext.UserId); - await _context.SaveChangesAsync(ct); - - _logger.LogInformation("Viewer added. Total viewers: {Count}", story.Viewers.Count); - - // Notify owner via SignalR - send to all, filter on client - var viewer = await _userRepository.GetByIdAsync(_userContext.UserId, ct); - - _logger.LogInformation("Sending story_viewed to owner {OwnerId}", story.UserId); - - // Get updated story with viewers to be sure count is accurate - var updatedStory = await _context.Stories.Include(s => s.Viewers).FirstAsync(s => s.Id == story.Id, ct); - - await _hubContext.Clients.All.SendAsync("story_viewed", new - { - storyId = story.Id, - userId = _userContext.UserId, - username = viewer?.Username, - displayName = viewer?.DisplayName, - avatar = viewer?.Avatar, - viewedAt = DateTime.UtcNow, - viewCount = updatedStory.Viewers.Count, - ownerId = story.UserId - }, ct); - - _logger.LogInformation("story_viewed sent successfully"); - } - else - { - _logger.LogInformation("User already viewed this story"); - } - - return Ok(new { message = "Story viewed" }); - } - catch (DbUpdateException ex) - { - _logger.LogError(ex, "DbUpdateException in ViewStory"); - return Ok(new { message = "Story already viewed" }); - } - catch (Exception ex) - { - _logger.LogError(ex, "ViewStory error"); - return StatusCode(500, ex.Message); - } + var result = await _sender.Send(new ViewStoryCommand(_userContext.UserId, id), ct); + if (result.IsFailure) return NotFound(); + return Ok(result.Value); } [HttpGet("{id}/viewers")] public async Task GetStoryViewers(Guid id, CancellationToken ct) { - _logger.LogInformation("GetStoryViewers called: StoryId={StoryId}, UserId={UserId}", id, _userContext.UserId); - - var story = await _context.Stories - .Include(s => s.Viewers) - .FirstOrDefaultAsync(s => s.Id == id, ct); - - if (story == null) + var result = await _sender.Send(new GetStoryViewersQuery(_userContext.UserId, id), ct); + if (result.IsFailure) { - _logger.LogWarning("Story not found: {StoryId}", id); + if (result.Error.Code == "Unauthorized") return Forbid(); return NotFound(); } - - if (story.UserId != _userContext.UserId) - { - _logger.LogWarning("Forbidden: User {UserId} is not owner of story {StoryId}", _userContext.UserId, id); - return Forbid(); - } - - _logger.LogInformation("Story has {Count} viewers", story.Viewers.Count); - - var viewerIds = story.Viewers.Select(v => v.UserId).ToList(); - var viewers = new List(); - - foreach (var viewerId in viewerIds) - { - var user = await _userRepository.GetByIdAsync(viewerId, ct); - if (user == null) continue; - - var viewerRecord = story.Viewers.First(v => v.UserId == viewerId); - - viewers.Add(new - { - userId = user.Id, - username = user.Username, - displayName = user.DisplayName, - avatar = user.Avatar, - viewedAt = viewerRecord.ViewedAt - }); - } - - _logger.LogInformation("Returning {Count} viewers", viewers.Count); - return Ok(viewers); - } - - private string GetStoryQuote(Story story) - { - if (!string.IsNullOrEmpty(story.Content)) return story.Content; - return story.Type.ToLower() switch - { - "image" => "🖼 Фото", - "video" => "🎬 Видео", - _ => "История" - }; + return Ok(result.Value); } [HttpPost("{id}/reaction")] public async Task AddReaction(Guid id, [FromBody] AddStoryReactionRequest request, CancellationToken ct) { - _logger.LogInformation("AddReaction called: StoryId={StoryId}, UserId={UserId}, Emoji={Emoji}", id, _userContext.UserId, request.Emoji); - - try - { - var story = await _context.Stories.FindAsync(new object[] { id }, ct); - if (story == null) - { - _logger.LogWarning("Story not found: {StoryId}", id); - return NotFound(); - } - - // Check if reaction already exists - var existing = await _context.StoryReactions - .FirstOrDefaultAsync(r => r.StoryId == id && r.UserId == _userContext.UserId && r.Emoji == request.Emoji, ct); - - if (existing != null) - { - _logger.LogInformation("Reaction already exists"); - return Ok(new { message = "Reaction already exists" }); - } - - // Add reaction directly via DbSet - var reaction = new StoryReaction(id, _userContext.UserId, request.Emoji); - _context.StoryReactions.Add(reaction); - await _context.SaveChangesAsync(ct); - - _logger.LogInformation("Reaction saved successfully"); - - // 1. Create/Find chat - var chatId = await GetOrCreatePersonalChatIdAsync(_userContext.UserId, story.UserId, ct); - - // 2. Threading: find last story message in this chat - var lastStoryMessage = await _messageRepository.GetLastStoryMessageAsync(chatId, story.Id, ct); - - if (lastStoryMessage != null) - { - // If message already exists for this story, add a reaction to it - var addReactionCommand = new Knot.Modules.Chats.Application.Messages.React.AddReactionCommand( - lastStoryMessage.Id, _userContext.UserId, request.Emoji, chatId); - await _sender.Send(addReactionCommand, ct); - } - else - { - // Create new message for the story - var storyQuote = GetStoryQuote(story); - var messageCommand = new SendMessageCommand( - ChatId: chatId, - SenderId: _userContext.UserId, - Content: request.Emoji, - Type: "text", - Quote: storyQuote, - StoryId: story.Id, - StoryMediaUrl: story.MediaUrl, - StoryMediaType: story.Type); - await _sender.Send(messageCommand, ct); - } - - // 3. Notify story owner in real-time (existing StoryViewer listeners) - var reactor = await _userRepository.GetByIdAsync(_userContext.UserId, ct); - await _hubContext.Clients.All.SendAsync("story_reaction", new - { - storyId = story.Id, - userId = _userContext.UserId, - username = reactor?.Username, - displayName = reactor?.DisplayName, - avatar = reactor?.Avatar, - emoji = request.Emoji, - createdAt = DateTime.UtcNow, - ownerId = story.UserId - }, ct); - - return Ok(new { message = "Reaction added" }); - } - catch (Exception ex) - { - _logger.LogError(ex, "AddReaction error"); - return StatusCode(500, ex.Message); - } + var result = await _sender.Send(new AddStoryReactionCommand(_userContext.UserId, id, request.Emoji), ct); + if (result.IsFailure) return NotFound(); + return Ok(result.Value); } [HttpDelete("{id}/reaction")] public async Task RemoveReaction(Guid id, [FromBody] RemoveStoryReactionRequest request, CancellationToken ct) { - var reaction = await _context.StoryReactions - .FirstOrDefaultAsync(r => r.StoryId == id && r.UserId == _userContext.UserId && r.Emoji == request.Emoji, ct); - - if (reaction == null) return Ok(new { message = "Reaction not found" }); - - _context.StoryReactions.Remove(reaction); - await _context.SaveChangesAsync(ct); - - return Ok(new { message = "Reaction removed" }); + var result = await _sender.Send(new RemoveStoryReactionCommand(_userContext.UserId, id, request.Emoji), ct); + if (result.IsFailure) return NotFound(); + return Ok(result.Value); } [HttpPost("{id}/reply")] public async Task AddReply(Guid id, [FromBody] AddStoryReplyRequest request, CancellationToken ct) { - _logger.LogInformation("AddReply called: StoryId={StoryId}, UserId={UserId}, Content={Content}", id, _userContext.UserId, request.Content); - - try - { - var story = await _context.Stories.FindAsync(new object[] { id }, ct); - if (story == null) - { - _logger.LogWarning("Story not found: {StoryId}", id); - return NotFound(); - } - - // Add reply directly via DbSet - var reply = new StoryReply(id, _userContext.UserId, request.Content); - _context.StoryReplies.Add(reply); - await _context.SaveChangesAsync(ct); - - _logger.LogInformation("Reply saved successfully"); - - // 1. Create/Find chat - var chatId = await GetOrCreatePersonalChatIdAsync(_userContext.UserId, story.UserId, ct); - - // 2. Find last message for threading - var lastStoryMessage = await _messageRepository.GetLastStoryMessageAsync(chatId, story.Id, ct); - - // 3. Send message to chat - var storyQuote = GetStoryQuote(story); - var messageCommand = new SendMessageCommand( - ChatId: chatId, - SenderId: _userContext.UserId, - Content: request.Content, - Type: "text", - Quote: storyQuote, - ReplyToId: lastStoryMessage?.Id, - StoryId: story.Id, - StoryMediaUrl: story.MediaUrl, - StoryMediaType: story.Type); - await _sender.Send(messageCommand, ct); - - // 3. Notify story owner in real-time (existing StoryViewer listeners) - var replier = await _userRepository.GetByIdAsync(_userContext.UserId, ct); - await _hubContext.Clients.All.SendAsync("story_reply", new - { - storyId = story.Id, - userId = _userContext.UserId, - username = replier?.Username, - displayName = replier?.DisplayName, - avatar = replier?.Avatar, - content = request.Content, - createdAt = DateTime.UtcNow, - ownerId = story.UserId - }, ct); - - return Ok(new { message = "Reply added" }); - } - catch (Exception ex) - { - _logger.LogError(ex, "AddReply error"); - return StatusCode(500, ex.Message); - } + var result = await _sender.Send(new AddStoryReplyCommand(_userContext.UserId, id, request.Content), ct); + if (result.IsFailure) return NotFound(); + return Ok(result.Value); } [HttpGet("{id}/replies")] public async Task GetReplies(Guid id, CancellationToken ct) { - var story = await _context.Stories - .Include(s => s.Replies) - .FirstOrDefaultAsync(s => s.Id == id, ct); - - if (story == null) return NotFound(); - if (story.UserId != _userContext.UserId) return Forbid(); - - var replies = new List(); - foreach (var reply in story.Replies.OrderBy(r => r.CreatedAt)) + var result = await _sender.Send(new GetStoryRepliesQuery(_userContext.UserId, id), ct); + if (result.IsFailure) { - var user = await _userRepository.GetByIdAsync(reply.UserId, ct); - if (user == null) continue; - - replies.Add(new - { - id = reply.Id, - userId = user.Id, - username = user.Username, - displayName = user.DisplayName, - avatar = user.Avatar, - content = reply.Content, - createdAt = reply.CreatedAt - }); + if (result.Error.Code == "Unauthorized") return Forbid(); + return NotFound(); } - - return Ok(replies); + return Ok(result.Value); } [HttpDelete("{id}")] public async Task DeleteStory(Guid id, CancellationToken ct) { - var story = await _context.Stories.FindAsync(new object[] { id }, ct); - if (story == null) return NotFound(); - if (story.UserId != _userContext.UserId) return Forbid(); - - _context.Stories.Remove(story); - await _context.SaveChangesAsync(ct); - - return Ok(new { message = "Story deleted" }); - } - private async Task GetOrCreatePersonalChatIdAsync(Guid userId1, Guid userId2, CancellationToken ct) - { - var userChats = await _chatRepository.GetUserChatsAsync(userId1, ct); - var personalChat = userChats.FirstOrDefault(c => - c.Type == ChatType.Personal && - c.Members.Any(m => m.UserId == userId2)); - - if (personalChat != null) return personalChat.Id; - - var command = new Knot.Modules.Chats.Application.Chats.Create.CreateChatCommand(string.Empty, ChatType.Personal, new List { userId1, userId2 }); - var result = await _sender.Send(command, ct); - return result.Value; + var result = await _sender.Send(new DeleteStoryCommand(_userContext.UserId, id), ct); + if (result.IsFailure) + { + if (result.Error.Code == "Unauthorized") return Forbid(); + return NotFound(); + } + return Ok(result.Value); } } - -public sealed record CreateStoryRequest(string Type, string? MediaUrl, string? Content, string? BgColor); -public sealed record AddStoryReactionRequest(string Emoji); -public sealed record RemoveStoryReactionRequest(string Emoji); -public sealed record AddStoryReplyRequest(string Content); diff --git a/apps/server-net/src/Host/Controllers/TelegramImportController.cs b/apps/server-net/src/Host/Controllers/TelegramImportController.cs index 0f6121f..defcf1b 100644 --- a/apps/server-net/src/Host/Controllers/TelegramImportController.cs +++ b/apps/server-net/src/Host/Controllers/TelegramImportController.cs @@ -1,24 +1,12 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using HtmlAgilityPack; -using MediatR; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.SignalR; -using Knot.Modules.Chats.Application.Abstractions; -using Knot.Modules.Chats.Domain; -using Knot.Modules.Identity.Domain; +using MediatR; +using Host.Models; using Knot.Shared.Kernel; -using Knot.Shared.Kernel.Storage; -using Knot.Modules.Chats.Infrastructure.SignalR; -using Knot.Modules.Chats.Application.Chats.Create; +using Knot.Modules.Chats.Application.TelegramImport; +using System.Threading; +using System.Threading.Tasks; namespace Host.Controllers; @@ -29,31 +17,11 @@ public sealed class TelegramImportController : ControllerBase { private readonly ISender _sender; private readonly IUserContext _userContext; - private readonly IChatsUnitOfWork _unitOfWork; - private readonly IChatRepository _chatRepository; - private readonly IMessageRepository _messageRepository; - private readonly IFileStorageService _fileStorage; - private readonly IHubContext _hubContext; - // In-memory store for uploaded zips (good enough for typical cases, ideally should be removed after use or by timer) - private static readonly ConcurrentDictionary _tempZips = new(); - - public TelegramImportController( - ISender sender, - IUserContext userContext, - IChatsUnitOfWork unitOfWork, - IChatRepository chatRepository, - IMessageRepository messageRepository, - IFileStorageService fileStorage, - IHubContext hubContext) + public TelegramImportController(ISender sender, IUserContext userContext) { _sender = sender; _userContext = userContext; - _unitOfWork = unitOfWork; - _chatRepository = chatRepository; - _messageRepository = messageRepository; - _fileStorage = fileStorage; - _hubContext = hubContext; } [HttpPost("analyze")] @@ -61,425 +29,36 @@ public sealed class TelegramImportController : ControllerBase [RequestFormLimits(MultipartBodyLengthLimit = 10L * 1024 * 1024 * 1024)] // 10GB for big exports public async Task Analyze(IFormFile file, CancellationToken ct) { - if (file == null || file.Length == 0) return BadRequest("No file uploaded"); - if (!file.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) return BadRequest("Must be a ZIP archive"); - - var token = Guid.NewGuid(); - var tempPath = Path.Combine(Path.GetTempPath(), $"{token}.zip"); - - await using (var fs = new FileStream(tempPath, FileMode.Create)) + if (file == null) { - await file.CopyToAsync(fs, ct); + return BadRequest("No file uploaded."); } - var names = new HashSet(); - - // Open zip and quickly scan messages.html - using (var archive = ZipFile.OpenRead(tempPath)) + using var stream = file.OpenReadStream(); + var command = new AnalyzeImportCommand(stream, file.FileName); + + var result = await _sender.Send(command, ct); + + if (result.IsFailure) { - var htmlEntries = archive.Entries - .Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase)); - - foreach (var entry in htmlEntries) - { - using var stream = entry.Open(); - var parser = new AngleSharp.Html.Parser.HtmlParser(); - var doc = parser.ParseDocument(stream); - - var messageNodes = doc.QuerySelectorAll(".message"); - if (messageNodes == null) continue; - - foreach (var node in messageNodes) - { - var fromNameNode = node.QuerySelector(".from_name"); - if (fromNameNode != null) - { - var nameNodeText = (AngleSharp.Dom.IElement)fromNameNode.Clone(); - var innerSpans = nameNodeText.QuerySelectorAll("span"); - foreach (var span in innerSpans) span.Remove(); - - var name = nameNodeText.TextContent.Trim(); - if (!string.IsNullOrWhiteSpace(name)) - { - names.Add(name); - } - } - } - } + return BadRequest(result.Error.Description ?? result.Error.Code); } - _tempZips[token] = tempPath; - - return Ok(new - { - token, - names = names.ToList() - }); + return Ok(result.Value); } - public sealed record ExecuteImportRequest(Guid Token, Dictionary Mapping, string? GroupName); - [HttpPost("execute")] public async Task Execute([FromBody] ExecuteImportRequest req, CancellationToken ct) { - if (!_tempZips.TryGetValue(req.Token, out var tempPath)) - return BadRequest("Session not found or expired"); + var command = new ExecuteImportCommand(_userContext.UserId, req.Token, req.Mapping, req.GroupName); + + var result = await _sender.Send(command, ct); - if (!System.IO.File.Exists(tempPath)) - return BadRequest("ZIP file lost"); - - var myId = _userContext.UserId; - // Collect targeted users to check whose chat it is. Find the friend. - // Usually, the mapping contains MyId and FriendId. - var targetUserIds = req.Mapping.Values.Distinct().Where(id => id != Guid.Empty).ToList(); - if (!targetUserIds.Contains(myId)) targetUserIds.Add(myId); - - Guid chatId = Guid.Empty; - var chatMembers = targetUserIds; - - if (chatMembers.Count <= 2) + if (result.IsFailure) { - // Find existing personal chat - var existingChats = await _chatRepository.GetUserChatsAsync(myId, ct); - var personalChat = existingChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.All(m => chatMembers.Contains(m.UserId)) && c.Members.Count == chatMembers.Count); - - if (personalChat != null) - { - chatId = personalChat.Id; - } - else - { - // Create personal chat - var friendId = chatMembers.FirstOrDefault(id => id != myId); - if (friendId == Guid.Empty) friendId = myId; // Notes to self - var command = new CreateChatCommand(string.Empty, ChatType.Personal, new List { myId, friendId }); - var res = await _sender.Send(command, ct); - if (res.IsFailure) return BadRequest(res.Error); - chatId = res.Value; - } - } - else - { - // Create a group - var command = new CreateChatCommand(req.GroupName ?? "Импортированный чат", ChatType.Group, chatMembers); - var res = await _sender.Send(command, ct); - if (res.IsFailure) return BadRequest(res.Error); - chatId = res.Value; + return BadRequest(result.Error.Description ?? result.Error.Code); } - int importedCount = 0; - - using (var archive = ZipFile.OpenRead(tempPath)) - { - var htmlEntries = archive.Entries - .Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase)) - .OrderBy(e => - { - var name = e.Name.ToLower().Replace("messages", "").Replace(".html", ""); - return string.IsNullOrEmpty(name) ? 0 : int.TryParse(name, out var num) ? num : 999999; - }) - .ToList(); - - Guid lastSenderGuid = myId; - DateTime lastCreatedAt = DateTime.UtcNow; - Dictionary messageIdMap = new(); - Message? lastSavedMessage = null; - - foreach (var entry in htmlEntries) - { - using var stream = entry.Open(); - var parser = new AngleSharp.Html.Parser.HtmlParser(); - var doc = parser.ParseDocument(stream); - - var messageNodes = doc.QuerySelectorAll(".message"); - if (messageNodes == null) continue; - - var baseDir = Path.GetDirectoryName(entry.FullName)?.Replace("\\", "/") ?? ""; - if (!string.IsNullOrEmpty(baseDir) && !baseDir.EndsWith("/")) baseDir += "/"; - - foreach (var node in messageNodes) - { - try - { - var fromNameNode = node.QuerySelector(".from_name"); - var textNode = node.QuerySelector(".text"); - - var dateNode = node.QuerySelector(".date[title]") - ?? node.QuerySelector(".pull_right[title]") - ?? node.QuerySelector("[title]"); - - if (fromNameNode != null) - { - var nameNodeText = (AngleSharp.Dom.IElement)fromNameNode.Clone(); - var innerSpans = nameNodeText.QuerySelectorAll("span"); - foreach (var span in innerSpans) span.Remove(); - - var name = nameNodeText.TextContent.Trim(); - if (req.Mapping.TryGetValue(name, out var mappedId) && mappedId != Guid.Empty) - lastSenderGuid = mappedId; - else - lastSenderGuid = myId; - } - - Guid senderGuid = lastSenderGuid; - - string content = ""; - var mainBodyNode = node.QuerySelector(".body"); - var isForwarded = node.QuerySelector(".forwarded") != null; - - // To avoid grabbing text from inside the forwarded block as main text, - // we can look for .text that is a direct child of the main .body - // The .forwarded block has its own .text - var contentTextNode = isForwarded - ? (node.QuerySelector(".body > .text") ?? node.QuerySelector(".text:not(.forwarded .text)")) - : node.QuerySelector(".text"); - - if (contentTextNode != null) - { - var html = contentTextNode.InnerHtml - .Replace("
", "\n", StringComparison.OrdinalIgnoreCase) - .Replace("
", "\n", StringComparison.OrdinalIgnoreCase) - .Replace("
", "\n", StringComparison.OrdinalIgnoreCase); - var tempParser = new AngleSharp.Html.Parser.HtmlParser(); - var tempDoc = tempParser.ParseDocument("
" + html + "
"); - content = tempDoc.Body?.TextContent.Trim() ?? ""; - } - - DateTime createdAt = lastCreatedAt; - var titleNodes = node.QuerySelectorAll("[title]"); - bool parsed = false; - - if (titleNodes != null) - { - foreach (var tnode in titleNodes) - { - var dateStr = tnode.GetAttribute("title")?.Trim() ?? ""; - - if (dateStr.Length >= 10 && char.IsDigit(dateStr[0]) && char.IsDigit(dateStr[1])) - { - // Remove "UTC" so we can parse timezone offset generically, e.g. "17.10.2025 12:56:25 +03:00" - var cleanStr = dateStr.Replace("UTC", "", StringComparison.OrdinalIgnoreCase).Trim(); - - if (DateTimeOffset.TryParseExact(cleanStr, "dd.MM.yyyy HH:mm:ss zzz", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dto)) - { - createdAt = dto.UtcDateTime; - parsed = true; - break; - } - else if (DateTime.TryParseExact(cleanStr, "dd.MM.yyyy HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var dt)) - { - createdAt = dt; - parsed = true; - break; - } - else if (DateTime.TryParse(cleanStr, out var dFallback)) - { - createdAt = dFallback.ToUniversalTime(); - parsed = true; - break; - } - } - } - } - - if (!parsed) - { - Console.WriteLine("Warning: Could not parse date in imported message! Using lastCreatedAt."); - } - else - { - lastCreatedAt = createdAt; - } - - // Parse forwards - Guid? forwardedFromId = null; - var forwardedNode = node.QuerySelector(".forwarded.body"); - if (forwardedNode != null) - { - var fwdNameNode = forwardedNode.QuerySelector(".from_name"); - var fwdNameText = fwdNameNode != null ? (AngleSharp.Dom.IElement)fwdNameNode.Clone() : null; - if (fwdNameText != null) - { - var innerSpans = fwdNameText.QuerySelectorAll("span"); - foreach (var s in innerSpans) s.Remove(); - } - var fwdName = fwdNameText != null ? fwdNameText.TextContent.Trim() : "Неизвестного"; - - if (req.Mapping.TryGetValue(fwdName, out var mappedFwdId) && mappedFwdId != Guid.Empty) - { - forwardedFromId = mappedFwdId; - } - else if (fwdName == "Это я" || fwdName == req.Mapping.FirstOrDefault(x => x.Value == myId).Key) - { - forwardedFromId = myId; - } - - var fwdTextNode = forwardedNode.QuerySelector(".text"); - string fwdContent = ""; - if (fwdTextNode != null) - { - var fHtml = fwdTextNode.InnerHtml - .Replace("
", "\n", StringComparison.OrdinalIgnoreCase) - .Replace("
", "\n", StringComparison.OrdinalIgnoreCase) - .Replace("
", "\n", StringComparison.OrdinalIgnoreCase); - var tempParser = new AngleSharp.Html.Parser.HtmlParser(); - var tempDoc = tempParser.ParseDocument("
" + fHtml + "
"); - fwdContent = tempDoc.Body?.TextContent.Trim() ?? ""; - } - - if (forwardedFromId == null) - { - // We don't have this user in the app, map to generic string - content = string.IsNullOrEmpty(content) - ? $"[Переслано от {fwdName}]:\n{fwdContent}" - : $"{content}\n\n[Переслано от {fwdName}]:\n{fwdContent}"; - } - else if (string.IsNullOrEmpty(content)) - { - content = fwdContent; - } - } - - // Parse replies - Guid? replyToId = null; - var replyNode = node.QuerySelector(".reply_to a"); - if (replyNode != null) - { - var href = replyNode.GetAttribute("href"); - if (href != null && href.StartsWith("#go_to_")) - { - var tgId = href.Substring("#go_to_".Length); - if (messageIdMap.TryGetValue(tgId, out var mappedMsgId)) - { - replyToId = mappedMsgId; - } - } - } - - var mediaNodes = node.QuerySelectorAll("a.photo_wrap, a.animated_wrap, video, audio, a.document, a.media_voice_message, a.media_video, img.sticker").ToList(); - if (mediaNodes.Count == 0) - { - var fallback = node.QuerySelectorAll(".media_wrap a[href]"); - mediaNodes.AddRange(fallback); - } - - var messageType = "text"; - - // Helper for categorizing files - (string mType, string cType) GetMediaTypes(string fileUrl) - { - var ext = Path.GetExtension(fileUrl)?.ToLower(); - return ext switch - { - ".jpg" or ".jpeg" or ".png" or ".webp" => ("image", "image/jpeg"), - ".mp4" or ".mov" or ".avi" => ("video", "video/mp4"), - ".ogg" or ".mp3" => ("voice", "audio/ogg"), - _ => ("file", "application/octet-stream") - }; - } - - if (mediaNodes != null && mediaNodes.Count > 0) - { - var firstHref = mediaNodes[0].GetAttribute("href") ?? mediaNodes[0].GetAttribute("src"); - if (firstHref != null) - { - messageType = GetMediaTypes(firstHref).mType; - if (mediaNodes[0].ClassName?.Contains("animated") == true || firstHref.EndsWith(".mp4")) - { - // Treat telegram animated gifs as image format in app (we auto-loop mp4 images) - // But web app specifically handles "image" and "video" and microlink crashes on mp4 video - // Let's just keep "video" or "image". Actually, telegram exports gifs as mp4. - // We should let our app player handle it. - if (mediaNodes[0].ClassName?.Contains("animated") == true) - messageType = "image"; - } - } - } - - bool isJoined = fromNameNode == null; - bool isMediaOnly = string.IsNullOrEmpty(content) && forwardedNode == null && replyToId == null; - Message? targetMessage = null; - - if (isJoined && isMediaOnly && lastSavedMessage != null && Math.Abs((createdAt - lastSavedMessage.CreatedAt).TotalSeconds) <= 60 && lastSavedMessage.SenderId == senderGuid) - { - targetMessage = lastSavedMessage; - } - else - { - string finalContent = content; - targetMessage = Message.Import(chatId, senderGuid, finalContent, messageType, createdAt, replyToId, forwardedFromId); - - var idAttr = node.GetAttribute("id"); - if (!string.IsNullOrEmpty(idAttr)) - { - messageIdMap[idAttr] = targetMessage.Id; - } - } - - if (mediaNodes != null) - { - foreach (var mediaNode in mediaNodes) - { - string? href = mediaNode.GetAttribute("href") ?? mediaNode.GetAttribute("src"); - if (!string.IsNullOrEmpty(href) && !href.StartsWith("http")) - { - var zipPath = baseDir + href.Replace("\\", "/"); - var zipEntry = archive.GetEntry(zipPath); - if (zipEntry != null) - { - using var ms = new MemoryStream(); - using var zipfs = zipEntry.Open(); - await zipfs.CopyToAsync(ms, ct); - ms.Position = 0; - - var types = GetMediaTypes(href); - var finalMType = types.mType; - if (mediaNode.ClassName?.Contains("animated") == true) finalMType = "image"; - var fileId = await _fileStorage.UploadFileAsync(ms, Path.GetFileName(href), types.cType); - targetMessage.AddMedia(finalMType, $"/api/files/{fileId}", Path.GetFileName(href), zipEntry.Length); - } - } - } - } - - // Parse reactions - var reactionNodes = node.QuerySelectorAll(".reactions .reaction"); - foreach (var reactionNode in reactionNodes) - { - var emojiNode = reactionNode.QuerySelector(".emoji"); - if (emojiNode == null) continue; - var emoji = emojiNode.TextContent.Trim(); - - var userpicNodes = reactionNode.QuerySelectorAll(".userpics .userpic .initials[title]"); - foreach (var userpicNode in userpicNodes) - { - var title = userpicNode.GetAttribute("title")?.Trim(); - if (!string.IsNullOrEmpty(title) && req.Mapping.TryGetValue(title, out var rUserId) && rUserId != Guid.Empty) - { - targetMessage.AddReaction(rUserId, emoji); - } - } - } - if (targetMessage != lastSavedMessage && (!string.IsNullOrEmpty(content) || targetMessage.Media.Any())) - { - _messageRepository.Add(targetMessage); - lastSavedMessage = targetMessage; - importedCount++; - } - } - catch { /* ignore single message parse error */ } - } - } - } - - await _unitOfWork.SaveChangesAsync(ct); - - // Delete temp zip - try { System.IO.File.Delete(tempPath); _tempZips.TryRemove(req.Token, out _); } catch { } - - // Notify UI for all members of the chat - await _hubContext.Clients.Users(chatMembers.Select(x => x.ToString())).SendAsync("history_updated", new { chatId }); - - return Ok(new { success = true, messagesImported = importedCount, chatId }); + return Ok(result.Value); } } diff --git a/apps/server-net/src/Host/Controllers/UsersController.cs b/apps/server-net/src/Host/Controllers/UsersController.cs index c1589e5..29141fe 100644 --- a/apps/server-net/src/Host/Controllers/UsersController.cs +++ b/apps/server-net/src/Host/Controllers/UsersController.cs @@ -1,10 +1,15 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization; -using Knot.Modules.Identity.Domain; using Knot.Shared.Kernel; -using Knot.Modules.Identity.Application.Abstractions; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Processing; +using Host.Models; +using Knot.Shared.Kernel.Storage; +using MediatR; +using Knot.Modules.Identity.Application.Users; +using Knot.Modules.Identity.Application.Users.Avatar; +using Knot.Modules.Identity.Application.Users.GetUser; +using Knot.Modules.Identity.Application.Users.Search; +using Knot.Modules.Identity.Application.Users.UpdateProfile; +using Knot.Modules.Identity.Application.Users.UpdateSettings; namespace Host.Controllers; @@ -13,200 +18,90 @@ namespace Host.Controllers; [Route("api/users")] public sealed class UsersController : ControllerBase { - private readonly IUserRepository _userRepository; + private readonly ISender _sender; private readonly IUserContext _userContext; - private readonly IIdentityUnitOfWork _unitOfWork; - public UsersController(IUserRepository userRepository, IUserContext userContext, IIdentityUnitOfWork unitOfWork) + public UsersController(ISender sender, IUserContext userContext) { - _userRepository = userRepository; + _sender = sender; _userContext = userContext; - _unitOfWork = unitOfWork; } [HttpGet("search")] public async Task Search([FromQuery] string q, CancellationToken ct) { - var users = await _userRepository.SearchUsersAsync(q, ct); - - var result = users.Select(user => new - { - id = user.Id, - username = user.Username, - displayName = user.DisplayName, - avatar = user.Avatar, - isOnline = false, - lastSeen = DateTime.UtcNow - }).ToList(); - - return Ok(result); + var result = await _sender.Send(new SearchUsersQuery(q), ct); + if (result.IsFailure) return BadRequest(result.Error); + return Ok(result.Value); } [HttpPut("settings")] public async Task UpdateSettings([FromBody] UpdateSettingsRequest request, CancellationToken ct) { - var user = await _userRepository.GetByIdAsync(_userContext.UserId, ct); - if (user == null) return NotFound(); - - user.UpdateSettings(request.HideStoryViews ?? user.HideStoryViews); - await _unitOfWork.SaveChangesAsync(ct); - - return Ok(new { - id = user.Id, - username = user.Username, - displayName = user.DisplayName, - avatar = user.Avatar, - bio = user.Bio, - birthday = user.Birthday, - createdAt = user.CreatedAt, - hideStoryViews = user.HideStoryViews - }); + var result = await _sender.Send(new UpdateSettingsCommand(_userContext.UserId, request.HideStoryViews), ct); + if (result.IsFailure) return NotFound(result.Error); + return Ok(result.Value); } [HttpPost("avatar")] - public async Task UploadAvatar(IFormFile avatar, [FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage, CancellationToken ct) + public async Task UploadAvatar(IFormFile avatar, CancellationToken ct) { - var user = await _userRepository.GetByIdAsync(_userContext.UserId, ct); - if (user == null) return NotFound(); + var fileToUpload = avatar ?? Request.Form.Files.FirstOrDefault(); + if (fileToUpload == null || fileToUpload.Length == 0) return BadRequest("No file uploaded"); - string? avatarUrl = null; - var fileToUpload = avatar != null && avatar.Length > 0 ? avatar : Request.Form.Files.Count > 0 ? Request.Form.Files[0] : null; + using var stream = fileToUpload.OpenReadStream(); + var command = new UploadAvatarCommand( + _userContext.UserId, + stream, + fileToUpload.FileName, + fileToUpload.ContentType + ); - if (fileToUpload != null) - { - using var stream = fileToUpload.OpenReadStream(); - var fileId = await fileStorage.UploadFileAsync(stream, fileToUpload.FileName, fileToUpload.ContentType); - avatarUrl = $"/api/files/{fileId}"; - } - else - { - return BadRequest("No file uploaded"); - } - - user.UpdateAvatar(avatarUrl); - await _unitOfWork.SaveChangesAsync(ct); - - return Ok(new { - id = user.Id, - username = user.Username, - displayName = user.DisplayName, - avatar = user.Avatar, - bio = user.Bio, - birthday = user.Birthday, - createdAt = user.CreatedAt - }); + var result = await _sender.Send(command, ct); + if (result.IsFailure) return NotFound(result.Error); + return Ok(result.Value); } [HttpPost("avatar/crop")] - public async Task CropAvatar([FromForm] IFormFile avatar, [FromForm] int x, [FromForm] int y, [FromForm] int width, [FromForm] int height, [FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage, CancellationToken ct) + public async Task CropAvatar([FromForm] IFormFile avatar, [FromForm] int x, [FromForm] int y, [FromForm] int width, [FromForm] int height, CancellationToken ct) { - var user = await _userRepository.GetByIdAsync(_userContext.UserId, ct); - if (user == null) return NotFound(); + if (avatar == null || avatar.Length == 0) return BadRequest("No file uploaded"); - if (avatar == null || avatar.Length == 0) return BadRequest("No file"); + using var stream = avatar.OpenReadStream(); + var command = new CropAvatarCommand( + _userContext.UserId, + stream, + avatar.FileName ?? "avatar.jpg", + avatar.ContentType, + x, y, width, height + ); - string avatarUrl; - - try - { - using (var inputStream = avatar.OpenReadStream()) - using (var image = await SixLabors.ImageSharp.Image.LoadAsync(inputStream)) - { - int startX = Math.Max(0, Math.Min(x, image.Width - 1)); - int startY = Math.Max(0, Math.Min(y, image.Height - 1)); - int rectWidth = Math.Max(1, Math.Min(width, image.Width - startX)); - int rectHeight = Math.Max(1, Math.Min(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, ct); - outStream.Position = 0; - - var fileName = avatar.FileName ?? "avatar.jpg"; - var id = await fileStorage.UploadFileAsync(outStream, fileName, "image/jpeg"); - avatarUrl = $"/api/files/{id}"; - } - } - catch (Exception ex) - { - return StatusCode(500, "Error processing image: " + ex.Message); - } - - user.UpdateAvatar(avatarUrl); - await _unitOfWork.SaveChangesAsync(ct); - - return Ok(new { - id = user.Id, - username = user.Username, - displayName = user.DisplayName, - avatar = user.Avatar, - bio = user.Bio, - birthday = user.Birthday, - createdAt = user.CreatedAt - }); + var result = await _sender.Send(command, ct); + if (result.IsFailure) return NotFound(result.Error); + return Ok(result.Value); } [HttpDelete("avatar")] public async Task DeleteAvatar(CancellationToken ct) { - var user = await _userRepository.GetByIdAsync(_userContext.UserId, ct); - if (user == null) return NotFound(); - - user.UpdateAvatar(null); - await _unitOfWork.SaveChangesAsync(ct); - - return Ok(new { - id = user.Id, - username = user.Username, - displayName = user.DisplayName, - avatar = user.Avatar, - bio = user.Bio, - birthday = user.Birthday, - createdAt = user.CreatedAt - }); + var result = await _sender.Send(new DeleteAvatarCommand(_userContext.UserId), ct); + if (result.IsFailure) return NotFound(result.Error); + return Ok(result.Value); } [HttpPut("profile")] public async Task UpdateProfile([FromBody] UpdateProfileRequest request, CancellationToken ct) { - var user = await _userRepository.GetByIdAsync(_userContext.UserId, ct); - if (user == null) return NotFound(); - - user.UpdateProfile(request.DisplayName ?? user.DisplayName, request.Bio, request.Birthday); - await _unitOfWork.SaveChangesAsync(ct); - - return Ok(new { - id = user.Id, - username = user.Username, - displayName = user.DisplayName, - avatar = user.Avatar, - bio = user.Bio, - birthday = user.Birthday, - createdAt = user.CreatedAt - }); + var result = await _sender.Send(new UpdateProfileCommand(_userContext.UserId, request.DisplayName, request.Bio, request.Birthday), ct); + if (result.IsFailure) return NotFound(result.Error); + return Ok(result.Value); } [HttpGet("{id:guid}")] public async Task GetUser(Guid id, CancellationToken ct) { - var user = await _userRepository.GetByIdAsync(id, ct); - if (user == null) return NotFound(); - - return Ok(new { - id = user.Id, - username = user.Username, - displayName = user.DisplayName, - avatar = user.Avatar, - bio = user.Bio, - birthday = user.Birthday, - createdAt = user.CreatedAt, - isOnline = false, - lastSeen = DateTime.UtcNow - }); + var result = await _sender.Send(new GetUserQuery(id), ct); + if (result.IsFailure) return NotFound(result.Error); + return Ok(result.Value); } } - -public sealed record UpdateProfileRequest(string? DisplayName, string? Bio, DateTime? Birthday); -public sealed record UpdateSettingsRequest(bool? HideStoryViews); diff --git a/apps/server-net/src/Host/Endpoints/ConfigEndpoints.cs b/apps/server-net/src/Host/Endpoints/ConfigEndpoints.cs index 8375f0d..c00df8b 100644 --- a/apps/server-net/src/Host/Endpoints/ConfigEndpoints.cs +++ b/apps/server-net/src/Host/Endpoints/ConfigEndpoints.cs @@ -1,5 +1,5 @@ using Carter; -using Host.Services; +using MediatR; using Knot.Shared.Kernel.Constants; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -14,12 +14,12 @@ public sealed class ConfigEndpoints : ICarterModule { public void AddRoutes(IEndpointRouteBuilder app) { - var group = app.MapGroup(Routes.ApiConfig); // Без RequiresAuthorization, т.к. контроллер раньше был AllowAnonymous для GET + var group = app.MapGroup(Routes.ApiConfig); - group.MapGet("/", (IConfigService configService) => + group.MapGet("/", async (ISender sender, CancellationToken ct) => { - var config = configService.GetPublicConfig(); - return Results.Ok(config); + var result = await sender.Send(new Host.Application.Config.Queries.GetPublicConfigQuery(), ct); + return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error); }); } } diff --git a/apps/server-net/src/Host/Endpoints/FederationEndpoints.cs b/apps/server-net/src/Host/Endpoints/FederationEndpoints.cs index b7c9be4..a331f74 100644 --- a/apps/server-net/src/Host/Endpoints/FederationEndpoints.cs +++ b/apps/server-net/src/Host/Endpoints/FederationEndpoints.cs @@ -1,5 +1,5 @@ using Carter; -using Host.Services; +using MediatR; using Knot.Shared.Kernel.Constants; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -16,31 +16,17 @@ public sealed class FederationEndpoints : ICarterModule { public void AddRoutes(IEndpointRouteBuilder app) { - var group = app.MapGroup(Routes.ApiFederation); // Без RequireAuthorization для handshake + var group = app.MapGroup(Routes.ApiFederation); - group.MapPost("/handshake", async ([FromBody] HandshakeRequest request, IFederationService federationService) => + group.MapPost("/handshake", async ([FromBody] Host.Application.Federation.Commands.HandshakeRequest request, ISender sender, CancellationToken ct) => { - try - { - var result = await federationService.HandshakeAsync(request); - return Results.Ok(result); - } - catch (InvalidOperationException ex) - { - return Results.StatusCode(503); // Service Unavailable - } - catch (ArgumentException ex) - { - return Results.BadRequest(new { error = ex.Message }); - } - catch (UnauthorizedAccessException ex) - { - return Results.Forbid(); - } - catch (Exception ex) - { - return Results.StatusCode(500); - } + var result = await sender.Send(new Host.Application.Federation.Commands.HandshakeFederationCommand(request), ct); + if (result.IsSuccess) return Results.Ok(result.Value); + + if (result.Error.Code == "Unauthorized") return Results.Forbid(); + if (result.Error.Code == Knot.Shared.Kernel.Constants.Errors.DisabledByAdmin) return Results.StatusCode(503); + + return Results.BadRequest(new { error = result.Error.Description }); }); } } diff --git a/apps/server-net/src/Host/Endpoints/KlipyEndpoints.cs b/apps/server-net/src/Host/Endpoints/KlipyEndpoints.cs index 60757df..462c094 100644 --- a/apps/server-net/src/Host/Endpoints/KlipyEndpoints.cs +++ b/apps/server-net/src/Host/Endpoints/KlipyEndpoints.cs @@ -1,4 +1,5 @@ using Carter; +using MediatR; using Knot.Shared.Kernel.Constants; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -6,7 +7,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using System; using System.Threading.Tasks; -using Host.Services; namespace Host.Endpoints; @@ -19,42 +19,16 @@ public sealed class KlipyEndpoints : ICarterModule { var group = app.MapGroup(Routes.ApiKlipy).RequireAuthorization(); - group.MapGet("/trending", async (IKlipyService klipyService) => + group.MapGet("/trending", async (ISender sender, CancellationToken ct) => { - try - { - var result = await klipyService.GetTrendingAsync(); - return Results.Ok(result); - } - catch (InvalidOperationException ex) - { - return Results.BadRequest(new { error = ex.Message }); - } - catch (Exception ex) - { - return Results.StatusCode(500); // Можно возвращать JSON с сообщением, но для продакшена лучше централизованный Exception handling - } + var result = await sender.Send(new Host.Application.Klipy.Queries.GetTrendingGifsQuery(), ct); + return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Description }); }); - group.MapGet("/search", async ([FromQuery] string q, IKlipyService klipyService) => + group.MapGet("/search", async ([FromQuery] string q, ISender sender, CancellationToken ct) => { - try - { - var result = await klipyService.SearchAsync(q); - return Results.Ok(result); - } - catch (ArgumentException ex) - { - return Results.BadRequest(new { error = ex.Message }); - } - catch (InvalidOperationException ex) - { - return Results.BadRequest(new { error = ex.Message }); - } - catch (Exception ex) - { - return Results.StatusCode(500); - } + var result = await sender.Send(new Host.Application.Klipy.Queries.SearchGifsQuery(q), ct); + return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Description }); }); } } diff --git a/apps/server-net/src/Host/Endpoints/WebRtcEndpoints.cs b/apps/server-net/src/Host/Endpoints/WebRtcEndpoints.cs index a7a7dac..f121727 100644 --- a/apps/server-net/src/Host/Endpoints/WebRtcEndpoints.cs +++ b/apps/server-net/src/Host/Endpoints/WebRtcEndpoints.cs @@ -1,9 +1,9 @@ using Carter; +using MediatR; using Knot.Shared.Kernel; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; -using Host.Services; namespace Host.Endpoints; @@ -17,8 +17,12 @@ public sealed class WebRtcEndpoints : ICarterModule var group = app.MapGroup(Knot.Shared.Kernel.Constants.Routes.ApiWebRtc) .RequireAuthorization(); - group.MapGet("/ice-servers", (IWebRtcService service) => service.GetIceServers()) - .WithName("GetIceServers") - .WithOpenApi(); + group.MapGet("/ice-servers", async (ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new Host.Application.WebRtc.Queries.GetIceServersQuery(), ct); + return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error); + }) + .WithName("GetIceServers") + .WithOpenApi(); } } diff --git a/apps/server-net/src/Host/Host.csproj b/apps/server-net/src/Host/Host.csproj index bed394f..d753cb1 100644 --- a/apps/server-net/src/Host/Host.csproj +++ b/apps/server-net/src/Host/Host.csproj @@ -15,7 +15,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/apps/server-net/src/Host/Models/Admin/AdminUserDetailsDto.cs b/apps/server-net/src/Host/Models/Admin/AdminUserDetailsDto.cs new file mode 100644 index 0000000..8040688 --- /dev/null +++ b/apps/server-net/src/Host/Models/Admin/AdminUserDetailsDto.cs @@ -0,0 +1,15 @@ +using System; + +namespace Host.Models; + +public record AdminUserDetailsDto( + Guid Id, + string Username, + string DisplayName, + string? Bio, + string? Avatar, + DateTime CreatedAt, + bool IsOnline, + DateTime LastOnlineAt, + AdminUserStatsDto Stats +); diff --git a/apps/server-net/src/Host/Models/Admin/AdminUserDto.cs b/apps/server-net/src/Host/Models/Admin/AdminUserDto.cs new file mode 100644 index 0000000..034f099 --- /dev/null +++ b/apps/server-net/src/Host/Models/Admin/AdminUserDto.cs @@ -0,0 +1,14 @@ +using System; + +namespace Host.Models; + +public record AdminUserDto( + Guid Id, + string Username, + string DisplayName, + string? Email, + string? Avatar, + DateTime CreatedAt, + bool IsOnline, + DateTime LastOnlineAt +); diff --git a/apps/server-net/src/Host/Models/Admin/AdminUserStatsDto.cs b/apps/server-net/src/Host/Models/Admin/AdminUserStatsDto.cs new file mode 100644 index 0000000..3c0a022 --- /dev/null +++ b/apps/server-net/src/Host/Models/Admin/AdminUserStatsDto.cs @@ -0,0 +1,9 @@ +namespace Host.Models; + +public record AdminUserStatsDto( + int MessagesCount, + int MediaCount, + int FilesCount, + int LinksCount, + long StorageUsedBytes +); diff --git a/apps/server-net/src/Host/Models/Admin/CleanupDryRunResultDto.cs b/apps/server-net/src/Host/Models/Admin/CleanupDryRunResultDto.cs new file mode 100644 index 0000000..581c583 --- /dev/null +++ b/apps/server-net/src/Host/Models/Admin/CleanupDryRunResultDto.cs @@ -0,0 +1,7 @@ +namespace Host.Models; + +public class CleanupDryRunResultDto +{ + public int OrphanedMessagesCount { get; set; } + public long OrphanedMediaBytes { get; set; } +} diff --git a/apps/server-net/src/Host/Models/Auth/AuthResponseDto.cs b/apps/server-net/src/Host/Models/Auth/AuthResponseDto.cs new file mode 100644 index 0000000..31a503a --- /dev/null +++ b/apps/server-net/src/Host/Models/Auth/AuthResponseDto.cs @@ -0,0 +1,20 @@ +using System; + +namespace Host.Models; + +public record AuthResponseDto( + string Token, + AuthUserDto User +); + +public record AuthUserDto( + Guid Id, + string Username, + string DisplayName, + string? Email, + string? Bio, + string? Avatar, + DateTime? Birthday, + bool IsOnline, + DateTime CreatedAt +); diff --git a/apps/server-net/src/Host/Models/Auth/ResetPasswordDto.cs b/apps/server-net/src/Host/Models/Auth/ResetPasswordDto.cs new file mode 100644 index 0000000..feeb162 --- /dev/null +++ b/apps/server-net/src/Host/Models/Auth/ResetPasswordDto.cs @@ -0,0 +1,6 @@ +namespace Host.Models; + +public class ResetPasswordDto +{ + public string NewPassword { get; set; } = string.Empty; +} diff --git a/apps/server-net/src/Host/Models/Config/PublicConfigDto.cs b/apps/server-net/src/Host/Models/Config/PublicConfigDto.cs new file mode 100644 index 0000000..c40fc96 --- /dev/null +++ b/apps/server-net/src/Host/Models/Config/PublicConfigDto.cs @@ -0,0 +1,26 @@ +using Knot.Shared.Kernel.Configuration; + +namespace Host.Models; + +public record PublicConfigDto +{ + public bool EnableCalls { get; init; } + public bool EnableKlipy { get; init; } + public int MaxFileSizeMb { get; init; } + public int MaxGroupMembers { get; init; } + public bool EnableConfederation { get; init; } + public bool EnableRegistration { get; init; } + + public static PublicConfigDto FromSettings(SystemSettingsDto settings) + { + return new PublicConfigDto + { + EnableCalls = settings.EnableCalls, + EnableKlipy = settings.EnableKlipy, + MaxFileSizeMb = settings.MaxFileSizeMb, + MaxGroupMembers = settings.MaxGroupMembers, + EnableConfederation = settings.EnableConfederation, + EnableRegistration = settings.EnableRegistration + }; + } +} diff --git a/apps/server-net/src/Host/Models/Friends/FriendDto.cs b/apps/server-net/src/Host/Models/Friends/FriendDto.cs new file mode 100644 index 0000000..a7bc80a --- /dev/null +++ b/apps/server-net/src/Host/Models/Friends/FriendDto.cs @@ -0,0 +1,13 @@ +using System; + +namespace Host.Models; + +public record FriendDto( + Guid Id, + string Username, + string DisplayName, + string? Avatar, + bool IsOnline, + DateTime LastSeen, + Guid FriendshipId +); diff --git a/apps/server-net/src/Host/Models/Friends/FriendRequestDto.cs b/apps/server-net/src/Host/Models/Friends/FriendRequestDto.cs new file mode 100644 index 0000000..ab502df --- /dev/null +++ b/apps/server-net/src/Host/Models/Friends/FriendRequestDto.cs @@ -0,0 +1,16 @@ +using System; + +namespace Host.Models; + +public record FriendRequestDto( + Guid Id, + FriendUserDto User, + DateTime CreatedAt +); + +public record FriendUserDto( + Guid Id, + string Username, + string DisplayName, + string? Avatar +); diff --git a/apps/server-net/src/Host/Models/Friends/SendFriendRequest.cs b/apps/server-net/src/Host/Models/Friends/SendFriendRequest.cs new file mode 100644 index 0000000..382ea0e --- /dev/null +++ b/apps/server-net/src/Host/Models/Friends/SendFriendRequest.cs @@ -0,0 +1,5 @@ +using System; + +namespace Host.Models; + +public record SendFriendRequest(Guid FriendId); diff --git a/apps/server-net/src/Host/Models/Import/AnalyzeImportResponseDto.cs b/apps/server-net/src/Host/Models/Import/AnalyzeImportResponseDto.cs new file mode 100644 index 0000000..b51898c --- /dev/null +++ b/apps/server-net/src/Host/Models/Import/AnalyzeImportResponseDto.cs @@ -0,0 +1,9 @@ +using System; +using System.Collections.Generic; + +namespace Host.Models; + +public record AnalyzeImportResponseDto( + Guid Token, + List Names +); diff --git a/apps/server-net/src/Host/Models/Import/ExecuteImportRequest.cs b/apps/server-net/src/Host/Models/Import/ExecuteImportRequest.cs new file mode 100644 index 0000000..3ceb4bf --- /dev/null +++ b/apps/server-net/src/Host/Models/Import/ExecuteImportRequest.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; + +namespace Host.Models; + +public record ExecuteImportRequest( + Guid Token, + Dictionary Mapping, + string? GroupName +); diff --git a/apps/server-net/src/Host/Models/Import/ExecuteImportResponseDto.cs b/apps/server-net/src/Host/Models/Import/ExecuteImportResponseDto.cs new file mode 100644 index 0000000..fa08484 --- /dev/null +++ b/apps/server-net/src/Host/Models/Import/ExecuteImportResponseDto.cs @@ -0,0 +1,9 @@ +using System; + +namespace Host.Models; + +public record ExecuteImportResponseDto( + bool Success, + int MessagesImported, + Guid ChatId +); diff --git a/apps/server-net/src/Host/Models/Stories/AddStoryReactionRequest.cs b/apps/server-net/src/Host/Models/Stories/AddStoryReactionRequest.cs new file mode 100644 index 0000000..0ff9548 --- /dev/null +++ b/apps/server-net/src/Host/Models/Stories/AddStoryReactionRequest.cs @@ -0,0 +1,5 @@ +using System; + +namespace Host.Models; + +public record AddStoryReactionRequest(string Emoji); diff --git a/apps/server-net/src/Host/Models/Stories/AddStoryReplyRequest.cs b/apps/server-net/src/Host/Models/Stories/AddStoryReplyRequest.cs new file mode 100644 index 0000000..5c4d2cc --- /dev/null +++ b/apps/server-net/src/Host/Models/Stories/AddStoryReplyRequest.cs @@ -0,0 +1,5 @@ +using System; + +namespace Host.Models; + +public record AddStoryReplyRequest(string Content); diff --git a/apps/server-net/src/Host/Models/Stories/CreateStoryRequest.cs b/apps/server-net/src/Host/Models/Stories/CreateStoryRequest.cs new file mode 100644 index 0000000..ef115c6 --- /dev/null +++ b/apps/server-net/src/Host/Models/Stories/CreateStoryRequest.cs @@ -0,0 +1,5 @@ +using System; + +namespace Host.Models; + +public record CreateStoryRequest(string Type, string? MediaUrl, string? Content, string? BgColor); diff --git a/apps/server-net/src/Host/Models/Stories/RemoveStoryReactionRequest.cs b/apps/server-net/src/Host/Models/Stories/RemoveStoryReactionRequest.cs new file mode 100644 index 0000000..f011f5e --- /dev/null +++ b/apps/server-net/src/Host/Models/Stories/RemoveStoryReactionRequest.cs @@ -0,0 +1,5 @@ +using System; + +namespace Host.Models; + +public record RemoveStoryReactionRequest(string Emoji); diff --git a/apps/server-net/src/Host/Models/Stories/StoriesDtos.cs b/apps/server-net/src/Host/Models/Stories/StoriesDtos.cs new file mode 100644 index 0000000..14ba7ed --- /dev/null +++ b/apps/server-net/src/Host/Models/Stories/StoriesDtos.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; + +namespace Host.Models; + +public record StoryGroupDto( + StoryUserDto User, + List Stories, + bool HasUnviewed +); diff --git a/apps/server-net/src/Host/Models/Stories/StoryDto.cs b/apps/server-net/src/Host/Models/Stories/StoryDto.cs new file mode 100644 index 0000000..520a235 --- /dev/null +++ b/apps/server-net/src/Host/Models/Stories/StoryDto.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; + +namespace Host.Models; + +public record StoryDto( + Guid Id, + string Type, + string? MediaUrl, + string? Content, + string? BgColor, + DateTime CreatedAt, + DateTime ExpiresAt, + int ViewCount, + bool Viewed, + List Reactions, + int ReplyCount +); diff --git a/apps/server-net/src/Host/Models/Stories/StoryReactionDto.cs b/apps/server-net/src/Host/Models/Stories/StoryReactionDto.cs new file mode 100644 index 0000000..fef41aa --- /dev/null +++ b/apps/server-net/src/Host/Models/Stories/StoryReactionDto.cs @@ -0,0 +1,10 @@ +using System; + +namespace Host.Models; + +public record StoryReactionDto( + Guid Id, + Guid UserId, + string Emoji, + DateTime CreatedAt +); diff --git a/apps/server-net/src/Host/Models/Stories/StoryReplyDto.cs b/apps/server-net/src/Host/Models/Stories/StoryReplyDto.cs new file mode 100644 index 0000000..a1555b8 --- /dev/null +++ b/apps/server-net/src/Host/Models/Stories/StoryReplyDto.cs @@ -0,0 +1,13 @@ +using System; + +namespace Host.Models; + +public record StoryReplyDto( + Guid Id, + Guid UserId, + string Username, + string DisplayName, + string? Avatar, + string Content, + DateTime CreatedAt +); diff --git a/apps/server-net/src/Host/Models/Stories/StoryUserDto.cs b/apps/server-net/src/Host/Models/Stories/StoryUserDto.cs new file mode 100644 index 0000000..433a4f0 --- /dev/null +++ b/apps/server-net/src/Host/Models/Stories/StoryUserDto.cs @@ -0,0 +1,10 @@ +using System; + +namespace Host.Models; + +public record StoryUserDto( + Guid Id, + string Username, + string DisplayName, + string? Avatar +); diff --git a/apps/server-net/src/Host/Models/Stories/StoryViewerDto.cs b/apps/server-net/src/Host/Models/Stories/StoryViewerDto.cs new file mode 100644 index 0000000..71a70d9 --- /dev/null +++ b/apps/server-net/src/Host/Models/Stories/StoryViewerDto.cs @@ -0,0 +1,11 @@ +using System; + +namespace Host.Models; + +public record StoryViewerDto( + Guid UserId, + string Username, + string DisplayName, + string? Avatar, + DateTime ViewedAt +); diff --git a/apps/server-net/src/Host/Models/Users/UpdateProfileRequest.cs b/apps/server-net/src/Host/Models/Users/UpdateProfileRequest.cs new file mode 100644 index 0000000..160eb9c --- /dev/null +++ b/apps/server-net/src/Host/Models/Users/UpdateProfileRequest.cs @@ -0,0 +1,5 @@ +using System; + +namespace Host.Models; + +public sealed record UpdateProfileRequest(string? DisplayName, string? Bio, DateTime? Birthday); diff --git a/apps/server-net/src/Host/Models/Users/UpdateSettingsRequest.cs b/apps/server-net/src/Host/Models/Users/UpdateSettingsRequest.cs new file mode 100644 index 0000000..188ab7a --- /dev/null +++ b/apps/server-net/src/Host/Models/Users/UpdateSettingsRequest.cs @@ -0,0 +1,3 @@ +namespace Host.Models; + +public sealed record UpdateSettingsRequest(bool? HideStoryViews); diff --git a/apps/server-net/src/Host/Models/Users/UserDto.cs b/apps/server-net/src/Host/Models/Users/UserDto.cs new file mode 100644 index 0000000..b0c62a7 --- /dev/null +++ b/apps/server-net/src/Host/Models/Users/UserDto.cs @@ -0,0 +1,12 @@ +using System; + +namespace Host.Models; + +public record UserDto( + Guid Id, + string Username, + string DisplayName, + string? Avatar, + bool IsOnline, + DateTime LastSeen +); diff --git a/apps/server-net/src/Host/Models/Users/UserProfileDto.cs b/apps/server-net/src/Host/Models/Users/UserProfileDto.cs new file mode 100644 index 0000000..7a1682b --- /dev/null +++ b/apps/server-net/src/Host/Models/Users/UserProfileDto.cs @@ -0,0 +1,16 @@ +using System; + +namespace Host.Models; + +public record UserProfileDto( + Guid Id, + string Username, + string DisplayName, + string? Avatar, + string? Bio, + DateTime? Birthday, + DateTime CreatedAt, + bool? HideStoryViews = null, + bool IsOnline = false, + DateTime? LastSeen = null +); diff --git a/apps/server-net/src/Host/Program.cs b/apps/server-net/src/Host/Program.cs index 0c62421..8304d72 100644 --- a/apps/server-net/src/Host/Program.cs +++ b/apps/server-net/src/Host/Program.cs @@ -1,3 +1,4 @@ +using Carter; using Knot.Shared.Infrastructure; using Knot.Modules.Identity; using Knot.Modules.Chats; @@ -50,6 +51,12 @@ builder.Services.AddIdentityModule(builder.Configuration); builder.Services.AddChatsModule(builder.Configuration); builder.Services.AddSharedInfrastructure(builder.Configuration); +// CQRS / MediatR для команд в Host (например, AdminController) +builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly)); + +// Carter для вызова Minimal APIs (Endpoints) +builder.Services.AddCarter(); + // Настройка CORS builder.Services.AddCors(options => { @@ -180,8 +187,9 @@ if (app.Environment.IsDevelopment()) app.UseAuthentication(); app.UseAuthorization(); -// Добавляем контроллеры +// Добавляем контроллеры и Carter (Minimal APIs) app.MapControllers(); +app.MapCarter(); // Добавляем SignalR хабы app.MapHub("/hubs/chat"); diff --git a/apps/server-net/src/Host/Services/ConfigService.cs b/apps/server-net/src/Host/Services/ConfigService.cs deleted file mode 100644 index 4e235e1..0000000 --- a/apps/server-net/src/Host/Services/ConfigService.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Knot.Shared.Kernel.Configuration; - -namespace Host.Services; - -public sealed class ConfigService : IConfigService -{ - private readonly ISettingsService _settings; - - public ConfigService(ISettingsService settings) - { - _settings = settings; - } - - public IPublicConfig GetPublicConfig() - { - return new PublicConfig(_settings.Current); - } -} diff --git a/apps/server-net/src/Host/Services/FederationService.cs b/apps/server-net/src/Host/Services/FederationService.cs deleted file mode 100644 index 6397ca6..0000000 --- a/apps/server-net/src/Host/Services/FederationService.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Knot.Shared.Kernel.Configuration; -using Knot.Shared.Kernel.Constants; -using System; -using System.Linq; -using System.Security.Cryptography; -using System.Threading.Tasks; - -namespace Host.Services; - -public sealed class FederationService : IFederationService -{ - private readonly ISettingsService _settings; - - public FederationService(ISettingsService settings) - { - _settings = settings; - } - - public Task HandshakeAsync(HandshakeRequest request) - { - var conf = _settings.Current; - if (!conf.EnableConfederation) - throw new InvalidOperationException(Errors.DisabledByAdmin); - - if (string.IsNullOrEmpty(request.Domain) || string.IsNullOrEmpty(request.PublicKey)) - throw new ArgumentException("Укажите Domain и PublicKey."); - - var allowedList = conf.AllowedDomains?.Select(d => d.Trim().ToLower()).ToList() ?? new System.Collections.Generic.List(); - if (!allowedList.Contains(request.Domain.ToLowerInvariant())) - throw new UnauthorizedAccessException("Домен не входит в белый список."); - - using var rsa = RSA.Create(2048); - var selfPublicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey()); - - var response = new HandshakeResponse - { - Domain = Environment.GetEnvironmentVariable("DOMAIN") ?? "knot.local", - PublicKey = selfPublicKey, - Status = "Accepted" - }; - - return Task.FromResult(response); - } -} diff --git a/apps/server-net/src/Host/Services/IConfigService.cs b/apps/server-net/src/Host/Services/IConfigService.cs deleted file mode 100644 index 12c550b..0000000 --- a/apps/server-net/src/Host/Services/IConfigService.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Knot.Shared.Kernel.Configuration; - -namespace Host.Services; - -/// -/// Сервис конфигурации приложения. -/// -public interface IConfigService -{ - IPublicConfig GetPublicConfig(); -} diff --git a/apps/server-net/src/Host/Services/IFederationService.cs b/apps/server-net/src/Host/Services/IFederationService.cs deleted file mode 100644 index cef9207..0000000 --- a/apps/server-net/src/Host/Services/IFederationService.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Threading.Tasks; - -namespace Host.Services; - -/// -/// Сервис федерации (связь с другими узлами). -/// -public interface IFederationService -{ - Task HandshakeAsync(HandshakeRequest request); -} - -public class HandshakeRequest -{ - public string Domain { get; set; } = string.Empty; - public string PublicKey { get; set; } = string.Empty; -} - -public class HandshakeResponse -{ - public string Domain { get; set; } = string.Empty; - public string PublicKey { get; set; } = string.Empty; - public string Status { get; set; } = string.Empty; -} diff --git a/apps/server-net/src/Host/Services/IKlipyService.cs b/apps/server-net/src/Host/Services/IKlipyService.cs deleted file mode 100644 index 75ad9c1..0000000 --- a/apps/server-net/src/Host/Services/IKlipyService.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Text.Json; -using System.Threading.Tasks; - -namespace Host.Services; - -/// -/// Сервис интеграции с Klipy GIF. -/// -public interface IKlipyService -{ - Task GetTrendingAsync(); - - Task SearchAsync(string query); -} diff --git a/apps/server-net/src/Host/Services/IPublicConfig.cs b/apps/server-net/src/Host/Services/IPublicConfig.cs deleted file mode 100644 index c93c1d1..0000000 --- a/apps/server-net/src/Host/Services/IPublicConfig.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Host.Services; - -/// -/// Интерфейс публичной конфигурации приложения. -/// -public interface IPublicConfig -{ - bool EnableCalls { get; } - bool EnableKlipy { get; } - int MaxFileSizeMb { get; } - int MaxGroupMembers { get; } - bool EnableConfederation { get; } - bool EnableRegistration { get; } -} diff --git a/apps/server-net/src/Host/Services/IWebRtcService.cs b/apps/server-net/src/Host/Services/IWebRtcService.cs deleted file mode 100644 index d7f2815..0000000 --- a/apps/server-net/src/Host/Services/IWebRtcService.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Knot.Shared.Kernel; -using Microsoft.AspNetCore.Http; -using System.Threading; -using System.Threading.Tasks; - -namespace Host.Services; - -/// -/// Сервис для работы с WebRTC конфигурацией. -/// -public interface IWebRtcService -{ - /// - /// Получить список ICE-серверов. - /// - IResult GetIceServers(); -} diff --git a/apps/server-net/src/Host/Services/KlipyService.cs b/apps/server-net/src/Host/Services/KlipyService.cs deleted file mode 100644 index 30cce88..0000000 --- a/apps/server-net/src/Host/Services/KlipyService.cs +++ /dev/null @@ -1,102 +0,0 @@ -using Knot.Shared.Kernel.Configuration; -using Knot.Shared.Kernel.Constants; -using Microsoft.Extensions.Caching.Memory; -using System; -using System.Net.Http; -using System.Net.Http.Json; -using System.Text.Json; -using System.Threading.Tasks; - -namespace Host.Services; - -public sealed class KlipyService : IKlipyService -{ - private readonly ISettingsService _settings; - private readonly IMemoryCache _cache; - private readonly IHttpClientFactory _httpClientFactory; - - public KlipyService(ISettingsService settings, IMemoryCache cache, IHttpClientFactory httpClientFactory) - { - _settings = settings; - _cache = cache; - _httpClientFactory = httpClientFactory; - } - - public async Task GetTrendingAsync() - { - var conf = _settings.Current; - if (!conf.EnableKlipy || string.IsNullOrEmpty(conf.KlipyApiKey)) - throw new InvalidOperationException(Errors.KlipyNotConfigured); - - var cacheKeyTrending = $"klipy_trending_{conf.KlipyApiKey}"; - - if (_cache.TryGetValue(cacheKeyTrending, out JsonElement cachedResult)) - { - return cachedResult; - } - - var customerId = string.IsNullOrWhiteSpace(conf.KlipyCustomerId) ? Klipy.DefaultCustomerId : conf.KlipyCustomerId; - var client = _httpClientFactory.CreateClient(); - - var urlCo = string.Format(Klipy.ApiUrlCo, conf.KlipyApiKey, Klipy.ResourceTrending, "", customerId); - - var response = await client.GetAsync(urlCo); - if (!response.IsSuccessStatusCode) - { - if (response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - var urlCom = string.Format(Klipy.ApiUrlCom, conf.KlipyApiKey, Klipy.ResourceTrending, "", customerId); - response = await client.GetAsync(urlCom); - } - - if (!response.IsSuccessStatusCode) - throw new HttpRequestException(Errors.KlipyApiError); - } - - var result = await response.Content.ReadFromJsonAsync(); - - _cache.Set(cacheKeyTrending, result, TimeSpan.FromMinutes(Klipy.TrendingCacheMinutes)); - return result; - } - - public async Task SearchAsync(string query) - { - var conf = _settings.Current; - if (!conf.EnableKlipy || string.IsNullOrEmpty(conf.KlipyApiKey)) - throw new InvalidOperationException(Errors.KlipyNotConfigured); - - if (string.IsNullOrWhiteSpace(query)) - throw new ArgumentException(Errors.InvalidQuery, nameof(query)); - - var cacheKey = $"klipy_search_{conf.KlipyApiKey}_{query.ToLowerInvariant()}"; - - if (_cache.TryGetValue(cacheKey, out JsonElement cachedResult)) - { - return cachedResult; - } - - var customerId = string.IsNullOrWhiteSpace(conf.KlipyCustomerId) ? Klipy.DefaultCustomerId : conf.KlipyCustomerId; - var client = _httpClientFactory.CreateClient(); - - var queryParam = $"q={Uri.EscapeDataString(query)}"; - var urlCo = string.Format(Klipy.ApiUrlCo, conf.KlipyApiKey, Klipy.ResourceSearch, queryParam, customerId); - - var response = await client.GetAsync(urlCo); - if (!response.IsSuccessStatusCode) - { - if (response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - var urlCom = string.Format(Klipy.ApiUrlCom, conf.KlipyApiKey, Klipy.ResourceSearch, queryParam, customerId); - response = await client.GetAsync(urlCom); - } - - if (!response.IsSuccessStatusCode) - throw new HttpRequestException(Errors.KlipySearchError); - } - - var result = await response.Content.ReadFromJsonAsync(); - - _cache.Set(cacheKey, result, TimeSpan.FromMinutes(Klipy.SearchCacheMinutes)); - return result; - } -} diff --git a/apps/server-net/src/Host/Services/PublicConfig.cs b/apps/server-net/src/Host/Services/PublicConfig.cs deleted file mode 100644 index 6d24f92..0000000 --- a/apps/server-net/src/Host/Services/PublicConfig.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Knot.Shared.Kernel.Configuration; - -namespace Host.Services; - -/// -/// Реализация публичной конфигурации приложения. -/// -public sealed class PublicConfig(SystemSettingsDto settings) : IPublicConfig -{ - public bool EnableCalls => settings.EnableCalls; - public bool EnableKlipy => settings.EnableKlipy; - public int MaxFileSizeMb => settings.MaxFileSizeMb; - public int MaxGroupMembers => settings.MaxGroupMembers; - public bool EnableConfederation => settings.EnableConfederation; - public bool EnableRegistration => settings.EnableRegistration; -} diff --git a/apps/server-net/src/Host/Services/WebRtcService.cs b/apps/server-net/src/Host/Services/WebRtcService.cs deleted file mode 100644 index 46306ca..0000000 --- a/apps/server-net/src/Host/Services/WebRtcService.cs +++ /dev/null @@ -1,71 +0,0 @@ -using Knot.Shared.Kernel.Configuration; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Configuration; -using System.Collections.Generic; - -namespace Host.Services; - -/// -/// Реализация сервиса управления WebRTC настройками. -/// -public sealed class WebRtcService : IWebRtcService -{ - private readonly IConfiguration _configuration; - private readonly ISettingsService _settingsService; - - public WebRtcService( - IConfiguration configuration, - ISettingsService settingsService) - { - _configuration = configuration; - _settingsService = settingsService; - } - - public IResult GetIceServers() - { - var settings = _settingsService.Current; - if (!settings.EnableCalls) - { - return Results.StatusCode(503); // TODO: Возвращать структуру ошибки с пояснением на русском языке (Knot.Shared.Kernel.Constants.Errors.DisabledByAdmin) - } - - var turnUrl = !string.IsNullOrEmpty(settings.TurnHost) - ? $"turn:{settings.TurnHost}:{settings.TurnPort}" - : _configuration["WebRtc:TurnUrl"]; - - var turnUsername = !string.IsNullOrEmpty(settings.TurnUser) - ? settings.TurnUser - : _configuration["WebRtc:TurnUsername"]; - - var turnSecret = !string.IsNullOrEmpty(settings.TurnSecret) - ? settings.TurnSecret - : _configuration["WebRtc:TurnPassword"]; - - var iceServers = new List(); - - if (!string.IsNullOrEmpty(turnUrl)) - { - var stunUrl = turnUrl.Replace("turn:", "stun:"); - iceServers.Add(new { urls = new[] { stunUrl } }); - - if (!string.IsNullOrEmpty(turnUsername)) - { - iceServers.Add(new - { - urls = new[] { turnUrl, turnUrl + "?transport=tcp" }, - username = turnUsername, - credential = !string.IsNullOrEmpty(turnSecret) ? turnSecret : turnUsername - }); - } - else - { - iceServers.Add(new - { - urls = new[] { turnUrl, turnUrl + "?transport=tcp" } - }); - } - } - - return Results.Ok(new { iceServers }); - } -} diff --git a/apps/server-net/src/Modules/Chats/Application/Chats/Avatar/Avatar.cs b/apps/server-net/src/Modules/Chats/Application/Chats/Avatar/Avatar.cs new file mode 100644 index 0000000..e9f501a --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/Chats/Avatar/Avatar.cs @@ -0,0 +1,123 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Knot.Shared.Kernel; +using Knot.Shared.Kernel.Storage; +using Knot.Modules.Chats.Domain; +using Knot.Modules.Chats.Application.Abstractions; +using MediatR; +using System.Linq; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Processing; + +namespace Knot.Modules.Chats.Application.Chats.Avatar; + +public record UploadGroupAvatarCommand(Guid ChatId, Guid UserId, string FileName, string ContentType, Stream FileStream) : ICommand; + +internal sealed class UploadGroupAvatarCommandHandler : ICommandHandler +{ + 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> 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(new Error("Chat.NotFound", "Chat not found or access denied")); + + 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; + +internal sealed class CropGroupAvatarCommandHandler : ICommandHandler +{ + 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> 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(new Error("Chat.NotFound", "Chat not found or access denied")); + + 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; + +internal sealed class RemoveGroupAvatarCommandHandler : ICommandHandler +{ + private readonly IChatRepository _chatRepository; + private readonly IChatsUnitOfWork _uow; + + public RemoveGroupAvatarCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow) + { + _chatRepository = chatRepository; + _uow = uow; + } + + public async Task> 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(new Error("Chat.NotFound", "Chat not found or access denied")); + + chat.UpdateAvatar(null); + _chatRepository.Update(chat); + await _uow.SaveChangesAsync(cancellationToken); + + return Result.Success(request.ChatId); + } +} diff --git a/apps/server-net/src/Modules/Chats/Application/Chats/Clear/ClearChat.cs b/apps/server-net/src/Modules/Chats/Application/Chats/Clear/ClearChat.cs new file mode 100644 index 0000000..5f9b6b0 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/Chats/Clear/ClearChat.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Knot.Shared.Kernel; +using Knot.Modules.Chats.Domain; +using Knot.Modules.Chats.Application.Abstractions; +using MediatR; +using System.Linq; + +namespace Knot.Modules.Chats.Application.Chats.Clear; + +public record ClearChatCommand(Guid ChatId, Guid UserId) : ICommand; + +internal sealed class ClearChatCommandHandler : ICommandHandler +{ + private readonly IChatRepository _chatRepository; + + public ClearChatCommandHandler(IChatRepository chatRepository) + { + _chatRepository = chatRepository; + } + + public async Task> 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(new Error("Chat.NotFound", "Chat not found or access denied")); + + // Currently a placeholder + return Result.Success(new MessageResponse("Cleared")); + } +} diff --git a/apps/server-net/src/Modules/Chats/Application/Chats/GetChatById/GetChatById.cs b/apps/server-net/src/Modules/Chats/Application/Chats/GetChatById/GetChatById.cs new file mode 100644 index 0000000..ea5d3eb --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/Chats/GetChatById/GetChatById.cs @@ -0,0 +1,116 @@ +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.Chats.Application.DTOs; +using Knot.Modules.Chats.Domain; +using Knot.Modules.Chats.Application.Abstractions; +using Knot.Modules.Identity.Domain; + +namespace Knot.Modules.Chats.Application.Chats.GetChatById; + +public record GetChatByIdQuery(Guid ChatId) : IQuery; + +internal sealed class GetChatByIdQueryHandler : IQueryHandler +{ + private readonly IChatRepository _chatRepository; + private readonly IUserRepository _userRepository; + private readonly IMessageRepository _messageRepository; + + public GetChatByIdQueryHandler(IChatRepository chatRepository, IUserRepository userRepository, IMessageRepository messageRepository) + { + _chatRepository = chatRepository; + _userRepository = userRepository; + _messageRepository = messageRepository; + } + + public async Task> Handle(GetChatByIdQuery request, CancellationToken cancellationToken) + { + var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken); + if (chat == null) return Result.Success(null); + + var members = new List(); + foreach (var m in chat.Members) + { + var user = await _userRepository.GetByIdAsync(m.UserId, cancellationToken); + members.Add(new ChatMemberDto( + m.Id, + m.UserId, + m.Role, + m.IsPinned, + user != null ? new ChatUserDto( + user.Id, + user.Username, + user.DisplayName, + user.Avatar, + false, + DateTime.UtcNow + ) : null + )); + } + + var chatMessages = await _messageRepository.GetChatMessagesAsync(chat.Id, 1, 0, cancellationToken); + var messagesList = new List(); + if (chatMessages.Any()) + { + var m = chatMessages.First(); + var senderObj = await _userRepository.GetByIdAsync(m.SenderId, cancellationToken); + + var reactionsWithUser = new List(); + foreach (var r in m.Reactions) + { + var rUser = await _userRepository.GetByIdAsync(r.UserId, cancellationToken); + reactionsWithUser.Add(new ReactionDto( + r.Id, + r.Emoji, + r.UserId, + rUser != null + ? new MessageSenderDto(rUser.Id, rUser.Username, rUser.DisplayName, rUser.Avatar) + : new MessageSenderDto(r.UserId, "unknown", "Unknown", null) + )); + } + + messagesList.Add(new ChatMessageDto( + m.Id, + m.ChatId, + m.SenderId, + m.Content, + m.Type, + m.ReplyToId, + m.Quote, + m.StoryId, + m.StoryMediaUrl, + m.StoryMediaType, + m.IsEdited, + m.IsDeleted, + m.CreatedAt, + m.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(m.SenderId, "unknown", "Unknown", null), + reactionsWithUser, + m.ReadBy.Select(r => new ReadByDto(r.UserId)).ToList() + )); + } + + 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, + 0 + ); + + return Result.Success(dto); + } +} diff --git a/apps/server-net/src/Modules/Chats/Application/Chats/GetChats/GetChats.cs b/apps/server-net/src/Modules/Chats/Application/Chats/GetChats/GetChats.cs new file mode 100644 index 0000000..b8032d8 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/Chats/GetChats/GetChats.cs @@ -0,0 +1,136 @@ +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.Chats.Application.DTOs; +using Knot.Modules.Chats.Domain; +using Knot.Modules.Chats.Application.Abstractions; +using Knot.Modules.Identity.Domain; + +namespace Knot.Modules.Chats.Application.Chats.GetChats; + +public record GetChatsQuery(Guid UserId) : IQuery>; + +internal sealed class GetChatsQueryHandler : IQueryHandler> +{ + private readonly IChatRepository _chatRepository; + private readonly IUserRepository _userRepository; + private readonly IMessageRepository _messageRepository; + + public GetChatsQueryHandler(IChatRepository chatRepository, IUserRepository userRepository, IMessageRepository messageRepository) + { + _chatRepository = chatRepository; + _userRepository = userRepository; + _messageRepository = messageRepository; + } + + public async Task>> Handle(GetChatsQuery request, CancellationToken cancellationToken) + { + var userChats = await _chatRepository.GetUserChatsAsync(request.UserId, cancellationToken); + var dtos = new List(); + bool hasFavorites = false; + + foreach (var c in userChats) + { + if (c.Type == ChatType.Favorites) + { + if (hasFavorites) continue; + hasFavorites = true; + } + + var members = new List(); + foreach (var m in c.Members) + { + var user = await _userRepository.GetByIdAsync(m.UserId, cancellationToken); + members.Add(new ChatMemberDto( + m.Id, + m.UserId, + m.Role, + m.IsPinned, + user != null ? new ChatUserDto( + user.Id, + user.Username, + user.DisplayName, + user.Avatar, + false, + DateTime.UtcNow + ) : null + )); + } + + var chatMessages = await _messageRepository.GetChatMessagesAsync(c.Id, 1, 0, cancellationToken); + var messagesList = new List(); + + if (chatMessages.Any()) + { + var m = chatMessages.First(); + var senderObj = await _userRepository.GetByIdAsync(m.SenderId, cancellationToken); + + var reactionsWithUser = new List(); + foreach (var r in m.Reactions) + { + var rUser = await _userRepository.GetByIdAsync(r.UserId, cancellationToken); + reactionsWithUser.Add(new ReactionDto( + r.Id, + r.Emoji, + r.UserId, + rUser != null + ? new MessageSenderDto(rUser.Id, rUser.Username, rUser.DisplayName, rUser.Avatar) + : new MessageSenderDto(r.UserId, "unknown", "Unknown", null) + )); + } + + messagesList.Add(new ChatMessageDto( + m.Id, + m.ChatId, + m.SenderId, + m.Content, + m.Type, + m.ReplyToId, + m.Quote, + m.StoryId, + m.StoryMediaUrl, + m.StoryMediaType, + m.IsEdited, + m.IsDeleted, + m.CreatedAt, + m.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(m.SenderId, "unknown", "Unknown", null), + reactionsWithUser, + m.ReadBy.Select(r => new ReadByDto(r.UserId)).ToList() + )); + } + + var unreadCount = await _messageRepository.GetUnreadCountAsync(c.Id, request.UserId, cancellationToken); + + dtos.Add(new ChatDto( + c.Id, + c.Type.ToString().ToLowerInvariant(), + c.Type == ChatType.Favorites ? "Избранное" : (c.Type == ChatType.Personal ? null : c.Name), + c.Description, + c.Avatar, + c.CreatedAt, + members, + messagesList, + unreadCount + )); + } + + if (!hasFavorites) + { + var favs = new ChatDto(Guid.Empty, "favorites", "Избранное", null, null, DateTime.UtcNow, new List(), new List(), 0); + dtos.Add(favs); + } + + var sorted = dtos.OrderByDescending(d => d.Messages.FirstOrDefault()?.CreatedAt ?? d.CreatedAt).ToList(); + return Result.Success(sorted); + } +} diff --git a/apps/server-net/src/Modules/Chats/Application/Chats/LeaveOrDelete/LeaveOrDeleteChat.cs b/apps/server-net/src/Modules/Chats/Application/Chats/LeaveOrDelete/LeaveOrDeleteChat.cs new file mode 100644 index 0000000..1a8edaa --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/Chats/LeaveOrDelete/LeaveOrDeleteChat.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Knot.Shared.Kernel; +using Knot.Modules.Chats.Domain; +using Knot.Modules.Chats.Application.Abstractions; +using MediatR; +using System.Linq; + +namespace Knot.Modules.Chats.Application.Chats.LeaveOrDelete; + +public record LeaveOrDeleteChatCommand(Guid ChatId, Guid UserId) : ICommand; + +internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler +{ + private readonly IChatRepository _chatRepository; + private readonly IChatsUnitOfWork _uow; + + public LeaveOrDeleteChatCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow) + { + _chatRepository = chatRepository; + _uow = uow; + } + + public async Task> 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(new Error("Unauthorized", "Access denied")); + + 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)); + } +} diff --git a/apps/server-net/src/Modules/Chats/Application/Chats/Members/Members.cs b/apps/server-net/src/Modules/Chats/Application/Chats/Members/Members.cs new file mode 100644 index 0000000..db0c5d4 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/Chats/Members/Members.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Knot.Shared.Kernel; +using Knot.Modules.Chats.Domain; +using Knot.Modules.Chats.Application.Abstractions; +using MediatR; +using System.Linq; + +namespace Knot.Modules.Chats.Application.Chats.Members; + +public record AddMembersCommand(Guid ChatId, Guid UserId, List UserIdsToAdd) : ICommand; + +internal sealed class AddMembersCommandHandler : ICommandHandler +{ + private readonly IChatRepository _chatRepository; + private readonly IChatsUnitOfWork _uow; + + public AddMembersCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow) + { + _chatRepository = chatRepository; + _uow = uow; + } + + public async Task> 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(new Error("Chat.NotFound", "Chat not found or access denied")); + + 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; + +internal sealed class RemoveMemberCommandHandler : ICommandHandler +{ + private readonly IChatRepository _chatRepository; + private readonly IChatsUnitOfWork _uow; + + public RemoveMemberCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow) + { + _chatRepository = chatRepository; + _uow = uow; + } + + public async Task> 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(new Error("Chat.NotFound", "Chat not found or access denied")); + + chat.RemoveMember(request.UserIdToRemove); + _chatRepository.Update(chat); + await _uow.SaveChangesAsync(cancellationToken); + + return Result.Success(request.ChatId); + } +} diff --git a/apps/server-net/src/Modules/Chats/Application/Chats/TogglePin/TogglePin.cs b/apps/server-net/src/Modules/Chats/Application/Chats/TogglePin/TogglePin.cs new file mode 100644 index 0000000..0739448 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/Chats/TogglePin/TogglePin.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Knot.Shared.Kernel; +using Knot.Modules.Chats.Domain; +using Knot.Modules.Chats.Application.Abstractions; +using Knot.Modules.Chats.Application.DTOs; +using MediatR; +using System.Linq; + +namespace Knot.Modules.Chats.Application.Chats.TogglePin; + +public record TogglePinCommand(Guid ChatId, Guid UserId) : ICommand; + +internal sealed class TogglePinCommandHandler : ICommandHandler +{ + private readonly IChatRepository _chatRepository; + private readonly IChatsUnitOfWork _uow; + + public TogglePinCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow) + { + _chatRepository = chatRepository; + _uow = uow; + } + + public async Task> Handle(TogglePinCommand request, CancellationToken cancellationToken) + { + var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken); + if (chat == null) + return Result.Failure(new Error("Chat.NotFound", "Chat not found")); + + var member = chat.Members.FirstOrDefault(m => m.UserId == request.UserId); + if (member == null) + return Result.Failure(new Error("Chat.NotFound", "Member not found")); + + member.TogglePin(); + _chatRepository.Update(chat); + await _uow.SaveChangesAsync(cancellationToken); + + return Result.Success(new TogglePinResponse(member.IsPinned)); + } +} diff --git a/apps/server-net/src/Modules/Chats/Application/Chats/Update/UpdateChat.cs b/apps/server-net/src/Modules/Chats/Application/Chats/Update/UpdateChat.cs new file mode 100644 index 0000000..393c1e5 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/Chats/Update/UpdateChat.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Knot.Shared.Kernel; +using Knot.Modules.Chats.Domain; +using Knot.Modules.Chats.Application.Abstractions; +using MediatR; +using System.Linq; + +namespace Knot.Modules.Chats.Application.Chats.Update; + +public record UpdateChatCommand(Guid ChatId, Guid UserId, string? Name, string? Description) : ICommand; + +internal sealed class UpdateChatCommandHandler : ICommandHandler +{ + private readonly IChatRepository _chatRepository; + private readonly IChatsUnitOfWork _uow; + + public UpdateChatCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow) + { + _chatRepository = chatRepository; + _uow = uow; + } + + public async Task> 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(new Error("Chat.NotFound", "Chat not found or access denied")); + + 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); + } +} diff --git a/apps/server-net/src/Modules/Chats/Application/DTOs/AddMembersRequest.cs b/apps/server-net/src/Modules/Chats/Application/DTOs/AddMembersRequest.cs new file mode 100644 index 0000000..f9190d7 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/DTOs/AddMembersRequest.cs @@ -0,0 +1,6 @@ +using System; +using System.Collections.Generic; + +namespace Knot.Modules.Chats.Application.DTOs; + +public sealed record AddMembersRequest(List UserIds); diff --git a/apps/server-net/src/Modules/Chats/Application/DTOs/ChatDto.cs b/apps/server-net/src/Modules/Chats/Application/DTOs/ChatDto.cs new file mode 100644 index 0000000..8664e61 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/DTOs/ChatDto.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; + +namespace Knot.Modules.Chats.Application.DTOs; + +public record ChatDto( + Guid Id, + string Type, + string? Name, + string? Description, + string? Avatar, + DateTime CreatedAt, + List Members, + List Messages, + int UnreadCount +); diff --git a/apps/server-net/src/Modules/Chats/Application/DTOs/ChatMemberDto.cs b/apps/server-net/src/Modules/Chats/Application/DTOs/ChatMemberDto.cs new file mode 100644 index 0000000..7798a15 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/DTOs/ChatMemberDto.cs @@ -0,0 +1,12 @@ +using Knot.Modules.Chats.Domain; +using System; + +namespace Knot.Modules.Chats.Application.DTOs; + +public record ChatMemberDto( + Guid Id, + Guid UserId, + string Role, + bool IsPinned, + ChatUserDto? User +); diff --git a/apps/server-net/src/Modules/Chats/Application/DTOs/ChatMessageDto.cs b/apps/server-net/src/Modules/Chats/Application/DTOs/ChatMessageDto.cs new file mode 100644 index 0000000..11c643a --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/DTOs/ChatMessageDto.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; + +namespace Knot.Modules.Chats.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, + List Media, + MessageSenderDto Sender, + List Reactions, + List ReadBy +); diff --git a/apps/server-net/src/Modules/Chats/Application/DTOs/ChatUserDto.cs b/apps/server-net/src/Modules/Chats/Application/DTOs/ChatUserDto.cs new file mode 100644 index 0000000..22cd440 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/DTOs/ChatUserDto.cs @@ -0,0 +1,12 @@ +using System; + +namespace Knot.Modules.Chats.Application.DTOs; + +public record ChatUserDto( + Guid Id, + string Username, + string DisplayName, + string? Avatar, + bool IsOnline, + DateTime LastSeen +); diff --git a/apps/server-net/src/Modules/Chats/Application/DTOs/CreateChatRequest.cs b/apps/server-net/src/Modules/Chats/Application/DTOs/CreateChatRequest.cs new file mode 100644 index 0000000..9446c22 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/DTOs/CreateChatRequest.cs @@ -0,0 +1,7 @@ +using System; +using System.Collections.Generic; +using Knot.Modules.Chats.Domain; + +namespace Knot.Modules.Chats.Application.DTOs; + +public sealed record CreateChatRequest(string Name, ChatType Type, List MemberIds); diff --git a/apps/server-net/src/Modules/Chats/Application/DTOs/CreateGroupChatRequest.cs b/apps/server-net/src/Modules/Chats/Application/DTOs/CreateGroupChatRequest.cs new file mode 100644 index 0000000..122feee --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/DTOs/CreateGroupChatRequest.cs @@ -0,0 +1,6 @@ +using System; +using System.Collections.Generic; + +namespace Knot.Modules.Chats.Application.DTOs; + +public sealed record CreateGroupChatRequest(string Name, List MemberIds); diff --git a/apps/server-net/src/Modules/Chats/Application/DTOs/CreatePersonalChatRequest.cs b/apps/server-net/src/Modules/Chats/Application/DTOs/CreatePersonalChatRequest.cs new file mode 100644 index 0000000..8683e77 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/DTOs/CreatePersonalChatRequest.cs @@ -0,0 +1,5 @@ +using System; + +namespace Knot.Modules.Chats.Application.DTOs; + +public sealed record CreatePersonalChatRequest(Guid UserId); diff --git a/apps/server-net/src/Modules/Chats/Application/DTOs/MediaDto.cs b/apps/server-net/src/Modules/Chats/Application/DTOs/MediaDto.cs new file mode 100644 index 0000000..bf2e24d --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/DTOs/MediaDto.cs @@ -0,0 +1,11 @@ +using System; + +namespace Knot.Modules.Chats.Application.DTOs; + +public record MediaDto( + Guid Id, + string Type, + string Url, + string? Filename, + long? Size +); diff --git a/apps/server-net/src/Modules/Chats/Application/DTOs/MessageDetailDto.cs b/apps/server-net/src/Modules/Chats/Application/DTOs/MessageDetailDto.cs new file mode 100644 index 0000000..c8d6556 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/DTOs/MessageDetailDto.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; + +namespace Knot.Modules.Chats.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, + Guid? ForwardedFromId, + MessageSenderDto? ForwardedFrom, + Guid? StoryId, + string? StoryMediaUrl, + string? StoryMediaType, + List Media, + MessageSenderDto? Sender, + List ReadBy, + List Reactions +); + +public record ReplyToMessageDto( + Guid Id, + string? Content, + bool IsDeleted, + List Media, + MessageSenderDto? Sender +); + +public record MessageReactionDto( + Guid Id, + string Emoji, + Guid UserId, + MessageSenderDto? User +); diff --git a/apps/server-net/src/Modules/Chats/Application/DTOs/MessageSenderDto.cs b/apps/server-net/src/Modules/Chats/Application/DTOs/MessageSenderDto.cs new file mode 100644 index 0000000..55c31ae --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/DTOs/MessageSenderDto.cs @@ -0,0 +1,10 @@ +using System; + +namespace Knot.Modules.Chats.Application.DTOs; + +public record MessageSenderDto( + Guid Id, + string Username, + string DisplayName, + string? Avatar +); diff --git a/apps/server-net/src/Modules/Chats/Application/DTOs/ReactionDto.cs b/apps/server-net/src/Modules/Chats/Application/DTOs/ReactionDto.cs new file mode 100644 index 0000000..988bf86 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/DTOs/ReactionDto.cs @@ -0,0 +1,10 @@ +using System; + +namespace Knot.Modules.Chats.Application.DTOs; + +public record ReactionDto( + Guid Id, + string Emoji, + Guid UserId, + MessageSenderDto User +); diff --git a/apps/server-net/src/Modules/Chats/Application/DTOs/ReadByDto.cs b/apps/server-net/src/Modules/Chats/Application/DTOs/ReadByDto.cs new file mode 100644 index 0000000..0f853d0 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/DTOs/ReadByDto.cs @@ -0,0 +1,7 @@ +using System; + +namespace Knot.Modules.Chats.Application.DTOs; + +public record ReadByDto( + Guid UserId +); diff --git a/apps/server-net/src/Modules/Chats/Application/DTOs/SearchMessageDto.cs b/apps/server-net/src/Modules/Chats/Application/DTOs/SearchMessageDto.cs new file mode 100644 index 0000000..5ecfbf9 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/DTOs/SearchMessageDto.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; + +namespace Knot.Modules.Chats.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, + Guid? ForwardedFromId, + MessageSenderDto? ForwardedFrom, + Guid? StoryId, + string? StoryMediaUrl, + string? StoryMediaType, + List Media, + MessageSenderDto Sender, + List Reactions, + List ReadBy +); + +public record SimpleReactionDto( + Guid UserId, + string Emoji +); diff --git a/apps/server-net/src/Modules/Chats/Application/DTOs/SendMessageRequest.cs b/apps/server-net/src/Modules/Chats/Application/DTOs/SendMessageRequest.cs new file mode 100644 index 0000000..01d66e8 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/DTOs/SendMessageRequest.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; + +namespace Knot.Modules.Chats.Application.DTOs; + +public sealed record SendMessageRequest( + string? Content, + string Type, + List? Attachments = null, + Guid? ReplyToId = null, + string? Quote = null, + Guid? ForwardedFromId = null); + +public sealed record AttachmentDto(string Type, string Url, string? FileName, long? FileSize); diff --git a/apps/server-net/src/Modules/Chats/Application/DTOs/SharedMediaDto.cs b/apps/server-net/src/Modules/Chats/Application/DTOs/SharedMediaDto.cs new file mode 100644 index 0000000..8914fd5 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/DTOs/SharedMediaDto.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; + +namespace Knot.Modules.Chats.Application.DTOs; + +public record SharedMediaDto( + Guid Id, + string? Content, + DateTime CreatedAt, + List? Links, + MessageSenderDto? Sender, + Guid? ReplyToId, + string? Quote, + Guid? StoryId, + string? StoryMediaUrl, + string? StoryMediaType, + bool? IsEdited, + string? Type, + List? Media +); diff --git a/apps/server-net/src/Modules/Chats/Application/DTOs/TogglePinResponse.cs b/apps/server-net/src/Modules/Chats/Application/DTOs/TogglePinResponse.cs new file mode 100644 index 0000000..57c2ce0 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/DTOs/TogglePinResponse.cs @@ -0,0 +1,3 @@ +namespace Knot.Modules.Chats.Application.DTOs; + +public record TogglePinResponse(bool IsPinned); diff --git a/apps/server-net/src/Modules/Chats/Application/DTOs/UpdateChatRequest.cs b/apps/server-net/src/Modules/Chats/Application/DTOs/UpdateChatRequest.cs new file mode 100644 index 0000000..2d95905 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/DTOs/UpdateChatRequest.cs @@ -0,0 +1,3 @@ +namespace Knot.Modules.Chats.Application.DTOs; + +public sealed record UpdateChatRequest(string? Name, string? Description); diff --git a/apps/server-net/src/Modules/Chats/Application/DTOs/UploadFileResponseDto.cs b/apps/server-net/src/Modules/Chats/Application/DTOs/UploadFileResponseDto.cs new file mode 100644 index 0000000..1ee6f95 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/DTOs/UploadFileResponseDto.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; + +namespace Knot.Modules.Chats.Application.DTOs; + +public record UploadFileResponseDto( + string Url, + string Filename, + long Size +); diff --git a/apps/server-net/src/Modules/Chats/Application/Messages/GetMessages/GetMessagesQuery.cs b/apps/server-net/src/Modules/Chats/Application/Messages/GetMessages/GetMessagesQuery.cs new file mode 100644 index 0000000..0b79734 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/Messages/GetMessages/GetMessagesQuery.cs @@ -0,0 +1,126 @@ +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.Chats.Application.DTOs; +using Knot.Modules.Chats.Domain; +using Knot.Modules.Identity.Domain; + +namespace Knot.Modules.Chats.Application.Messages.GetMessages; + +public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor) : IQuery>; + +internal sealed class GetMessagesQueryHandler : IQueryHandler> +{ + private readonly IMessageRepository _messageRepository; + private readonly IUserRepository _userRepository; + + public GetMessagesQueryHandler(IMessageRepository messageRepository, IUserRepository userRepository) + { + _messageRepository = messageRepository; + _userRepository = userRepository; + } + + public async Task>> Handle(GetMessagesQuery request, CancellationToken cancellationToken) + { + 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, 100, cancellationToken); + var senders = new Dictionary(); + var result = new List(); + + foreach (var m in messages) + { + if (m.DeletedByUsers.Contains(request.UserId)) continue; + + if (!senders.ContainsKey(m.SenderId)) + { + var user = await _userRepository.GetByIdAsync(m.SenderId, cancellationToken); + if (user != null) senders[m.SenderId] = user; + } + + if (m.ForwardedFromId.HasValue && !senders.ContainsKey(m.ForwardedFromId.Value)) + { + var fuser = await _userRepository.GetByIdAsync(m.ForwardedFromId.Value, cancellationToken); + if (fuser != null) senders[m.ForwardedFromId.Value] = fuser; + } + + ReplyToMessageDto? replyToObj = null; + if (m.ReplyToId.HasValue) + { + var replyMsg = await _messageRepository.GetByIdAsync(m.ReplyToId.Value, cancellationToken); + if (replyMsg != null) + { + if (!senders.ContainsKey(replyMsg.SenderId)) + { + var replySender = await _userRepository.GetByIdAsync(replyMsg.SenderId, cancellationToken); + if (replySender != null) senders[replyMsg.SenderId] = replySender; + } + 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(); + foreach (var r in m.Reactions) + { + if (!senders.ContainsKey(r.UserId)) + { + var rUser = await _userRepository.GetByIdAsync(r.UserId, cancellationToken); + if (rUser != null) senders[r.UserId] = rUser; + } + var userObj = senders.TryGetValue(r.UserId, out var ru) + ? new MessageSenderDto(ru.Id, ru.Username, ru.DisplayName, ru.Avatar) + : new MessageSenderDto(r.UserId, "unknown", "Unknown", null); + + reactionsWithUser.Add(new MessageReactionDto( + r.Id, + r.Emoji, + r.UserId, + userObj + )); + } + + result.Add(new MessageDetailDto( + m.Id, + m.ChatId, + m.SenderId, + m.Content, + m.Type, + m.ReplyToId, + replyToObj, + m.Quote, + m.IsEdited, + m.IsDeleted, + m.CreatedAt, + m.ForwardedFromId, + m.ForwardedFromId.HasValue && senders.TryGetValue(m.ForwardedFromId.Value, out var fwd) ? new MessageSenderDto(fwd.Id, fwd.Username, fwd.DisplayName, fwd.Avatar) : null, + m.StoryId, + m.StoryMediaUrl, + m.StoryMediaType, + m.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(), + senders.TryGetValue(m.SenderId, out var s) ? new MessageSenderDto(s.Id, s.Username, s.DisplayName, s.Avatar) : null, + m.ReadBy.Select(r => new ReadByDto(r.UserId)).ToList(), + reactionsWithUser + )); + } + + return Result.Success(result); + } +} diff --git a/apps/server-net/src/Modules/Chats/Application/Messages/GetSharedMedia/GetSharedMediaQuery.cs b/apps/server-net/src/Modules/Chats/Application/Messages/GetSharedMedia/GetSharedMediaQuery.cs new file mode 100644 index 0000000..cf91437 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/Messages/GetSharedMedia/GetSharedMediaQuery.cs @@ -0,0 +1,95 @@ +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.Chats.Application.DTOs; +using Knot.Modules.Chats.Domain; +using Knot.Modules.Identity.Domain; + +namespace Knot.Modules.Chats.Application.Messages.GetSharedMedia; + +public record GetSharedMediaQuery(Guid UserId, Guid ChatId, string? Type) : IQuery>; + +internal sealed class GetSharedMediaQueryHandler : IQueryHandler> +{ + private readonly IMessageRepository _messageRepository; + private readonly IUserRepository _userRepository; + + public GetSharedMediaQueryHandler(IMessageRepository messageRepository, IUserRepository userRepository) + { + _messageRepository = messageRepository; + _userRepository = userRepository; + } + + public async Task>> Handle(GetSharedMediaQuery request, CancellationToken cancellationToken) + { + var messages = await _messageRepository.GetChatMessagesAsync(request.ChatId, 300, 0, cancellationToken); + messages = messages.Where(m => !m.IsDeleted && !m.DeletedByUsers.Contains(request.UserId)).ToList(); + + var result = new List(); + var filterType = request.Type?.ToLower(); + + foreach (var m in messages) + { + if (filterType == "links") + { + var linkRegex = new Regex(@"https?://[^\s]+", RegexOptions.IgnoreCase); + var contentLinks = !string.IsNullOrEmpty(m.Content) ? linkRegex.Matches(m.Content).Select(match => match.Value).ToList() : new List(); + var mediaLinks = (m.Media ?? Enumerable.Empty()).Where(media => media.Type?.ToLower() == "link").Select(media => media.Url).ToList(); + var allLinks = contentLinks.Concat(mediaLinks).Distinct().ToList(); + + if (allLinks.Any()) + { + var sender = await _userRepository.GetByIdAsync(m.SenderId, cancellationToken); + result.Add(new SharedMediaDto( + m.Id, + m.Content, + m.CreatedAt, + allLinks, + sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : null, + null, null, null, null, null, null, null, null + )); + } + continue; + } + + if (m.Media == null || !m.Media.Any()) continue; + + var filteredMedia = m.Media.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 == "media") return (mediaType == "image" || mediaType == "video") && !isGif; + if (filterType == "gifs") return isGif; + if (filterType == "files") return mediaType != "image" && mediaType != "video" && mediaType != "link"; + return true; + }).ToList(); + + if (filteredMedia.Any()) + { + var sender = await _userRepository.GetByIdAsync(m.SenderId, cancellationToken); + result.Add(new SharedMediaDto( + m.Id, + m.Content, + m.CreatedAt, + null, + sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : null, + m.ReplyToId, + m.Quote, + m.StoryId, + m.StoryMediaUrl, + m.StoryMediaType, + m.IsEdited, + m.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()); + } +} diff --git a/apps/server-net/src/Modules/Chats/Application/Messages/SearchMessages/SearchMessagesQuery.cs b/apps/server-net/src/Modules/Chats/Application/Messages/SearchMessages/SearchMessagesQuery.cs new file mode 100644 index 0000000..646b8ed --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/Messages/SearchMessages/SearchMessagesQuery.cs @@ -0,0 +1,66 @@ +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.Chats.Application.DTOs; +using Knot.Modules.Chats.Domain; +using Knot.Modules.Identity.Domain; + +namespace Knot.Modules.Chats.Application.Messages.SearchMessages; + +public record SearchMessagesQuery(Guid UserId, string Query, Guid? ChatId) : IQuery>; + +internal sealed class SearchMessagesQueryHandler : IQueryHandler> +{ + private readonly IMessageRepository _messageRepository; + private readonly IUserRepository _userRepository; + + public SearchMessagesQueryHandler(IMessageRepository messageRepository, IUserRepository userRepository) + { + _messageRepository = messageRepository; + _userRepository = userRepository; + } + + public async Task>> Handle(SearchMessagesQuery request, CancellationToken cancellationToken) + { + var messages = await _messageRepository.SearchMessagesAsync(request.Query, request.ChatId, cancellationToken); + messages = messages.Where(m => !m.DeletedByUsers.Contains(request.UserId)).ToList(); + + var userIds = messages.Select(m => m.SenderId).ToList(); + userIds.AddRange(messages.Where(m => m.ForwardedFromId.HasValue).Select(m => m.ForwardedFromId!.Value)); + + var senders = new Dictionary(); + foreach (var id in userIds.Distinct()) + { + var user = await _userRepository.GetByIdAsync(id, cancellationToken); + if (user != null) senders[id] = user; + } + + var result = messages.Select(m => new SearchMessageDto( + m.Id, + m.ChatId, + m.SenderId, + m.Content, + m.Type, + m.ReplyToId, + m.Quote, + m.IsEdited, + m.IsDeleted, + m.CreatedAt, + m.ForwardedFromId, + m.ForwardedFromId.HasValue && senders.TryGetValue(m.ForwardedFromId.Value, out var fwd) ? new MessageSenderDto(fwd.Id, fwd.Username, fwd.DisplayName, fwd.Avatar) : null, + m.StoryId, + m.StoryMediaUrl, + m.StoryMediaType, + m.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(), + senders.TryGetValue(m.SenderId, out var s) ? new MessageSenderDto(s.Id, s.Username, s.DisplayName, s.Avatar) : new MessageSenderDto(m.SenderId, "unknown", "Unknown", null), + m.Reactions.Select(r => new SimpleReactionDto(r.UserId, r.Emoji)).ToList(), + m.ReadBy.Select(r => new ReadByDto(r.UserId)).ToList() + )).ToList(); + + return Result.Success(result); + } +} diff --git a/apps/server-net/src/Modules/Chats/Application/Messages/UploadFile/UploadFileCommand.cs b/apps/server-net/src/Modules/Chats/Application/Messages/UploadFile/UploadFileCommand.cs new file mode 100644 index 0000000..e0fe529 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/Messages/UploadFile/UploadFileCommand.cs @@ -0,0 +1,38 @@ +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.Chats.Application.DTOs; + +namespace Knot.Modules.Chats.Application.Messages.UploadFile; + +public record UploadFileCommand(string FileName, string ContentType, long Length, Stream FileStream) : ICommand; + +internal sealed class UploadFileCommandHandler : ICommandHandler +{ + private readonly IFileStorageService _fileStorage; + private readonly ISettingsService _settingsService; + + public UploadFileCommandHandler(IFileStorageService fileStorage, ISettingsService settingsService) + { + _fileStorage = fileStorage; + _settingsService = settingsService; + } + + public async Task> Handle(UploadFileCommand request, CancellationToken cancellationToken) + { + if (request.Length == 0) return Result.Failure(new Error("File.Empty", "No file uploaded")); + + var maxMb = _settingsService.Current.MaxFileSizeMb; + if (request.Length > maxMb * 1024 * 1024) + return Result.Failure(new Error("File.TooLarge", $"File exceeds the maximum allowed size of {maxMb}MB.")); + + var fileId = await _fileStorage.UploadFileAsync(request.FileStream, request.FileName, request.ContentType); + + return Result.Success(new UploadFileResponseDto("/api/files/" + fileId, request.FileName, request.Length)); + } +} diff --git a/apps/server-net/src/Modules/Chats/Application/TelegramImport/AnalyzeImportCommand.cs b/apps/server-net/src/Modules/Chats/Application/TelegramImport/AnalyzeImportCommand.cs new file mode 100644 index 0000000..c95ec2f --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/TelegramImport/AnalyzeImportCommand.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AngleSharp.Html.Parser; +using AngleSharp.Dom; +using Knot.Shared.Kernel; +using MediatR; + +namespace Knot.Modules.Chats.Application.TelegramImport; + +public static class TelegramImportState +{ + public static readonly ConcurrentDictionary TempZips = new(); +} + +public record AnalyzeImportResponseDto(Guid Token, List Names); + +public record AnalyzeImportCommand(Stream FileStream, string FileName) : ICommand; + +internal sealed class AnalyzeImportCommandHandler : ICommandHandler +{ + public async Task> Handle(AnalyzeImportCommand request, CancellationToken cancellationToken) + { + if (request.FileStream == null || request.FileStream.Length == 0) + return Result.Failure(new Error("File.Empty", "No file uploaded")); + + if (!request.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + return Result.Failure(new Error("File.InvalidExtension", "Must be a ZIP archive")); + + var token = Guid.NewGuid(); + var tempPath = Path.Combine(Path.GetTempPath(), $"{token}.zip"); + + await using (var fs = new FileStream(tempPath, FileMode.Create)) + { + await request.FileStream.CopyToAsync(fs, cancellationToken); + } + + var names = new HashSet(); + + using (var archive = ZipFile.OpenRead(tempPath)) + { + var htmlEntries = archive.Entries + .Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase)); + + foreach (var entry in htmlEntries) + { + using var stream = entry.Open(); + var parser = new HtmlParser(); + var doc = parser.ParseDocument(stream); + + var messageNodes = doc.QuerySelectorAll(".message"); + if (messageNodes == null) continue; + + foreach (var node in messageNodes) + { + var fromNameNode = node.QuerySelector(".from_name"); + if (fromNameNode != null) + { + var nameNodeText = (IElement)fromNameNode.Clone(); + var innerSpans = nameNodeText.QuerySelectorAll("span"); + foreach (var span in innerSpans) span.Remove(); + + var name = nameNodeText.TextContent.Trim(); + if (!string.IsNullOrWhiteSpace(name)) + { + names.Add(name); + } + } + } + } + } + + TelegramImportState.TempZips[token] = tempPath; + + return Result.Success(new AnalyzeImportResponseDto(token, names.ToList())); + } +} diff --git a/apps/server-net/src/Modules/Chats/Application/TelegramImport/ExecuteImportCommand.cs b/apps/server-net/src/Modules/Chats/Application/TelegramImport/ExecuteImportCommand.cs new file mode 100644 index 0000000..54e6e16 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Application/TelegramImport/ExecuteImportCommand.cs @@ -0,0 +1,397 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AngleSharp.Dom; +using AngleSharp.Html.Parser; +using MediatR; +using Knot.Shared.Kernel; +using Knot.Shared.Kernel.Storage; +using Knot.Modules.Chats.Domain; +using Knot.Modules.Chats.Application.Abstractions; +using Knot.Modules.Chats.Application.Chats.Create; +using Knot.Modules.Chats.Infrastructure.SignalR; +using Microsoft.AspNetCore.SignalR; + +namespace Knot.Modules.Chats.Application.TelegramImport; + +public record ExecuteImportResponseDto(bool Success, int MessagesImported, Guid ChatId); + +public record ExecuteImportCommand( + Guid CurrentUserId, + Guid Token, + Dictionary Mapping, + string? GroupName +) : ICommand; + +internal sealed class ExecuteImportCommandHandler : ICommandHandler +{ + private readonly ISender _sender; + private readonly IChatsUnitOfWork _uow; + private readonly IChatRepository _chatRepository; + private readonly IMessageRepository _messageRepository; + private readonly IFileStorageService _fileStorage; + private readonly IHubContext _hubContext; + + public ExecuteImportCommandHandler( + ISender sender, + IChatsUnitOfWork uow, + IChatRepository chatRepository, + IMessageRepository messageRepository, + IFileStorageService fileStorage, + IHubContext hubContext) + { + _sender = sender; + _uow = uow; + _chatRepository = chatRepository; + _messageRepository = messageRepository; + _fileStorage = fileStorage; + _hubContext = hubContext; + } + + public async Task> Handle(ExecuteImportCommand request, CancellationToken cancellationToken) + { + if (!TelegramImportState.TempZips.TryGetValue(request.Token, out var tempPath)) + return Result.Failure(new Error("Import.Expired", "Session not found or expired")); + + if (!System.IO.File.Exists(tempPath)) + return Result.Failure(new Error("Import.Missing", "ZIP file lost")); + + var myId = request.CurrentUserId; + var targetUserIds = request.Mapping.Values.Distinct().Where(id => id != Guid.Empty).ToList(); + if (!targetUserIds.Contains(myId)) targetUserIds.Add(myId); + + Guid chatId = Guid.Empty; + var chatMembers = targetUserIds; + + if (chatMembers.Count <= 2) + { + var existingChats = await _chatRepository.GetUserChatsAsync(myId, cancellationToken); + var personalChat = existingChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.All(m => chatMembers.Contains(m.UserId)) && c.Members.Count == chatMembers.Count); + + if (personalChat != null) + { + chatId = personalChat.Id; + } + else + { + var friendId = chatMembers.FirstOrDefault(id => id != myId); + if (friendId == Guid.Empty) friendId = myId; + var command = new CreateChatCommand(string.Empty, ChatType.Personal, new List { myId, friendId }); + var res = await _sender.Send(command, cancellationToken); + if (res.IsFailure) return Result.Failure(new Error("Import.CreateChatFailed", res.Error.Description ?? res.Error.Code)); + chatId = res.Value; + } + } + else + { + var command = new CreateChatCommand(request.GroupName ?? "Импортированный чат", ChatType.Group, chatMembers); + var res = await _sender.Send(command, cancellationToken); + if (res.IsFailure) return Result.Failure(new Error("Import.CreateChatFailed", res.Error.Description ?? res.Error.Code)); + chatId = res.Value; + } + + int importedCount = 0; + + using (var archive = ZipFile.OpenRead(tempPath)) + { + var htmlEntries = archive.Entries + .Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase)) + .OrderBy(e => + { + var name = e.Name.ToLower().Replace("messages", "").Replace(".html", ""); + return string.IsNullOrEmpty(name) ? 0 : int.TryParse(name, out var num) ? num : 999999; + }) + .ToList(); + + Guid lastSenderGuid = myId; + DateTime lastCreatedAt = DateTime.UtcNow; + Dictionary messageIdMap = new(); + Message? lastSavedMessage = null; + + foreach (var entry in htmlEntries) + { + using var stream = entry.Open(); + var parser = new HtmlParser(); + var doc = parser.ParseDocument(stream); + + var messageNodes = doc.QuerySelectorAll(".message"); + if (messageNodes == null) continue; + + var baseDir = Path.GetDirectoryName(entry.FullName)?.Replace("\\", "/") ?? ""; + if (!string.IsNullOrEmpty(baseDir) && !baseDir.EndsWith("/")) baseDir += "/"; + + foreach (var node in messageNodes) + { + try + { + var fromNameNode = node.QuerySelector(".from_name"); + var textNode = node.QuerySelector(".text"); + + var dateNode = node.QuerySelector(".date[title]") + ?? node.QuerySelector(".pull_right[title]") + ?? node.QuerySelector("[title]"); + + if (fromNameNode != null) + { + var nameNodeText = (AngleSharp.Dom.IElement)fromNameNode.Clone(); + var innerSpans = nameNodeText.QuerySelectorAll("span"); + foreach (var span in innerSpans) span.Remove(); + + var name = nameNodeText.TextContent.Trim(); + if (request.Mapping.TryGetValue(name, out var mappedId) && mappedId != Guid.Empty) + lastSenderGuid = mappedId; + else + lastSenderGuid = myId; + } + + Guid senderGuid = lastSenderGuid; + + string content = ""; + var mainBodyNode = node.QuerySelector(".body"); + var isForwarded = node.QuerySelector(".forwarded") != null; + + var contentTextNode = isForwarded + ? (node.QuerySelector(".body > .text") ?? node.QuerySelector(".text:not(.forwarded .text)")) + : node.QuerySelector(".text"); + + if (contentTextNode != null) + { + var html = contentTextNode.InnerHtml + .Replace("
", "\n", StringComparison.OrdinalIgnoreCase) + .Replace("
", "\n", StringComparison.OrdinalIgnoreCase) + .Replace("
", "\n", StringComparison.OrdinalIgnoreCase); + var tempParser = new HtmlParser(); + var tempDoc = tempParser.ParseDocument("
" + html + "
"); + content = tempDoc.Body?.TextContent.Trim() ?? ""; + } + + DateTime createdAt = lastCreatedAt; + var titleNodes = node.QuerySelectorAll("[title]"); + bool parsed = false; + + if (titleNodes != null) + { + foreach (var tnode in titleNodes) + { + var dateStr = tnode.GetAttribute("title")?.Trim() ?? ""; + + if (dateStr.Length >= 10 && char.IsDigit(dateStr[0]) && char.IsDigit(dateStr[1])) + { + var cleanStr = dateStr.Replace("UTC", "", StringComparison.OrdinalIgnoreCase).Trim(); + + if (DateTimeOffset.TryParseExact(cleanStr, "dd.MM.yyyy HH:mm:ss zzz", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dto)) + { + createdAt = dto.UtcDateTime; + parsed = true; + break; + } + else if (DateTime.TryParseExact(cleanStr, "dd.MM.yyyy HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var dt)) + { + createdAt = dt; + parsed = true; + break; + } + else if (DateTime.TryParse(cleanStr, out var dFallback)) + { + createdAt = dFallback.ToUniversalTime(); + parsed = true; + break; + } + } + } + } + + if (!parsed) + { + Console.WriteLine("Warning: Could not parse date in imported message! Using lastCreatedAt."); + } + else + { + lastCreatedAt = createdAt; + } + + Guid? forwardedFromId = null; + var forwardedNode = node.QuerySelector(".forwarded.body"); + if (forwardedNode != null) + { + var fwdNameNode = forwardedNode.QuerySelector(".from_name"); + var fwdNameText = fwdNameNode != null ? (AngleSharp.Dom.IElement)fwdNameNode.Clone() : null; + if (fwdNameText != null) + { + var innerSpans = fwdNameText.QuerySelectorAll("span"); + foreach (var s in innerSpans) s.Remove(); + } + var fwdName = fwdNameText != null ? fwdNameText.TextContent.Trim() : "Неизвестного"; + + if (request.Mapping.TryGetValue(fwdName, out var mappedFwdId) && mappedFwdId != Guid.Empty) + { + forwardedFromId = mappedFwdId; + } + else if (fwdName == "Это я" || fwdName == request.Mapping.FirstOrDefault(x => x.Value == myId).Key) + { + forwardedFromId = myId; + } + + var fwdTextNode = forwardedNode.QuerySelector(".text"); + string fwdContent = ""; + if (fwdTextNode != null) + { + var fHtml = fwdTextNode.InnerHtml + .Replace("
", "\n", StringComparison.OrdinalIgnoreCase) + .Replace("
", "\n", StringComparison.OrdinalIgnoreCase) + .Replace("
", "\n", StringComparison.OrdinalIgnoreCase); + var tempParser = new HtmlParser(); + var tempDoc = tempParser.ParseDocument("
" + fHtml + "
"); + fwdContent = tempDoc.Body?.TextContent.Trim() ?? ""; + } + + if (forwardedFromId == null) + { + content = string.IsNullOrEmpty(content) + ? $"[Переслано от {fwdName}]:\n{fwdContent}" + : $"{content}\n\n[Переслано от {fwdName}]:\n{fwdContent}"; + } + else if (string.IsNullOrEmpty(content)) + { + content = fwdContent; + } + } + + Guid? replyToId = null; + var replyNode = node.QuerySelector(".reply_to a"); + if (replyNode != null) + { + var href = replyNode.GetAttribute("href"); + if (href != null && href.StartsWith("#go_to_")) + { + var tgId = href.Substring("#go_to_".Length); + if (messageIdMap.TryGetValue(tgId, out var mappedMsgId)) + { + replyToId = mappedMsgId; + } + } + } + + var mediaNodes = node.QuerySelectorAll("a.photo_wrap, a.animated_wrap, video, audio, a.document, a.media_voice_message, a.media_video, img.sticker").ToList(); + if (mediaNodes.Count == 0) + { + var fallback = node.QuerySelectorAll(".media_wrap a[href]"); + mediaNodes.AddRange(fallback); + } + + var messageType = "text"; + + (string mType, string cType) GetMediaTypes(string fileUrl) + { + var ext = Path.GetExtension(fileUrl)?.ToLower(); + return ext switch + { + ".jpg" or ".jpeg" or ".png" or ".webp" => ("image", "image/jpeg"), + ".mp4" or ".mov" or ".avi" => ("video", "video/mp4"), + ".ogg" or ".mp3" => ("voice", "audio/ogg"), + _ => ("file", "application/octet-stream") + }; + } + + if (mediaNodes != null && mediaNodes.Count > 0) + { + var firstHref = mediaNodes[0].GetAttribute("href") ?? mediaNodes[0].GetAttribute("src"); + if (firstHref != null) + { + messageType = GetMediaTypes(firstHref).mType; + if (mediaNodes[0].ClassName?.Contains("animated") == true || firstHref.EndsWith(".mp4")) + { + if (mediaNodes[0].ClassName?.Contains("animated") == true) + messageType = "image"; + } + } + } + + bool isJoined = fromNameNode == null; + bool isMediaOnly = string.IsNullOrEmpty(content) && forwardedNode == null && replyToId == null; + Message? targetMessage = null; + + if (isJoined && isMediaOnly && lastSavedMessage != null && Math.Abs((createdAt - lastSavedMessage.CreatedAt).TotalSeconds) <= 60 && lastSavedMessage.SenderId == senderGuid) + { + targetMessage = lastSavedMessage; + } + else + { + string finalContent = content; + targetMessage = Message.Import(chatId, senderGuid, finalContent, messageType, createdAt, replyToId, forwardedFromId); + + var idAttr = node.GetAttribute("id"); + if (!string.IsNullOrEmpty(idAttr)) + { + messageIdMap[idAttr] = targetMessage.Id; + } + } + + if (mediaNodes != null) + { + foreach (var mediaNode in mediaNodes) + { + string? href = mediaNode.GetAttribute("href") ?? mediaNode.GetAttribute("src"); + if (!string.IsNullOrEmpty(href) && !href.StartsWith("http")) + { + var zipPath = baseDir + href.Replace("\\", "/"); + var zipEntry = archive.GetEntry(zipPath); + if (zipEntry != null) + { + using var ms = new MemoryStream(); + using var zipfs = zipEntry.Open(); + await zipfs.CopyToAsync(ms, cancellationToken); + ms.Position = 0; + + var types = GetMediaTypes(href); + var finalMType = types.mType; + if (mediaNode.ClassName?.Contains("animated") == true) finalMType = "image"; + var fileId = await _fileStorage.UploadFileAsync(ms, Path.GetFileName(href), types.cType); + targetMessage.AddMedia(finalMType, $"/api/files/{fileId}", Path.GetFileName(href), zipEntry.Length); + } + } + } + } + + var reactionNodes = node.QuerySelectorAll(".reactions .reaction"); + foreach (var reactionNode in reactionNodes) + { + var emojiNode = reactionNode.QuerySelector(".emoji"); + if (emojiNode == null) continue; + var emoji = emojiNode.TextContent.Trim(); + + var userpicNodes = reactionNode.QuerySelectorAll(".userpics .userpic .initials[title]"); + foreach (var userpicNode in userpicNodes) + { + var title = userpicNode.GetAttribute("title")?.Trim(); + if (!string.IsNullOrEmpty(title) && request.Mapping.TryGetValue(title, out var rUserId) && rUserId != Guid.Empty) + { + targetMessage.AddReaction(rUserId, emoji); + } + } + } + if (targetMessage != lastSavedMessage && (!string.IsNullOrEmpty(content) || targetMessage.Media.Any())) + { + _messageRepository.Add(targetMessage); + lastSavedMessage = targetMessage; + importedCount++; + } + } + catch { /* ignore single message parse error */ } + } + } + } + + await _uow.SaveChangesAsync(cancellationToken); + + try { System.IO.File.Delete(tempPath); TelegramImportState.TempZips.TryRemove(request.Token, out _); } catch { } + + await _hubContext.Clients.Users(chatMembers.Select(x => x.ToString())).SendAsync("history_updated", new { chatId }); + + return Result.Success(new ExecuteImportResponseDto(true, importedCount, chatId)); + } +} diff --git a/apps/server-net/src/Modules/Chats/Domain/IMessageRepository.cs b/apps/server-net/src/Modules/Chats/Domain/IMessageRepository.cs index 39075d1..d515432 100644 --- a/apps/server-net/src/Modules/Chats/Domain/IMessageRepository.cs +++ b/apps/server-net/src/Modules/Chats/Domain/IMessageRepository.cs @@ -13,4 +13,5 @@ public interface IMessageRepository Task RemoveReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken); Task> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, int limit, CancellationToken cancellationToken); Task GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken); + Task GetUnreadCountAsync(Guid chatId, Guid userId, CancellationToken cancellationToken); } diff --git a/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/MessageRepository.cs b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/MessageRepository.cs index 0a092db..e1d4787 100644 --- a/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/MessageRepository.cs +++ b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/MessageRepository.cs @@ -138,4 +138,11 @@ public sealed class MessageRepository : IMessageRepository .OrderByDescending(m => m.CreatedAt) .FirstOrDefaultAsync(cancellationToken); } + + public async Task GetUnreadCountAsync(Guid chatId, Guid userId, CancellationToken cancellationToken) + { + return await _dbContext.Messages + .Where(m => m.ChatId == chatId && m.SenderId != userId && !m.ReadBy.Any(r => r.UserId == userId)) + .CountAsync(cancellationToken); + } } diff --git a/apps/server-net/src/Modules/Chats/Knot.Modules.Chats.csproj b/apps/server-net/src/Modules/Chats/Knot.Modules.Chats.csproj index 2352201..be0a51d 100644 --- a/apps/server-net/src/Modules/Chats/Knot.Modules.Chats.csproj +++ b/apps/server-net/src/Modules/Chats/Knot.Modules.Chats.csproj @@ -2,9 +2,11 @@ + + @@ -12,6 +14,7 @@ + diff --git a/apps/server-net/src/Modules/Identity/Application/Abstractions/IIdentityDbContext.cs b/apps/server-net/src/Modules/Identity/Application/Abstractions/IIdentityDbContext.cs new file mode 100644 index 0000000..c02d5e2 --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Application/Abstractions/IIdentityDbContext.cs @@ -0,0 +1,14 @@ +using System.Threading; +using System.Threading.Tasks; +using Knot.Modules.Identity.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Knot.Modules.Identity.Application.Abstractions; + +public interface IIdentityDbContext +{ + DbSet Users { get; } + DbSet Friendships { get; } + Task SaveChangesAsync(CancellationToken cancellationToken); +} diff --git a/apps/server-net/src/Modules/Identity/Application/Friends/AcceptFriendRequest.cs b/apps/server-net/src/Modules/Identity/Application/Friends/AcceptFriendRequest.cs new file mode 100644 index 0000000..2cc2358 --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Application/Friends/AcceptFriendRequest.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Knot.Modules.Identity.Application.Abstractions; +using Knot.Shared.Kernel; + +namespace Knot.Modules.Identity.Application.Friends; + +public record AcceptFriendRequestCommand(Guid UserId, Guid FriendshipId) : ICommand; + +internal sealed class AcceptFriendRequestCommandHandler : ICommandHandler +{ + private readonly IIdentityDbContext _context; + private readonly IIdentityUnitOfWork _unitOfWork; + + public AcceptFriendRequestCommandHandler(IIdentityDbContext context, IIdentityUnitOfWork unitOfWork) + { + _context = context; + _unitOfWork = unitOfWork; + } + + public async Task> Handle(AcceptFriendRequestCommand request, CancellationToken cancellationToken) + { + var friendship = await _context.Friendships.FindAsync(new object[] { request.FriendshipId }, cancellationToken); + if (friendship == null || friendship.FriendId != request.UserId) + return Result.Failure(new Error("Friends.NotFound", "Request not found")); + + friendship.Accept(); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(friendship.Id); + } +} diff --git a/apps/server-net/src/Modules/Identity/Application/Friends/DeclineFriendRequest.cs b/apps/server-net/src/Modules/Identity/Application/Friends/DeclineFriendRequest.cs new file mode 100644 index 0000000..82992fd --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Application/Friends/DeclineFriendRequest.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Knot.Modules.Identity.Application.Abstractions; +using Knot.Shared.Kernel; + +namespace Knot.Modules.Identity.Application.Friends; + +public record DeclineFriendRequestCommand(Guid UserId, Guid FriendshipId) : ICommand; + +internal sealed class DeclineFriendRequestCommandHandler : ICommandHandler +{ + private readonly IIdentityDbContext _context; + private readonly IIdentityUnitOfWork _unitOfWork; + + public DeclineFriendRequestCommandHandler(IIdentityDbContext context, IIdentityUnitOfWork unitOfWork) + { + _context = context; + _unitOfWork = unitOfWork; + } + + public async Task Handle(DeclineFriendRequestCommand request, CancellationToken cancellationToken) + { + var friendship = await _context.Friendships.FindAsync(new object[] { request.FriendshipId }, cancellationToken); + if (friendship == null || friendship.FriendId != request.UserId) + return Result.Failure(new Error("Friends.NotFound", "Request not found")); + + friendship.Decline(); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/apps/server-net/src/Modules/Identity/Application/Friends/FriendDto.cs b/apps/server-net/src/Modules/Identity/Application/Friends/FriendDto.cs new file mode 100644 index 0000000..9aceef6 --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Application/Friends/FriendDto.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; + +namespace Knot.Modules.Identity.Application.Friends; + +public record FriendDto( + Guid Id, + string Username, + string DisplayName, + string? Avatar, + bool IsOnline, + DateTime LastSeenAt, + Guid FriendshipId +); diff --git a/apps/server-net/src/Modules/Identity/Application/Friends/FriendRequestDto.cs b/apps/server-net/src/Modules/Identity/Application/Friends/FriendRequestDto.cs new file mode 100644 index 0000000..30c6bf3 --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Application/Friends/FriendRequestDto.cs @@ -0,0 +1,11 @@ +using System; + +namespace Knot.Modules.Identity.Application.Friends; + +public record FriendUserDto(Guid Id, string Username, string DisplayName, string? Avatar); + +public record FriendRequestDto( + Guid Id, + FriendUserDto User, + DateTime CreatedAt +); diff --git a/apps/server-net/src/Modules/Identity/Application/Friends/FriendshipStatusResponse.cs b/apps/server-net/src/Modules/Identity/Application/Friends/FriendshipStatusResponse.cs new file mode 100644 index 0000000..bff4353 --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Application/Friends/FriendshipStatusResponse.cs @@ -0,0 +1,9 @@ +using System; + +namespace Knot.Modules.Identity.Application.Friends; + +public record FriendshipStatusResponse( + string Status, + Guid? FriendshipId = null, + string? Direction = null +); diff --git a/apps/server-net/src/Modules/Identity/Application/Friends/GetFriends.cs b/apps/server-net/src/Modules/Identity/Application/Friends/GetFriends.cs new file mode 100644 index 0000000..594d213 --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Application/Friends/GetFriends.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Knot.Modules.Identity.Application.Abstractions; +using Knot.Modules.Identity.Domain; +using Knot.Shared.Kernel; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Knot.Modules.Identity.Application.Friends; + +public record GetFriendsQuery(Guid UserId) : IQuery>; + +internal sealed class GetFriendsQueryHandler : IQueryHandler> +{ + private readonly IIdentityDbContext _context; + private readonly IUserRepository _userRepository; + + public GetFriendsQueryHandler(IIdentityDbContext context, IUserRepository userRepository) + { + _context = context; + _userRepository = userRepository; + } + + public async Task>> Handle(GetFriendsQuery request, CancellationToken cancellationToken) + { + var friendships = await _context.Friendships + .Where(f => (f.UserId == request.UserId || f.FriendId == request.UserId) && f.Status == FriendshipStatus.Accepted) + .ToListAsync(cancellationToken); + + var friendIds = friendships.Select(f => f.UserId == request.UserId ? f.FriendId : f.UserId).ToList(); + var friends = new List(); + + foreach (var id in friendIds) + { + var user = await _userRepository.GetByIdAsync(id, cancellationToken); + if (user == null) continue; + + var fs = friendships.First(f => f.UserId == id || f.FriendId == id); + + friends.Add(new FriendDto( + user.Id, + user.Username, + user.DisplayName, + user.Avatar, + false, // IsOnline logic shouldn't be here, but we'll leave default for now + DateTime.UtcNow, + fs.Id + )); + } + + return Result.Success(friends); + } +} diff --git a/apps/server-net/src/Modules/Identity/Application/Friends/GetFriendshipStatus.cs b/apps/server-net/src/Modules/Identity/Application/Friends/GetFriendshipStatus.cs new file mode 100644 index 0000000..e759644 --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Application/Friends/GetFriendshipStatus.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Knot.Modules.Identity.Application.Abstractions; +using Knot.Shared.Kernel; +using Microsoft.EntityFrameworkCore; + +namespace Knot.Modules.Identity.Application.Friends; + +public record GetFriendshipStatusQuery(Guid CurrentUserId, Guid TargetUserId) : IQuery; + +internal sealed class GetFriendshipStatusQueryHandler : IQueryHandler +{ + private readonly IIdentityDbContext _context; + + public GetFriendshipStatusQueryHandler(IIdentityDbContext context) + { + _context = context; + } + + public async Task> Handle(GetFriendshipStatusQuery request, CancellationToken cancellationToken) + { + if (request.CurrentUserId == request.TargetUserId) + return Result.Success(new FriendshipStatusResponse("self")); + + var fs = await _context.Friendships + .FirstOrDefaultAsync(f => (f.UserId == request.CurrentUserId && f.FriendId == request.TargetUserId) || + (f.UserId == request.TargetUserId && f.FriendId == request.CurrentUserId), cancellationToken); + + if (fs == null) + return Result.Success(new FriendshipStatusResponse("none")); + + return Result.Success(new FriendshipStatusResponse( + fs.Status.ToString().ToLowerInvariant(), + fs.Id, + fs.UserId == request.CurrentUserId ? "outgoing" : "incoming" + )); + } +} diff --git a/apps/server-net/src/Modules/Identity/Application/Friends/GetIncomingRequests.cs b/apps/server-net/src/Modules/Identity/Application/Friends/GetIncomingRequests.cs new file mode 100644 index 0000000..a3e2a9b --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Application/Friends/GetIncomingRequests.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Knot.Modules.Identity.Application.Abstractions; +using Knot.Modules.Identity.Domain; +using Knot.Shared.Kernel; +using Microsoft.EntityFrameworkCore; + +namespace Knot.Modules.Identity.Application.Friends; + +public record GetIncomingRequestsQuery(Guid UserId) : IQuery>; + +internal sealed class GetIncomingRequestsQueryHandler : IQueryHandler> +{ + private readonly IIdentityDbContext _context; + private readonly IUserRepository _userRepository; + + public GetIncomingRequestsQueryHandler(IIdentityDbContext context, IUserRepository userRepository) + { + _context = context; + _userRepository = userRepository; + } + + public async Task>> Handle(GetIncomingRequestsQuery request, CancellationToken cancellationToken) + { + var friendships = await _context.Friendships + .Where(f => f.FriendId == request.UserId && f.Status == FriendshipStatus.Pending) + .ToListAsync(cancellationToken); + + var requestsList = new List(); + foreach (var fs in friendships) + { + var user = await _userRepository.GetByIdAsync(fs.UserId, cancellationToken); + if (user == null) continue; + + requestsList.Add(new FriendRequestDto( + fs.Id, + new FriendUserDto(user.Id, user.Username, user.DisplayName, user.Avatar), + fs.CreatedAt + )); + } + return Result.Success(requestsList); + } +} diff --git a/apps/server-net/src/Modules/Identity/Application/Friends/GetOutgoingRequests.cs b/apps/server-net/src/Modules/Identity/Application/Friends/GetOutgoingRequests.cs new file mode 100644 index 0000000..c0b8395 --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Application/Friends/GetOutgoingRequests.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Knot.Modules.Identity.Application.Abstractions; +using Knot.Modules.Identity.Domain; +using Knot.Shared.Kernel; +using Microsoft.EntityFrameworkCore; + +namespace Knot.Modules.Identity.Application.Friends; + +public record GetOutgoingRequestsQuery(Guid UserId) : IQuery>; + +internal sealed class GetOutgoingRequestsQueryHandler : IQueryHandler> +{ + private readonly IIdentityDbContext _context; + private readonly IUserRepository _userRepository; + + public GetOutgoingRequestsQueryHandler(IIdentityDbContext context, IUserRepository userRepository) + { + _context = context; + _userRepository = userRepository; + } + + public async Task>> Handle(GetOutgoingRequestsQuery request, CancellationToken cancellationToken) + { + var friendships = await _context.Friendships + .Where(f => f.UserId == request.UserId && f.Status == FriendshipStatus.Pending) + .ToListAsync(cancellationToken); + + var requestsList = new List(); + foreach (var fs in friendships) + { + var user = await _userRepository.GetByIdAsync(fs.FriendId, cancellationToken); + if (user == null) continue; + + requestsList.Add(new FriendRequestDto( + fs.Id, + new FriendUserDto(user.Id, user.Username, user.DisplayName, user.Avatar), + fs.CreatedAt + )); + } + return Result.Success(requestsList); + } +} diff --git a/apps/server-net/src/Modules/Identity/Application/Friends/RemoveFriend.cs b/apps/server-net/src/Modules/Identity/Application/Friends/RemoveFriend.cs new file mode 100644 index 0000000..8ae9a3c --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Application/Friends/RemoveFriend.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Knot.Modules.Identity.Application.Abstractions; +using Knot.Shared.Kernel; + +namespace Knot.Modules.Identity.Application.Friends; + +public record RemoveFriendCommand(Guid UserId, Guid FriendshipId) : ICommand; + +internal sealed class RemoveFriendCommandHandler : ICommandHandler +{ + private readonly IIdentityDbContext _context; + private readonly IIdentityUnitOfWork _unitOfWork; + + public RemoveFriendCommandHandler(IIdentityDbContext context, IIdentityUnitOfWork unitOfWork) + { + _context = context; + _unitOfWork = unitOfWork; + } + + public async Task Handle(RemoveFriendCommand request, CancellationToken cancellationToken) + { + var friendship = await _context.Friendships.FindAsync(new object[] { request.FriendshipId }, cancellationToken); + if (friendship == null || (friendship.UserId != request.UserId && friendship.FriendId != request.UserId)) + return Result.Failure(new Error("Friends.NotFound", "Friendship not found")); + + _context.Friendships.Remove(friendship); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/apps/server-net/src/Modules/Identity/Application/Friends/SendFriendRequest.cs b/apps/server-net/src/Modules/Identity/Application/Friends/SendFriendRequest.cs new file mode 100644 index 0000000..2806746 --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Application/Friends/SendFriendRequest.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Knot.Modules.Identity.Application.Abstractions; +using Knot.Modules.Identity.Domain; +using Knot.Shared.Kernel; +using Microsoft.EntityFrameworkCore; + +namespace Knot.Modules.Identity.Application.Friends; + +public record SendFriendRequestCommand(Guid UserId, Guid FriendId) : ICommand; + +internal sealed class SendFriendRequestCommandHandler : ICommandHandler +{ + private readonly IIdentityDbContext _context; + private readonly IIdentityUnitOfWork _unitOfWork; + + public SendFriendRequestCommandHandler(IIdentityDbContext context, IIdentityUnitOfWork unitOfWork) + { + _context = context; + _unitOfWork = unitOfWork; + } + + public async Task Handle(SendFriendRequestCommand request, CancellationToken cancellationToken) + { + if (request.UserId == request.FriendId) + return Result.Failure(new Error("Friends.Self", "Cannot add yourself")); + + var existing = await _context.Friendships + .FirstOrDefaultAsync(f => (f.UserId == request.UserId && f.FriendId == request.FriendId) || + (f.UserId == request.FriendId && f.FriendId == request.UserId), cancellationToken); + + if (existing != null) + return Result.Failure(new Error("Friends.Exists", "Friendship already exists")); + + var friendship = Friendship.Create(request.UserId, request.FriendId); + _context.Friendships.Add(friendship); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/apps/server-net/src/Modules/Identity/Application/Users/Auth/AuthResponseDto.cs b/apps/server-net/src/Modules/Identity/Application/Users/Auth/AuthResponseDto.cs new file mode 100644 index 0000000..fdedad6 --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Application/Users/Auth/AuthResponseDto.cs @@ -0,0 +1,20 @@ +using System; + +namespace Knot.Modules.Identity.Application.Users.Auth; + +public record AuthResponseDto( + string Token, + AuthUserDto User +); + +public record AuthUserDto( + Guid Id, + string Username, + string DisplayName, + string? Email, + string? Bio, + string? Avatar, + DateTime? Birthday, + bool IsOnline, + DateTime CreatedAt +); diff --git a/apps/server-net/src/Modules/Identity/Application/Users/Avatar/CropAvatar.cs b/apps/server-net/src/Modules/Identity/Application/Users/Avatar/CropAvatar.cs new file mode 100644 index 0000000..9d585ab --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Application/Users/Avatar/CropAvatar.cs @@ -0,0 +1,65 @@ +using Knot.Shared.Kernel; +using Knot.Modules.Identity.Domain; +using Knot.Modules.Identity.Application.Abstractions; +using Knot.Shared.Kernel.Storage; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Processing; + +namespace Knot.Modules.Identity.Application.Users.Avatar; + +public sealed record CropAvatarCommand(Guid UserId, Stream FileStream, string FileName, string ContentType, int X, int Y, int Width, int Height) : ICommand; + +internal sealed class CropAvatarCommandHandler : ICommandHandler +{ + private readonly IUserRepository _userRepository; + private readonly IIdentityUnitOfWork _unitOfWork; + private readonly IFileStorageService _fileStorage; + + public CropAvatarCommandHandler(IUserRepository userRepository, IIdentityUnitOfWork unitOfWork, IFileStorageService fileStorage) + { + _userRepository = userRepository; + _unitOfWork = unitOfWork; + _fileStorage = fileStorage; + } + + public async Task> Handle(CropAvatarCommand request, CancellationToken cancellationToken) + { + var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken); + if (user == null) return Result.Failure(new Error("User.NotFound", "User not found")); + + string avatarUrl; + + using (var image = await SixLabors.ImageSharp.Image.LoadAsync(request.FileStream, cancellationToken)) + { + 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 id = await _fileStorage.UploadFileAsync(outStream, request.FileName, "image/jpeg"); + avatarUrl = $"/api/files/{id}"; + } + + user.UpdateAvatar(avatarUrl); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + var dto = new UserProfileDto( + user.Id, + user.Username, + user.DisplayName, + user.Avatar, + user.Bio, + user.Birthday, + user.CreatedAt + ); + + return Result.Success(dto); + } +} diff --git a/apps/server-net/src/Modules/Identity/Application/Users/Avatar/DeleteAvatar.cs b/apps/server-net/src/Modules/Identity/Application/Users/Avatar/DeleteAvatar.cs new file mode 100644 index 0000000..c049efb --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Application/Users/Avatar/DeleteAvatar.cs @@ -0,0 +1,40 @@ +using Knot.Shared.Kernel; +using Knot.Modules.Identity.Domain; +using Knot.Modules.Identity.Application.Abstractions; + +namespace Knot.Modules.Identity.Application.Users.Avatar; + +public sealed record DeleteAvatarCommand(Guid UserId) : ICommand; + +internal sealed class DeleteAvatarCommandHandler : ICommandHandler +{ + private readonly IUserRepository _userRepository; + private readonly IIdentityUnitOfWork _unitOfWork; + + public DeleteAvatarCommandHandler(IUserRepository userRepository, IIdentityUnitOfWork unitOfWork) + { + _userRepository = userRepository; + _unitOfWork = unitOfWork; + } + + public async Task> Handle(DeleteAvatarCommand request, CancellationToken cancellationToken) + { + var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken); + if (user == null) return Result.Failure(new Error("User.NotFound", "User not found")); + + user.UpdateAvatar(null); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + var dto = new UserProfileDto( + user.Id, + user.Username, + user.DisplayName, + user.Avatar, + user.Bio, + user.Birthday, + user.CreatedAt + ); + + return Result.Success(dto); + } +} diff --git a/apps/server-net/src/Modules/Identity/Application/Users/Avatar/UploadAvatar.cs b/apps/server-net/src/Modules/Identity/Application/Users/Avatar/UploadAvatar.cs new file mode 100644 index 0000000..021e983 --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Application/Users/Avatar/UploadAvatar.cs @@ -0,0 +1,46 @@ +using Knot.Shared.Kernel; +using Knot.Modules.Identity.Domain; +using Knot.Modules.Identity.Application.Abstractions; +using Knot.Shared.Kernel.Storage; + +namespace Knot.Modules.Identity.Application.Users.Avatar; + +public sealed record UploadAvatarCommand(Guid UserId, Stream FileStream, string FileName, string ContentType) : ICommand; + +internal sealed class UploadAvatarCommandHandler : ICommandHandler +{ + private readonly IUserRepository _userRepository; + private readonly IIdentityUnitOfWork _unitOfWork; + private readonly IFileStorageService _fileStorage; + + public UploadAvatarCommandHandler(IUserRepository userRepository, IIdentityUnitOfWork unitOfWork, IFileStorageService fileStorage) + { + _userRepository = userRepository; + _unitOfWork = unitOfWork; + _fileStorage = fileStorage; + } + + public async Task> Handle(UploadAvatarCommand request, CancellationToken cancellationToken) + { + var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken); + if (user == null) return Result.Failure(new Error("User.NotFound", "User not found")); + + var fileId = await _fileStorage.UploadFileAsync(request.FileStream, request.FileName, request.ContentType); + var avatarUrl = $"/api/files/{fileId}"; + + user.UpdateAvatar(avatarUrl); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + var dto = new UserProfileDto( + user.Id, + user.Username, + user.DisplayName, + user.Avatar, + user.Bio, + user.Birthday, + user.CreatedAt + ); + + return Result.Success(dto); + } +} diff --git a/apps/server-net/src/Modules/Identity/Application/Users/GetMe/GetMe.cs b/apps/server-net/src/Modules/Identity/Application/Users/GetMe/GetMe.cs new file mode 100644 index 0000000..0b8780d --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Application/Users/GetMe/GetMe.cs @@ -0,0 +1,41 @@ +using Knot.Shared.Kernel; +using Knot.Modules.Identity.Domain; +using Knot.Modules.Identity.Application.Abstractions; +using Knot.Modules.Identity.Application.Users.Auth; + +namespace Knot.Modules.Identity.Application.Users.GetMe; + +public sealed record GetMeQuery(Guid UserId) : IQuery; + +internal sealed class GetMeQueryHandler : IQueryHandler +{ + private readonly IUserRepository _userRepository; + + public GetMeQueryHandler(IUserRepository userRepository) + { + _userRepository = userRepository; + } + + public async Task> Handle(GetMeQuery request, CancellationToken cancellationToken) + { + var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken); + if (user == null) return Result.Failure(new Error("User.NotFound", "User not found")); + + var response = new AuthResponseDto( + string.Empty, + new AuthUserDto( + user.Id, + user.Username, + user.DisplayName, + user.Email, + user.Bio, + user.Avatar, + user.Birthday, + true, + user.CreatedAt + ) + ); + + return Result.Success(response); + } +} diff --git a/apps/server-net/src/Modules/Identity/Application/Users/GetUser/GetUser.cs b/apps/server-net/src/Modules/Identity/Application/Users/GetUser/GetUser.cs new file mode 100644 index 0000000..34bde91 --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Application/Users/GetUser/GetUser.cs @@ -0,0 +1,38 @@ +using Knot.Shared.Kernel; +using Knot.Modules.Identity.Domain; +using Knot.Modules.Identity.Application.Abstractions; + +namespace Knot.Modules.Identity.Application.Users.GetUser; + +public sealed record GetUserQuery(Guid Id) : IQuery; + +internal sealed class GetUserQueryHandler : IQueryHandler +{ + private readonly IUserRepository _userRepository; + + public GetUserQueryHandler(IUserRepository userRepository) + { + _userRepository = userRepository; + } + + public async Task> Handle(GetUserQuery request, CancellationToken cancellationToken) + { + var user = await _userRepository.GetByIdAsync(request.Id, cancellationToken); + if (user == null) return Result.Failure(new Error("User.NotFound", "User not found")); + + var dto = new UserProfileDto( + user.Id, + user.Username, + user.DisplayName, + user.Avatar, + user.Bio, + user.Birthday, + user.CreatedAt, + null, + false, + DateTime.UtcNow + ); + + return Result.Success(dto); + } +} diff --git a/apps/server-net/src/Modules/Identity/Application/Users/Login/LoginUserCommandHandler.cs b/apps/server-net/src/Modules/Identity/Application/Users/Login/LoginUserCommandHandler.cs index b47d41c..d58ff6e 100644 --- a/apps/server-net/src/Modules/Identity/Application/Users/Login/LoginUserCommandHandler.cs +++ b/apps/server-net/src/Modules/Identity/Application/Users/Login/LoginUserCommandHandler.cs @@ -1,16 +1,17 @@ using Knot.Modules.Identity.Application.Abstractions; using Knot.Modules.Identity.Domain; using Knot.Shared.Kernel; +using Knot.Modules.Identity.Application.Users.Auth; using BCrypt.Net; namespace Knot.Modules.Identity.Application.Users.Login; /// -/// Команда для входа пользователя. Возвращает JWT токен. +/// Команда для входа пользователя. Возвращает AuthResponseDto. /// -public sealed record LoginUserCommand(string Username, string Password) : ICommand; +public sealed record LoginUserCommand(string Username, string Password) : ICommand; -public sealed class LoginUserCommandHandler : ICommandHandler +public sealed class LoginUserCommandHandler : ICommandHandler { private readonly IUserRepository _userRepository; private readonly IJwtTokenProvider _tokenProvider; @@ -21,17 +22,30 @@ public sealed class LoginUserCommandHandler : ICommandHandler> Handle(LoginUserCommand request, CancellationToken cancellationToken) + public async Task> Handle(LoginUserCommand request, CancellationToken cancellationToken) { var user = await _userRepository.GetByUsernameAsync(request.Username, cancellationToken); if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash)) { - return Result.Failure(new Error("Identity.InvalidCredentials", "Неверное имя пользователя или пароль.")); + return Result.Failure(new Error("Identity.InvalidCredentials", "Неверное имя пользователя или пароль.")); } string token = _tokenProvider.Generate(user); - return Result.Success(token); + return Result.Success(new AuthResponseDto( + token, + new AuthUserDto( + user.Id, + user.Username, + user.DisplayName, + user.Email, + user.Bio, + user.Avatar, + user.Birthday, + true, // IsOnline (placeholder) + user.CreatedAt + ) + )); } } diff --git a/apps/server-net/src/Modules/Identity/Application/Users/Register/RegisterUserCommandHandler.cs b/apps/server-net/src/Modules/Identity/Application/Users/Register/RegisterUserCommandHandler.cs index b4b8c6f..9c136b7 100644 --- a/apps/server-net/src/Modules/Identity/Application/Users/Register/RegisterUserCommandHandler.cs +++ b/apps/server-net/src/Modules/Identity/Application/Users/Register/RegisterUserCommandHandler.cs @@ -1,6 +1,8 @@ using Knot.Modules.Identity.Application.Abstractions; using Knot.Modules.Identity.Domain; using Knot.Shared.Kernel; +using Knot.Shared.Kernel.Configuration; +using Knot.Modules.Identity.Application.Users.Auth; using BCrypt.Net; namespace Knot.Modules.Identity.Application.Users.Register; @@ -13,28 +15,41 @@ public sealed record RegisterUserCommand( string Password, string DisplayName, string? Email, - string? Bio) : ICommand; + string? Bio) : ICommand; /// /// Обработчик команды регистрации. /// -public sealed class RegisterUserCommandHandler : ICommandHandler +public sealed class RegisterUserCommandHandler : ICommandHandler { private readonly IUserRepository _userRepository; private readonly IIdentityUnitOfWork _unitOfWork; + private readonly ISettingsService _settings; + private readonly IJwtTokenProvider _tokenProvider; - public RegisterUserCommandHandler(IUserRepository userRepository, IIdentityUnitOfWork unitOfWork) + public RegisterUserCommandHandler( + IUserRepository userRepository, + IIdentityUnitOfWork unitOfWork, + ISettingsService settings, + IJwtTokenProvider tokenProvider) { _userRepository = userRepository; _unitOfWork = unitOfWork; + _settings = settings; + _tokenProvider = tokenProvider; } - public async Task> Handle(RegisterUserCommand request, CancellationToken cancellationToken) + public async Task> Handle(RegisterUserCommand request, CancellationToken cancellationToken) { + if (!_settings.Current.EnableRegistration) + { + return Result.Failure(new Error("Identity.RegistrationDisabled", "Registration is disabled by the administrator.")); + } + // 1. Проверка уникальности username if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken)) { - return Result.Failure(new Error("Identity.UsernameNotUnique", "Это имя пользователя уже занято.")); + return Result.Failure(new Error("Identity.UsernameNotUnique", "Это имя пользователя уже занято.")); } // 2. Хеширование пароля (здесь будет вызов сервиса, пока заглушка) @@ -52,6 +67,21 @@ public sealed class RegisterUserCommandHandler : ICommandHandler>; + +internal sealed class SearchUsersQueryHandler : IQueryHandler> +{ + private readonly IUserRepository _userRepository; + + public SearchUsersQueryHandler(IUserRepository userRepository) + { + _userRepository = userRepository; + } + + public async Task>> Handle(SearchUsersQuery request, CancellationToken cancellationToken) + { + var users = await _userRepository.SearchUsersAsync(request.Query, cancellationToken); + + var result = users.Select(user => new UserDto( + user.Id, + user.Username, + user.DisplayName, + user.Avatar, + false, + DateTime.UtcNow + )).ToList(); + + return Result.Success(result); + } +} diff --git a/apps/server-net/src/Modules/Identity/Application/Users/UpdateProfile/UpdateProfile.cs b/apps/server-net/src/Modules/Identity/Application/Users/UpdateProfile/UpdateProfile.cs new file mode 100644 index 0000000..cd62f9e --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Application/Users/UpdateProfile/UpdateProfile.cs @@ -0,0 +1,40 @@ +using Knot.Shared.Kernel; +using Knot.Modules.Identity.Domain; +using Knot.Modules.Identity.Application.Abstractions; + +namespace Knot.Modules.Identity.Application.Users.UpdateProfile; + +public sealed record UpdateProfileCommand(Guid UserId, string? DisplayName, string? Bio, DateTime? Birthday) : ICommand; + +internal sealed class UpdateProfileCommandHandler : ICommandHandler +{ + private readonly IUserRepository _userRepository; + private readonly IIdentityUnitOfWork _unitOfWork; + + public UpdateProfileCommandHandler(IUserRepository userRepository, IIdentityUnitOfWork unitOfWork) + { + _userRepository = userRepository; + _unitOfWork = unitOfWork; + } + + public async Task> Handle(UpdateProfileCommand request, CancellationToken cancellationToken) + { + var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken); + if (user == null) return Result.Failure(new Error("User.NotFound", "User not found")); + + user.UpdateProfile(request.DisplayName ?? user.DisplayName, request.Bio, request.Birthday); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + var dto = new UserProfileDto( + user.Id, + user.Username, + user.DisplayName, + user.Avatar, + user.Bio, + user.Birthday, + user.CreatedAt + ); + + return Result.Success(dto); + } +} diff --git a/apps/server-net/src/Modules/Identity/Application/Users/UpdateSettings/UpdateSettings.cs b/apps/server-net/src/Modules/Identity/Application/Users/UpdateSettings/UpdateSettings.cs new file mode 100644 index 0000000..2614157 --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Application/Users/UpdateSettings/UpdateSettings.cs @@ -0,0 +1,41 @@ +using Knot.Shared.Kernel; +using Knot.Modules.Identity.Domain; +using Knot.Modules.Identity.Application.Abstractions; + +namespace Knot.Modules.Identity.Application.Users.UpdateSettings; + +public sealed record UpdateSettingsCommand(Guid UserId, bool? HideStoryViews) : ICommand; + +internal sealed class UpdateSettingsCommandHandler : ICommandHandler +{ + private readonly IUserRepository _userRepository; + private readonly IIdentityUnitOfWork _unitOfWork; + + public UpdateSettingsCommandHandler(IUserRepository userRepository, IIdentityUnitOfWork unitOfWork) + { + _userRepository = userRepository; + _unitOfWork = unitOfWork; + } + + public async Task> Handle(UpdateSettingsCommand request, CancellationToken cancellationToken) + { + var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken); + if (user == null) return Result.Failure(new Error("User.NotFound", "User not found")); + + user.UpdateSettings(request.HideStoryViews ?? user.HideStoryViews); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + var dto = new UserProfileDto( + user.Id, + user.Username, + user.DisplayName, + user.Avatar, + user.Bio, + user.Birthday, + user.CreatedAt, + user.HideStoryViews + ); + + return Result.Success(dto); + } +} diff --git a/apps/server-net/src/Modules/Identity/Application/Users/UserDto.cs b/apps/server-net/src/Modules/Identity/Application/Users/UserDto.cs new file mode 100644 index 0000000..66169a2 --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Application/Users/UserDto.cs @@ -0,0 +1,12 @@ +using System; + +namespace Knot.Modules.Identity.Application.Users; + +public record UserDto( + Guid Id, + string Username, + string DisplayName, + string? Avatar, + bool IsOnline, + DateTime LastSeen +); diff --git a/apps/server-net/src/Modules/Identity/Application/Users/UserProfileDto.cs b/apps/server-net/src/Modules/Identity/Application/Users/UserProfileDto.cs new file mode 100644 index 0000000..07b0887 --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Application/Users/UserProfileDto.cs @@ -0,0 +1,16 @@ +using System; + +namespace Knot.Modules.Identity.Application.Users; + +public record UserProfileDto( + Guid Id, + string Username, + string DisplayName, + string? Avatar, + string? Bio, + DateTime? Birthday, + DateTime CreatedAt, + bool? HideStoryViews = null, + bool IsOnline = false, + DateTime? LastSeen = null +); diff --git a/apps/server-net/src/Modules/Identity/DependencyInjection.cs b/apps/server-net/src/Modules/Identity/DependencyInjection.cs index 57065f3..47f2dfb 100644 --- a/apps/server-net/src/Modules/Identity/DependencyInjection.cs +++ b/apps/server-net/src/Modules/Identity/DependencyInjection.cs @@ -26,6 +26,7 @@ public static class DependencyInjection // Регистрация Unit of Work и Репозиториев services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/IdentityDbContext.cs b/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/IdentityDbContext.cs index db9da30..8a66bb2 100644 --- a/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/IdentityDbContext.cs +++ b/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/IdentityDbContext.cs @@ -12,7 +12,7 @@ namespace Knot.Modules.Identity.Infrastructure.Persistence; /// /// Контекст базы данных для модуля Identity. /// -public sealed class IdentityDbContext : DbContext, IIdentityUnitOfWork +public sealed class IdentityDbContext : DbContext, IIdentityUnitOfWork, Knot.Modules.Identity.Application.Abstractions.IIdentityDbContext { private readonly IMediator _mediator; private readonly IEncryptionService _encryptionService; diff --git a/apps/server-net/src/Modules/Identity/Knot.Modules.Identity.csproj b/apps/server-net/src/Modules/Identity/Knot.Modules.Identity.csproj index 48acc15..f2abb95 100644 --- a/apps/server-net/src/Modules/Identity/Knot.Modules.Identity.csproj +++ b/apps/server-net/src/Modules/Identity/Knot.Modules.Identity.csproj @@ -20,6 +20,7 @@ + diff --git a/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Storage/S3FileStorageService.cs b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Storage/S3FileStorageService.cs index 6c1e952..ca1ef25 100644 --- a/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Storage/S3FileStorageService.cs +++ b/apps/server-net/src/Shared/Knot.Shared.Infrastructure/Storage/S3FileStorageService.cs @@ -113,7 +113,7 @@ public class S3FileStorageService : IFileStorageService var tempFilePath = Path.GetTempFileName(); string contentType = "application/octet-stream"; string fileName = fileId; - string ivBase64 = null; + string ivBase64 = string.Empty; var statArgs = new StatObjectArgs().WithBucket(_bucketName).WithObject(fileId); var stat = await _minioClient.StatObjectAsync(statArgs).ConfigureAwait(false); @@ -139,7 +139,7 @@ public class S3FileStorageService : IFileStorageService var msResult = new MemoryStream(); using (var fsRead = new FileStream(tempFilePath, FileMode.Open, FileAccess.Read)) { - var iv = Convert.FromBase64String(ivBase64); + var iv = string.IsNullOrEmpty(ivBase64) ? new byte[16] : Convert.FromBase64String(ivBase64); using var cryptoStream = _encryptionService.CreateDecryptionStream(fsRead, iv); await cryptoStream.CopyToAsync(msResult); } diff --git a/apps/server-net/src/Shared/Knot.Shared.Kernel/IQuery.cs b/apps/server-net/src/Shared/Knot.Shared.Kernel/IQuery.cs new file mode 100644 index 0000000..54f7702 --- /dev/null +++ b/apps/server-net/src/Shared/Knot.Shared.Kernel/IQuery.cs @@ -0,0 +1,13 @@ +using MediatR; + +namespace Knot.Shared.Kernel; + +public interface IQuery : IRequest> +{ +} + +public interface IQueryHandler + : IRequestHandler> + where TQuery : IQuery +{ +} diff --git a/apps/server-net/src/Shared/Knot.Shared.Kernel/MessageResponse.cs b/apps/server-net/src/Shared/Knot.Shared.Kernel/MessageResponse.cs new file mode 100644 index 0000000..3fd56fb --- /dev/null +++ b/apps/server-net/src/Shared/Knot.Shared.Kernel/MessageResponse.cs @@ -0,0 +1,3 @@ +namespace Knot.Shared.Kernel; + +public record MessageResponse(string Message); diff --git a/apps/server-net/src/Shared/Knot.Shared.Kernel/SuccessResponse.cs b/apps/server-net/src/Shared/Knot.Shared.Kernel/SuccessResponse.cs new file mode 100644 index 0000000..f74d8f3 --- /dev/null +++ b/apps/server-net/src/Shared/Knot.Shared.Kernel/SuccessResponse.cs @@ -0,0 +1,3 @@ +namespace Knot.Shared.Kernel; + +public record SuccessResponse(bool Success);