Модуль связей и Klipy

This commit is contained in:
Халимов Рустам
2026-03-27 01:22:20 +03:00
parent 7ef73b414c
commit b399649cfd
31 changed files with 603 additions and 472 deletions

View File

@@ -6,9 +6,10 @@ using System.Threading.Tasks;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Abstractions;
public interface IRelationsDbContext
public interface IContactsDbContext
{
DbSet<Friendship> Friendships { get; }
DbSet<Contact> Contacts { get; }
DbSet<UserReplica> UserReplicas { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,35 @@
using Knot.Modules.Relations.Domain;
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Relations.Application.Abstractions;
using Microsoft.EntityFrameworkCore;
namespace Knot.Modules.Relations.Application.Contacts;
public record AcceptContactRequestCommand(Guid UserId, Guid ContactId) : ICommand<Guid>;
internal sealed class AcceptContactRequestCommandHandler : ICommandHandler<AcceptContactRequestCommand, Guid>
{
private readonly IContactsDbContext _context;
public AcceptContactRequestCommandHandler(IContactsDbContext context)
{
_context = context;
}
public async Task<Result<Guid>> Handle(AcceptContactRequestCommand request, CancellationToken cancellationToken)
{
var contact = await _context.Contacts.FirstOrDefaultAsync(c => c.Id == request.ContactId, cancellationToken);
if (contact == null || contact.ContactId != request.UserId)
{
return Result.Failure<Guid>(new Error("Contacts.NotFound", "Contact request not found."));
}
contact.Accept();
await _context.SaveChangesAsync(cancellationToken);
return Result.Success(contact.Id);
}
}

View File

@@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Relations.Domain;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Contacts;
public record ContactDto(
Guid Id,
string Username,
string DisplayName,
string Avatar,
bool IsOnline,
DateTime? LastSeen,
Guid RelationId,
bool IsBlocked = false,
bool IsExternal = false,
string? Domain = null);
public record GetContactsQuery(Guid UserId) : IQuery<List<ContactDto>>;
internal sealed class GetContactsQueryHandler : IQueryHandler<GetContactsQuery, List<ContactDto>>
{
private readonly IContactsDbContext _context;
public GetContactsQueryHandler(IContactsDbContext context)
{
_context = context;
}
public async Task<Result<List<ContactDto>>> Handle(GetContactsQuery request, CancellationToken cancellationToken)
{
var relations = await _context.Contacts
.Where(c => (c.UserId == request.UserId || c.ContactId == request.UserId) && c.Status == ContactStatus.Accepted)
.ToListAsync(cancellationToken);
var contactIds = relations.Select(c => c.UserId == request.UserId ? c.ContactId : c.UserId).ToList();
var replicas = await _context.UserReplicas
.Where(r => contactIds.Contains(r.Id))
.ToListAsync(cancellationToken);
var result = new List<ContactDto>();
foreach (var replica in replicas)
{
var rel = relations.First(c => c.UserId == replica.Id || c.ContactId == replica.Id);
result.Add(new ContactDto(
replica.Id,
replica.Username,
replica.DisplayName,
replica.Avatar,
false, // Presence handled by another service or real-time
null,
rel.Id,
rel.Status == ContactStatus.Blocked,
replica.IsExternal,
replica.Domain
));
}
return Result.Success(result);
}
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Relations.Domain;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Contacts;
public record ContactUserDto(Guid Id, string Username, string DisplayName, string Avatar);
public record ContactRequestDto(Guid Id, ContactUserDto User, DateTime CreatedAt);
public record GetIncomingRequestsQuery(Guid UserId) : IQuery<List<ContactRequestDto>>;
internal sealed class GetIncomingRequestsQueryHandler : IQueryHandler<GetIncomingRequestsQuery, List<ContactRequestDto>>
{
private readonly IContactsDbContext _context;
public GetIncomingRequestsQueryHandler(IContactsDbContext context)
{
_context = context;
}
public async Task<Result<List<ContactRequestDto>>> Handle(GetIncomingRequestsQuery request, CancellationToken cancellationToken)
{
var contacts = await _context.Contacts
.Where(c => c.ContactId == request.UserId && c.Status == ContactStatus.Pending)
.ToListAsync(cancellationToken);
var requesterIds = contacts.Select(c => c.UserId).ToList();
var replicas = await _context.UserReplicas
.Where(r => requesterIds.Contains(r.Id))
.ToDictionaryAsync(r => r.Id, cancellationToken);
var result = contacts
.Where(c => replicas.ContainsKey(c.UserId))
.Select(c =>
{
var user = replicas[c.UserId];
return new ContactRequestDto(
c.Id,
new ContactUserDto(user.Id, user.Username, user.DisplayName, user.Avatar),
c.CreatedAt
);
})
.ToList();
return Result.Success(result);
}
}

View File

@@ -0,0 +1,43 @@
using Knot.Modules.Relations.Domain;
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Relations.Application.Abstractions;
using Microsoft.EntityFrameworkCore;
namespace Knot.Modules.Relations.Application.Contacts;
public record RemoveContactCommand(Guid UserId, Guid ContactRelationId) : ICommand;
internal sealed class RemoveContactCommandHandler : ICommandHandler<RemoveContactCommand>
{
private readonly IContactsDbContext _context;
public ContactRelationNotFound() : base("Contacts.NotFound", "Contact relation not found.") { }
public ContactRelationUnauthorized() : base("Contacts.Unauthorized", "You are not authorized to remove this contact.") { }
public RemoveContactCommandHandler(IContactsDbContext context)
{
_context = context;
}
public async Task<Result> Handle(RemoveContactCommand request, CancellationToken cancellationToken)
{
var contact = await _context.Contacts.FirstOrDefaultAsync(c => c.Id == request.ContactRelationId, cancellationToken);
if (contact == null)
{
return Result.Failure(new Error("Contacts.NotFound", "Contact relation not found."));
}
if (contact.UserId != request.UserId && contact.ContactId != request.UserId)
{
return Result.Failure(new Error("Contacts.Unauthorized", "You are not authorized to remove this contact."));
}
_context.Contacts.Remove(contact);
await _context.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}

View File

@@ -0,0 +1,44 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Relations.Domain;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Contacts;
public record SendContactRequestCommand(Guid UserId, Guid ContactId) : ICommand;
internal sealed class SendContactRequestCommandHandler : ICommandHandler<SendContactRequestCommand>
{
private readonly IContactsDbContext _context;
public SendContactRequestCommandHandler(IContactsDbContext context)
{
_context = context;
}
public async Task<Result> Handle(SendContactRequestCommand request, CancellationToken cancellationToken)
{
if (request.UserId == request.ContactId)
{
return Result.Failure(new Error("Contacts.Self", "You cannot add yourself to contacts."));
}
var existing = await _context.Contacts
.FirstOrDefaultAsync(f => (f.UserId == request.UserId && f.ContactId == request.ContactId) ||
(f.UserId == request.ContactId && f.ContactId == request.UserId), cancellationToken);
if (existing != null)
{
return Result.Failure(new Error("Contacts.Exists", "Contact relation already exists."));
}
var contact = Contact.CreateRequest(request.UserId, request.ContactId);
_context.Contacts.Add(contact);
await _context.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}

View File

@@ -1,37 +0,0 @@
using Knot.Modules.Relations.Domain;
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends;
public record AcceptFriendRequestCommand(Guid UserId, Guid FriendshipId) : ICommand<Guid>;
internal sealed class AcceptFriendRequestCommandHandler : ICommandHandler<AcceptFriendRequestCommand, Guid>
{
private readonly IRelationsDbContext _context;
private readonly IRelationsUnitOfWork _unitOfWork;
public AcceptFriendRequestCommandHandler(IRelationsDbContext context, IRelationsUnitOfWork 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);
}
}

View File

@@ -1,14 +0,0 @@
using System;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends.DTOs;
public record FriendDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar,
bool IsOnline,
DateTime LastSeen,
Guid FriendshipId
);

View File

@@ -1,17 +0,0 @@
using System;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends.DTOs;
public record FriendRequestDto(
Guid Id,
FriendUserDto User,
DateTime CreatedAt
);
public record FriendUserDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar
);

View File

@@ -1,6 +0,0 @@
using System;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends.DTOs;
public record SendFriendRequest(Guid FriendId);

View File

@@ -1,37 +0,0 @@
using Knot.Modules.Relations.Domain;
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends;
public record DeclineFriendRequestCommand(Guid UserId, Guid FriendshipId) : ICommand;
internal sealed class DeclineFriendRequestCommandHandler : ICommandHandler<DeclineFriendRequestCommand>
{
private readonly IRelationsDbContext _context;
private readonly IRelationsUnitOfWork _unitOfWork;
public DeclineFriendRequestCommandHandler(IRelationsDbContext context, IRelationsUnitOfWork 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();
}
}

View File

@@ -1,15 +0,0 @@
using System;
using System.Collections.Generic;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends;
public record FriendDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar,
bool IsOnline,
DateTime LastSeenAt,
Guid FriendshipId
);

View File

@@ -1,12 +0,0 @@
using System;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends;
public record FriendUserDto(Guid Id, string Username, string DisplayName, string? Avatar);
public record FriendRequestDto(
Guid Id,
FriendUserDto User,
DateTime CreatedAt
);

View File

@@ -1,10 +0,0 @@
using System;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends;
public record FriendshipStatusResponse(
string Status,
Guid? FriendshipId = null,
string? Direction = null
);

View File

@@ -1,60 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Relations.Domain;
using Knot.Shared.Kernel;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends;
public record GetFriendsQuery(Guid UserId) : IQuery<List<FriendDto>>;
internal sealed class GetFriendsQueryHandler : IQueryHandler<GetFriendsQuery, List<FriendDto>>
{
private readonly IRelationsDbContext _context;
private readonly IUserRepository _userRepository;
public GetFriendsQueryHandler(IRelationsDbContext 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);
}
}

