Reorganize root folder structure: Remove apps layer layer
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Unit of Work специфичный для модуля Chats.
|
||||
/// </summary>
|
||||
public interface IChatsUnitOfWork : IUnitOfWork
|
||||
{
|
||||
}
|
||||
129
backend/src/Modules/Chats/Application/Chats/Avatar/Avatar.cs
Normal file
129
backend/src/Modules/Chats/Application/Chats/Avatar/Avatar.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
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<Guid>;
|
||||
|
||||
internal sealed class UploadGroupAvatarCommandHandler : ICommandHandler<UploadGroupAvatarCommand, Guid>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _uow;
|
||||
private readonly IFileStorageService _fileStorage;
|
||||
|
||||
public UploadGroupAvatarCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow, IFileStorageService fileStorage)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_uow = uow;
|
||||
_fileStorage = fileStorage;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(UploadGroupAvatarCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
|
||||
{
|
||||
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
|
||||
}
|
||||
|
||||
var fileId = await _fileStorage.UploadFileAsync(request.FileStream, request.FileName, request.ContentType);
|
||||
var url = $"/api/files/{fileId}";
|
||||
|
||||
chat.UpdateAvatar(url);
|
||||
_chatRepository.Update(chat);
|
||||
await _uow.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(request.ChatId);
|
||||
}
|
||||
}
|
||||
|
||||
public record CropGroupAvatarCommand(Guid ChatId, Guid UserId, string FileName, string ContentType, Stream FileStream, int X, int Y, int Width, int Height) : ICommand<Guid>;
|
||||
|
||||
internal sealed class CropGroupAvatarCommandHandler : ICommandHandler<CropGroupAvatarCommand, Guid>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _uow;
|
||||
private readonly IFileStorageService _fileStorage;
|
||||
|
||||
public CropGroupAvatarCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow, IFileStorageService fileStorage)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_uow = uow;
|
||||
_fileStorage = fileStorage;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(CropGroupAvatarCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
|
||||
{
|
||||
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
|
||||
}
|
||||
|
||||
string url;
|
||||
|
||||
using (var image = await SixLabors.ImageSharp.Image.LoadAsync(request.FileStream))
|
||||
{
|
||||
int startX = Math.Max(0, Math.Min(request.X, image.Width - 1));
|
||||
int startY = Math.Max(0, Math.Min(request.Y, image.Height - 1));
|
||||
int rectWidth = Math.Max(1, Math.Min(request.Width, image.Width - startX));
|
||||
int rectHeight = Math.Max(1, Math.Min(request.Height, image.Height - startY));
|
||||
|
||||
image.Mutate(ctx => ctx.Crop(new SixLabors.ImageSharp.Rectangle(startX, startY, rectWidth, rectHeight)));
|
||||
image.Mutate(ctx => ctx.Resize(400, 400));
|
||||
|
||||
using var outStream = new MemoryStream();
|
||||
await image.SaveAsJpegAsync(outStream, cancellationToken);
|
||||
outStream.Position = 0;
|
||||
|
||||
var fileName = request.FileName ?? "avatar.jpg";
|
||||
var fileId = await _fileStorage.UploadFileAsync(outStream, fileName, "image/jpeg");
|
||||
url = $"/api/files/{fileId}";
|
||||
}
|
||||
|
||||
chat.UpdateAvatar(url);
|
||||
_chatRepository.Update(chat);
|
||||
await _uow.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(request.ChatId);
|
||||
}
|
||||
}
|
||||
|
||||
public record RemoveGroupAvatarCommand(Guid ChatId, Guid UserId) : ICommand<Guid>;
|
||||
|
||||
internal sealed class RemoveGroupAvatarCommandHandler : ICommandHandler<RemoveGroupAvatarCommand, Guid>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _uow;
|
||||
|
||||
public RemoveGroupAvatarCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(RemoveGroupAvatarCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
|
||||
{
|
||||
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
|
||||
}
|
||||
|
||||
chat.UpdateAvatar(null);
|
||||
_chatRepository.Update(chat);
|
||||
await _uow.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(request.ChatId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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<MessageResponse>;
|
||||
|
||||
internal sealed class ClearChatCommandHandler : ICommandHandler<ClearChatCommand, MessageResponse>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
|
||||
public ClearChatCommandHandler(IChatRepository chatRepository)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
}
|
||||
|
||||
public async Task<Result<MessageResponse>> Handle(ClearChatCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
|
||||
{
|
||||
return Result.Failure<MessageResponse>(ChatErrors.ChatNotFound);
|
||||
}
|
||||
|
||||
// Currently a placeholder
|
||||
return Result.Success(new MessageResponse("Cleared"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
using Knot.Modules.Chats.Application.Abstractions;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Chats.Create;
|
||||
|
||||
/// <summary>
|
||||
/// Команда для создания чата.
|
||||
/// </summary>
|
||||
public sealed record CreateChatCommand(
|
||||
string Name,
|
||||
ChatType Type,
|
||||
List<Guid> MemberIds) : ICommand<Guid>;
|
||||
|
||||
public sealed class CreateChatCommandHandler : ICommandHandler<CreateChatCommand, Guid>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _unitOfWork;
|
||||
|
||||
public CreateChatCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(CreateChatCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = Chat.Create(request.Name, request.Type);
|
||||
|
||||
for (int i = 0; i < request.MemberIds.Count; i++)
|
||||
{
|
||||
var userId = request.MemberIds[i];
|
||||
var role = (i == 0) ? ChatRole.Owner : ChatRole.Member;
|
||||
chat.AddMember(userId, role);
|
||||
}
|
||||
|
||||
_chatRepository.Add(chat);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(chat.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
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;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Chats.GetChatById;
|
||||
|
||||
public record GetChatByIdQuery(Guid UserId, Guid ChatId) : IQuery<ChatDto?>;
|
||||
|
||||
internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery, ChatDto?>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IUserDisplayNameProvider _userProvider;
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
|
||||
public GetChatByIdQueryHandler(IChatRepository chatRepository, IUserDisplayNameProvider userProvider, IMessageRepository messageRepository)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_userProvider = userProvider;
|
||||
_messageRepository = messageRepository;
|
||||
}
|
||||
|
||||
public async Task<Result<ChatDto?>> Handle(GetChatByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat == null)
|
||||
{
|
||||
return Result.Success<ChatDto?>(null);
|
||||
}
|
||||
|
||||
if (!chat.Members.Any(m => m.UserId == request.UserId))
|
||||
{
|
||||
return Result.Failure<ChatDto?>(ChatErrors.ChatsForbidden);
|
||||
}
|
||||
|
||||
var userIdsToFetch = new HashSet<Guid>();
|
||||
foreach (var member in chat.Members)
|
||||
{
|
||||
userIdsToFetch.Add(member.UserId);
|
||||
}
|
||||
|
||||
var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken);
|
||||
if (latestMessage != null)
|
||||
{
|
||||
userIdsToFetch.Add(latestMessage.SenderId);
|
||||
foreach (var reaction in latestMessage.Reactions)
|
||||
{
|
||||
userIdsToFetch.Add(reaction.UserId);
|
||||
}
|
||||
}
|
||||
|
||||
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
|
||||
|
||||
var members = new List<ChatMemberDto>();
|
||||
foreach (var member in chat.Members)
|
||||
{
|
||||
usersInfo.TryGetValue(member.UserId, out var user);
|
||||
members.Add(new ChatMemberDto(
|
||||
member.Id,
|
||||
member.UserId,
|
||||
member.Role,
|
||||
member.IsPinned,
|
||||
user != null ? new ChatUserDto(
|
||||
user.Id,
|
||||
user.Username,
|
||||
user.DisplayName,
|
||||
user.Avatar,
|
||||
false,
|
||||
DateTime.UtcNow
|
||||
) : null
|
||||
));
|
||||
}
|
||||
|
||||
var messagesList = new List<ChatMessageDto>();
|
||||
if (latestMessage != null)
|
||||
{
|
||||
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
|
||||
|
||||
var reactionsWithUser = new List<ReactionDto>();
|
||||
foreach (var reaction in latestMessage.Reactions)
|
||||
{
|
||||
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
|
||||
reactionsWithUser.Add(new ReactionDto(
|
||||
reaction.Id,
|
||||
reaction.Emoji,
|
||||
reaction.UserId,
|
||||
reactionUser != null
|
||||
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
|
||||
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null)
|
||||
));
|
||||
}
|
||||
|
||||
messagesList.Add(new ChatMessageDto(
|
||||
latestMessage.Id,
|
||||
latestMessage.ChatId,
|
||||
latestMessage.SenderId,
|
||||
latestMessage.Content,
|
||||
latestMessage.Type,
|
||||
latestMessage.ReplyToId,
|
||||
latestMessage.Quote,
|
||||
latestMessage.StoryId,
|
||||
latestMessage.StoryMediaUrl,
|
||||
latestMessage.StoryMediaType,
|
||||
latestMessage.IsEdited,
|
||||
latestMessage.IsDeleted,
|
||||
latestMessage.CreatedAt,
|
||||
latestMessage.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
|
||||
senderObj != null ? new MessageSenderDto(
|
||||
senderObj.Id,
|
||||
senderObj.Username,
|
||||
senderObj.DisplayName,
|
||||
senderObj.Avatar
|
||||
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
|
||||
reactionsWithUser,
|
||||
latestMessage.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.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<ChatDto?>(dto);
|
||||
}
|
||||
}
|
||||
|
||||
148
backend/src/Modules/Chats/Application/Chats/GetChats/GetChats.cs
Normal file
148
backend/src/Modules/Chats/Application/Chats/GetChats/GetChats.cs
Normal file
@@ -0,0 +1,148 @@
|
||||
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;
|
||||
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Chats.GetChats;
|
||||
|
||||
public record GetChatsQuery(Guid UserId) : IQuery<List<ChatDto>>;
|
||||
|
||||
internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<ChatDto>>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IUserDisplayNameProvider _userProvider;
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
|
||||
public GetChatsQueryHandler(IChatRepository chatRepository, IUserDisplayNameProvider userProvider, IMessageRepository messageRepository)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_userProvider = userProvider;
|
||||
_messageRepository = messageRepository;
|
||||
}
|
||||
|
||||
public async Task<Result<List<ChatDto>>> Handle(GetChatsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var userChats = await _chatRepository.GetUserChatsAsync(request.UserId, cancellationToken);
|
||||
var dtos = new List<ChatDto>();
|
||||
bool hasFavorites = false;
|
||||
|
||||
foreach (var chat in userChats)
|
||||
{
|
||||
var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken);
|
||||
|
||||
if (latestMessage == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var userIdsToFetch = new HashSet<Guid>();
|
||||
foreach (var member in chat.Members)
|
||||
{
|
||||
userIdsToFetch.Add(member.UserId);
|
||||
}
|
||||
|
||||
userIdsToFetch.Add(latestMessage.SenderId);
|
||||
foreach (var r in latestMessage.Reactions)
|
||||
{
|
||||
userIdsToFetch.Add(r.UserId);
|
||||
}
|
||||
|
||||
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
|
||||
|
||||
var members = new List<ChatMemberDto>();
|
||||
foreach (var member in chat.Members)
|
||||
{
|
||||
usersInfo.TryGetValue(member.UserId, out var user);
|
||||
members.Add(new ChatMemberDto(
|
||||
member.Id,
|
||||
member.UserId,
|
||||
member.Role,
|
||||
member.IsPinned,
|
||||
user != null ? new ChatUserDto(
|
||||
user.Id,
|
||||
user.Username,
|
||||
user.DisplayName,
|
||||
user.Avatar,
|
||||
false,
|
||||
DateTime.UtcNow
|
||||
) : null
|
||||
));
|
||||
}
|
||||
|
||||
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
|
||||
|
||||
var reactionsWithUser = new List<ReactionDto>();
|
||||
foreach (var reaction in latestMessage.Reactions)
|
||||
{
|
||||
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
|
||||
reactionsWithUser.Add(new ReactionDto(
|
||||
reaction.Id,
|
||||
reaction.Emoji,
|
||||
reaction.UserId,
|
||||
reactionUser != null
|
||||
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
|
||||
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null)
|
||||
));
|
||||
}
|
||||
|
||||
var messagesList = new List<ChatMessageDto>
|
||||
{
|
||||
new ChatMessageDto(
|
||||
latestMessage.Id,
|
||||
latestMessage.ChatId,
|
||||
latestMessage.SenderId,
|
||||
latestMessage.Content,
|
||||
latestMessage.Type,
|
||||
latestMessage.ReplyToId,
|
||||
latestMessage.Quote,
|
||||
latestMessage.StoryId,
|
||||
latestMessage.StoryMediaUrl,
|
||||
latestMessage.StoryMediaType,
|
||||
latestMessage.IsEdited,
|
||||
latestMessage.IsDeleted,
|
||||
latestMessage.CreatedAt,
|
||||
latestMessage.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
|
||||
senderObj != null ? new MessageSenderDto(
|
||||
senderObj.Id,
|
||||
senderObj.Username,
|
||||
senderObj.DisplayName,
|
||||
senderObj.Avatar
|
||||
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
|
||||
reactionsWithUser,
|
||||
latestMessage.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList()
|
||||
)
|
||||
};
|
||||
|
||||
var unreadCount = await _messageRepository.GetUnreadCountAsync(chat.Id, request.UserId, cancellationToken);
|
||||
|
||||
dtos.Add(new ChatDto(
|
||||
chat.Id,
|
||||
chat.Type.ToString().ToLowerInvariant(),
|
||||
chat.Type == ChatType.Favorites ? "Избранное" : (chat.Type == ChatType.Personal ? null : chat.Name),
|
||||
chat.Description,
|
||||
chat.Avatar,
|
||||
chat.CreatedAt,
|
||||
members,
|
||||
messagesList,
|
||||
unreadCount
|
||||
));
|
||||
}
|
||||
|
||||
if (!hasFavorites)
|
||||
{
|
||||
var favs = new ChatDto(Guid.Empty, "favorites", "Избранное", null, null, DateTime.UtcNow, new List<ChatMemberDto>(), new List<ChatMessageDto>(), 0);
|
||||
dtos.Add(favs);
|
||||
}
|
||||
|
||||
var sorted = dtos.OrderByDescending(d => d.Messages.FirstOrDefault()?.CreatedAt ?? d.CreatedAt).ToList();
|
||||
return Result.Success(sorted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Chats.Application.Abstractions;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Chats.GetOrCreateFavorites;
|
||||
|
||||
public sealed record GetOrCreateFavoritesCommand(Guid UserId) : ICommand<Guid>;
|
||||
|
||||
public sealed class GetOrCreateFavoritesCommandHandler : ICommandHandler<GetOrCreateFavoritesCommand, Guid>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _unitOfWork;
|
||||
|
||||
public GetOrCreateFavoritesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(GetOrCreateFavoritesCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var favorites = await _chatRepository.GetFavoritesAsync(request.UserId, cancellationToken);
|
||||
|
||||
if (favorites != null)
|
||||
{
|
||||
return Result.Success(favorites.Id);
|
||||
}
|
||||
|
||||
// Create new favorites chat
|
||||
var chat = Chat.Create("Избранное", ChatType.Favorites);
|
||||
chat.AddMember(request.UserId, ChatRole.Owner);
|
||||
|
||||
_chatRepository.Add(chat);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(chat.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
|
||||
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<SuccessResponse>;
|
||||
|
||||
internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrDeleteChatCommand, SuccessResponse>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _uow;
|
||||
|
||||
public LeaveOrDeleteChatCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<Result<SuccessResponse>> Handle(LeaveOrDeleteChatCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat == null)
|
||||
{
|
||||
return Result.Success(new SuccessResponse(true));
|
||||
}
|
||||
|
||||
if (!chat.Members.Any(m => m.UserId == request.UserId))
|
||||
{
|
||||
return Result.Failure<SuccessResponse>(ChatErrors.Unauthorized);
|
||||
}
|
||||
|
||||
if (chat.Type == ChatType.Group)
|
||||
{
|
||||
chat.RemoveMember(request.UserId);
|
||||
_chatRepository.Update(chat);
|
||||
}
|
||||
else
|
||||
{
|
||||
_chatRepository.Remove(chat);
|
||||
}
|
||||
|
||||
await _uow.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(new SuccessResponse(true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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<Guid> UserIdsToAdd) : ICommand<Guid>;
|
||||
|
||||
internal sealed class AddMembersCommandHandler : ICommandHandler<AddMembersCommand, Guid>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _uow;
|
||||
|
||||
public AddMembersCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(AddMembersCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
|
||||
{
|
||||
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
|
||||
}
|
||||
|
||||
foreach (var userId in request.UserIdsToAdd)
|
||||
{
|
||||
chat.AddMember(userId);
|
||||
}
|
||||
_chatRepository.Update(chat);
|
||||
await _uow.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(request.ChatId);
|
||||
}
|
||||
}
|
||||
|
||||
public record RemoveMemberCommand(Guid ChatId, Guid UserId, Guid UserIdToRemove) : ICommand<Guid>;
|
||||
|
||||
internal sealed class RemoveMemberCommandHandler : ICommandHandler<RemoveMemberCommand, Guid>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _uow;
|
||||
|
||||
public RemoveMemberCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(RemoveMemberCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
|
||||
{
|
||||
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
|
||||
}
|
||||
|
||||
chat.RemoveMember(request.UserIdToRemove);
|
||||
_chatRepository.Update(chat);
|
||||
await _uow.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(request.ChatId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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<TogglePinResponse>;
|
||||
|
||||
internal sealed class TogglePinCommandHandler : ICommandHandler<TogglePinCommand, TogglePinResponse>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _uow;
|
||||
|
||||
public TogglePinCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<Result<TogglePinResponse>> Handle(TogglePinCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat == null)
|
||||
{
|
||||
return Result.Failure<TogglePinResponse>(ChatErrors.ChatNotFound);
|
||||
}
|
||||
|
||||
var member = chat.Members.FirstOrDefault(m => m.UserId == request.UserId);
|
||||
if (member == null)
|
||||
{
|
||||
return Result.Failure<TogglePinResponse>(ChatErrors.ChatNotFound);
|
||||
}
|
||||
|
||||
member.TogglePin();
|
||||
_chatRepository.Update(chat);
|
||||
await _uow.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(new TogglePinResponse(member.IsPinned));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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<Guid>;
|
||||
|
||||
internal sealed class UpdateChatCommandHandler : ICommandHandler<UpdateChatCommand, Guid>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _uow;
|
||||
|
||||
public UpdateChatCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(UpdateChatCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
|
||||
{
|
||||
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
|
||||
}
|
||||
|
||||
if (request.Name != null)
|
||||
{
|
||||
chat.UpdateName(request.Name);
|
||||
}
|
||||
|
||||
if (request.Description != null)
|
||||
{
|
||||
chat.UpdateDescription(request.Description);
|
||||
}
|
||||
|
||||
_chatRepository.Update(chat);
|
||||
await _uow.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(chat.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.DTOs;
|
||||
|
||||
public sealed record AddMembersRequest(List<Guid> UserIds);
|
||||
16
backend/src/Modules/Chats/Application/DTOs/ChatDto.cs
Normal file
16
backend/src/Modules/Chats/Application/DTOs/ChatDto.cs
Normal file
@@ -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<ChatMemberDto> Members,
|
||||
List<ChatMessageDto> Messages,
|
||||
int UnreadCount
|
||||
);
|
||||
12
backend/src/Modules/Chats/Application/DTOs/ChatMemberDto.cs
Normal file
12
backend/src/Modules/Chats/Application/DTOs/ChatMemberDto.cs
Normal file
@@ -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
|
||||
);
|
||||
24
backend/src/Modules/Chats/Application/DTOs/ChatMessageDto.cs
Normal file
24
backend/src/Modules/Chats/Application/DTOs/ChatMessageDto.cs
Normal file
@@ -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<MediaDto> Media,
|
||||
MessageSenderDto Sender,
|
||||
List<ReactionDto> Reactions,
|
||||
List<ReadByDto> ReadBy
|
||||
);
|
||||
12
backend/src/Modules/Chats/Application/DTOs/ChatUserDto.cs
Normal file
12
backend/src/Modules/Chats/Application/DTOs/ChatUserDto.cs
Normal file
@@ -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
|
||||
);
|
||||
@@ -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<Guid> MemberIds);
|
||||
@@ -0,0 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.DTOs;
|
||||
|
||||
public sealed record CreateGroupChatRequest(string Name, List<Guid> MemberIds);
|
||||
@@ -0,0 +1,5 @@
|
||||
using System;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.DTOs;
|
||||
|
||||
public sealed record CreatePersonalChatRequest(Guid UserId);
|
||||
11
backend/src/Modules/Chats/Application/DTOs/MediaDto.cs
Normal file
11
backend/src/Modules/Chats/Application/DTOs/MediaDto.cs
Normal file
@@ -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
|
||||
);
|
||||
@@ -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<MediaDto> Media,
|
||||
MessageSenderDto? Sender,
|
||||
List<ReadByDto> ReadBy,
|
||||
List<MessageReactionDto> Reactions
|
||||
);
|
||||
|
||||
public record ReplyToMessageDto(
|
||||
Guid Id,
|
||||
string? Content,
|
||||
bool IsDeleted,
|
||||
List<MediaDto> Media,
|
||||
MessageSenderDto? Sender
|
||||
);
|
||||
|
||||
public record MessageReactionDto(
|
||||
Guid Id,
|
||||
string Emoji,
|
||||
Guid UserId,
|
||||
MessageSenderDto? User
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.DTOs;
|
||||
|
||||
public record MessageSenderDto(
|
||||
Guid Id,
|
||||
string Username,
|
||||
string DisplayName,
|
||||
string? Avatar
|
||||
);
|
||||
10
backend/src/Modules/Chats/Application/DTOs/ReactionDto.cs
Normal file
10
backend/src/Modules/Chats/Application/DTOs/ReactionDto.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.DTOs;
|
||||
|
||||
public record ReactionDto(
|
||||
Guid Id,
|
||||
string Emoji,
|
||||
Guid UserId,
|
||||
MessageSenderDto User
|
||||
);
|
||||
7
backend/src/Modules/Chats/Application/DTOs/ReadByDto.cs
Normal file
7
backend/src/Modules/Chats/Application/DTOs/ReadByDto.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
using System;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.DTOs;
|
||||
|
||||
public record ReadByDto(
|
||||
Guid UserId
|
||||
);
|
||||
@@ -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<MediaDto> Media,
|
||||
MessageSenderDto Sender,
|
||||
List<SimpleReactionDto> Reactions,
|
||||
List<ReadByDto> ReadBy
|
||||
);
|
||||
|
||||
public record SimpleReactionDto(
|
||||
Guid UserId,
|
||||
string Emoji
|
||||
);
|
||||
@@ -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<AttachmentDto>? Attachments = null,
|
||||
Guid? ReplyToId = null,
|
||||
string? Quote = null,
|
||||
Guid? ForwardedFromId = null);
|
||||
|
||||
public sealed record AttachmentDto(string Type, string Url, string? FileName, long? FileSize);
|
||||
20
backend/src/Modules/Chats/Application/DTOs/SharedMediaDto.cs
Normal file
20
backend/src/Modules/Chats/Application/DTOs/SharedMediaDto.cs
Normal file
@@ -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<string>? Links,
|
||||
MessageSenderDto? Sender,
|
||||
Guid? ReplyToId,
|
||||
string? Quote,
|
||||
Guid? StoryId,
|
||||
string? StoryMediaUrl,
|
||||
string? StoryMediaType,
|
||||
bool? IsEdited,
|
||||
string? Type,
|
||||
List<MediaDto>? Media
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Knot.Modules.Chats.Application.DTOs;
|
||||
|
||||
public record TogglePinResponse(bool IsPinned);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Knot.Modules.Chats.Application.DTOs;
|
||||
|
||||
public sealed record UpdateChatRequest(string? Name, string? Description);
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,79 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using global::Knot.Modules.Chats.Domain;
|
||||
using global::Knot.Modules.Chats.Infrastructure.SignalR;
|
||||
using global::Knot.Shared.Kernel;
|
||||
using global::Knot.Modules.Chats.Application.Abstractions;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Messages.Delete;
|
||||
|
||||
public sealed record DeleteMessagesCommand(
|
||||
Guid ChatId,
|
||||
Guid UserId,
|
||||
List<Guid> MessageIds,
|
||||
bool DeleteForAll) : ICommand;
|
||||
|
||||
public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessagesCommand>
|
||||
{
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly IChatsUnitOfWork _unitOfWork;
|
||||
private readonly IHubContext<ChatHub> _hubContext;
|
||||
|
||||
public DeleteMessagesCommandHandler(
|
||||
IMessageRepository messageRepository,
|
||||
IChatsUnitOfWork unitOfWork,
|
||||
IHubContext<ChatHub> hubContext)
|
||||
{
|
||||
_messageRepository = messageRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
_hubContext = hubContext;
|
||||
}
|
||||
|
||||
public async Task<global::Knot.Shared.Kernel.Result> Handle(DeleteMessagesCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var id in request.MessageIds)
|
||||
{
|
||||
var message = await _messageRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (message is null || message.ChatId != request.ChatId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (request.DeleteForAll)
|
||||
{
|
||||
if (message.SenderId == request.UserId)
|
||||
{
|
||||
message.Delete();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
message.DeleteForUser(request.UserId);
|
||||
}
|
||||
|
||||
await _messageRepository.UpdateAsync(message, cancellationToken);
|
||||
}
|
||||
|
||||
if (request.DeleteForAll)
|
||||
{
|
||||
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
messageIds = request.MessageIds,
|
||||
deleteForAll = true
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// Уведомляем только самого пользователя (все его текущие сессии)
|
||||
await _hubContext.Clients.User(request.UserId.ToString()).SendAsync("messages_deleted", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
messageIds = request.MessageIds,
|
||||
deleteForAll = false
|
||||
});
|
||||
}
|
||||
|
||||
return global::Knot.Shared.Kernel.Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Messages.GetMessages;
|
||||
|
||||
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor) : IQuery<List<MessageDetailDto>>;
|
||||
|
||||
internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>>
|
||||
{
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly IUserDisplayNameProvider _userProvider;
|
||||
private readonly IChatRepository _chatRepository;
|
||||
|
||||
public GetMessagesQueryHandler(IMessageRepository messageRepository, IUserDisplayNameProvider userProvider, IChatRepository chatRepository)
|
||||
{
|
||||
_messageRepository = messageRepository;
|
||||
_userProvider = userProvider;
|
||||
_chatRepository = chatRepository;
|
||||
}
|
||||
|
||||
public async Task<Result<List<MessageDetailDto>>> Handle(GetMessagesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
|
||||
{
|
||||
return Result.Failure<List<MessageDetailDto>>(ChatErrors.ChatsForbidden);
|
||||
}
|
||||
|
||||
DateTime? cursorDate = null;
|
||||
if (!string.IsNullOrEmpty(request.Cursor) && DateTime.TryParse(request.Cursor, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
|
||||
{
|
||||
cursorDate = parsed.ToUniversalTime();
|
||||
}
|
||||
|
||||
var messages = await _messageRepository.GetChatMessagesCursorAsync(request.ChatId, cursorDate, ChatConstants.DefaultMessageQueryLimit, cancellationToken);
|
||||
var result = new List<MessageDetailDto>();
|
||||
|
||||
var userIdsToFetch = new HashSet<Guid>();
|
||||
var replyMessages = new Dictionary<Guid, Message>();
|
||||
|
||||
// Filter messages that are deleted for the current user before processing
|
||||
var filteredMessages = messages.Where(m => !m.IsDeletedForUser(request.UserId)).ToList();
|
||||
|
||||
foreach (var m in filteredMessages)
|
||||
{
|
||||
userIdsToFetch.Add(m.SenderId);
|
||||
|
||||
if (!m.ReplyToId.HasValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var replyMsg = await _messageRepository.GetByIdAsync(m.ReplyToId.Value, cancellationToken);
|
||||
if (replyMsg == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
replyMessages[replyMsg.Id] = replyMsg;
|
||||
userIdsToFetch.Add(replyMsg.SenderId);
|
||||
}
|
||||
|
||||
var senders = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
if (message.IsDeletedForUser(request.UserId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ReplyToMessageDto? replyToObj = null;
|
||||
if (message.ReplyToId.HasValue && replyMessages.TryGetValue(message.ReplyToId.Value, out var replyMsg))
|
||||
{
|
||||
var senderObj = senders.TryGetValue(replyMsg.SenderId, out var rs)
|
||||
? new MessageSenderDto(rs.Id, rs.Username, rs.DisplayName, rs.Avatar)
|
||||
: null;
|
||||
|
||||
replyToObj = new ReplyToMessageDto(
|
||||
replyMsg.Id,
|
||||
replyMsg.Content,
|
||||
replyMsg.IsDeleted,
|
||||
replyMsg.Media.Select(rm => new MediaDto(rm.Id, rm.Type, rm.Url, rm.Filename, rm.Size)).ToList(),
|
||||
senderObj
|
||||
);
|
||||
}
|
||||
|
||||
var reactionsWithUser = new List<MessageReactionDto>();
|
||||
foreach (var reaction in message.Reactions)
|
||||
{
|
||||
var userObj = senders.TryGetValue(reaction.UserId, out var reactionUser)
|
||||
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
|
||||
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null);
|
||||
|
||||
reactionsWithUser.Add(new MessageReactionDto(
|
||||
reaction.Id,
|
||||
reaction.Emoji,
|
||||
reaction.UserId,
|
||||
userObj
|
||||
));
|
||||
}
|
||||
|
||||
result.Add(new MessageDetailDto(
|
||||
message.Id,
|
||||
message.ChatId,
|
||||
message.SenderId,
|
||||
message.Content,
|
||||
message.Type,
|
||||
message.ReplyToId,
|
||||
replyToObj,
|
||||
message.Quote,
|
||||
message.IsEdited,
|
||||
message.IsDeleted,
|
||||
message.CreatedAt,
|
||||
message.ForwardedFromId,
|
||||
message.ForwardedFromId.HasValue && senders.TryGetValue(message.ForwardedFromId.Value, out var fwdUser) ? new MessageSenderDto(fwdUser.Id, fwdUser.Username, fwdUser.DisplayName, fwdUser.Avatar) : null,
|
||||
message.StoryId,
|
||||
message.StoryMediaUrl,
|
||||
message.StoryMediaType,
|
||||
message.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
|
||||
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : null,
|
||||
message.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList(),
|
||||
reactionsWithUser
|
||||
));
|
||||
}
|
||||
|
||||
return Result.Success(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
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.Chats.Application.Abstractions;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Messages.GetSharedMedia;
|
||||
|
||||
public record GetSharedMediaQuery(Guid UserId, Guid ChatId, string? Type) : IQuery<List<SharedMediaDto>>;
|
||||
|
||||
internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQuery, List<SharedMediaDto>>
|
||||
{
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly IUserDisplayNameProvider _userProvider;
|
||||
private readonly IChatRepository _chatRepository;
|
||||
|
||||
public GetSharedMediaQueryHandler(IMessageRepository messageRepository, IUserDisplayNameProvider userProvider, IChatRepository chatRepository)
|
||||
{
|
||||
_messageRepository = messageRepository;
|
||||
_userProvider = userProvider;
|
||||
_chatRepository = chatRepository;
|
||||
}
|
||||
|
||||
public async Task<Result<List<SharedMediaDto>>> Handle(GetSharedMediaQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat == null || !chat.Members.Any(member => member.UserId == request.UserId))
|
||||
{
|
||||
return Result.Failure<List<SharedMediaDto>>(ChatErrors.ChatsForbidden);
|
||||
}
|
||||
|
||||
var messages = await _messageRepository.GetChatMessagesAsync(request.ChatId, ChatConstants.MaxSharedMediaQueryLimit, 0, cancellationToken);
|
||||
messages = messages.Where(message => !message.IsDeletedForUser(request.UserId)).ToList();
|
||||
|
||||
var result = new List<SharedMediaDto>();
|
||||
var filterType = request.Type?.ToLower();
|
||||
|
||||
var userIds = messages.Select(message => message.SenderId).Distinct();
|
||||
var senders = await _userProvider.GetUsersInfoAsync(userIds, cancellationToken);
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
if (filterType == "links")
|
||||
{
|
||||
var messageContent = message.Content;
|
||||
var linkRegex = new Regex(@"https?://[^\s]+", RegexOptions.IgnoreCase);
|
||||
var contentLinks = !string.IsNullOrEmpty(messageContent) ? linkRegex.Matches(messageContent).Select(match => match.Value).ToList() : new List<string>();
|
||||
var messageMediaColl = message.Media;
|
||||
var mediaLinks = (messageMediaColl ?? Enumerable.Empty<Media>()).Where(media => media.Type?.ToString().ToLower() == "link").Select(media => media.Url).ToList();
|
||||
var allLinks = contentLinks.Concat(mediaLinks).Distinct().ToList();
|
||||
|
||||
if (allLinks.Any())
|
||||
{
|
||||
senders.TryGetValue(message.SenderId, out var sender);
|
||||
result.Add(new SharedMediaDto(
|
||||
message.Id,
|
||||
messageContent,
|
||||
message.CreatedAt,
|
||||
allLinks,
|
||||
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : null,
|
||||
null, null, null, null, null, null, null, null
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var messageMedia = message.Media;
|
||||
if (messageMedia == null || !messageMedia.Any())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var filteredMedia = messageMedia.Where(media =>
|
||||
{
|
||||
var mediaType = media.Type?.ToLower() ?? "file";
|
||||
var isGif = mediaType == "image" && (media.Url.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) || media.Url.EndsWith(".gif", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (filterType == "gifs")
|
||||
{
|
||||
return isGif;
|
||||
}
|
||||
|
||||
if (filterType == "files")
|
||||
{
|
||||
return mediaType != "image" && mediaType != "video" && mediaType != "link";
|
||||
}
|
||||
|
||||
return true;
|
||||
}).ToList();
|
||||
|
||||
if (filteredMedia.Any())
|
||||
{
|
||||
senders.TryGetValue(message.SenderId, out var sender);
|
||||
result.Add(new SharedMediaDto(
|
||||
message.Id,
|
||||
message.Content,
|
||||
message.CreatedAt,
|
||||
null,
|
||||
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : null,
|
||||
message.ReplyToId,
|
||||
message.Quote,
|
||||
message.StoryId,
|
||||
message.StoryMediaUrl,
|
||||
message.StoryMediaType,
|
||||
message.IsEdited,
|
||||
message.Type,
|
||||
filteredMedia.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return Result.Success(result.OrderByDescending(x => x.CreatedAt).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Knot.Modules.Chats.Application.Abstractions;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Modules.Chats.Infrastructure.SignalR;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Messages.React;
|
||||
|
||||
public sealed record AddReactionCommand(
|
||||
Guid MessageId,
|
||||
Guid UserId,
|
||||
string Emoji,
|
||||
Guid ChatId) : ICommand;
|
||||
|
||||
public sealed class AddReactionCommandHandler : ICommandHandler<AddReactionCommand>
|
||||
{
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly IChatsUnitOfWork _unitOfWork;
|
||||
private readonly IHubContext<ChatHub> _hubContext;
|
||||
private readonly IUserDisplayNameProvider _displayNameProvider;
|
||||
private readonly ILogger<AddReactionCommandHandler> _logger;
|
||||
|
||||
public AddReactionCommandHandler(
|
||||
IMessageRepository messageRepository,
|
||||
|
||||
IChatsUnitOfWork unitOfWork,
|
||||
IHubContext<ChatHub> hubContext,
|
||||
IUserDisplayNameProvider displayNameProvider,
|
||||
ILogger<AddReactionCommandHandler> logger)
|
||||
{
|
||||
_messageRepository = messageRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
_hubContext = hubContext;
|
||||
_displayNameProvider = displayNameProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Result> Handle(AddReactionCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("AddReaction: MessageId={MessageId}, UserId={UserId}, Emoji={Emoji}, ChatId={ChatId}",
|
||||
|
||||
request.MessageId, request.UserId, request.Emoji, request.ChatId);
|
||||
|
||||
|
||||
var success = await _messageRepository.AddReactionAsync(
|
||||
request.MessageId,
|
||||
|
||||
request.UserId,
|
||||
|
||||
request.Emoji,
|
||||
|
||||
cancellationToken);
|
||||
|
||||
|
||||
if (!success)
|
||||
{
|
||||
_logger.LogWarning("AddReaction: Message not found {MessageId}", request.MessageId);
|
||||
return Result.Failure(ChatErrors.MessagesNotFound);
|
||||
}
|
||||
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
|
||||
_logger.LogInformation("AddReaction: Reaction saved to database");
|
||||
|
||||
var username = await _displayNameProvider.GetDisplayNameAsync(request.UserId, cancellationToken);
|
||||
|
||||
|
||||
_logger.LogInformation("AddReaction: Sending reaction_added to group {ChatId}", request.ChatId);
|
||||
|
||||
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("reaction_added", new
|
||||
{
|
||||
messageId = request.MessageId,
|
||||
chatId = request.ChatId,
|
||||
userId = request.UserId,
|
||||
username = username,
|
||||
emoji = request.Emoji
|
||||
});
|
||||
|
||||
_logger.LogInformation("AddReaction: reaction_added sent successfully");
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Knot.Modules.Chats.Application.Abstractions;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Modules.Chats.Infrastructure.SignalR;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Messages.React;
|
||||
|
||||
public sealed record RemoveReactionCommand(
|
||||
Guid MessageId,
|
||||
Guid UserId,
|
||||
string Emoji,
|
||||
Guid ChatId) : ICommand;
|
||||
|
||||
public sealed class RemoveReactionCommandHandler : ICommandHandler<RemoveReactionCommand>
|
||||
{
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly IChatsUnitOfWork _unitOfWork;
|
||||
private readonly IHubContext<ChatHub> _hubContext;
|
||||
private readonly ILogger<RemoveReactionCommandHandler> _logger;
|
||||
|
||||
public RemoveReactionCommandHandler(
|
||||
IMessageRepository messageRepository,
|
||||
|
||||
IChatsUnitOfWork unitOfWork,
|
||||
IHubContext<ChatHub> hubContext,
|
||||
ILogger<RemoveReactionCommandHandler> logger)
|
||||
{
|
||||
_messageRepository = messageRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
_hubContext = hubContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Result> Handle(RemoveReactionCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("RemoveReaction: MessageId={MessageId}, UserId={UserId}, Emoji={Emoji}, ChatId={ChatId}",
|
||||
request.MessageId, request.UserId, request.Emoji, request.ChatId);
|
||||
|
||||
var success = await _messageRepository.RemoveReactionAsync(
|
||||
request.MessageId,
|
||||
request.UserId,
|
||||
request.Emoji,
|
||||
cancellationToken);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
_logger.LogWarning("RemoveReaction: Reaction not found");
|
||||
// Не возвращаем ошибку - реакция уже удалена или не существовала
|
||||
}
|
||||
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("RemoveReaction: Reaction removed from database");
|
||||
|
||||
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("reaction_removed", new
|
||||
{
|
||||
messageId = request.MessageId,
|
||||
chatId = request.ChatId,
|
||||
userId = request.UserId,
|
||||
emoji = request.Emoji
|
||||
});
|
||||
|
||||
_logger.LogInformation("RemoveReaction: reaction_removed sent successfully");
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using MediatR;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Chats.Application.Abstractions;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Messages.Read;
|
||||
|
||||
public sealed record ReadMessagesCommand(Guid ChatId, Guid UserId, List<Guid> MessageIds) : ICommand;
|
||||
|
||||
public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCommand>
|
||||
{
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly IChatsUnitOfWork _unitOfWork;
|
||||
|
||||
public ReadMessagesCommandHandler(IMessageRepository messageRepository, IChatsUnitOfWork unitOfWork)
|
||||
{
|
||||
_messageRepository = messageRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public async Task<Result> Handle(ReadMessagesCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.MessageIds == null || !request.MessageIds.Any())
|
||||
{
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
await _messageRepository.AddReadReceiptsAsync(request.UserId, request.MessageIds, cancellationToken);
|
||||
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Messages.SearchMessages;
|
||||
|
||||
public record SearchMessagesQuery(Guid UserId, string Query, Guid? ChatId) : IQuery<List<SearchMessageDto>>;
|
||||
|
||||
internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQuery, List<SearchMessageDto>>
|
||||
{
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly IUserDisplayNameProvider _userProvider;
|
||||
|
||||
public SearchMessagesQueryHandler(IMessageRepository messageRepository, IUserDisplayNameProvider userProvider)
|
||||
{
|
||||
_messageRepository = messageRepository;
|
||||
_userProvider = userProvider;
|
||||
}
|
||||
|
||||
public async Task<Result<List<SearchMessageDto>>> Handle(SearchMessagesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var messages = await _messageRepository.SearchMessagesAsync(request.Query, request.ChatId, request.UserId, cancellationToken);
|
||||
messages = messages.Where(message => !message.IsDeletedForUser(request.UserId)).ToList();
|
||||
|
||||
var userIds = messages.Select(message => message.SenderId).ToList();
|
||||
userIds.AddRange(messages.Where(message => message.ForwardedFromId.HasValue).Select(message => message.ForwardedFromId!.Value));
|
||||
|
||||
var senders = await _userProvider.GetUsersInfoAsync(userIds.Distinct(), cancellationToken);
|
||||
|
||||
var result = messages.Select(message => new SearchMessageDto(
|
||||
message.Id,
|
||||
message.ChatId,
|
||||
message.SenderId,
|
||||
message.Content,
|
||||
message.Type,
|
||||
message.ReplyToId,
|
||||
message.Quote,
|
||||
message.IsEdited,
|
||||
message.IsDeleted,
|
||||
message.CreatedAt,
|
||||
message.ForwardedFromId,
|
||||
null,
|
||||
message.StoryId,
|
||||
message.StoryMediaUrl,
|
||||
message.StoryMediaType,
|
||||
message.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
|
||||
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
|
||||
message.Reactions.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList(),
|
||||
message.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList()
|
||||
)).ToList();
|
||||
|
||||
return Result.Success(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Chats.Application.Abstractions;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Messages.Send;
|
||||
|
||||
/// <summary>
|
||||
/// Команда для отправки сообщения в чат.
|
||||
/// </summary>
|
||||
public record AttachmentRequest(string Type, string Url, string? FileName, long? FileSize);
|
||||
|
||||
public sealed record SendMessageCommand(
|
||||
Guid ChatId,
|
||||
Guid SenderId,
|
||||
string? Content,
|
||||
string Type,
|
||||
List<AttachmentRequest>? Attachments = null,
|
||||
Guid? ReplyToId = null,
|
||||
string? Quote = null,
|
||||
Guid? ForwardedFromId = null,
|
||||
Guid? StoryId = null,
|
||||
string? StoryMediaUrl = null,
|
||||
string? StoryMediaType = null) : ICommand<Guid>;
|
||||
|
||||
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly IChatsUnitOfWork _unitOfWork;
|
||||
private readonly MediatR.IMediator _mediator;
|
||||
|
||||
public SendMessageCommandHandler(
|
||||
IChatRepository chatRepository,
|
||||
IMessageRepository messageRepository,
|
||||
IChatsUnitOfWork unitOfWork,
|
||||
MediatR.IMediator mediator)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_messageRepository = messageRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
_mediator = mediator;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(SendMessageCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. Проверяем существование чата
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat is null)
|
||||
{
|
||||
return Result.Failure<Guid>(ChatErrors.ChatsNotFound);
|
||||
}
|
||||
|
||||
// 2. Проверяем, является ли отправитель участником
|
||||
if (!chat.Members.Any(m => m.UserId == request.SenderId))
|
||||
{
|
||||
return Result.Failure<Guid>(ChatErrors.ChatsForbidden);
|
||||
}
|
||||
|
||||
// 3. Создаем сообщение
|
||||
Message message;
|
||||
if (request.Type == "story_reply" || request.Type == "story_reaction")
|
||||
{
|
||||
var parsedStoryMediaType = Enum.TryParse<MediaType>(request.StoryMediaType, true, out var sTypeEnum) ? sTypeEnum : MediaType.Image;
|
||||
message = new StoryMessage(
|
||||
Guid.NewGuid(),
|
||||
request.ChatId,
|
||||
request.SenderId,
|
||||
request.StoryId ?? Guid.Empty,
|
||||
request.StoryMediaUrl ?? string.Empty,
|
||||
parsedStoryMediaType,
|
||||
request.Content,
|
||||
request.ReplyToId,
|
||||
request.ForwardedFromId,
|
||||
DateTime.UtcNow,
|
||||
false);
|
||||
}
|
||||
else if (request.Attachments != null && request.Attachments.Any())
|
||||
{
|
||||
var firstAtt = request.Attachments.First();
|
||||
var parsedType = Enum.TryParse<MediaType>(firstAtt.Type, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
|
||||
|
||||
message = new MediaMessage(
|
||||
Guid.NewGuid(),
|
||||
request.ChatId,
|
||||
request.SenderId,
|
||||
parsedType,
|
||||
request.Content,
|
||||
request.ReplyToId,
|
||||
request.ForwardedFromId,
|
||||
DateTime.UtcNow,
|
||||
false);
|
||||
|
||||
foreach (var att in request.Attachments)
|
||||
{
|
||||
var pType = Enum.TryParse<MediaType>(att.Type, true, out var tEnum) ? tEnum : MediaType.File;
|
||||
((MediaMessage)message).AddMedia(pType, att.Url, att.FileName, att.FileSize);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
message = new TextMessage(
|
||||
Guid.NewGuid(),
|
||||
request.ChatId,
|
||||
request.SenderId,
|
||||
request.Content ?? string.Empty,
|
||||
request.ReplyToId,
|
||||
request.Quote,
|
||||
request.ForwardedFromId,
|
||||
DateTime.UtcNow,
|
||||
false);
|
||||
}
|
||||
|
||||
// 4. Сохраняем
|
||||
_messageRepository.Add(message);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _mediator.Publish(new MessageSentDomainEvent(
|
||||
message.Id,
|
||||
message.ChatId,
|
||||
message.SenderId,
|
||||
message.Content),
|
||||
cancellationToken);
|
||||
|
||||
return Result.Success(message.Id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediatR;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Shared.Kernel.Storage;
|
||||
using Knot.Shared.Kernel.Configuration;
|
||||
using Knot.Modules.Chats.Application.DTOs;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Messages.UploadFile;
|
||||
|
||||
public record UploadFileCommand(string FileName, string ContentType, long Length, Stream FileStream) : ICommand<UploadFileResponseDto>;
|
||||
|
||||
internal sealed class UploadFileCommandHandler : ICommandHandler<UploadFileCommand, UploadFileResponseDto>
|
||||
{
|
||||
private readonly IFileStorageService _fileStorage;
|
||||
private readonly ISettingsService _settingsService;
|
||||
|
||||
public UploadFileCommandHandler(IFileStorageService fileStorage, ISettingsService settingsService)
|
||||
{
|
||||
_fileStorage = fileStorage;
|
||||
_settingsService = settingsService;
|
||||
}
|
||||
|
||||
public async Task<Result<UploadFileResponseDto>> Handle(UploadFileCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Length == 0)
|
||||
{
|
||||
return Result.Failure<UploadFileResponseDto>(ChatErrors.FileEmpty);
|
||||
}
|
||||
|
||||
var maxMb = _settingsService.Current.MaxFileSizeMb;
|
||||
if (request.Length > maxMb * 1024 * 1024)
|
||||
{
|
||||
return Result.Failure<UploadFileResponseDto>(ChatErrors.FileTooLarge(maxMb));
|
||||
}
|
||||
|
||||
var fileId = await _fileStorage.UploadFileAsync(request.FileStream, request.FileName, request.ContentType);
|
||||
|
||||
return Result.Success(new UploadFileResponseDto("/api/files/" + fileId, request.FileName, request.Length));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using Knot.Modules.Chats.Domain;
|
||||
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<Guid, string> TempZips = new();
|
||||
}
|
||||
|
||||
public record AnalyzeImportResponseDto(Guid Token, List<string> Names);
|
||||
|
||||
public record AnalyzeImportCommand(Stream FileStream, string FileName) : ICommand<AnalyzeImportResponseDto>;
|
||||
|
||||
internal sealed class AnalyzeImportCommandHandler : ICommandHandler<AnalyzeImportCommand, AnalyzeImportResponseDto>
|
||||
{
|
||||
public async Task<Result<AnalyzeImportResponseDto>> Handle(AnalyzeImportCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.FileStream == null || request.FileStream.Length == 0)
|
||||
{
|
||||
return Result.Failure<AnalyzeImportResponseDto>(ChatErrors.FileEmpty);
|
||||
}
|
||||
|
||||
if (!request.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Result.Failure<AnalyzeImportResponseDto>(ChatErrors.FileInvalidExtension);
|
||||
}
|
||||
|
||||
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<string>();
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
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<string, Guid> Mapping,
|
||||
string? GroupName
|
||||
) : ICommand<ExecuteImportResponseDto>;
|
||||
|
||||
internal sealed class ExecuteImportCommandHandler : ICommandHandler<ExecuteImportCommand, ExecuteImportResponseDto>
|
||||
{
|
||||
private readonly ISender _sender;
|
||||
private readonly IChatsUnitOfWork _uow;
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly IFileStorageService _fileStorage;
|
||||
private readonly IHubContext<ChatHub> _hubContext;
|
||||
|
||||
public ExecuteImportCommandHandler(
|
||||
ISender sender,
|
||||
IChatsUnitOfWork uow,
|
||||
IChatRepository chatRepository,
|
||||
IMessageRepository messageRepository,
|
||||
IFileStorageService fileStorage,
|
||||
IHubContext<ChatHub> hubContext)
|
||||
{
|
||||
_sender = sender;
|
||||
_uow = uow;
|
||||
_chatRepository = chatRepository;
|
||||
_messageRepository = messageRepository;
|
||||
_fileStorage = fileStorage;
|
||||
_hubContext = hubContext;
|
||||
}
|
||||
|
||||
public async Task<Result<ExecuteImportResponseDto>> Handle(ExecuteImportCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TelegramImportState.TempZips.TryGetValue(request.Token, out var tempPath))
|
||||
{
|
||||
return Result.Failure<ExecuteImportResponseDto>(ChatErrors.ImportExpired);
|
||||
}
|
||||
|
||||
if (!System.IO.File.Exists(tempPath))
|
||||
{
|
||||
return Result.Failure<ExecuteImportResponseDto>(ChatErrors.ImportMissing);
|
||||
}
|
||||
|
||||
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<Guid> { myId, friendId });
|
||||
var res = await _sender.Send(command, cancellationToken);
|
||||
if (res.IsFailure)
|
||||
{
|
||||
return Result.Failure<ExecuteImportResponseDto>(ChatErrors.ImportCreateChatFailed(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<ExecuteImportResponseDto>(ChatErrors.ImportCreateChatFailed(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<string, Guid> 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("<br>", "\n", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("<br/>", "\n", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("<br />", "\n", StringComparison.OrdinalIgnoreCase);
|
||||
var tempParser = new HtmlParser();
|
||||
var tempDoc = tempParser.ParseDocument("<div>" + html + "</div>");
|
||||
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("<br>", "\n", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("<br/>", "\n", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("<br />", "\n", StringComparison.OrdinalIgnoreCase);
|
||||
var tempParser = new HtmlParser();
|
||||
var tempDoc = tempParser.ParseDocument("<div>" + fHtml + "</div>");
|
||||
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;
|
||||
bool hasMedia = mediaNodes != null && mediaNodes.Count > 0;
|
||||
Message? targetMessage = null;
|
||||
|
||||
if (isJoined && isMediaOnly && lastSavedMessage is MediaMessage && Math.Abs((createdAt - lastSavedMessage.CreatedAt).TotalSeconds) <= 60 && lastSavedMessage.SenderId == senderGuid)
|
||||
{
|
||||
targetMessage = lastSavedMessage;
|
||||
}
|
||||
else
|
||||
{
|
||||
string finalContent = content;
|
||||
if (hasMedia)
|
||||
{
|
||||
targetMessage = new MediaMessage(Guid.NewGuid(), chatId, senderGuid, MediaType.File, finalContent, replyToId, forwardedFromId, createdAt, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
targetMessage = new TextMessage(Guid.NewGuid(), chatId, senderGuid, finalContent, replyToId, null, forwardedFromId, createdAt, true);
|
||||
}
|
||||
|
||||
var idAttr = node.GetAttribute("id");
|
||||
if (!string.IsNullOrEmpty(idAttr))
|
||||
{
|
||||
messageIdMap[idAttr] = targetMessage.Id;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMedia)
|
||||
{
|
||||
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 parsedType = Enum.TryParse<MediaType>(finalMType, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
|
||||
if (targetMessage is MediaMessage mm)
|
||||
{
|
||||
var fileId = await _fileStorage.UploadFileAsync(ms, Path.GetFileName(href), types.cType);
|
||||
mm.AddMedia(parsedType, $"/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));
|
||||
}
|
||||
}
|
||||
|
||||
48
backend/src/Modules/Chats/DependencyInjection.cs
Normal file
48
backend/src/Modules/Chats/DependencyInjection.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using MongoDB.Driver;
|
||||
using Knot.Modules.Chats.Infrastructure.Persistence;
|
||||
using Knot.Modules.Chats.Infrastructure.Persistence.Mongo;
|
||||
|
||||
using Knot.Modules.Chats.Application.Abstractions;
|
||||
|
||||
namespace Knot.Modules.Chats;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
/// <summary>
|
||||
/// Регистрация сервисов модуля Chats.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddChatsModule(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// Настройка базы данных
|
||||
string connectionString = configuration.GetConnectionString("DefaultConnection")!;
|
||||
|
||||
services.AddDbContext<ChatsDbContext>(options =>
|
||||
options.UseNpgsql(connectionString));
|
||||
|
||||
// MongoDB Setup for Messages
|
||||
MongoDbMapConfigurator.Configure();
|
||||
|
||||
var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017";
|
||||
services.AddSingleton<IMongoClient>(new MongoClient(mongoConnectionString));
|
||||
services.AddScoped<IMongoDatabase>(sp =>
|
||||
sp.GetRequiredService<IMongoClient>().GetDatabase("forkmessager_chats"));
|
||||
|
||||
// Registration
|
||||
services.AddScoped<IChatsUnitOfWork>(sp => sp.GetRequiredService<ChatsDbContext>());
|
||||
services.AddScoped<IChatRepository, ChatRepository>();
|
||||
services.AddScoped<IMessageRepository, MessageRepository>();
|
||||
|
||||
// MediatR
|
||||
services.AddMediatR(config =>
|
||||
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
132
backend/src/Modules/Chats/Domain/Chat.cs
Normal file
132
backend/src/Modules/Chats/Domain/Chat.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Chats.Domain;
|
||||
|
||||
public sealed record ChatCreatedDomainEvent(Chat Chat) : IDomainEvent;
|
||||
public sealed record ChatMemberAddedDomainEvent(Guid ChatId, Guid UserId) : IDomainEvent;
|
||||
|
||||
/// <summary>
|
||||
/// Тип чата: личный или групповой.
|
||||
/// </summary>
|
||||
public enum ChatType
|
||||
{
|
||||
Personal,
|
||||
Group,
|
||||
Favorites
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Роль участника в чате.
|
||||
/// </summary>
|
||||
public static class ChatRole
|
||||
{
|
||||
public const string Owner = "owner";
|
||||
public const string Admin = "admin";
|
||||
public const string Member = "member";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сущность чата (Агрегат).
|
||||
/// </summary>
|
||||
public sealed class Chat : AggregateRoot<Guid>
|
||||
{
|
||||
public ChatType Type { get; private set; }
|
||||
public string? Name { get; private set; }
|
||||
public string? Description { get; private set; }
|
||||
public string? Avatar { get; private set; }
|
||||
public DateTime CreatedAt { get; private set; }
|
||||
|
||||
private readonly List<ChatMember> _members = new();
|
||||
public IReadOnlyCollection<ChatMember> Members => _members.AsReadOnly();
|
||||
|
||||
private Chat(Guid id, ChatType type, string? name, string? avatar, string? description = null) : base(id)
|
||||
{
|
||||
Type = type;
|
||||
Name = name;
|
||||
Avatar = avatar;
|
||||
Description = description;
|
||||
CreatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создает личный чат между двумя пользователями.
|
||||
/// </summary>
|
||||
public static Chat CreatePersonal()
|
||||
{
|
||||
var chat = new Chat(Guid.NewGuid(), ChatType.Personal, null, null);
|
||||
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
|
||||
return chat;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создает групповой чат.
|
||||
/// </summary>
|
||||
public static Chat CreateGroup(string name, string? avatar = null)
|
||||
{
|
||||
var chat = new Chat(Guid.NewGuid(), ChatType.Group, name, avatar);
|
||||
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
|
||||
return chat;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Фабричный метод для создания чата.
|
||||
/// </summary>
|
||||
public static Chat Create(string? name, ChatType type, string? avatar = null, string? description = null)
|
||||
{
|
||||
var chat = new Chat(Guid.NewGuid(), type, name, avatar, description);
|
||||
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
|
||||
return chat;
|
||||
}
|
||||
|
||||
public void AddMember(Guid userId, string role = "member")
|
||||
{
|
||||
if (_members.Any(m => m.UserId == userId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_members.Add(new ChatMember(Id, userId, role));
|
||||
RaiseDomainEvent(new ChatMemberAddedDomainEvent(Id, userId));
|
||||
}
|
||||
|
||||
public void RemoveMember(Guid userId)
|
||||
{
|
||||
var member = _members.FirstOrDefault(m => m.UserId == userId);
|
||||
if (member != null)
|
||||
{
|
||||
_members.Remove(member);
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateName(string name) => Name = name;
|
||||
|
||||
public void UpdateDescription(string? description) => Description = description;
|
||||
|
||||
public void UpdateAvatar(string? avatarUrl) => Avatar = avatarUrl;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Участник чата.
|
||||
/// </summary>
|
||||
public sealed class ChatMember : Entity<Guid>
|
||||
{
|
||||
public Guid ChatId { get; private set; }
|
||||
public Guid UserId { get; private set; }
|
||||
public string Role { get; private set; }
|
||||
public DateTime JoinedAt { get; private set; }
|
||||
public bool IsPinned { get; private set; }
|
||||
public bool IsMuted { get; private set; }
|
||||
|
||||
// For EF Core
|
||||
private ChatMember() : base(Guid.Empty) { Role = "member"; }
|
||||
|
||||
internal ChatMember(Guid chatId, Guid userId, string role) : base(Guid.NewGuid())
|
||||
{
|
||||
ChatId = chatId;
|
||||
UserId = userId;
|
||||
Role = role;
|
||||
JoinedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public void TogglePin() => IsPinned = !IsPinned;
|
||||
}
|
||||
9
backend/src/Modules/Chats/Domain/ChatConstants.cs
Normal file
9
backend/src/Modules/Chats/Domain/ChatConstants.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Knot.Modules.Chats.Domain;
|
||||
|
||||
public static class ChatConstants
|
||||
{
|
||||
public const int DefaultMessageQueryLimit = 100;
|
||||
public const int MaxSharedMediaQueryLimit = 300;
|
||||
public const int SearchMessagesLimit = 50;
|
||||
public const int MaxFileUploadSizeMb = 50;
|
||||
}
|
||||
19
backend/src/Modules/Chats/Domain/ChatErrors.cs
Normal file
19
backend/src/Modules/Chats/Domain/ChatErrors.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Chats.Domain;
|
||||
|
||||
public static class ChatErrors
|
||||
{
|
||||
public static readonly Error FileEmpty = new Error("File.Empty", "No file uploaded");
|
||||
public static readonly Error FileInvalidExtension = new Error("File.InvalidExtension", "Must be a ZIP archive");
|
||||
public static readonly Error ImportExpired = new Error("Import.Expired", "Session not found or expired");
|
||||
public static readonly Error ImportMissing = new Error("Import.Missing", "ZIP file lost");
|
||||
public static readonly Error ChatNotFound = new Error("Chat.NotFound", "Chat not found or access denied");
|
||||
public static readonly Error ChatsForbidden = new Error("Chats.Forbidden", "Вы не являетесь участником этого чата.");
|
||||
public static readonly Error MessagesNotFound = new Error("Messages.NotFound", "Message not found.");
|
||||
public static readonly Error ChatsNotFound = new Error("Chats.NotFound", "Чат не найден.");
|
||||
public static readonly Error Unauthorized = new Error("Chats.Unauthorized", "Access denied");
|
||||
|
||||
public static Error ImportCreateChatFailed(string msg) => new Error("Import.CreateChatFailed", msg);
|
||||
public static Error FileTooLarge(int maxMb) => new Error("File.TooLarge", $"File exceeds the maximum allowed size of {maxMb}MB.");
|
||||
}
|
||||
21
backend/src/Modules/Chats/Domain/DeletedMessage.cs
Normal file
21
backend/src/Modules/Chats/Domain/DeletedMessage.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Chats.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Запись о том, что конкретный пользователь удалил у себя сообщение.
|
||||
/// </summary>
|
||||
public sealed class DeletedMessage : Entity<Guid>
|
||||
{
|
||||
public Guid MessageId { get; private set; }
|
||||
public Guid UserId { get; private set; }
|
||||
|
||||
internal DeletedMessage(Guid messageId, Guid userId) : base(Guid.NewGuid())
|
||||
{
|
||||
MessageId = messageId;
|
||||
UserId = userId;
|
||||
}
|
||||
|
||||
private DeletedMessage() : base(Guid.Empty) { }
|
||||
}
|
||||
13
backend/src/Modules/Chats/Domain/IChatRepository.cs
Normal file
13
backend/src/Modules/Chats/Domain/IChatRepository.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using Knot.Modules.Chats.Domain;
|
||||
|
||||
namespace Knot.Modules.Chats.Domain;
|
||||
|
||||
public interface IChatRepository
|
||||
{
|
||||
void Add(Chat chat);
|
||||
void Update(Chat chat);
|
||||
void Remove(Chat chat);
|
||||
Task<Chat?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task<Chat?> GetFavoritesAsync(Guid userId, CancellationToken cancellationToken);
|
||||
Task<List<Chat>> GetUserChatsAsync(Guid userId, CancellationToken cancellationToken);
|
||||
}
|
||||
19
backend/src/Modules/Chats/Domain/IMessageRepository.cs
Normal file
19
backend/src/Modules/Chats/Domain/IMessageRepository.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using Knot.Modules.Chats.Domain;
|
||||
|
||||
namespace Knot.Modules.Chats.Domain;
|
||||
|
||||
public interface IMessageRepository
|
||||
{
|
||||
void Add(Message message);
|
||||
Task<Message?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task<List<Message>> GetChatMessagesAsync(Guid chatId, int limit, int offset, CancellationToken cancellationToken);
|
||||
Task<Message?> GetLatestChatMessageAsync(Guid chatId, CancellationToken cancellationToken);
|
||||
Task<List<Message>> SearchMessagesAsync(string query, Guid? chatId, Guid requestingUserId, CancellationToken cancellationToken);
|
||||
Task AddReadReceiptsAsync(Guid userId, List<Guid> messageIds, CancellationToken cancellationToken);
|
||||
Task<bool> AddReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken);
|
||||
Task<bool> RemoveReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken);
|
||||
Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, int limit, CancellationToken cancellationToken);
|
||||
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
|
||||
Task<int> GetUnreadCountAsync(Guid chatId, Guid userId, CancellationToken cancellationToken);
|
||||
Task UpdateAsync(Message message, CancellationToken cancellationToken);
|
||||
}
|
||||
31
backend/src/Modules/Chats/Domain/Media.cs
Normal file
31
backend/src/Modules/Chats/Domain/Media.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Chats.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Медиа-файл, прикрепленный к сообщению.
|
||||
/// </summary>
|
||||
public sealed class Media : Entity<Guid>
|
||||
{
|
||||
public Guid MessageId { get; private set; }
|
||||
public string Type { get; private set; }
|
||||
public string Url { get; private set; }
|
||||
public string? Filename { get; private set; }
|
||||
public long? Size { get; private set; }
|
||||
|
||||
internal Media(Guid messageId, string type, string url, string? filename, long? size) : base(Guid.NewGuid())
|
||||
{
|
||||
MessageId = messageId;
|
||||
Type = type;
|
||||
Url = url;
|
||||
Filename = filename;
|
||||
Size = size;
|
||||
}
|
||||
|
||||
private Media() : base(Guid.Empty)
|
||||
{
|
||||
Type = string.Empty;
|
||||
Url = string.Empty;
|
||||
}
|
||||
}
|
||||
58
backend/src/Modules/Chats/Domain/MediaMessage.cs
Normal file
58
backend/src/Modules/Chats/Domain/MediaMessage.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Knot.Modules.Chats.Domain;
|
||||
|
||||
public class MediaMessage : Message
|
||||
{
|
||||
public override string Type => MediaType.ToString().ToLower();
|
||||
public override string? Content { get; protected set; } // Map to Caption
|
||||
public string? Caption { get => Content; private set => Content = value; }
|
||||
public MediaType MediaType { get; private set; } // image, video, file, voice
|
||||
|
||||
private readonly List<Media> _media = new();
|
||||
public override IReadOnlyCollection<Media> Media => _media.AsReadOnly();
|
||||
|
||||
private MediaMessage() : base()
|
||||
{
|
||||
MediaType = MediaType.File;
|
||||
}
|
||||
|
||||
public MediaMessage(
|
||||
Guid id,
|
||||
Guid chatId,
|
||||
Guid senderId,
|
||||
MediaType mediaType,
|
||||
string? caption,
|
||||
Guid? replyToId,
|
||||
Guid? forwardedFromId,
|
||||
DateTime createdAt,
|
||||
bool isImported)
|
||||
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported)
|
||||
{
|
||||
MediaType = mediaType;
|
||||
Caption = caption;
|
||||
|
||||
if (!isImported)
|
||||
{
|
||||
RaiseDomainEvent(new MessageSentDomainEvent(Id, ChatId, SenderId, Caption));
|
||||
}
|
||||
}
|
||||
|
||||
public void AddMedia(MediaType type, string url, string? filename, long? size)
|
||||
{
|
||||
_media.Add(new Domain.Media(Id, type.ToString().ToLower(), url, filename, size));
|
||||
}
|
||||
|
||||
public void Edit(string newCaption)
|
||||
{
|
||||
Caption = newCaption;
|
||||
AddState(MessageState.IsEdited);
|
||||
}
|
||||
|
||||
public override void Delete()
|
||||
{
|
||||
Caption = null;
|
||||
base.Delete();
|
||||
}
|
||||
}
|
||||
12
backend/src/Modules/Chats/Domain/MediaType.cs
Normal file
12
backend/src/Modules/Chats/Domain/MediaType.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Knot.Modules.Chats.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Тип медиа-контента.
|
||||
/// </summary>
|
||||
public enum MediaType
|
||||
{
|
||||
Image,
|
||||
Video,
|
||||
Voice,
|
||||
File
|
||||
}
|
||||
106
backend/src/Modules/Chats/Domain/Message.cs
Normal file
106
backend/src/Modules/Chats/Domain/Message.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Chats.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Абстрактная база агрегата Сообщение.
|
||||
/// </summary>
|
||||
public abstract class Message : AggregateRoot<Guid>
|
||||
{
|
||||
// ================== Базовые поля ==================
|
||||
public Guid ChatId { get; protected set; }
|
||||
public Guid SenderId { get; protected set; }
|
||||
public DateTime CreatedAt { get; protected set; }
|
||||
|
||||
// ================== Опциональные метаданные (общего назначения) ==================
|
||||
public Guid? ReplyToId { get; protected set; }
|
||||
public Guid? ForwardedFromId { get; protected set; }
|
||||
|
||||
// ================== Флаги ==================
|
||||
public MessageState State { get; protected set; }
|
||||
|
||||
// ================== Абстрактные / Виртуальные свойства ==================
|
||||
public abstract string Type { get; }
|
||||
public abstract string? Content { get; protected set; }
|
||||
|
||||
public virtual string? Quote { get; protected set; } = null;
|
||||
public virtual Guid? StoryId => null;
|
||||
public virtual string? StoryMediaUrl => null;
|
||||
public virtual string? StoryMediaType => null;
|
||||
public virtual IReadOnlyCollection<Media> Media => Array.Empty<Media>();
|
||||
|
||||
public bool IsEdited => HasState(MessageState.IsEdited);
|
||||
public bool IsDeleted => HasState(MessageState.IsDeleted);
|
||||
public bool IsDeletedForUser(Guid userId) => _deletedFor.Exists(d => d.UserId == userId);
|
||||
|
||||
// ================== Связанные коллекции (общего назначения) ==================
|
||||
protected readonly List<ReadReceipt> _readBy = new();
|
||||
public IReadOnlyCollection<ReadReceipt> ReadBy => _readBy.AsReadOnly();
|
||||
|
||||
protected readonly List<DeletedMessage> _deletedFor = new();
|
||||
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
|
||||
|
||||
protected readonly List<Reaction> _reactions = new();
|
||||
public IReadOnlyCollection<Reaction> Reactions => _reactions.AsReadOnly();
|
||||
|
||||
// ================== Инфраструктурный конструктор EF ==================
|
||||
protected Message() : base(Guid.Empty) { }
|
||||
|
||||
protected Message(
|
||||
Guid id,
|
||||
Guid chatId,
|
||||
Guid senderId,
|
||||
Guid? replyToId,
|
||||
Guid? forwardedFromId,
|
||||
DateTime createdAt,
|
||||
bool isImported) : base(id)
|
||||
{
|
||||
ChatId = chatId;
|
||||
SenderId = senderId;
|
||||
ReplyToId = replyToId;
|
||||
ForwardedFromId = forwardedFromId;
|
||||
CreatedAt = createdAt;
|
||||
|
||||
if (isImported) AddState(MessageState.IsImported);
|
||||
}
|
||||
|
||||
// ================== Управление Состоянием ==================
|
||||
public void AddState(MessageState state) => State |= state;
|
||||
public void RemoveState(MessageState state) => State &= ~state;
|
||||
public bool HasState(MessageState state) => (State & state) == state;
|
||||
|
||||
// ================== Общие операции ==================
|
||||
public void AddReaction(Guid userId, string emoji)
|
||||
{
|
||||
var existing = _reactions.Find(r => r.UserId == userId && r.Emoji == emoji);
|
||||
if (existing == null)
|
||||
{
|
||||
_reactions.Add(new Reaction(Id, userId, emoji));
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveReaction(Guid userId, string emoji)
|
||||
{
|
||||
var existing = _reactions.Find(r => r.UserId == userId && r.Emoji == emoji);
|
||||
if (existing != null)
|
||||
{
|
||||
_reactions.Remove(existing);
|
||||
}
|
||||
}
|
||||
|
||||
public virtual void Delete()
|
||||
{
|
||||
AddState(MessageState.IsDeleted);
|
||||
_reactions.Clear();
|
||||
}
|
||||
|
||||
public void DeleteForUser(Guid userId)
|
||||
{
|
||||
if (!_deletedFor.Exists(x => x.UserId == userId))
|
||||
{
|
||||
_deletedFor.Add(new DeletedMessage(Id, userId));
|
||||
}
|
||||
}
|
||||
}
|
||||
16
backend/src/Modules/Chats/Domain/MessageFlags.cs
Normal file
16
backend/src/Modules/Chats/Domain/MessageFlags.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
|
||||
namespace Knot.Modules.Chats.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Флаги состояния сообщения
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum MessageFlags
|
||||
{
|
||||
None = 0,
|
||||
IsEdited = 1,
|
||||
IsDeleted = 2,
|
||||
IsImported = 4,
|
||||
IsPinned = 8
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Chats.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Доменное событие: сообщение отправлено.
|
||||
/// </summary>
|
||||
public sealed record MessageSentDomainEvent(Guid MessageId, Guid ChatId, Guid SenderId, string? Content) : IDomainEvent;
|
||||
16
backend/src/Modules/Chats/Domain/MessageState.cs
Normal file
16
backend/src/Modules/Chats/Domain/MessageState.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
|
||||
namespace Knot.Modules.Chats.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Состояние сообщения
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum MessageState
|
||||
{
|
||||
None = 0,
|
||||
IsEdited = 1,
|
||||
IsDeleted = 2,
|
||||
IsImported = 4,
|
||||
IsPinned = 8
|
||||
}
|
||||
26
backend/src/Modules/Chats/Domain/Reaction.cs
Normal file
26
backend/src/Modules/Chats/Domain/Reaction.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Chats.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Реакция на сообщение.
|
||||
/// </summary>
|
||||
public sealed class Reaction : Entity<Guid>
|
||||
{
|
||||
public Guid MessageId { get; private set; }
|
||||
public Guid UserId { get; private set; }
|
||||
public string Emoji { get; private set; }
|
||||
|
||||
internal Reaction(Guid messageId, Guid userId, string emoji) : base(Guid.NewGuid())
|
||||
{
|
||||
MessageId = messageId;
|
||||
UserId = userId;
|
||||
Emoji = emoji;
|
||||
}
|
||||
|
||||
private Reaction() : base(Guid.Empty)
|
||||
{
|
||||
Emoji = string.Empty;
|
||||
}
|
||||
}
|
||||
20
backend/src/Modules/Chats/Domain/ReadReceipt.cs
Normal file
20
backend/src/Modules/Chats/Domain/ReadReceipt.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Chats.Domain;
|
||||
|
||||
public sealed class ReadReceipt : Entity<Guid>
|
||||
{
|
||||
public Guid MessageId { get; private set; }
|
||||
public Guid UserId { get; private set; }
|
||||
public DateTime ReadAt { get; private set; }
|
||||
|
||||
// Для EF Core
|
||||
private ReadReceipt() : base(Guid.Empty) { }
|
||||
|
||||
public ReadReceipt(Guid messageId, Guid userId) : base(Guid.NewGuid())
|
||||
{
|
||||
MessageId = messageId;
|
||||
UserId = userId;
|
||||
ReadAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
55
backend/src/Modules/Chats/Domain/StoryMessage.cs
Normal file
55
backend/src/Modules/Chats/Domain/StoryMessage.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
|
||||
namespace Knot.Modules.Chats.Domain;
|
||||
|
||||
public class StoryMessage : Message
|
||||
{
|
||||
public override string Type => "story";
|
||||
public override string? Content { get; protected set; }
|
||||
|
||||
public Guid InternalStoryId { get; private set; }
|
||||
public override Guid? StoryId => InternalStoryId;
|
||||
|
||||
public string InternalStoryMediaUrl { get; private set; }
|
||||
public override string? StoryMediaUrl => InternalStoryMediaUrl;
|
||||
|
||||
public MediaType InternalStoryMediaType { get; private set; }
|
||||
public override string? StoryMediaType => InternalStoryMediaType.ToString().ToLower();
|
||||
|
||||
private StoryMessage() : base()
|
||||
{
|
||||
InternalStoryMediaUrl = string.Empty;
|
||||
InternalStoryMediaType = MediaType.Image;
|
||||
}
|
||||
|
||||
public StoryMessage(
|
||||
Guid id,
|
||||
Guid chatId,
|
||||
Guid senderId,
|
||||
Guid storyId,
|
||||
string storyMediaUrl,
|
||||
MediaType storyMediaType,
|
||||
string? content,
|
||||
Guid? replyToId,
|
||||
Guid? forwardedFromId,
|
||||
DateTime createdAt,
|
||||
bool isImported)
|
||||
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported)
|
||||
{
|
||||
InternalStoryId = storyId;
|
||||
InternalStoryMediaUrl = storyMediaUrl;
|
||||
InternalStoryMediaType = storyMediaType;
|
||||
Content = content;
|
||||
|
||||
if (!isImported)
|
||||
{
|
||||
RaiseDomainEvent(new MessageSentDomainEvent(Id, ChatId, SenderId, Content));
|
||||
}
|
||||
}
|
||||
|
||||
public override void Delete()
|
||||
{
|
||||
Content = null;
|
||||
base.Delete();
|
||||
}
|
||||
}
|
||||
48
backend/src/Modules/Chats/Domain/TextMessage.cs
Normal file
48
backend/src/Modules/Chats/Domain/TextMessage.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
|
||||
namespace Knot.Modules.Chats.Domain;
|
||||
|
||||
public class TextMessage : Message
|
||||
{
|
||||
public override string Type => "text";
|
||||
public override string? Content { get; protected set; }
|
||||
public override string? Quote { get; protected set; }
|
||||
|
||||
private TextMessage() : base()
|
||||
{
|
||||
Content = string.Empty;
|
||||
}
|
||||
|
||||
public TextMessage(
|
||||
Guid id,
|
||||
Guid chatId,
|
||||
Guid senderId,
|
||||
string content,
|
||||
Guid? replyToId,
|
||||
string? quote,
|
||||
Guid? forwardedFromId,
|
||||
DateTime createdAt,
|
||||
bool isImported)
|
||||
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported)
|
||||
{
|
||||
Content = content;
|
||||
Quote = quote;
|
||||
|
||||
if (!isImported)
|
||||
{
|
||||
RaiseDomainEvent(new MessageSentDomainEvent(Id, ChatId, SenderId, Content));
|
||||
}
|
||||
}
|
||||
|
||||
public void Edit(string newContent)
|
||||
{
|
||||
Content = newContent;
|
||||
AddState(MessageState.IsEdited);
|
||||
}
|
||||
|
||||
public override void Delete()
|
||||
{
|
||||
Content = string.Empty;
|
||||
base.Delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Modules.Chats.Infrastructure.SignalR;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Knot.Modules.Chats.Infrastructure.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// Обработчик доменного события создания чата.
|
||||
/// </summary>
|
||||
public sealed class ChatCreatedDomainEventHandler : INotificationHandler<ChatCreatedDomainEvent>, INotificationHandler<ChatMemberAddedDomainEvent>
|
||||
{
|
||||
private readonly IHubContext<ChatHub> _hubContext;
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IUserDisplayNameProvider _displayNameProvider;
|
||||
|
||||
public ChatCreatedDomainEventHandler(
|
||||
IHubContext<ChatHub> hubContext,
|
||||
IChatRepository chatRepository,
|
||||
IUserDisplayNameProvider displayNameProvider)
|
||||
{
|
||||
_hubContext = hubContext;
|
||||
_chatRepository = chatRepository;
|
||||
_displayNameProvider = displayNameProvider;
|
||||
}
|
||||
|
||||
public async Task Handle(ChatCreatedDomainEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
await NotifyNewChatAsync(notification.Chat, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task Handle(ChatMemberAddedDomainEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(notification.ChatId, cancellationToken);
|
||||
if (chat != null)
|
||||
{
|
||||
// Уведомляем всех участников, что кто-то добавлен (или пользователь сам видит новый чат)
|
||||
await NotifyNewChatAsync(chat, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task NotifyNewChatAsync(Chat chat, CancellationToken cancellationToken, string? targetUserId = null)
|
||||
{
|
||||
var members = new List<object>();
|
||||
foreach (var member in chat.Members)
|
||||
{
|
||||
var userInfo = await _displayNameProvider.GetUserInfoAsync(member.UserId, cancellationToken);
|
||||
members.Add(new
|
||||
{
|
||||
member.Id,
|
||||
member.UserId,
|
||||
member.Role,
|
||||
member.JoinedAt,
|
||||
User = userInfo != null
|
||||
? new { userInfo.Id, userInfo.Username, userInfo.DisplayName, userInfo.Avatar, isOnline = false }
|
||||
: new { Id = member.UserId, Username = "unknown", DisplayName = "Unknown", Avatar = (string?)null, isOnline = false }
|
||||
});
|
||||
}
|
||||
|
||||
var payload = new
|
||||
{
|
||||
id = chat.Id,
|
||||
type = chat.Type.ToString().ToLowerInvariant(),
|
||||
name = chat.Type == ChatType.Favorites ? "Избранное" : (chat.Type == ChatType.Personal ? null : chat.Name),
|
||||
avatar = chat.Avatar,
|
||||
createdAt = chat.CreatedAt,
|
||||
members = members,
|
||||
messages = new List<object>(),
|
||||
unreadCount = 0
|
||||
};
|
||||
|
||||
if (targetUserId != null)
|
||||
{
|
||||
// Отправляем конкретному пользователю (по всем его соединениям)
|
||||
// Мы можем использовать метод Hub или просто отправить в группу UserId (если она есть)
|
||||
// В нашем ChatHub мы добавляем пользователя в группы чатов при подключении.
|
||||
// Но для нового чата он еще не в группе.
|
||||
// Поэтому отправляем через User ID (SignalR поддерживает Clients.User(id))
|
||||
await _hubContext.Clients.User(targetUserId).SendAsync("new_chat", payload, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Уведомляем всех текущих участников
|
||||
foreach (var member in chat.Members)
|
||||
{
|
||||
await _hubContext.Clients.User(member.UserId.ToString()).SendAsync("new_chat", payload, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Modules.Chats.Infrastructure.SignalR;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Chats.Infrastructure.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// Обработчик доменного события отправки сообщения.
|
||||
/// Отправляет уведомление через SignalR всем участникам чата.
|
||||
/// </summary>
|
||||
public sealed class MessageSentDomainEventHandler : INotificationHandler<MessageSentDomainEvent>
|
||||
{
|
||||
private readonly IHubContext<ChatHub> _hubContext;
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly IUserDisplayNameProvider _displayNameProvider;
|
||||
|
||||
public MessageSentDomainEventHandler(
|
||||
IHubContext<ChatHub> hubContext,
|
||||
IMessageRepository messageRepository,
|
||||
IUserDisplayNameProvider displayNameProvider)
|
||||
{
|
||||
_hubContext = hubContext;
|
||||
_messageRepository = messageRepository;
|
||||
_displayNameProvider = displayNameProvider;
|
||||
}
|
||||
|
||||
public async Task Handle(MessageSentDomainEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
var message = await _messageRepository.GetByIdAsync(notification.MessageId, cancellationToken);
|
||||
if (message is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var userInfo = await _displayNameProvider.GetUserInfoAsync(message.SenderId, cancellationToken);
|
||||
var senderObj = userInfo != null
|
||||
? new { Id = userInfo.Id, Username = userInfo.Username, DisplayName = userInfo.DisplayName, Avatar = userInfo.Avatar }
|
||||
: new { Id = message.SenderId, Username = "unknown", DisplayName = "Unknown", Avatar = (string?)null };
|
||||
|
||||
// Fetch forwarded from info if exists
|
||||
object? forwardedFromObj = null;
|
||||
if (message.ForwardedFromId.HasValue)
|
||||
{
|
||||
var fwdUserInfo = await _displayNameProvider.GetUserInfoAsync(message.ForwardedFromId.Value, cancellationToken);
|
||||
forwardedFromObj = fwdUserInfo != null
|
||||
? new { Id = fwdUserInfo.Id, Username = fwdUserInfo.Username, DisplayName = fwdUserInfo.DisplayName, Avatar = fwdUserInfo.Avatar }
|
||||
: new { Id = message.ForwardedFromId.Value, Username = "unknown", DisplayName = "Unknown", Avatar = (string?)null };
|
||||
}
|
||||
|
||||
// Fetch reply info if exists
|
||||
object? replyToObj = null;
|
||||
if (message.ReplyToId.HasValue)
|
||||
{
|
||||
var replyMsg = await _messageRepository.GetByIdAsync(message.ReplyToId.Value, cancellationToken);
|
||||
if (replyMsg != null)
|
||||
{
|
||||
var replySenderInfo = await _displayNameProvider.GetUserInfoAsync(replyMsg.SenderId, cancellationToken);
|
||||
replyToObj = new
|
||||
{
|
||||
Id = replyMsg.Id,
|
||||
Content = replyMsg.Content,
|
||||
Quote = message.Quote,
|
||||
media = replyMsg.Media.Select(rm => new { rm.Id, rm.Type, rm.Url }).ToList(),
|
||||
Sender = replySenderInfo != null
|
||||
? new { Id = replySenderInfo.Id, Username = replySenderInfo.Username, DisplayName = replySenderInfo.DisplayName }
|
||||
: new { Id = replyMsg.SenderId, Username = "unknown", DisplayName = "Unknown" }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Отправляем сообщение в "комнату" чата.
|
||||
await _hubContext.Clients.Group(notification.ChatId.ToString())
|
||||
.SendAsync("new_message", new
|
||||
{
|
||||
message.Id,
|
||||
message.ChatId,
|
||||
message.SenderId,
|
||||
Content = message.Content,
|
||||
Type = message.Type,
|
||||
message.CreatedAt,
|
||||
message.ForwardedFromId,
|
||||
ForwardedFrom = forwardedFromObj,
|
||||
message.ReplyToId,
|
||||
ReplyTo = replyToObj,
|
||||
Quote = message.Quote,
|
||||
Media = message.Media.Select(m => new
|
||||
{
|
||||
m.Id,
|
||||
m.Type,
|
||||
m.Url,
|
||||
Filename = m.Filename,
|
||||
Size = m.Size
|
||||
}).ToList(),
|
||||
Sender = senderObj,
|
||||
ReadBy = new List<object>(),
|
||||
StoryId = message.StoryId,
|
||||
StoryMediaUrl = message.StoryMediaUrl,
|
||||
StoryMediaType = message.StoryMediaType
|
||||
}, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
|
||||
namespace Knot.Modules.Chats.Infrastructure.Persistence;
|
||||
|
||||
public sealed class ChatRepository : IChatRepository
|
||||
{
|
||||
private readonly ChatsDbContext _dbContext;
|
||||
|
||||
public ChatRepository(ChatsDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
public void Add(Chat chat)
|
||||
{
|
||||
_dbContext.Chats.Add(chat);
|
||||
}
|
||||
|
||||
public void Update(Chat chat)
|
||||
{
|
||||
_dbContext.Chats.Update(chat);
|
||||
}
|
||||
|
||||
public void Remove(Chat chat)
|
||||
{
|
||||
_dbContext.Chats.Remove(chat);
|
||||
}
|
||||
|
||||
public async Task<Chat?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _dbContext.Chats
|
||||
.Include(c => c.Members)
|
||||
.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Chat?> GetFavoritesAsync(Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _dbContext.Chats
|
||||
.Include(c => c.Members)
|
||||
.FirstOrDefaultAsync(c =>
|
||||
c.Type == ChatType.Favorites &&
|
||||
c.Members.Any(m => m.UserId == userId),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<Chat>> GetUserChatsAsync(Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _dbContext.Chats
|
||||
.Include(c => c.Members)
|
||||
.Where(c => c.Members.Any(m => m.UserId == userId))
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Knot.Modules.Chats.Application.Abstractions;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Shared.Kernel.Security;
|
||||
|
||||
namespace Knot.Modules.Chats.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Контекст базы данных для модуля чатов.
|
||||
/// </summary>
|
||||
public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IEncryptionService _encryptionService;
|
||||
|
||||
public ChatsDbContext(DbContextOptions<ChatsDbContext> options, IMediator mediator, IEncryptionService encryptionService)
|
||||
: base(options)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_encryptionService = encryptionService;
|
||||
}
|
||||
|
||||
public DbSet<Chat> Chats => Set<Chat>();
|
||||
|
||||
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
base.OnConfiguring(optionsBuilder);
|
||||
optionsBuilder.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning));
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema("chats");
|
||||
|
||||
modelBuilder.Entity<Chat>(builder =>
|
||||
{
|
||||
builder.ToTable("Chats");
|
||||
builder.HasKey(c => c.Id);
|
||||
builder.Property(c => c.Type).HasConversion<string>();
|
||||
|
||||
|
||||
builder.OwnsMany(c => c.Members, mb =>
|
||||
{
|
||||
mb.ToTable("ChatMembers");
|
||||
mb.HasKey(m => m.Id);
|
||||
mb.WithOwner().HasForeignKey(m => m.ChatId);
|
||||
mb.HasIndex(m => new { m.ChatId, m.UserId }).IsUnique();
|
||||
}).Navigation(c => c.Members).UsePropertyAccessMode(PropertyAccessMode.Field);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. Получаем все события из агрегатов
|
||||
var domainEvents = ChangeTracker
|
||||
.Entries<IAggregateRoot>()
|
||||
.SelectMany(x =>
|
||||
|
||||
{
|
||||
if (x.Entity is AggregateRoot<Guid> root)
|
||||
{
|
||||
var events = root.GetDomainEvents().ToList();
|
||||
root.ClearDomainEvents();
|
||||
return events;
|
||||
}
|
||||
return Enumerable.Empty<IDomainEvent>();
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// 2. Сохраняем изменения
|
||||
int result = await base.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 3. Публикуем события через MediatR
|
||||
foreach (var domainEvent in domainEvents)
|
||||
{
|
||||
await _mediator.Publish(domainEvent, cancellationToken);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Driver.Linq;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using System.Text.RegularExpressions;
|
||||
using MongoDB.Bson;
|
||||
|
||||
namespace Knot.Modules.Chats.Infrastructure.Persistence;
|
||||
|
||||
public sealed class MessageRepository : IMessageRepository
|
||||
{
|
||||
private readonly IMongoCollection<Message> _messages;
|
||||
private readonly ChatsDbContext _dbContext;
|
||||
private readonly MediatR.IMediator _mediator;
|
||||
|
||||
public MessageRepository(IMongoDatabase mongoDatabase, ChatsDbContext dbContext, MediatR.IMediator mediator)
|
||||
{
|
||||
_messages = mongoDatabase.GetCollection<Message>("messages");
|
||||
_dbContext = dbContext;
|
||||
_mediator = mediator;
|
||||
}
|
||||
|
||||
public void Add(Message message)
|
||||
{
|
||||
_messages.InsertOne(message);
|
||||
|
||||
// Publish domain events manualy for mongo entities
|
||||
var events = message.GetDomainEvents().ToList();
|
||||
message.ClearDomainEvents();
|
||||
|
||||
// This runs synchronously or without waiting, better to run async but Add is void
|
||||
// In this implementation setting, fire and forget or wrap sync
|
||||
foreach (var domainEvent in events)
|
||||
{
|
||||
_mediator.Publish(domainEvent).GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Message?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<Message>.Filter.Eq(m => m.Id, id);
|
||||
return await _messages.Find(filter).FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<Message>> GetChatMessagesAsync(Guid chatId, int limit, int offset, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<Message>.Filter.Eq(m => m.ChatId, chatId);
|
||||
return await _messages.Find(filter)
|
||||
.SortByDescending(m => m.CreatedAt)
|
||||
.Skip(offset)
|
||||
.Limit(limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Message?> GetLatestChatMessageAsync(Guid chatId, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<Message>.Filter.Eq(m => m.ChatId, chatId);
|
||||
return await _messages.Find(filter)
|
||||
.SortByDescending(m => m.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
var builder = Builders<Message>.Filter;
|
||||
var filter = builder.Eq(m => m.ChatId, chatId);
|
||||
|
||||
if (cursor.HasValue)
|
||||
{
|
||||
filter &= builder.Lt(m => m.CreatedAt, cursor.Value);
|
||||
}
|
||||
|
||||
return await _messages.Find(filter)
|
||||
.SortByDescending(m => m.CreatedAt)
|
||||
.Limit(limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<Message>> SearchMessagesAsync(string query, Guid? chatId, Guid requestingUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
// Not ideal for SQL/Mongo combination but keeping the signature
|
||||
var validChatIdsQuery = _dbContext.Chats
|
||||
.Where(c => c.Members.Any(m => m.UserId == requestingUserId))
|
||||
.Select(c => c.Id)
|
||||
.ToList();
|
||||
|
||||
var builder = Builders<Message>.Filter;
|
||||
var filter = builder.In(m => m.ChatId, validChatIdsQuery);
|
||||
|
||||
if (chatId.HasValue)
|
||||
{
|
||||
filter &= builder.Eq(m => m.ChatId, chatId.Value);
|
||||
}
|
||||
|
||||
var textFilter = Builders<Message>.Filter.Regex("Content", new BsonRegularExpression(Regex.Escape(query), "i"));
|
||||
filter &= textFilter;
|
||||
|
||||
return await _messages.Find(filter)
|
||||
.SortByDescending(m => m.CreatedAt)
|
||||
.Limit(ChatConstants.SearchMessagesLimit)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddReadReceiptsAsync(Guid userId, List<Guid> messageIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<Message>.Filter.In(m => m.Id, messageIds);
|
||||
|
||||
var messages = await _messages.Find(filter).ToListAsync(cancellationToken);
|
||||
|
||||
var writes = new List<WriteModel<Message>>();
|
||||
foreach (var msg in messages)
|
||||
{
|
||||
if (!msg.ReadBy.Any(r => r.UserId == userId))
|
||||
{
|
||||
var receipt = new ReadReceipt(msg.Id, userId);
|
||||
var pushUpdate = Builders<Message>.Update.Push("ReadBy", receipt);
|
||||
var updateModel = new UpdateOneModel<Message>(Builders<Message>.Filter.Eq(m => m.Id, msg.Id), pushUpdate);
|
||||
writes.Add(updateModel);
|
||||
}
|
||||
}
|
||||
|
||||
if (writes.Any())
|
||||
{
|
||||
await _messages.BulkWriteAsync(writes, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> AddReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<Message>.Filter.Eq(m => m.Id, messageId);
|
||||
var msg = await _messages.Find(filter).FirstOrDefaultAsync(cancellationToken);
|
||||
if (msg == null) return false;
|
||||
|
||||
if (msg.Reactions.Any(r => r.UserId == userId && r.Emoji == emoji))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var reaction = new Reaction(messageId, userId, emoji);
|
||||
var update = Builders<Message>.Update.Push("Reactions", reaction);
|
||||
await _messages.UpdateOneAsync(filter, update, cancellationToken: cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> RemoveReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<Message>.Filter.Eq(m => m.Id, messageId);
|
||||
var msg = await _messages.Find(filter).FirstOrDefaultAsync(cancellationToken);
|
||||
if (msg == null) return false;
|
||||
|
||||
var reaction = msg.Reactions.FirstOrDefault(r => r.UserId == userId && r.Emoji == emoji);
|
||||
if (reaction == null) return false;
|
||||
|
||||
var update = Builders<Message>.Update.PullFilter("Reactions",
|
||||
Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("UserId", userId),
|
||||
Builders<BsonDocument>.Filter.Eq("Emoji", emoji)
|
||||
));
|
||||
|
||||
await _messages.UpdateOneAsync(filter, update, cancellationToken: cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<Message>.Filter.And(
|
||||
Builders<Message>.Filter.Eq(m => m.ChatId, chatId),
|
||||
Builders<Message>.Filter.Eq("_t", "StoryMessage"),
|
||||
Builders<Message>.Filter.Eq("StoryId", storyId)
|
||||
);
|
||||
|
||||
return await _messages.Find(filter)
|
||||
.SortByDescending(m => m.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<int> GetUnreadCountAsync(Guid chatId, Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var notReadFilter = Builders<Message>.Filter.Not(
|
||||
Builders<Message>.Filter.ElemMatch("ReadBy",
|
||||
Builders<BsonDocument>.Filter.Eq("UserId", userId))
|
||||
);
|
||||
|
||||
var finalFilter = Builders<Message>.Filter.And(
|
||||
Builders<Message>.Filter.Eq(m => m.ChatId, chatId),
|
||||
Builders<Message>.Filter.Ne(m => m.SenderId, userId),
|
||||
notReadFilter
|
||||
);
|
||||
|
||||
return (int)await _messages.CountDocumentsAsync(finalFilter, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Message message, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<Message>.Filter.Eq(m => m.Id, message.Id);
|
||||
await _messages.ReplaceOneAsync(filter, message, new ReplaceOptions { IsUpsert = true }, cancellationToken);
|
||||
|
||||
// Publish domain events
|
||||
var events = message.GetDomainEvents().ToList();
|
||||
message.ClearDomainEvents();
|
||||
foreach (var domainEvent in events)
|
||||
{
|
||||
await _mediator.Publish(domainEvent, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using MongoDB.Bson.Serialization;
|
||||
using MongoDB.Bson.Serialization.Serializers;
|
||||
using Knot.Shared.Kernel.Security;
|
||||
|
||||
namespace Knot.Modules.Chats.Infrastructure.Persistence.Mongo;
|
||||
|
||||
public class EncryptedStringSerializer : SerializerBase<string>
|
||||
{
|
||||
public static IEncryptionService? EncryptionService { get; set; }
|
||||
|
||||
public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || EncryptionService == null)
|
||||
{
|
||||
context.Writer.WriteString(value ?? string.Empty);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var encrypted = EncryptionService.EncryptMessage(value);
|
||||
context.Writer.WriteString(encrypted);
|
||||
}
|
||||
catch
|
||||
{
|
||||
context.Writer.WriteString(value);
|
||||
}
|
||||
}
|
||||
|
||||
public override string Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
|
||||
{
|
||||
var value = context.Reader.ReadString();
|
||||
if (string.IsNullOrEmpty(value) || EncryptionService == null)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return EncryptionService.DecryptMessage(value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback for already unencrypted, or failed to decrypt
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization;
|
||||
using MongoDB.Bson.Serialization.Serializers;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Chats.Infrastructure.Persistence.Mongo;
|
||||
|
||||
public static class MongoDbMapConfigurator
|
||||
{
|
||||
private static bool _initialized;
|
||||
|
||||
public static void Configure()
|
||||
{
|
||||
if (_initialized) return;
|
||||
|
||||
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
|
||||
|
||||
BsonSerializer.RegisterSerializer(new EnumSerializer<MessageState>(BsonType.String));
|
||||
BsonSerializer.RegisterSerializer(new EnumSerializer<MediaType>(BsonType.String));
|
||||
|
||||
BsonClassMap.RegisterClassMap<Entity<Guid>>(cm =>
|
||||
{
|
||||
cm.AutoMap();
|
||||
cm.MapIdProperty(e => e.Id);
|
||||
});
|
||||
|
||||
BsonClassMap.RegisterClassMap<Message>(cm =>
|
||||
{
|
||||
cm.AutoMap();
|
||||
cm.MapField("_deletedFor").SetElementName("DeletedFor");
|
||||
cm.MapField("_readBy").SetElementName("ReadBy");
|
||||
cm.MapField("_reactions").SetElementName("Reactions");
|
||||
cm.MapProperty(c => c.Quote).SetSerializer(new EncryptedStringSerializer());
|
||||
cm.SetIsRootClass(true);
|
||||
});
|
||||
|
||||
BsonClassMap.RegisterClassMap<TextMessage>(cm =>
|
||||
{
|
||||
cm.AutoMap();
|
||||
cm.SetDiscriminator("TextMessage");
|
||||
cm.MapProperty(c => c.Content).SetSerializer(new EncryptedStringSerializer());
|
||||
});
|
||||
|
||||
BsonClassMap.RegisterClassMap<MediaMessage>(cm =>
|
||||
{
|
||||
cm.AutoMap();
|
||||
cm.MapField("_media").SetElementName("Media");
|
||||
cm.SetDiscriminator("MediaMessage");
|
||||
cm.MapProperty(c => c.Caption).SetSerializer(new EncryptedStringSerializer());
|
||||
});
|
||||
|
||||
BsonClassMap.RegisterClassMap<StoryMessage>(cm =>
|
||||
{
|
||||
cm.AutoMap();
|
||||
cm.SetDiscriminator("StoryMessage");
|
||||
cm.MapProperty(c => c.Content).SetSerializer(new EncryptedStringSerializer());
|
||||
cm.MapProperty(c => c.InternalStoryMediaUrl).SetSerializer(new EncryptedStringSerializer());
|
||||
});
|
||||
|
||||
BsonClassMap.RegisterClassMap<DeletedMessage>(cm => cm.AutoMap());
|
||||
BsonClassMap.RegisterClassMap<ReadReceipt>(cm => cm.AutoMap());
|
||||
BsonClassMap.RegisterClassMap<Reaction>(cm => cm.AutoMap());
|
||||
|
||||
BsonClassMap.RegisterClassMap<Media>(cm =>
|
||||
{
|
||||
cm.AutoMap();
|
||||
cm.MapProperty(c => c.Url).SetSerializer(new EncryptedStringSerializer());
|
||||
cm.MapProperty(c => c.Filename).SetSerializer(new EncryptedStringSerializer());
|
||||
});
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
}
|
||||
670
backend/src/Modules/Chats/Infrastructure/SignalR/ChatHub.cs
Normal file
670
backend/src/Modules/Chats/Infrastructure/SignalR/ChatHub.cs
Normal file
@@ -0,0 +1,670 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Claims;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Knot.Modules.Chats.Application.Messages.Send;
|
||||
using Knot.Modules.Chats.Application.Messages.Read;
|
||||
using Knot.Modules.Chats.Application.Messages.Delete;
|
||||
using Knot.Modules.Chats.Application.Messages.React;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace Knot.Modules.Chats.Infrastructure.SignalR;
|
||||
|
||||
/// <summary>
|
||||
/// Хаб SignalR для обработки сообщений и WebRTC сигналинга в реальном времени.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public sealed class ChatHub : Hub
|
||||
{
|
||||
// Маппинг userId → список connectionId
|
||||
private static readonly ConcurrentDictionary<string, HashSet<string>> _userConnections = new();
|
||||
// chatId → (userId → ParticipantInfo)
|
||||
private static readonly ConcurrentDictionary<string, ConcurrentDictionary<string, ParticipantInfo>> _groupCallParticipants = new();
|
||||
|
||||
public static int OnlineUsersCount => _userConnections.Count;
|
||||
public static bool IsUserOnline(string userId) => _userConnections.ContainsKey(userId);
|
||||
|
||||
private readonly ISender _sender;
|
||||
private readonly IUserContext _userContext;
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly ILogger<ChatHub> _logger;
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
public ChatHub(ISender sender, IUserContext userContext, IChatRepository chatRepository, ILogger<ChatHub> logger, IMemoryCache cache)
|
||||
{
|
||||
_sender = sender;
|
||||
_userContext = userContext;
|
||||
_chatRepository = chatRepository;
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
if (_userContext.IsAuthenticated)
|
||||
{
|
||||
var userId = _userContext.UserId.ToString();
|
||||
_userConnections.AddOrUpdate(
|
||||
userId,
|
||||
_ => new HashSet<string> { Context.ConnectionId },
|
||||
(_, set) => { lock (set) { set.Add(Context.ConnectionId); } return set; }
|
||||
);
|
||||
|
||||
_cache.Set("Global_OnlineUsersCount", _userConnections.Count);
|
||||
|
||||
var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted);
|
||||
foreach (var chat in userChats)
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, chat.Id.ToString());
|
||||
}
|
||||
|
||||
_logger.LogInformation("User {UserId} connected with {ConnectionId}, added to {ChatCount} chats",
|
||||
|
||||
userId, Context.ConnectionId, userChats.Count);
|
||||
|
||||
await Clients.Others.SendAsync("user_online", new { userId });
|
||||
}
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
if (_userContext.IsAuthenticated)
|
||||
{
|
||||
var userId = _userContext.UserId.ToString();
|
||||
if (_userConnections.TryGetValue(userId, out var set))
|
||||
{
|
||||
lock (set) { set.Remove(Context.ConnectionId); }
|
||||
if (set.Count == 0)
|
||||
{
|
||||
_userConnections.TryRemove(userId, out _);
|
||||
await Clients.Others.SendAsync("user_offline", new { userId, lastSeen = DateTime.UtcNow });
|
||||
}
|
||||
}
|
||||
_cache.Set("Global_OnlineUsersCount", _userConnections.Count);
|
||||
_logger.LogInformation("User {UserId} disconnected", userId);
|
||||
}
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Chat methods
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
[HubMethodName("send_message")]
|
||||
public async Task SendMessage(SendMessageHubRequest request)
|
||||
{
|
||||
var attachments = request.Attachments?.Select(a =>
|
||||
|
||||
new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList();
|
||||
|
||||
var command = new SendMessageCommand(
|
||||
request.ChatId,
|
||||
_userContext.UserId,
|
||||
request.Content,
|
||||
request.Type,
|
||||
attachments,
|
||||
request.ReplyToId,
|
||||
request.Quote,
|
||||
request.ForwardedFromId);
|
||||
|
||||
await _sender.Send(command);
|
||||
}
|
||||
|
||||
[HubMethodName("read_messages")]
|
||||
public async Task ReadMessages(ReadMessagesRequest request)
|
||||
{
|
||||
if (request.MessageIds != null && request.MessageIds.Any())
|
||||
{
|
||||
var parsedIds = request.MessageIds
|
||||
.Select(id => Guid.TryParse(id, out var parsed) ? parsed : Guid.Empty)
|
||||
.Where(id => id != Guid.Empty)
|
||||
.ToList();
|
||||
|
||||
if (parsedIds.Any())
|
||||
{
|
||||
var command = new ReadMessagesCommand(
|
||||
request.ChatId, _userContext.UserId, parsedIds);
|
||||
await _sender.Send(command);
|
||||
}
|
||||
}
|
||||
|
||||
await Clients.Group(request.ChatId.ToString()).SendAsync("messages_read", new
|
||||
{
|
||||
ChatId = request.ChatId.ToString(),
|
||||
UserId = _userContext.UserId,
|
||||
MessageIds = request.MessageIds ?? new List<string>()
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("delete_messages")]
|
||||
public async Task DeleteMessages(DeleteMessagesHubRequest request)
|
||||
{
|
||||
var parsedIds = request.MessageIds
|
||||
.Select(id => Guid.TryParse(id, out var parsed) ? parsed : Guid.Empty)
|
||||
.Where(id => id != Guid.Empty)
|
||||
.ToList();
|
||||
|
||||
if (parsedIds.Any())
|
||||
{
|
||||
var command = new DeleteMessagesCommand(
|
||||
request.ChatId, _userContext.UserId, parsedIds, request.DeleteForAll);
|
||||
await _sender.Send(command);
|
||||
}
|
||||
}
|
||||
|
||||
[HubMethodName("typing_start")]
|
||||
public async Task TypingStart(string chatId)
|
||||
{
|
||||
await Clients.Group(chatId).SendAsync("user_typing", new { ChatId = chatId, UserId = _userContext.UserId });
|
||||
}
|
||||
|
||||
[HubMethodName("typing_stop")]
|
||||
public async Task TypingStop(string chatId)
|
||||
{
|
||||
await Clients.Group(chatId).SendAsync("user_stopped_typing", new { ChatId = chatId, UserId = _userContext.UserId });
|
||||
}
|
||||
|
||||
[HubMethodName("join_chat")]
|
||||
public async Task JoinChat(string chatId)
|
||||
{
|
||||
// Simple security check: check if user is member of chat (optional but recommended)
|
||||
if (Guid.TryParse(chatId, out var chatGuid))
|
||||
{
|
||||
var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted);
|
||||
if (userChats.Any(c => c.Id == chatGuid))
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, chatId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HubMethodName("add_reaction")]
|
||||
public async Task AddReaction(AddReactionRequest request)
|
||||
{
|
||||
_logger.LogInformation("AddReaction called: MessageId={MessageId}, ChatId={ChatId}, Emoji={Emoji}, UserId={UserId}",
|
||||
|
||||
request.MessageId, request.ChatId, request.Emoji, _userContext.UserId);
|
||||
|
||||
|
||||
var command = new AddReactionCommand(
|
||||
request.MessageId, _userContext.UserId, request.Emoji, request.ChatId);
|
||||
var result = await _sender.Send(command);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
_logger.LogWarning("AddReaction failed: {Error}", result.Error.Description);
|
||||
throw new HubException(result.Error.Description);
|
||||
}
|
||||
|
||||
|
||||
_logger.LogInformation("AddReaction completed successfully");
|
||||
}
|
||||
|
||||
[HubMethodName("remove_reaction")]
|
||||
public async Task RemoveReaction(RemoveReactionRequest request)
|
||||
{
|
||||
_logger.LogInformation("RemoveReaction called: MessageId={MessageId}, ChatId={ChatId}, Emoji={Emoji}, UserId={UserId}",
|
||||
|
||||
request.MessageId, request.ChatId, request.Emoji, _userContext.UserId);
|
||||
|
||||
|
||||
var command = new RemoveReactionCommand(
|
||||
request.MessageId, _userContext.UserId, request.Emoji, request.ChatId);
|
||||
var result = await _sender.Send(command);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
_logger.LogWarning("RemoveReaction failed: {Error}", result.Error.Description);
|
||||
throw new HubException(result.Error.Description);
|
||||
}
|
||||
|
||||
|
||||
_logger.LogInformation("RemoveReaction completed successfully");
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Friend signals (Proxy methods for real-time notification)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
[HubMethodName("friend_request")]
|
||||
public async Task FriendRequest(FriendSignalRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.FriendId, "friend_request_received", new { userId = _userContext.UserId });
|
||||
}
|
||||
|
||||
[HubMethodName("friend_accepted")]
|
||||
public async Task FriendAccepted(FriendSignalRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.FriendId, "friend_request_accepted", new { userId = _userContext.UserId });
|
||||
}
|
||||
|
||||
[HubMethodName("friend_removed")]
|
||||
public async Task FriendRemoved(FriendSignalRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.FriendId, "friend_removed_notify", new { userId = _userContext.UserId });
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// WebRTC signaling
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task SendToUserAsync(string targetUserId, string method, object payload)
|
||||
{
|
||||
if (_userConnections.TryGetValue(targetUserId, out var connectionIds))
|
||||
{
|
||||
string[] ids;
|
||||
lock (connectionIds) { ids = connectionIds.ToArray(); }
|
||||
foreach (var connId in ids)
|
||||
{
|
||||
await Clients.Client(connId).SendAsync(method, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HubMethodName("call_offer")]
|
||||
public async Task CallOffer(CallOfferRequest request)
|
||||
{
|
||||
// Try to get caller info from current user's claims
|
||||
var displayName = Context.User?.FindFirstValue("name") ?? Context.User?.FindFirstValue(ClaimTypes.Name) ?? "User";
|
||||
var avatar = Context.User?.FindFirstValue("avatar");
|
||||
var username = Context.User?.FindFirstValue("unique_name") ?? Context.User?.Identity?.Name;
|
||||
|
||||
await SendToUserAsync(request.TargetUserId, "call_incoming", new
|
||||
{
|
||||
from = _userContext.UserId.ToString(),
|
||||
offer = request.Offer,
|
||||
callType = request.CallType,
|
||||
chatId = request.ChatId,
|
||||
callerInfo = new
|
||||
{
|
||||
|
||||
id = _userContext.UserId.ToString(),
|
||||
displayName = displayName,
|
||||
avatar = avatar,
|
||||
username = username
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("call_answer")]
|
||||
public async Task CallAnswer(CallAnswerRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "call_answered", new
|
||||
{
|
||||
from = _userContext.UserId.ToString(),
|
||||
answer = request.Answer,
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("call_decline")]
|
||||
public async Task CallDecline(TargetUserRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "call_declined", new
|
||||
{
|
||||
from = _userContext.UserId.ToString(),
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("call_end")]
|
||||
public async Task CallEnd(TargetUserRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "call_ended", new
|
||||
{
|
||||
from = _userContext.UserId.ToString(),
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("ice_candidate")]
|
||||
public async Task IceCandidate(IceCandidateRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "ice_candidate", new
|
||||
{
|
||||
from = _userContext.UserId.ToString(),
|
||||
candidate = request.Candidate,
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("renegotiate")]
|
||||
public async Task Renegotiate(RenegotiateRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "renegotiate", new
|
||||
{
|
||||
from = _userContext.UserId.ToString(),
|
||||
offer = request.Offer,
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("renegotiate_answer")]
|
||||
public async Task RenegotiateAnswer(RenegotiateAnswerRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "renegotiate_answer", new
|
||||
{
|
||||
from = _userContext.UserId.ToString(),
|
||||
answer = request.Answer,
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("call_type_changed")]
|
||||
public async Task CallTypeChanged(CallTypeChangedRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "call_type_changed", new
|
||||
{
|
||||
from = _userContext.UserId.ToString(),
|
||||
callType = request.CallType,
|
||||
isScreenSharing = request.IsScreenSharing
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("call_status")]
|
||||
public async Task CallStatus(CallStatusRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "call_status_updated", new
|
||||
{
|
||||
from = _userContext.UserId.ToString(),
|
||||
isMuted = request.IsMuted,
|
||||
isVideoOff = request.IsVideoOff
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Group Call signals
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
[HubMethodName("group_call_join")]
|
||||
public async Task GroupCallJoin(GroupCallJoinRequest request)
|
||||
{
|
||||
var chatId = request.ChatId;
|
||||
var userId = _userContext.UserId.ToString();
|
||||
|
||||
|
||||
var displayName = Context.User?.FindFirstValue("name") ?? Context.User?.FindFirstValue(ClaimTypes.Name) ?? "User";
|
||||
var avatar = Context.User?.FindFirstValue("avatar");
|
||||
var username = Context.User?.FindFirstValue("unique_name") ?? Context.User?.Identity?.Name ?? "user";
|
||||
|
||||
var userInfo = new ParticipantInfo(userId, username, displayName, avatar);
|
||||
|
||||
var participants = _groupCallParticipants.GetOrAdd(chatId, _ => new ConcurrentDictionary<string, ParticipantInfo>());
|
||||
var isFirst = participants.IsEmpty;
|
||||
participants.TryAdd(userId, userInfo);
|
||||
|
||||
// Notify others
|
||||
await Clients.Group(chatId).SendAsync("group_call_user_joined", new
|
||||
{
|
||||
chatId = chatId,
|
||||
userId = userId,
|
||||
userInfo = userInfo
|
||||
});
|
||||
|
||||
// Send current participants to joiner (excluding self)
|
||||
var others = participants.Values.Where(p => p.Id != userId).ToList();
|
||||
await Clients.Caller.SendAsync("group_call_participants", new
|
||||
{
|
||||
chatId = chatId,
|
||||
participants = others
|
||||
});
|
||||
|
||||
// Broadcast active call participants to everyone in the chat
|
||||
await Clients.Group(chatId).SendAsync("group_call_active", new
|
||||
{
|
||||
chatId = chatId,
|
||||
participants = participants.Keys.ToList()
|
||||
});
|
||||
|
||||
if (isFirst)
|
||||
{
|
||||
await Clients.Group(chatId).SendAsync("group_call_incoming", new
|
||||
{
|
||||
chatId = chatId,
|
||||
from = userId,
|
||||
callerInfo = userInfo,
|
||||
callType = request.CallType
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[HubMethodName("group_call_leave")]
|
||||
public async Task GroupCallLeave(GroupLeaveRequest request)
|
||||
{
|
||||
var chatId = request.ChatId;
|
||||
var userId = _userContext.UserId.ToString();
|
||||
if (_groupCallParticipants.TryGetValue(chatId, out var participants))
|
||||
{
|
||||
participants.TryRemove(userId, out _);
|
||||
|
||||
// Broadcast updated participants list
|
||||
await Clients.Group(chatId).SendAsync("group_call_active", new
|
||||
{
|
||||
chatId = chatId,
|
||||
participants = participants.Keys.ToList()
|
||||
});
|
||||
|
||||
if (participants.IsEmpty)
|
||||
{
|
||||
_groupCallParticipants.TryRemove(chatId, out _);
|
||||
await Clients.Group(chatId).SendAsync("group_call_ended", new { chatId = chatId });
|
||||
}
|
||||
}
|
||||
|
||||
await Clients.Group(chatId).SendAsync("group_call_user_left", new
|
||||
{
|
||||
chatId = chatId,
|
||||
userId = userId
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("group_call_offer")]
|
||||
public async Task GroupCallOffer(GroupCallOfferRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "group_call_offer", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
from = _userContext.UserId.ToString(),
|
||||
offer = request.Offer
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("group_call_answer")]
|
||||
public async Task GroupCallAnswer(GroupCallAnswerRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "group_call_answer", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
from = _userContext.UserId.ToString(),
|
||||
answer = request.Answer
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("group_ice_candidate")]
|
||||
public async Task GroupIceCandidate(GroupIceCandidateRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "group_ice_candidate", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
from = _userContext.UserId.ToString(),
|
||||
candidate = request.Candidate
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("group_call_renegotiate")]
|
||||
public async Task GroupRenegotiate(GroupRenegotiateRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "group_call_renegotiate", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
from = _userContext.UserId.ToString(),
|
||||
offer = request.Offer
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("group_call_renegotiate_answer")]
|
||||
public async Task GroupRenegotiateAnswer(GroupRenegotiateAnswerRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.TargetUserId, "group_call_renegotiate_answer", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
from = _userContext.UserId.ToString(),
|
||||
answer = request.Answer
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("group_call_status")]
|
||||
public async Task GroupCallStatus(GroupCallStatusRequest request)
|
||||
{
|
||||
var chatId = request.ChatId;
|
||||
var userId = _userContext.UserId.ToString();
|
||||
|
||||
if (_groupCallParticipants.TryGetValue(chatId, out var participants))
|
||||
{
|
||||
if (participants.TryGetValue(userId, out var info))
|
||||
{
|
||||
// Update local state if needed (e.g. muted status)
|
||||
participants[userId] = info with
|
||||
{
|
||||
IsMuted = request.IsMuted,
|
||||
IsVideoOff = request.IsVideoOff
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await Clients.Group(request.ChatId).SendAsync("group_call_status_updated", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
userId = Context.UserIdentifier,
|
||||
isMuted = request.IsMuted,
|
||||
isVideoOff = request.IsVideoOff
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("group_call_status_params")]
|
||||
public async Task GroupCallStatusParams(string chatId, bool isMuted, bool isVideoOff)
|
||||
{
|
||||
var userId = _userContext.UserId.ToString();
|
||||
|
||||
if (_groupCallParticipants.TryGetValue(chatId, out var participants))
|
||||
{
|
||||
if (participants.TryGetValue(userId, out var info))
|
||||
{
|
||||
// Update local state if needed (e.g. muted status)
|
||||
participants[userId] = info with
|
||||
{
|
||||
IsMuted = isMuted,
|
||||
IsVideoOff = isVideoOff
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await Clients.Group(chatId).SendAsync("group_call_status_updated", new
|
||||
{
|
||||
chatId = chatId,
|
||||
userId = Context.UserIdentifier,
|
||||
isMuted = isMuted,
|
||||
isVideoOff = isVideoOff
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("get_group_call_status")]
|
||||
public async Task GetGroupCallStatus(GetGroupCallStatusRequest request)
|
||||
{
|
||||
var chatId = request.ChatId;
|
||||
if (_groupCallParticipants.TryGetValue(chatId, out var participants))
|
||||
{
|
||||
var others = participants.Values.ToList();
|
||||
_logger.LogInformation("Found {Count} participants for chat {ChatId}", others.Count, chatId);
|
||||
await Clients.Caller.SendAsync("group_call_active", new
|
||||
{
|
||||
chatId = chatId,
|
||||
participants = others.Select(p => p.Id).ToList()
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("No active call for chat {ChatId}", chatId);
|
||||
await Clients.Caller.SendAsync("group_call_active", new
|
||||
{
|
||||
chatId = chatId,
|
||||
participants = new List<string>()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[HubMethodName("screen_share_started")]
|
||||
public async Task ScreenShareStarted(string chatId)
|
||||
{
|
||||
var userId = _userContext.UserId.ToString();
|
||||
if (_groupCallParticipants.TryGetValue(chatId, out var participants))
|
||||
{
|
||||
if (participants.TryGetValue(userId, out var info))
|
||||
{
|
||||
participants[userId] = info with { IsSharingScreen = true };
|
||||
}
|
||||
}
|
||||
|
||||
await Clients.Group(chatId).SendAsync("screen_share_started", new
|
||||
{
|
||||
chatId = chatId,
|
||||
userId = userId
|
||||
});
|
||||
}
|
||||
|
||||
[HubMethodName("screen_share_stopped")]
|
||||
public async Task ScreenShareStopped(string chatId)
|
||||
{
|
||||
var userId = _userContext.UserId.ToString();
|
||||
if (_groupCallParticipants.TryGetValue(chatId, out var participants))
|
||||
{
|
||||
if (participants.TryGetValue(userId, out var info))
|
||||
{
|
||||
participants[userId] = info with { IsSharingScreen = false };
|
||||
}
|
||||
}
|
||||
|
||||
await Clients.Group(chatId).SendAsync("screen_share_stopped", new
|
||||
{
|
||||
chatId = chatId,
|
||||
userId = userId
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Records
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
public record AttachmentHubRequest(string Type, string Url, string? FileName, long? FileSize);
|
||||
public record SendMessageHubRequest(
|
||||
Guid ChatId,
|
||||
|
||||
string? Content,
|
||||
|
||||
string Type,
|
||||
|
||||
List<AttachmentHubRequest>? Attachments = null,
|
||||
Guid? ReplyToId = null,
|
||||
string? Quote = null,
|
||||
Guid? ForwardedFromId = null);
|
||||
public record ReadMessagesRequest(Guid ChatId, List<string>? MessageIds);
|
||||
public record CallOfferRequest(string TargetUserId, object Offer, string CallType, string? ChatId);
|
||||
public record CallAnswerRequest(string TargetUserId, object Answer);
|
||||
public record TargetUserRequest(string TargetUserId);
|
||||
public record IceCandidateRequest(string TargetUserId, object Candidate);
|
||||
public record RenegotiateRequest(string TargetUserId, object Offer);
|
||||
public record RenegotiateAnswerRequest(string TargetUserId, object Answer);
|
||||
public record CallTypeChangedRequest(string TargetUserId, string CallType, bool IsScreenSharing = false);
|
||||
public record CallStatusRequest(string TargetUserId, bool IsMuted, bool IsVideoOff);
|
||||
public record AddReactionRequest(Guid MessageId, Guid ChatId, string Emoji);
|
||||
public record RemoveReactionRequest(Guid MessageId, Guid ChatId, string Emoji);
|
||||
public record DeleteMessagesHubRequest(Guid ChatId, List<string> MessageIds, bool DeleteForAll);
|
||||
public record GroupCallJoinRequest(string ChatId, string CallType);
|
||||
public record ParticipantInfo(string Id, string Username, string DisplayName, string? Avatar, bool IsSharingScreen = false, bool IsMuted = false, bool IsVideoOff = false);
|
||||
public record GroupLeaveRequest(string ChatId);
|
||||
public record GetGroupCallStatusRequest(string ChatId);
|
||||
public record GroupCallStatusRequest(string ChatId, bool IsMuted, bool IsVideoOff);
|
||||
public record GroupCallOfferRequest(string ChatId, string TargetUserId, object Offer);
|
||||
public record GroupCallAnswerRequest(string ChatId, string TargetUserId, object Answer);
|
||||
public record GroupIceCandidateRequest(string ChatId, string TargetUserId, object Candidate);
|
||||
public record GroupRenegotiateRequest(string ChatId, string TargetUserId, object Offer);
|
||||
public record GroupRenegotiateAnswerRequest(string ChatId, string TargetUserId, object Answer);
|
||||
public record FriendSignalRequest(string FriendId);
|
||||
}
|
||||
35
backend/src/Modules/Chats/Knot.Modules.Chats.csproj
Normal file
35
backend/src/Modules/Chats/Knot.Modules.Chats.csproj
Normal file
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" Version="1.4.0" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
|
||||
<PackageReference Include="MediatR" Version="12.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.4" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.7.1" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Knot.Modules.Chats.UnitTests" />
|
||||
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
99
backend/src/Modules/Chats/Migrations/20260319124845_InitialChats.Designer.cs
generated
Normal file
99
backend/src/Modules/Chats/Migrations/20260319124845_InitialChats.Designer.cs
generated
Normal file
@@ -0,0 +1,99 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Knot.Modules.Chats.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Knot.Modules.Chats.Migrations
|
||||
{
|
||||
[DbContext(typeof(ChatsDbContext))]
|
||||
[Migration("20260319124845_InitialChats")]
|
||||
partial class InitialChats
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("chats")
|
||||
.HasAnnotation("ProductVersion", "10.0.4")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Avatar")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Chats", "chats");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
|
||||
{
|
||||
b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<Guid>("ChatId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<bool>("IsMuted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b1.Property<bool>("IsPinned")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b1.Property<DateTime>("JoinedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b1.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.HasKey("Id");
|
||||
|
||||
b1.HasIndex("ChatId", "UserId")
|
||||
.IsUnique();
|
||||
|
||||
b1.ToTable("ChatMembers", "chats");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("ChatId");
|
||||
});
|
||||
|
||||
b.Navigation("Members");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Knot.Modules.Chats.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialChats : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "chats");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Chats",
|
||||
schema: "chats",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Type = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: true),
|
||||
Description = table.Column<string>(type: "text", nullable: true),
|
||||
Avatar = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Chats", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ChatMembers",
|
||||
schema: "chats",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ChatId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Role = table.Column<string>(type: "text", nullable: false),
|
||||
JoinedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
IsPinned = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsMuted = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ChatMembers", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ChatMembers_Chats_ChatId",
|
||||
column: x => x.ChatId,
|
||||
principalSchema: "chats",
|
||||
principalTable: "Chats",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ChatMembers_ChatId_UserId",
|
||||
schema: "chats",
|
||||
table: "ChatMembers",
|
||||
columns: new[] { "ChatId", "UserId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ChatMembers",
|
||||
schema: "chats");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Chats",
|
||||
schema: "chats");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Knot.Modules.Chats.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Knot.Modules.Chats.Migrations
|
||||
{
|
||||
[DbContext(typeof(ChatsDbContext))]
|
||||
partial class ChatsDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("chats")
|
||||
.HasAnnotation("ProductVersion", "10.0.4")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Avatar")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Chats", "chats");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
|
||||
{
|
||||
b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<Guid>("ChatId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<bool>("IsMuted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b1.Property<bool>("IsPinned")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b1.Property<DateTime>("JoinedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b1.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.HasKey("Id");
|
||||
|
||||
b1.HasIndex("ChatId", "UserId")
|
||||
.IsUnique();
|
||||
|
||||
b1.ToTable("ChatMembers", "chats");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("ChatId");
|
||||
});
|
||||
|
||||
b.Navigation("Members");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<User> Users { get; }
|
||||
DbSet<Friendship> Friendships { get; }
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Identity.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Unit of Work специфичный для модуля Identity.
|
||||
/// </summary>
|
||||
public interface IIdentityUnitOfWork : IUnitOfWork
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Knot.Modules.Identity.Domain;
|
||||
|
||||
namespace Knot.Modules.Identity.Application.Abstractions;
|
||||
|
||||
public interface IJwtTokenProvider
|
||||
{
|
||||
string Generate(User user);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Knot.Modules.Identity.Domain;
|
||||
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<Guid>;
|
||||
|
||||
internal sealed class AcceptFriendRequestCommandHandler : ICommandHandler<AcceptFriendRequestCommand, Guid>
|
||||
{
|
||||
private readonly IIdentityDbContext _context;
|
||||
private readonly IIdentityUnitOfWork _unitOfWork;
|
||||
|
||||
public AcceptFriendRequestCommandHandler(IIdentityDbContext context, IIdentityUnitOfWork unitOfWork)
|
||||
{
|
||||
_context = context;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> 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<Guid>(IdentityErrors.FriendsNotFound);
|
||||
}
|
||||
|
||||
friendship.Accept();
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(friendship.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Knot.Modules.Identity.Domain;
|
||||
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<DeclineFriendRequestCommand>
|
||||
{
|
||||
private readonly IIdentityDbContext _context;
|
||||
private readonly IIdentityUnitOfWork _unitOfWork;
|
||||
|
||||
public DeclineFriendRequestCommandHandler(IIdentityDbContext context, IIdentityUnitOfWork unitOfWork)
|
||||
{
|
||||
_context = context;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public async Task<Result> 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(IdentityErrors.FriendsNotFound);
|
||||
}
|
||||
|
||||
friendship.Decline();
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
using System;
|
||||
|
||||
namespace Knot.Modules.Identity.Application.Friends;
|
||||
|
||||
public record FriendshipStatusResponse(
|
||||
string Status,
|
||||
Guid? FriendshipId = null,
|
||||
string? Direction = null
|
||||
);
|
||||
@@ -0,0 +1,59 @@
|
||||
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<List<FriendDto>>;
|
||||
|
||||
internal sealed class GetFriendsQueryHandler : IQueryHandler<GetFriendsQuery, List<FriendDto>>
|
||||
{
|
||||
private readonly IIdentityDbContext _context;
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public GetFriendsQueryHandler(IIdentityDbContext context, IUserRepository userRepository)
|
||||
{
|
||||
_context = context;
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
public async Task<Result<List<FriendDto>>> 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<FriendDto>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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<FriendshipStatusResponse>;
|
||||
|
||||
internal sealed class GetFriendshipStatusQueryHandler : IQueryHandler<GetFriendshipStatusQuery, FriendshipStatusResponse>
|
||||
{
|
||||
private readonly IIdentityDbContext _context;
|
||||
|
||||
public GetFriendshipStatusQueryHandler(IIdentityDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<Result<FriendshipStatusResponse>> 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"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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<List<FriendRequestDto>>;
|
||||
|
||||
internal sealed class GetIncomingRequestsQueryHandler : IQueryHandler<GetIncomingRequestsQuery, List<FriendRequestDto>>
|
||||
{
|
||||
private readonly IIdentityDbContext _context;
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public GetIncomingRequestsQueryHandler(IIdentityDbContext context, IUserRepository userRepository)
|
||||
{
|
||||
_context = context;
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
public async Task<Result<List<FriendRequestDto>>> 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<FriendRequestDto>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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<List<FriendRequestDto>>;
|
||||
|
||||
internal sealed class GetOutgoingRequestsQueryHandler : IQueryHandler<GetOutgoingRequestsQuery, List<FriendRequestDto>>
|
||||
{
|
||||
private readonly IIdentityDbContext _context;
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public GetOutgoingRequestsQueryHandler(IIdentityDbContext context, IUserRepository userRepository)
|
||||
{
|
||||
_context = context;
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
public async Task<Result<List<FriendRequestDto>>> 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<FriendRequestDto>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Knot.Modules.Identity.Domain;
|
||||
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<RemoveFriendCommand>
|
||||
{
|
||||
private readonly IIdentityDbContext _context;
|
||||
private readonly IIdentityUnitOfWork _unitOfWork;
|
||||
|
||||
public RemoveFriendCommandHandler(IIdentityDbContext context, IIdentityUnitOfWork unitOfWork)
|
||||
{
|
||||
_context = context;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public async Task<Result> 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(IdentityErrors.FriendsNotFound);
|
||||
}
|
||||
|
||||
_context.Friendships.Remove(friendship);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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<SendFriendRequestCommand>
|
||||
{
|
||||
private readonly IIdentityDbContext _context;
|
||||
private readonly IIdentityUnitOfWork _unitOfWork;
|
||||
|
||||
public SendFriendRequestCommandHandler(IIdentityDbContext context, IIdentityUnitOfWork unitOfWork)
|
||||
{
|
||||
_context = context;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public async Task<Result> Handle(SendFriendRequestCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.UserId == request.FriendId)
|
||||
{
|
||||
return Result.Failure(IdentityErrors.FriendsSelf);
|
||||
}
|
||||
|
||||
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(IdentityErrors.FriendsExists);
|
||||
}
|
||||
|
||||
var friendship = Friendship.Create(request.UserId, request.FriendId);
|
||||
_context.Friendships.Add(friendship);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,68 @@
|
||||
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<UserProfileDto>;
|
||||
|
||||
internal sealed class CropAvatarCommandHandler : ICommandHandler<CropAvatarCommand, UserProfileDto>
|
||||
{
|
||||
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<Result<UserProfileDto>> Handle(CropAvatarCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return Result.Failure<UserProfileDto>(IdentityErrors.UserNotFound);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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<UserProfileDto>;
|
||||
|
||||
internal sealed class DeleteAvatarCommandHandler : ICommandHandler<DeleteAvatarCommand, UserProfileDto>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IIdentityUnitOfWork _unitOfWork;
|
||||
|
||||
public DeleteAvatarCommandHandler(IUserRepository userRepository, IIdentityUnitOfWork unitOfWork)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileDto>> Handle(DeleteAvatarCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return Result.Failure<UserProfileDto>(IdentityErrors.UserNotFound);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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<UserProfileDto>;
|
||||
|
||||
internal sealed class UploadAvatarCommandHandler : ICommandHandler<UploadAvatarCommand, UserProfileDto>
|
||||
{
|
||||
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<Result<UserProfileDto>> Handle(UploadAvatarCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return Result.Failure<UserProfileDto>(IdentityErrors.UserNotFound);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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<AuthResponseDto>;
|
||||
|
||||
internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponseDto>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public GetMeQueryHandler(IUserRepository userRepository)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
public async Task<Result<AuthResponseDto>> Handle(GetMeQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return Result.Failure<AuthResponseDto>(IdentityErrors.UserNotFound);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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.GetUser;
|
||||
|
||||
public sealed record GetUserQuery(Guid Id) : IQuery<UserProfileDto>;
|
||||
|
||||
internal sealed class GetUserQueryHandler : IQueryHandler<GetUserQuery, UserProfileDto>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public GetUserQueryHandler(IUserRepository userRepository)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileDto>> Handle(GetUserQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(request.Id, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return Result.Failure<UserProfileDto>(IdentityErrors.UserNotFound);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Команда для входа пользователя. Возвращает AuthResponseDto.
|
||||
/// </summary>
|
||||
public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>;
|
||||
|
||||
public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, AuthResponseDto>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IJwtTokenProvider _tokenProvider;
|
||||
|
||||
public LoginUserCommandHandler(IUserRepository userRepository, IJwtTokenProvider tokenProvider)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_tokenProvider = tokenProvider;
|
||||
}
|
||||
|
||||
public async Task<Result<AuthResponseDto>> 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<AuthResponseDto>(IdentityErrors.IdentityInvalidCredentials);
|
||||
}
|
||||
|
||||
string token = _tokenProvider.Generate(user);
|
||||
|
||||
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
|
||||
)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Команда для регистрации нового пользователя.
|
||||
/// </summary>
|
||||
public sealed record RegisterUserCommand(
|
||||
string Username,
|
||||
string Password,
|
||||
string DisplayName,
|
||||
string? Email,
|
||||
string? Bio) : ICommand<AuthResponseDto>;
|
||||
|
||||
/// <summary>
|
||||
/// Обработчик команды регистрации.
|
||||
/// </summary>
|
||||
public sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IIdentityUnitOfWork _unitOfWork;
|
||||
private readonly ISettingsService _settings;
|
||||
private readonly IJwtTokenProvider _tokenProvider;
|
||||
|
||||
public RegisterUserCommandHandler(
|
||||
IUserRepository userRepository,
|
||||
IIdentityUnitOfWork unitOfWork,
|
||||
ISettingsService settings,
|
||||
IJwtTokenProvider tokenProvider)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
_settings = settings;
|
||||
_tokenProvider = tokenProvider;
|
||||
}
|
||||
|
||||
public async Task<Result<AuthResponseDto>> Handle(RegisterUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_settings.Current.EnableRegistration)
|
||||
{
|
||||
return Result.Failure<AuthResponseDto>(IdentityErrors.IdentityRegistrationDisabled);
|
||||
}
|
||||
|
||||
// 1. Проверка уникальности username
|
||||
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
|
||||
{
|
||||
return Result.Failure<AuthResponseDto>(IdentityErrors.IdentityUsernameNotUnique);
|
||||
}
|
||||
|
||||
// 2. Хеширование пароля (здесь будет вызов сервиса, пока заглушка)
|
||||
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
|
||||
|
||||
// 3. Создание сущности
|
||||
var user = User.Create(
|
||||
request.Username,
|
||||
passwordHash,
|
||||
request.DisplayName,
|
||||
request.Email,
|
||||
request.Bio);
|
||||
|
||||
// 4. Сохранение
|
||||
_userRepository.Add(user);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
string token = _tokenProvider.Generate(user);
|
||||
|
||||
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
|
||||
)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Identity.Domain;
|
||||
using Knot.Modules.Identity.Application.Abstractions;
|
||||
|
||||
namespace Knot.Modules.Identity.Application.Users.Search;
|
||||
|
||||
public sealed record SearchUsersQuery(string Query) : IQuery<List<UserDto>>;
|
||||
|
||||
internal sealed class SearchUsersQueryHandler : IQueryHandler<SearchUsersQuery, List<UserDto>>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public SearchUsersQueryHandler(IUserRepository userRepository)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
public async Task<Result<List<UserDto>>> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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<UserProfileDto>;
|
||||
|
||||
internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfileCommand, UserProfileDto>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IIdentityUnitOfWork _unitOfWork;
|
||||
|
||||
public UpdateProfileCommandHandler(IUserRepository userRepository, IIdentityUnitOfWork unitOfWork)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileDto>> Handle(UpdateProfileCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return Result.Failure<UserProfileDto>(IdentityErrors.UserNotFound);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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<UserProfileDto>;
|
||||
|
||||
internal sealed class UpdateSettingsCommandHandler : ICommandHandler<UpdateSettingsCommand, UserProfileDto>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IIdentityUnitOfWork _unitOfWork;
|
||||
|
||||
public UpdateSettingsCommandHandler(IUserRepository userRepository, IIdentityUnitOfWork unitOfWork)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileDto>> Handle(UpdateSettingsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return Result.Failure<UserProfileDto>(IdentityErrors.UserNotFound);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
12
backend/src/Modules/Identity/Application/Users/UserDto.cs
Normal file
12
backend/src/Modules/Identity/Application/Users/UserDto.cs
Normal file
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
40
backend/src/Modules/Identity/DependencyInjection.cs
Normal file
40
backend/src/Modules/Identity/DependencyInjection.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Knot.Modules.Identity.Infrastructure.Persistence;
|
||||
using Knot.Modules.Identity.Domain;
|
||||
using Knot.Modules.Identity.Application.Abstractions;
|
||||
using Knot.Modules.Identity.Infrastructure.Authentication;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Identity;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
/// <summary>
|
||||
/// Регистрация сервисов модуля Identity.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddIdentityModule(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// Настройка базы данных
|
||||
string connectionString = configuration.GetConnectionString("DefaultConnection")!;
|
||||
|
||||
services.AddDbContext<IdentityDbContext>(options =>
|
||||
options.UseNpgsql(connectionString));
|
||||
|
||||
// Регистрация Unit of Work и Репозиториев
|
||||
services.AddScoped<IIdentityUnitOfWork>(sp => sp.GetRequiredService<IdentityDbContext>());
|
||||
services.AddScoped<IIdentityDbContext>(sp => sp.GetRequiredService<IdentityDbContext>());
|
||||
services.AddScoped<IUserRepository, UserRepository>();
|
||||
services.AddScoped<IJwtTokenProvider, JwtTokenProvider>();
|
||||
services.AddScoped<IUserDisplayNameProvider, Knot.Modules.Identity.Infrastructure.Services.UserDisplayNameProvider>();
|
||||
|
||||
// Регистрация MediatR для этого модуля
|
||||
services.AddMediatR(config =>
|
||||
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
34
backend/src/Modules/Identity/Domain/Friendship.cs
Normal file
34
backend/src/Modules/Identity/Domain/Friendship.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Identity.Domain;
|
||||
|
||||
public enum FriendshipStatus
|
||||
{
|
||||
Pending,
|
||||
Accepted,
|
||||
Declined
|
||||
}
|
||||
|
||||
public sealed class Friendship : Entity<Guid>
|
||||
{
|
||||
public Guid UserId { get; private set; }
|
||||
public Guid FriendId { get; private set; }
|
||||
public FriendshipStatus Status { get; private set; }
|
||||
public DateTime CreatedAt { get; private set; }
|
||||
|
||||
private Friendship(Guid id, Guid userId, Guid friendId, FriendshipStatus status) : base(id)
|
||||
{
|
||||
UserId = userId;
|
||||
FriendId = friendId;
|
||||
Status = status;
|
||||
CreatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public static Friendship Create(Guid userId, Guid friendId)
|
||||
{
|
||||
return new Friendship(Guid.NewGuid(), userId, friendId, FriendshipStatus.Pending);
|
||||
}
|
||||
|
||||
public void Accept() => Status = FriendshipStatus.Accepted;
|
||||
public void Decline() => Status = FriendshipStatus.Declined;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user