View File

@@ -1,44 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends;
public record GetFriendshipStatusQuery(Guid CurrentUserId, Guid TargetUserId) : IQuery<FriendshipStatusResponse>;
internal sealed class GetFriendshipStatusQueryHandler : IQueryHandler<GetFriendshipStatusQuery, FriendshipStatusResponse>
{
private readonly IRelationsDbContext _context;
public GetFriendshipStatusQueryHandler(IRelationsDbContext 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"
));
}
}

View File

@@ -1,50 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Relations.Domain;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends;
public record GetIncomingRequestsQuery(Guid UserId) : IQuery<List<FriendRequestDto>>;
internal sealed class GetIncomingRequestsQueryHandler : IQueryHandler<GetIncomingRequestsQuery, List<FriendRequestDto>>
{
private readonly IRelationsDbContext _context;
private readonly IUserRepository _userRepository;
public GetIncomingRequestsQueryHandler(IRelationsDbContext 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);
}
}

View File

@@ -1,50 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Relations.Domain;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends;
public record GetOutgoingRequestsQuery(Guid UserId) : IQuery<List<FriendRequestDto>>;
internal sealed class GetOutgoingRequestsQueryHandler : IQueryHandler<GetOutgoingRequestsQuery, List<FriendRequestDto>>
{
private readonly IRelationsDbContext _context;
private readonly IUserRepository _userRepository;
public GetOutgoingRequestsQueryHandler(IRelationsDbContext 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);
}
}

View File

@@ -1,37 +0,0 @@
using Knot.Modules.Relations.Domain;
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends;
public record RemoveFriendCommand(Guid UserId, Guid FriendshipId) : ICommand;
internal sealed class RemoveFriendCommandHandler : ICommandHandler<RemoveFriendCommand>
{
private readonly IRelationsDbContext _context;
private readonly IRelationsUnitOfWork _unitOfWork;
public RemoveFriendCommandHandler(IRelationsDbContext context, IRelationsUnitOfWork 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();
}
}

View File

@@ -1,47 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Relations.Domain;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Relations.Application.Abstractions;
namespace Knot.Modules.Relations.Application.Friends;
public record SendFriendRequestCommand(Guid UserId, Guid FriendId) : ICommand;
internal sealed class SendFriendRequestCommandHandler : ICommandHandler<SendFriendRequestCommand>
{
private readonly IRelationsDbContext _context;
private readonly IRelationsUnitOfWork _unitOfWork;
public SendFriendRequestCommandHandler(IRelationsDbContext context, IRelationsUnitOfWork 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();
}
}