Зависимости на контракты

This commit is contained in:
Халимов Рустам
2026-03-30 17:48:39 +03:00
parent 09e5cbaa76
commit cb93ff7240
41 changed files with 620 additions and 344 deletions
@@ -0,0 +1,22 @@
namespace Knot.Contracts.Admin.Abstractions;
/// <summary>
/// Базовый класс для агрегатов - сущностей с бизнес-логикой и доменными событиями.
/// </summary>
public abstract class AggregateRoot<TId> : Entity<TId> where TId : notnull
{
private readonly List<IDomainEvent> _domainEvents = new();
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected AggregateRoot(TId id) : base(id) { }
protected void RaiseDomainEvent(IDomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
}
@@ -0,0 +1,43 @@
namespace Knot.Contracts.Admin.Abstractions;
/// <summary>
/// Базовый класс для сущностей с уникальным идентификатором.
/// </summary>
public abstract class Entity<TId> where TId : notnull
{
public TId Id { get; protected set; }
protected Entity(TId id)
{
Id = id;
}
public override bool Equals(object? obj)
{
if (obj is not Entity<TId> other)
return false;
if (ReferenceEquals(this, other))
return true;
if (GetType() != other.GetType())
return false;
return Id.Equals(other.Id);
}
public override int GetHashCode() => Id.GetHashCode();
public static bool operator ==(Entity<TId>? a, Entity<TId>? b)
{
if (a is null && b is null)
return true;
if (a is null || b is null)
return false;
return a.Equals(b);
}
public static bool operator !=(Entity<TId>? a, Entity<TId>? b) => !(a == b);
}
@@ -0,0 +1,15 @@
using MediatR;
namespace Knot.Contracts.Admin.Abstractions;
public interface ICommand : IRequest<Result> { }
public interface ICommand<TResponse> : IRequest<Result<TResponse>> { }
public interface ICommandHandler<in TCommand> : IRequestHandler<TCommand, Result>
where TCommand : ICommand
{ }
public interface ICommandHandler<in TCommand, TResponse> : IRequestHandler<TCommand, Result<TResponse>>
where TCommand : ICommand<TResponse>
{ }
@@ -0,0 +1,8 @@
using MediatR;
namespace Knot.Contracts.Admin.Abstractions;
/// <summary>
/// Интерфейс для доменных событий.
/// </summary>
public interface IDomainEvent : INotification { }
@@ -0,0 +1,13 @@
using MediatR;
namespace Knot.Contracts.Admin.Abstractions;
public interface IQuery<TResponse> : IRequest<Result<TResponse>>
{
}
public interface IQueryHandler<TQuery, TResponse>
: IRequestHandler<TQuery, Result<TResponse>>
where TQuery : IQuery<TResponse>
{
}
@@ -0,0 +1,3 @@
namespace Knot.Contracts.Admin.Abstractions;
public record MessageResponse(string Message);
@@ -0,0 +1,63 @@
namespace Knot.Contracts.Admin.Abstractions;
/// <summary>
/// Представляет ошибку в доменной логике.
/// </summary>
public sealed record Error(string Code, string Description)
{
public static readonly Error None = new(string.Empty, string.Empty);
public static Error NotFound(string code, string description) => new(code, description);
}
/// <summary>
/// Общая обертка для результата операции. Позволяет избегать использования исключений для управления потоком.
/// </summary>
public class Result
{
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public Error Error { get; }
protected Result(bool isSuccess, Error error)
{
if (isSuccess && error != Error.None)
{
throw new InvalidOperationException();
}
if (!isSuccess && error == Error.None)
{
throw new InvalidOperationException();
}
IsSuccess = isSuccess;
Error = error;
}
public static Result Success() => new(true, Error.None);
public static Result Failure(Error error) => new(false, error);
public static Result<TValue> Success<TValue>(TValue value) => Result<TValue>.Success(value);
public static Result<TValue> Failure<TValue>(Error error) => Result<TValue>.Failure(error);
}
/// <summary>
/// Результ операции, содержащий значение.
/// </summary>
public class Result<TValue> : Result
{
private readonly TValue? _value;
protected internal Result(TValue? value, bool isSuccess, Error error)
: base(isSuccess, error)
{
_value = value;
}
public TValue Value => IsSuccess
? _value!
: throw new InvalidOperationException("Нельзя получить значение ошибочного результата.");
public static Result<TValue> Success(TValue value) => new(value, true, Error.None);
public new static Result<TValue> Failure(Error error) => new(default, false, error);
}
@@ -0,0 +1,3 @@
namespace Knot.Contracts.Admin.Abstractions;
public record SuccessResponse(bool Success);
@@ -6,10 +6,6 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MediatR" Version="12.0.1" /> <PackageReference Include="MediatR" Version="12.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
@@ -0,0 +1,13 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Auth.Domain;
namespace Knot.Contracts.Auth.Infrastructure.Persistence;
public interface IAuthDbContext
{
IQueryable<UserContract> Users { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -1,20 +1,18 @@
using System.Linq; using System.Linq;
using System.Threading; using Knot.Contracts.Conversations.Domain;
using System.Threading.Tasks;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Shared.Kernel;
namespace Knot.Contracts.Conversations.Infrastructure.Persistence; namespace Knot.Contracts.Conversations.Infrastructure.Persistence;
public interface IChatsDbContext : IChatsUnitOfWork public interface IChatsDbContext
{ {
IQueryable<Chat> Chats { get; } IQueryable<Chat> Chats { get; }
} }
public class Chat : AggregateRoot<Guid> /// <summary>
/// Упрощенная проекция чата для запросов (не доменная сущность).
/// </summary>
public class Chat
{ {
public Chat() : base(Guid.NewGuid()) { } public Guid Id { get; set; }
public string? Avatar { get; set; } public string? Avatar { get; set; }
} }
@@ -0,0 +1,9 @@
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Contracts.Klipy.Application.Abstractions;
public interface IKlipyClient
{
Task<bool> TestConnectionAsync(string apiKey, string appName, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Contracts.Messaging.Application.Abstractions;
public interface IMessageQueryService
{
Task<List<MessageInfo>> GetAllMessagesAsync(CancellationToken cancellationToken);
Task<List<MessageInfo>> GetOrphanedMessagesAsync(HashSet<Guid> activeChatIds, CancellationToken cancellationToken);
Task DeleteMessagesAsync(List<Guid> messageIds, CancellationToken cancellationToken);
}
public record MessageInfo(Guid Id, Guid ChatId, bool IsDeleted, string? MediaUrl, List<MediaInfo>? Media);
public record MediaInfo(string Url);
@@ -0,0 +1,12 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Contracts.Messaging.Application.Abstractions;
public interface IUserStatsService
{
Task<Dictionary<Guid, UserStats>> GetStatsForUsersAsync(List<Guid> userIds, CancellationToken cancellationToken = default);
}
public record UserStats(int MessageCount, long StorageSize);
@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Contracts.Settings.Abstractions;
public interface IStatisticsService
{
Task<DashboardStatsDto> GetDashboardStatsAsync(CancellationToken cancellationToken = default);
}
public class DashboardStatsDto
{
public long StorageUsedBytes { get; set; }
public long StorageLimitBytes { get; set; }
public long OnlineUsers { get; set; }
public long OfflineUsers { get; set; }
public long TotalUsers { get; set; }
public List<ActivityStatDto> ActivityTimeline { get; set; } = new();
public List<TopUserDto> TopUsersByMessages { get; set; } = new();
public List<TopUserDto> TopUsersByStorage { get; set; } = new();
}
public class ActivityStatDto
{
public DateTime Date { get; set; }
public long Messages { get; set; }
public long FilesSize { get; set; }
}
public class TopUserDto
{
public Guid UserId { get; set; }
public string Username { get; set; } = string.Empty;
public long Value { get; set; } // messages count or bytes
}
@@ -0,0 +1,25 @@
using System.IO;
using System.Threading.Tasks;
namespace Knot.Contracts.Storage.Abstractions;
public interface IFileStorageService
{
// Загружает поток файла и возвращает его уникальный идентификатор (SHA256 хеш или GUID).
Task<string> UploadFileAsync(Stream stream, string fileName, string contentType);
// Скачивает файл и возвращает его расшифрованный поток и тип содержимого.
Task<(Stream Stream, string ContentType, string FileName)> DownloadFileAsync(string fileId);
// Удаляет файл из хранилища.
Task DeleteFileAsync(string fileId);
// Получает список всех файлов в хранилище с их размерами.
Task<IEnumerable<(string FileId, long Size)>> ListFilesAsync();
// Получает размер конкретного файла
Task<long> GetFileSizeAsync(string fileId, CancellationToken ct = default);
// Получает размеры списка файлов
Task<Dictionary<string, long>> GetFileSizesAsync(IEnumerable<string> fileIds, CancellationToken ct = default);
}
@@ -0,0 +1,20 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Contracts.Stories.Infrastructure.Persistence;
public interface IStoryCollection
{
Task<List<StoryContract>> GetAllAsync(CancellationToken cancellationToken);
}
public class StoryContract
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string? MediaUrl { get; set; }
public string? MediaType { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? ExpiresAt { get; set; }
}
@@ -1,16 +1,16 @@
using Knot.Contracts.Auth.Domain;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Shared.Kernel;
using System; using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Contracts.Admin.Abstractions;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Domain;
using MediatR; using MediatR;
namespace Knot.Modules.Admin.Application.Admin.Commands; namespace Knot.Modules.Admin.Application.Admin.Commands;
public record BanUserCommand(Guid UserId) : ICommand<Result>; public record BanUserCommand(Guid UserId) : ICommand<SuccessResponse>;
internal sealed class BanUserCommandHandler : ICommandHandler<BanUserCommand, Result> internal sealed class BanUserCommandHandler : ICommandHandler<BanUserCommand, SuccessResponse>
{ {
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly IAuthUnitOfWork _unitOfWork; private readonly IAuthUnitOfWork _unitOfWork;
@@ -21,16 +21,16 @@ internal sealed class BanUserCommandHandler : ICommandHandler<BanUserCommand, Re
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
} }
public async Task<Result<Result>> Handle(BanUserCommand request, CancellationToken cancellationToken) public async Task<Result<SuccessResponse>> Handle(BanUserCommand request, CancellationToken cancellationToken)
{ {
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken); var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null) return Result.Failure<Result>(Error.NotFound("User.NotFound", "User not found")); if (user == null) return Result.Failure<SuccessResponse>(Error.NotFound("User.NotFound", "User not found"));
user.IsBanned = true; user.IsBanned = true;
user.BannedUntil = null; // Permanent ban user.BannedUntil = null; // Permanent ban
await _userRepository.UpdateAsync(user, cancellationToken); await _userRepository.UpdateAsync(user, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken); await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(Result.Success()); return Result.Success(new SuccessResponse(true));
} }
} }
@@ -1,16 +1,16 @@
using Knot.Contracts.Auth.Domain;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Shared.Kernel;
using System; using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Contracts.Admin.Abstractions;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Domain;
using MediatR; using MediatR;
namespace Knot.Modules.Admin.Application.Admin.Commands; namespace Knot.Modules.Admin.Application.Admin.Commands;
public record UnbanUserCommand(Guid UserId) : ICommand<Result>; public record UnbanUserCommand(Guid UserId) : ICommand<SuccessResponse>;
internal sealed class UnbanUserCommandHandler : ICommandHandler<UnbanUserCommand, Result> internal sealed class UnbanUserCommandHandler : ICommandHandler<UnbanUserCommand, SuccessResponse>
{ {
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly IAuthUnitOfWork _unitOfWork; private readonly IAuthUnitOfWork _unitOfWork;
@@ -21,16 +21,16 @@ internal sealed class UnbanUserCommandHandler : ICommandHandler<UnbanUserCommand
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
} }
public async Task<Result<Result>> Handle(UnbanUserCommand request, CancellationToken cancellationToken) public async Task<Result<SuccessResponse>> Handle(UnbanUserCommand request, CancellationToken cancellationToken)
{ {
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken); var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null) return Result.Failure<Result>(Error.NotFound("User.NotFound", "User not found")); if (user == null) return Result.Failure<SuccessResponse>(Error.NotFound("User.NotFound", "User not found"));
user.IsBanned = false; user.IsBanned = false;
user.BannedUntil = null; user.BannedUntil = null;
await _userRepository.UpdateAsync(user, cancellationToken); await _userRepository.UpdateAsync(user, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken); await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(Result.Success()); return Result.Success(new SuccessResponse(true));
} }
} }
@@ -3,17 +3,16 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Contracts.Auth.Application.Abstractions; using Knot.Contracts.Admin.Abstractions;
using Knot.Contracts.Messaging.Domain; using Knot.Contracts.Auth.Infrastructure.Persistence;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Infrastructure.Persistence;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Storage.Abstractions;
using Knot.Contracts.Stories.Infrastructure.Persistence;
using Knot.Modules.Admin.Application.Admin.DTOs; using Knot.Modules.Admin.Application.Admin.DTOs;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Stories.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using MediatR; using MediatR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MongoDB.Driver;
namespace Knot.Modules.Admin.Application.Admin.Commands; namespace Knot.Modules.Admin.Application.Admin.Commands;
@@ -21,17 +20,22 @@ public record CleanRunCommand() : ICommand<MessageResponse>;
internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand, MessageResponse> internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand, MessageResponse>
{ {
private readonly ChatsDbContext _chatsDbContext; private readonly Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext _chatsDbContext;
private readonly IMongoCollection<Message> _messages; private readonly Knot.Contracts.Messaging.Application.Abstractions.IMessageQueryService _messageQueryService;
private readonly IMongoCollection<Story> _stories; private readonly Knot.Contracts.Stories.Infrastructure.Persistence.IStoryCollection _storyCollection;
private readonly IFileStorageService _fileStorage; private readonly IFileStorageService _fileStorage;
private readonly AuthDbContext _identityDb; private readonly Knot.Contracts.Auth.Infrastructure.Persistence.IAuthDbContext _identityDb;
public CleanRunCommandHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb, IFileStorageService fileStorage, AuthDbContext identityDb) public CleanRunCommandHandler(
Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext chatsDbContext,
Knot.Contracts.Messaging.Application.Abstractions.IMessageQueryService messageQueryService,
Knot.Contracts.Stories.Infrastructure.Persistence.IStoryCollection storyCollection,
IFileStorageService fileStorage,
Knot.Contracts.Auth.Infrastructure.Persistence.IAuthDbContext identityDb)
{ {
_chatsDbContext = chatsDbContext; _chatsDbContext = chatsDbContext;
_messages = mongoDb.GetCollection<Message>("messages"); _messageQueryService = messageQueryService;
_stories = mongoDb.GetCollection<Story>("stories"); _storyCollection = storyCollection;
_fileStorage = fileStorage; _fileStorage = fileStorage;
_identityDb = identityDb; _identityDb = identityDb;
} }
@@ -40,30 +44,27 @@ internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand,
{ {
try try
{ {
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken); var allChats = await _chatsDbContext.Chats.ToListAsync(cancellationToken);
var activeChatIds = allChats.Select(c => c.Id).ToHashSet(); var activeChatIds = allChats.Select(c => c.Id).ToHashSet();
var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken); var allMessages = await _messageQueryService.GetAllMessagesAsync(cancellationToken);
var orphanMessages = await _messageQueryService.GetOrphanedMessagesAsync(activeChatIds, cancellationToken);
var orphanMessages = allMessages
.Where(m => !activeChatIds.Contains(m.ChatId) || m.IsDeleted)
.ToList();
var keptMessages = allMessages var keptMessages = allMessages
.Where(m => activeChatIds.Contains(m.ChatId) && !m.IsDeleted) .Where(m => !orphanMessages.Any(om => om.Id == m.Id))
.ToList(); .ToList();
var allMinioFiles = (await _fileStorage.ListFilesAsync()).ToList(); var allMinioFiles = (await _fileStorage.ListFilesAsync()).ToList();
var allUsers = await _identityDb.Users.AsNoTracking().ToListAsync(cancellationToken); var allUsers = await _identityDb.Users.ToListAsync(cancellationToken);
var allStories = await _stories.Find(_ => true).ToListAsync(cancellationToken); var allStories = await _storyCollection.GetAllAsync(cancellationToken);
var validUrls = new HashSet<string>(); var validUrls = new HashSet<string>();
var activeMessageUrls = keptMessages.OfType<MediaMessage>() var activeMessageUrls = keptMessages
.Where(m => m.Media != null) .Where(m => m.Media != null)
.SelectMany(m => m.Media) .SelectMany(m => m.Media!)
.Select(me => me.Url) .Select(me => me.Url)
.Where(u => !string.IsNullOrEmpty(u)); .Where(u => !string.IsNullOrEmpty(u));
@@ -100,8 +101,7 @@ internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand,
if (orphanMessages.Any()) if (orphanMessages.Any())
{ {
var orphanIds = orphanMessages.Select(m => m.Id).ToList(); var orphanIds = orphanMessages.Select(m => m.Id).ToList();
var filter = Builders<Message>.Filter.In(m => m.Id, orphanIds); await _messageQueryService.DeleteMessagesAsync(orphanIds, cancellationToken);
await _messages.DeleteManyAsync(filter, cancellationToken);
} }
return Result.Success(new MessageResponse("Cleanup completed successfully")); return Result.Success(new MessageResponse("Cleanup completed successfully"));
@@ -6,7 +6,7 @@ using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediatR; using MediatR;
using Knot.Shared.Kernel; using Knot.Contracts.Admin.Abstractions;
namespace Knot.Modules.Admin.Application.Admin.Commands; namespace Knot.Modules.Admin.Application.Admin.Commands;
@@ -28,7 +28,7 @@ internal sealed class ResetUserPasswordCommandHandler : ICommandHandler<ResetUse
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken); var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null) if (user == null)
{ {
return Result.Failure<SuccessResponse>(AuthErrors.UserNotFound); return Result.Failure<SuccessResponse>(Error.NotFound("Auth.UserNotFound", "User not found"));
} }
if (string.IsNullOrWhiteSpace(request.NewPassword)) if (string.IsNullOrWhiteSpace(request.NewPassword))
@@ -42,3 +42,4 @@ internal sealed class ResetUserPasswordCommandHandler : ICommandHandler<ResetUse
return Result.Success(new SuccessResponse(true)); return Result.Success(new SuccessResponse(true));
} }
} }
@@ -1,10 +1,10 @@
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediatR; using Knot.Contracts.Admin.Abstractions;
using Knot.Shared.Kernel; using Knot.Contracts.Klipy.Application.Abstractions;
using Knot.Contracts.Settings.Application.Abstractions; using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs; using Knot.Contracts.Settings.Application.DTOs;
using Knot.Modules.Klipy.Application.Abstractions; using MediatR;
namespace Knot.Modules.Admin.Application.Admin.Commands.TestKlipy; namespace Knot.Modules.Admin.Application.Admin.Commands.TestKlipy;
@@ -13,9 +13,9 @@ public record TestKlipyConnectionCommand(string ApiKey, string AppName) : IComma
internal sealed class TestKlipyConnectionCommandHandler : ICommandHandler<TestKlipyConnectionCommand, bool> internal sealed class TestKlipyConnectionCommandHandler : ICommandHandler<TestKlipyConnectionCommand, bool>
{ {
private readonly ISettingsService _settingsService; private readonly ISettingsService _settingsService;
private readonly IKlipyClient _klipyClient; private readonly Knot.Contracts.Klipy.Application.Abstractions.IKlipyClient _klipyClient;
public TestKlipyConnectionCommandHandler(ISettingsService settingsService, IKlipyClient klipyClient) public TestKlipyConnectionCommandHandler(ISettingsService settingsService, Knot.Contracts.Klipy.Application.Abstractions.IKlipyClient klipyClient)
{ {
_settingsService = settingsService; _settingsService = settingsService;
_klipyClient = klipyClient; _klipyClient = klipyClient;
@@ -1,12 +1,12 @@
using Knot.Modules.Admin.Application.Admin.DTOs;
using System; using System;
using System.Security.Cryptography;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediatR; using Knot.Contracts.Admin.Abstractions;
using Knot.Shared.Kernel;
using Knot.Contracts.Settings.Application.Abstractions; using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs; using Knot.Contracts.Settings.Application.DTOs;
using System.Security.Cryptography; using Knot.Modules.Admin.Application.Admin.DTOs;
using MediatR;
namespace Knot.Modules.Admin.Application.Admin.Commands; namespace Knot.Modules.Admin.Application.Admin.Commands;
@@ -0,0 +1,9 @@
namespace Knot.Modules.Admin.Application.Admin.DTOs;
public record CleanDryRunResult(
int OrphanedMessagesCount,
int OrphanedMediaCount,
long OrphanedMediaBytes,
int ExpiredStoriesCount,
long ExpiredStoriesSize
);
@@ -1,136 +1,77 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Contracts.Auth.Application.Abstractions; using Knot.Contracts.Admin.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs; using Knot.Contracts.Auth.Infrastructure.Persistence;
using Knot.Contracts.Messaging.Domain; using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Infrastructure.Persistence;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Stories.Infrastructure.Persistence;
using Knot.Modules.Admin.Application.Admin.DTOs; using Knot.Modules.Admin.Application.Admin.DTOs;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Modules.Auth.Application.Users;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Stories.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using MediatR; using MediatR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MongoDB.Bson;
using MongoDB.Driver;
namespace Knot.Modules.Admin.Application.Admin.Queries; namespace Knot.Modules.Admin.Application.Admin.Queries;
public record CleanDryRunQuery() : IQuery<CleanupDryRunResultDto>; public record CleanDryRunQuery() : IQuery<CleanDryRunResult>;
internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery, CleanupDryRunResultDto> internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery, CleanDryRunResult>
{ {
private readonly ChatsDbContext _chatsDbContext; private readonly Knot.Contracts.Messaging.Application.Abstractions.IMessageQueryService _messageService;
private readonly IMongoCollection<Message> _messages; private readonly IAuthDbContext _authDbContext;
private readonly IMongoCollection<Story> _stories; private readonly IChatsDbContext _chatsDbContext;
private readonly IFileStorageService _fileStorage; private readonly IStoryCollection _storyCollection;
private readonly AuthDbContext _identityDb;
public CleanDryRunQueryHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb, IFileStorageService fileStorage, AuthDbContext identityDb) public CleanDryRunQueryHandler(
Knot.Contracts.Messaging.Application.Abstractions.IMessageQueryService messageService,
IAuthDbContext authDbContext,
IChatsDbContext chatsDbContext,
IStoryCollection storyCollection)
{ {
_messageService = messageService;
_authDbContext = authDbContext;
_chatsDbContext = chatsDbContext; _chatsDbContext = chatsDbContext;
_messages = mongoDb.GetCollection<Message>("messages"); _storyCollection = storyCollection;
_stories = mongoDb.GetCollection<Story>("stories");
_fileStorage = fileStorage;
_identityDb = identityDb;
} }
public async Task<Result<CleanupDryRunResultDto>> Handle(CleanDryRunQuery request, CancellationToken ct) public async Task<Result<CleanDryRunResult>> Handle(CleanDryRunQuery request, CancellationToken ct)
{ {
try try
{ {
// 1. Получаем ID активных чатов (SQL) var activeChatIds = _chatsDbContext.Chats.Select(c => c.Id).ToHashSet();
var activeChats = await _chatsDbContext.Chats
.AsNoTracking()
.Select(c => new { c.Id, c.Avatar })
.ToListAsync(ct);
var activeChatIds = activeChats.Select(c => c.Id).ToHashSet(); var orphanedMessages = await _messageService.GetOrphanedMessagesAsync(activeChatIds, ct);
var orphanedMediaCount = orphanedMessages.Count(m => m.MediaUrl != null);
var orphanedMessageCount = orphanedMessages.Count;
// 2. Считаем сообщения подлежащие удалению (MongoDB) var validIds = new HashSet<string>();
var orphanedFilter = Builders<Message>.Filter.Or( foreach (var msg in orphanedMessages.Where(m => m.MediaUrl != null))
Builders<Message>.Filter.BitsAnySet(m => m.State, (long)MessageState.IsDeleted),
Builders<Message>.Filter.Nin(m => m.ChatId, activeChatIds)
);
var orphanedMessagesCount = await _messages.CountDocumentsAsync(orphanedFilter, cancellationToken: ct);
// 3. Собираем ID всех используемых файлов
var validFileIds = new HashSet<string>();
// Аватары чатов и пользователей
foreach (var chat in activeChats) AddFileIdIfValid(chat.Avatar, validFileIds);
var userAvatars = await _identityDb.Users.AsNoTracking()
.Where(u => !string.IsNullOrEmpty(u.Avatar))
.Select(u => u.Avatar).ToListAsync(ct);
foreach (var avatar in userAvatars) AddFileIdIfValid(avatar, validFileIds);
// Медиа из актуальных сторис
var storiesMedia = await _stories.Find(s => s.CreatedAt > DateTime.UtcNow.AddHours(-24))
.Project(s => s.MediaUrl).ToListAsync(ct);
foreach (var url in storiesMedia) AddFileIdIfValid(url, validFileIds);
// Медиа из активных сообщений (Проекция для скорости)
var activeFilter = Builders<Message>.Filter.And(
Builders<Message>.Filter.BitsAllClear(m => m.State, (long)MessageState.IsDeleted),
Builders<Message>.Filter.In(m => m.ChatId, activeChatIds)
);
var projection = Builders<Message>.Projection.Include("Media");
using (var cursor = await _messages.Find(activeFilter).Project(projection).ToCursorAsync(ct))
{ {
while (await cursor.MoveNextAsync(ct)) var parts = msg.MediaUrl.Split('/');
var fileId = parts.LastOrDefault();
if (!string.IsNullOrEmpty(fileId))
{ {
foreach (var doc in cursor.Current) validIds.Add(fileId);
{
if (doc.Contains("Media") && doc["Media"].IsBsonArray)
{
foreach (var media in doc["Media"].AsBsonArray)
{
if (media.IsBsonDocument && media.AsBsonDocument.Contains("Url"))
AddFileIdIfValid(media.AsBsonDocument["Url"].AsString, validFileIds);
}
}
}
} }
} }
// 4. Анализ физического хранилища var stories = await _storyCollection.GetAllAsync(ct);
var allStoredFiles = await _fileStorage.ListFilesAsync(); var expiredStoriesCount = stories.Count(s => s.ExpiresAt.HasValue && s.ExpiresAt.Value < DateTime.UtcNow);
long orphanedBytes = allStoredFiles var expiredStoriesSize = stories.Where(s => s.ExpiresAt.HasValue && s.ExpiresAt.Value < DateTime.UtcNow).Sum(s => s.MediaUrl?.Length ?? 0);
.Where(file => !validFileIds.Contains(file.FileId))
.Sum(file => file.Size);
return Result.Success(new CleanupDryRunResultDto return Result.Success(new CleanDryRunResult(
{ orphanedMessageCount,
OrphanedMessagesCount = (int)orphanedMessagesCount, orphanedMediaCount,
OrphanedMediaBytes = orphanedBytes 0,
}); expiredStoriesCount,
expiredStoriesSize
));
} }
catch (Exception ex) catch (Exception ex)
{ {
return Result.Failure<CleanupDryRunResultDto>(new Error("Cleanup.Error", $"Ошибка при анализе данных: {ex.Message}")); return Result.Failure<CleanDryRunResult>(new Error("Admin.CleanDryRun.Error", $"Error during dry run: {ex.Message}"));
}
}
private void AddFileIdIfValid(string? url, HashSet<string> validIds)
{
if (string.IsNullOrEmpty(url)) return;
// Предполагаем формат /api/files/{id}
if (url.Contains("/api/files/"))
{
var parts = url.Split('/');
var fileId = parts.LastOrDefault();
if (!string.IsNullOrEmpty(fileId))
{
validIds.Add(fileId);
}
} }
} }
} }
@@ -1,9 +1,9 @@
using Knot.Modules.Admin.Application.Admin.DTOs;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Contracts.Admin.Abstractions;
using Knot.Contracts.Settings.Abstractions;
using Knot.Modules.Admin.Application.Admin.DTOs;
using MediatR; using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Services;
namespace Knot.Modules.Admin.Application.Admin.Queries; namespace Knot.Modules.Admin.Application.Admin.Queries;
@@ -1,11 +1,11 @@
using Knot.Modules.Admin.Application.Admin.DTOs;
using System; using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediatR; using Knot.Contracts.Admin.Abstractions;
using Knot.Shared.Kernel;
using Knot.Contracts.Settings.Application.Abstractions; using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs; using Knot.Contracts.Settings.Application.DTOs;
using Knot.Modules.Admin.Application.Admin.DTOs;
using MediatR;
namespace Knot.Modules.Admin.Application.Admin.Queries; namespace Knot.Modules.Admin.Application.Admin.Queries;
@@ -1,91 +1,64 @@
using Knot.Contracts.Auth.Domain; using System;
using Knot.Contracts.Auth.Application.Auth.DTOs; using System.Collections.Generic;
using Knot.Modules.Admin.Application.Admin.DTOs;
using Knot.Modules.Messaging.Domain;
using System;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Contracts.Admin.Abstractions;
using Knot.Contracts.Auth.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Modules.Admin.Application.Admin.DTOs;
using MediatR; using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain;
using MongoDB.Driver;
namespace Knot.Modules.Admin.Application.Admin.Queries; namespace Knot.Modules.Admin.Application.Admin.Queries;
public record GetUserDetailsQuery(Guid UserId) : IQuery<AdminUserDetailsDto>; public record GetUserDetailsQuery(Guid UserId) : IQuery<AdminUserDto>;
internal sealed class GetUserDetailsQueryHandler : IQueryHandler<GetUserDetailsQuery, AdminUserDetailsDto> internal sealed class GetUserDetailsQueryHandler : IQueryHandler<GetUserDetailsQuery, AdminUserDto>
{ {
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly IMongoCollection<Message> _messages; private readonly Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService _statsService;
private readonly Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService _statusService;
public GetUserDetailsQueryHandler(IUserRepository userRepository, IMongoDatabase mongoDatabase) public GetUserDetailsQueryHandler(
IUserRepository userRepository,
Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService statsService,
Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService statusService)
{ {
_userRepository = userRepository; _userRepository = userRepository;
_messages = mongoDatabase.GetCollection<Message>("Messages"); _statsService = statsService;
_statusService = statusService;
} }
public async Task<Result<AdminUserDetailsDto>> Handle(GetUserDetailsQuery request, CancellationToken cancellationToken) public async Task<Result<AdminUserDto>> Handle(GetUserDetailsQuery request, CancellationToken ct)
{ {
var targetUser = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (targetUser == null)
{
return Result.Failure<AdminUserDetailsDto>(AuthErrors.UserNotFound);
}
List<Message> userMessages;
try try
{ {
var filter = Builders<Message>.Filter.Eq(m => m.SenderId, request.UserId); var user = await _userRepository.GetByIdAsync(request.UserId, ct);
userMessages = await _messages.Find(filter).ToListAsync(cancellationToken); if (user == null)
return Result.Failure<AdminUserDto>(new Error("Admin.GetUserDetails.NotFound", "User not found"));
var statsMap = await _statsService.GetStatsForUsersAsync(new List<Guid> { request.UserId }, ct);
var stats = statsMap.GetValueOrDefault(request.UserId, new Knot.Contracts.Messaging.Application.Abstractions.UserStats(0, 0L));
var isOnline = _statusService.IsUserOnline(request.UserId.ToString());
return Result.Success(new AdminUserDto(
user.Id,
user.Username ?? "unknown",
user.DisplayName ?? "User",
user.Email,
user.Avatar,
user.CreatedAt,
isOnline,
isOnline ? DateTime.UtcNow : (user.CreatedAt),
user.IsBanned,
stats.MessageCount,
stats.StorageSize
));
} }
catch (Exception) catch (Exception ex)
{ {
userMessages = new List<Message>(); return Result.Failure<AdminUserDto>(new Error("Admin.GetUserDetails.Error", $"Error getting user details: {ex.Message}"));
} }
var messagesCount = userMessages.Count;
var mediaMessages = userMessages.OfType<MediaMessage>().ToList();
var allUserMedia = mediaMessages
.Where(m => m.Media != null)
.SelectMany(m => m.Media)
.ToList();
var mediaCount = allUserMedia.Count(m => m.Type == "image" || m.Type == "video");
var filesCount = allUserMedia.Count(m => m.Type == "file" || m.Type == "audio");
var storageUsed = allUserMedia.Sum(m => m.Size ?? 0);
var userContents = userMessages.OfType<TextMessage>()
.Select(m => m.Content)
.Concat(mediaMessages.Where(m => m.Caption != null).Select(m => m.Caption))
.Where(c => c != null)
.ToList();
var linksCount = userContents.Count(c => c != null && c.Contains("http", StringComparison.OrdinalIgnoreCase));
var isOnline = Knot.Modules.Conversations.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString());
var result = new AdminUserDetailsDto(
targetUser.Id,
targetUser.Username ?? "unknown",
targetUser.DisplayName ?? "User",
targetUser.Bio,
targetUser.Avatar,
targetUser.CreatedAt,
isOnline,
isOnline ? DateTime.UtcNow : (targetUser.CreatedAt),
targetUser.IsBanned,
new AdminUserStatsDto(
messagesCount,
mediaCount,
filesCount,
linksCount,
storageUsed
)
);
return Result.Success(result);
} }
} }
@@ -1,14 +1,14 @@
using Knot.Contracts.Auth.Domain; using System;
using Knot.Modules.Admin.Application.Admin.DTOs;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Contracts.Admin.Abstractions;
using Knot.Contracts.Auth.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Modules.Admin.Application.Admin.DTOs;
using MediatR; using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Admin.Application.Admin.Queries; namespace Knot.Modules.Admin.Application.Admin.Queries;
@@ -17,13 +17,13 @@ public record SearchUsersQuery(string Query) : IQuery<List<AdminUserDto>>;
internal sealed class SearchUsersQueryHandler : IQueryHandler<SearchUsersQuery, List<AdminUserDto>> internal sealed class SearchUsersQueryHandler : IQueryHandler<SearchUsersQuery, List<AdminUserDto>>
{ {
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly IUserStatsService _statsService; private readonly Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService _statsService;
private readonly IUserStatusService _statusService; private readonly Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService _statusService;
public SearchUsersQueryHandler( public SearchUsersQueryHandler(
IUserRepository userRepository, IUserRepository userRepository,
IUserStatsService statsService, Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService statsService,
IUserStatusService statusService) Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService statusService)
{ {
_userRepository = userRepository; _userRepository = userRepository;
_statsService = statsService; _statsService = statsService;
@@ -34,20 +34,19 @@ internal sealed class SearchUsersQueryHandler : IQueryHandler<SearchUsersQuery,
{ {
try try
{ {
// 1. Ïîèñê ïîëüçîâàòåëåé â ðåëÿöèîííîé ÁÄ
var users = await _userRepository.SearchUsersAsync(request.Query ?? "", ct); var users = await _userRepository.SearchUsersAsync(request.Query ?? "", ct);
if (!users.Any()) return Result.Success(new List<AdminUserDto>()); if (!users.Any()) return Result.Success(new List<AdminUserDto>());
var userIds = users.Select(u => u.Id).ToList(); var userIds = users.Select(u => u.Id).ToList();
// 2. Ïîëó÷åíèå àãðåãèðîâàííîé ñòàòèñòèêè èç NoSQL
var statsMap = await _statsService.GetStatsForUsersAsync(userIds, ct); var statsMap = await _statsService.GetStatsForUsersAsync(userIds, ct);
// 3. Ñáîðêà DTO ñ èñïîëüçîâàíèåì ñåðâèñîâ ñòàòóñà è ñòàòèñòèêè var result = users.Select(u =>
var result = users.Select(u => { {
var stats = statsMap.GetValueOrDefault(u.Id, new UserStats(0, 0L)); var stats = statsMap.GetValueOrDefault(u.Id, new Knot.Contracts.Messaging.Application.Abstractions.UserStats(0, 0L));
var isOnline = _statusService.IsUserOnline(u.Id.ToString()); var isOnline = _statusService.IsUserOnline(u.Id.ToString());
return new AdminUserDto( return new AdminUserDto(
u.Id, u.Id,
u.Username ?? "unknown", u.Username ?? "unknown",
@@ -67,7 +66,7 @@ internal sealed class SearchUsersQueryHandler : IQueryHandler<SearchUsersQuery,
} }
catch (Exception ex) catch (Exception ex)
{ {
return Result.Failure<List<AdminUserDto>>(new Error("Admin.SearchUsers.Error", $"Îøèáêà ïðè ïîèñêå ïîëüçîâàòåëåé: {ex.Message}")); return Result.Failure<List<AdminUserDto>>(new Error("Admin.SearchUsers.Error", $"Error searching users: {ex.Message}"));
} }
} }
} }
@@ -1,5 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using Knot.Shared.Kernel; using Knot.Contracts.Admin.Abstractions;
using Knot.Contracts.Settings.Application.Abstractions; using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs; using Knot.Contracts.Settings.Application.DTOs;
@@ -10,3 +10,4 @@ namespace Knot.Modules.Admin.Domain.Events;
/// </summary> /// </summary>
public sealed record SystemSettingsUpdatedDomainEvent(SystemSettingsDto Settings) : IDomainEvent; public sealed record SystemSettingsUpdatedDomainEvent(SystemSettingsDto Settings) : IDomainEvent;
@@ -4,9 +4,9 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" /> <ProjectReference Include="..\..\Contracts\Admin\Knot.Contracts.Admin.csproj" />
<ProjectReference Include="..\..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
<ProjectReference Include="..\..\Contracts\Auth\Knot.Contracts.Auth.csproj" /> <ProjectReference Include="..\..\Contracts\Auth\Knot.Contracts.Auth.csproj" />
<ProjectReference Include="..\..\Contracts\Profiles\Knot.Contracts.Profiles.csproj" /> <ProjectReference Include="..\..\Contracts\Profiles\Knot.Contracts.Profiles.csproj" />
<ProjectReference Include="..\..\Contracts\Conversations\Knot.Contracts.Conversations.csproj" /> <ProjectReference Include="..\..\Contracts\Conversations\Knot.Contracts.Conversations.csproj" />
@@ -14,14 +14,12 @@
<ProjectReference Include="..\..\Contracts\Settings\Knot.Contracts.Settings.csproj" /> <ProjectReference Include="..\..\Contracts\Settings\Knot.Contracts.Settings.csproj" />
<ProjectReference Include="..\..\Contracts\Stories\Knot.Contracts.Stories.csproj" /> <ProjectReference Include="..\..\Contracts\Stories\Knot.Contracts.Stories.csproj" />
<ProjectReference Include="..\..\Contracts\Klipy\Knot.Contracts.Klipy.csproj" /> <ProjectReference Include="..\..\Contracts\Klipy\Knot.Contracts.Klipy.csproj" />
<!-- Admin needs direct module access for cleanup operations - PrivateAssets prevents module exposure --> <ProjectReference Include="..\..\Contracts\Storage\Knot.Contracts.Storage.csproj" />
<ProjectReference Include="..\Auth\Knot.Modules.Auth.csproj" PrivateAssets="all" />
<ProjectReference Include="..\Conversations\Knot.Modules.Conversations.csproj" PrivateAssets="all" />
<ProjectReference Include="..\Stories\Knot.Modules.Stories.csproj" PrivateAssets="all" />
<ProjectReference Include="..\Klipy\Knot.Modules.Klipy.csproj" PrivateAssets="all" />
<ProjectReference Include="..\Messaging\Knot.Modules.Messaging.csproj" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Carter" Version="10.0.0" /> <PackageReference Include="Carter" Version="10.0.0" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,22 +1,19 @@
using Carter; using Carter;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Shared.Kernel;
using Knot.Contracts.Settings.Application.Abstractions; using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs; using Knot.Contracts.Settings.Application.DTOs;
using Knot.Modules.Auth.Application.Users.Register;
using MediatR;
using Knot.Modules.Admin.Application.Admin.Queries;
using Knot.Modules.Admin.Application.Admin.Commands; using Knot.Modules.Admin.Application.Admin.Commands;
using Knot.Modules.Admin.Application.Admin.Commands.TestKlipy;
using Knot.Modules.Admin.Application.Admin.Queries;
using MediatR;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Knot.Modules.Admin.Application.Admin.Commands.TestKlipy; using Microsoft.AspNetCore.Routing;
using Knot.Modules.Conversations.Application.Users.Commands.DeleteUser;
namespace Knot.Host.Presentation.Endpoints; namespace Knot.Host.Presentation.Endpoints;
public record KlipyTestDto(string ApiKey, string AppName); public record KlipyTestDto(string ApiKey, string AppName);
public record ResetPasswordRequest(string NewPassword);
public sealed class AdminEndpoints : ICarterModule public sealed class AdminEndpoints : ICarterModule
{ {
@@ -48,16 +45,7 @@ public sealed class AdminEndpoints : ICarterModule
return Results.Ok(result.Value); return Results.Ok(result.Value);
}); });
group.MapPost("users", async ([FromBody] RegisterUserCommand command, ISender sender, CancellationToken ct) => group.MapPost("users/{userId:guid}/reset-password", async (Guid userId, [FromBody] ResetPasswordRequest dto, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(command, ct);
if (result.IsFailure) return Results.BadRequest(new { error = result.Error.Description });
var userDetails = await sender.Send(new GetUserDetailsQuery(result.Value.UserId), ct);
return Results.Ok(userDetails.Value);
});
group.MapPost("users/{userId:guid}/reset-password", async (Guid userId, [FromBody] ResetPasswordDto dto, ISender sender, CancellationToken ct) =>
{ {
var result = await sender.Send(new ResetUserPasswordCommand(userId, dto.NewPassword), ct); var result = await sender.Send(new ResetUserPasswordCommand(userId, dto.NewPassword), ct);
if (result.IsFailure) if (result.IsFailure)
@@ -80,12 +68,6 @@ public sealed class AdminEndpoints : ICarterModule
return result.IsSuccess ? Results.Ok() : Results.BadRequest(new { error = result.Error.Description }); return result.IsSuccess ? Results.Ok() : Results.BadRequest(new { error = result.Error.Description });
}); });
group.MapDelete("users/{userId:guid}", async (Guid userId, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new DeleteUserCommand(userId), ct);
return result.IsSuccess ? Results.Ok() : Results.BadRequest(new { error = result.Error.Description });
});
group.MapGet("users", async (ISender sender, [FromQuery] string query = "", CancellationToken ct = default) => group.MapGet("users", async (ISender sender, [FromQuery] string query = "", CancellationToken ct = default) =>
{ {
var result = await sender.Send(new SearchUsersQuery(query), ct); var result = await sender.Send(new SearchUsersQuery(query), ct);
@@ -117,7 +99,8 @@ public sealed class AdminEndpoints : ICarterModule
{ {
// Получаем все системные часовые пояса и формируем удобный для фронтенда формат // Получаем все системные часовые пояса и формируем удобный для фронтенда формат
var zones = TimeZoneInfo.GetSystemTimeZones() var zones = TimeZoneInfo.GetSystemTimeZones()
.Select(tz => new { .Select(tz => new
{
id = tz.Id, id = tz.Id,
displayName = tz.DisplayName, displayName = tz.DisplayName,
standardName = tz.StandardName, standardName = tz.StandardName,
@@ -1,5 +1,6 @@
using Knot.Contracts.Auth.Application.Abstractions; using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Domain; using Knot.Contracts.Auth.Domain;
using Knot.Contracts.Auth.Infrastructure.Persistence;
using Knot.Modules.Auth.Infrastructure.Authentication; using Knot.Modules.Auth.Infrastructure.Authentication;
using Knot.Modules.Auth.Infrastructure.Persistence; using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
@@ -11,27 +12,22 @@ namespace Knot.Modules.Auth;
public static class DependencyInjection public static class DependencyInjection
{ {
/// <summary>
/// Регистрация сервисов модуля Identity.
/// </summary>
public static IServiceCollection AddAuthModule( public static IServiceCollection AddAuthModule(
this IServiceCollection services, this IServiceCollection services,
IConfiguration configuration) IConfiguration configuration)
{ {
// Настройка базы данных
string connectionString = configuration.GetConnectionString("DefaultConnection")!; string connectionString = configuration.GetConnectionString("DefaultConnection")!;
services.AddDbContext<AuthDbContext>(options => services.AddDbContext<AuthDbContext>(options =>
options.UseNpgsql(connectionString)); options.UseNpgsql(connectionString));
// Регистрация Unit of Work и Репозиториев
services.AddScoped<IAuthUnitOfWork>(sp => sp.GetRequiredService<AuthDbContext>()); services.AddScoped<IAuthUnitOfWork>(sp => sp.GetRequiredService<AuthDbContext>());
services.AddScoped<IAuthDbContext>(sp => sp.GetRequiredService<AuthDbContext>()); services.AddScoped<Knot.Contracts.Auth.Application.Abstractions.IAuthDbContext>(sp => sp.GetRequiredService<AuthDbContext>());
services.AddScoped<Knot.Contracts.Auth.Infrastructure.Persistence.IAuthDbContext>(sp => sp.GetRequiredService<AuthDbContext>());
services.AddScoped<IUserRepository, UserRepository>(); services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IJwtTokenProvider, JwtTokenProvider>(); services.AddScoped<IJwtTokenProvider, JwtTokenProvider>();
services.AddScoped<IUserDisplayNameProvider, Knot.Modules.Auth.Infrastructure.Services.UserDisplayNameProvider>(); services.AddScoped<IUserDisplayNameProvider, Knot.Modules.Auth.Infrastructure.Services.UserDisplayNameProvider>();
// Регистрация MediatR для этого модуля
services.AddMediatR(config => services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly)); config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
@@ -1,5 +1,6 @@
using Knot.Contracts.Auth.Application.Abstractions; using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Domain; using Knot.Contracts.Auth.Domain;
using Knot.Contracts.Auth.Infrastructure.Persistence;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Security; using Knot.Shared.Kernel.Security;
using MediatR; using MediatR;
@@ -9,7 +10,7 @@ using DomainUser = Knot.Modules.Auth.Domain.User;
namespace Knot.Modules.Auth.Infrastructure.Persistence; namespace Knot.Modules.Auth.Infrastructure.Persistence;
public sealed class AuthDbContext : DbContext, IAuthUnitOfWork, IAuthDbContext public sealed class AuthDbContext : DbContext, IAuthUnitOfWork, Knot.Contracts.Auth.Application.Abstractions.IAuthDbContext, Knot.Contracts.Auth.Infrastructure.Persistence.IAuthDbContext
{ {
private readonly IMediator _mediator; private readonly IMediator _mediator;
private readonly IEncryptionService _encryptionService; private readonly IEncryptionService _encryptionService;
@@ -31,6 +32,21 @@ public sealed class AuthDbContext : DbContext, IAuthUnitOfWork, IAuthDbContext
Set<DomainUser>().Add(domainUser); Set<DomainUser>().Add(domainUser);
} }
IQueryable<UserContract> Knot.Contracts.Auth.Infrastructure.Persistence.IAuthDbContext.Users =>
Set<DomainUser>().Select(u => new UserContract
{
Id = u.Id,
Username = u.Username,
DisplayName = u.DisplayName,
Email = u.Email,
Avatar = u.Avatar,
PhoneNumber = u.PhoneNumber,
CreatedAt = u.CreatedAt,
IsBanned = u.IsBanned,
Bio = u.Bio,
PasswordHash = u.PasswordHash
});
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
base.OnConfiguring(optionsBuilder); base.OnConfiguring(optionsBuilder);
@@ -1,4 +1,5 @@
using Knot.Contracts.Conversations.Application.Abstractions; using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Infrastructure.Persistence;
using Knot.Contracts.Messaging.Application.Abstractions; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain; using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions; using Knot.Modules.Conversations.Application.Abstractions;
@@ -29,6 +30,7 @@ public static class DependencyInjection
var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017"; var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017";
services.AddScoped<ConversationsAbstractions.IChatsUnitOfWork>(sp => sp.GetRequiredService<ChatsDbContext>()); services.AddScoped<ConversationsAbstractions.IChatsUnitOfWork>(sp => sp.GetRequiredService<ChatsDbContext>());
services.AddScoped<Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext>(sp => sp.GetRequiredService<ChatsDbContext>());
services.AddScoped<IChatRepository, ChatRepository>(); services.AddScoped<IChatRepository, ChatRepository>();
services.AddScoped<IFolderRepository, FolderRepository>(); services.AddScoped<IFolderRepository, FolderRepository>();
services.AddScoped<IUserChatSettingsRepository, UserChatSettingsRepository>(); services.AddScoped<IUserChatSettingsRepository, UserChatSettingsRepository>();
@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Contracts.Conversations.Application.Abstractions; using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Application.Abstractions; using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Domain; using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
@@ -11,13 +12,11 @@ using Knot.Shared.Kernel.Security;
using MediatR; using MediatR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Diagnostics;
using DomainChat = Knot.Modules.Conversations.Domain.Chat;
namespace Knot.Modules.Conversations.Infrastructure.Persistence; namespace Knot.Modules.Conversations.Infrastructure.Persistence;
/// <summary> public sealed class ChatsDbContext : DbContext, Knot.Modules.Conversations.Application.Abstractions.IChatsUnitOfWork, Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext
/// Контекст базы данных для модуля чатов.
/// </summary>
public sealed class ChatsDbContext : DbContext, Knot.Modules.Conversations.Application.Abstractions.IChatsUnitOfWork
{ {
private readonly IMediator _mediator; private readonly IMediator _mediator;
private readonly IEncryptionService _encryptionService; private readonly IEncryptionService _encryptionService;
@@ -29,10 +28,13 @@ public sealed class ChatsDbContext : DbContext, Knot.Modules.Conversations.Appli
_encryptionService = encryptionService; _encryptionService = encryptionService;
} }
public DbSet<Chat> Chats => Set<Chat>(); public DbSet<DomainChat> Chats => Set<DomainChat>();
public DbSet<Folder> Folders => Set<Folder>(); public DbSet<Folder> Folders => Set<Folder>();
public DbSet<UserChatSettings> UserChatSettings => Set<UserChatSettings>(); public DbSet<UserChatSettings> UserChatSettings => Set<UserChatSettings>();
IQueryable<Knot.Contracts.Conversations.Infrastructure.Persistence.Chat> Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext.Chats =>
Set<DomainChat>().Select(c => new Knot.Contracts.Conversations.Infrastructure.Persistence.Chat { Avatar = c.Avatar });
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
base.OnConfiguring(optionsBuilder); base.OnConfiguring(optionsBuilder);
@@ -43,20 +45,20 @@ public sealed class ChatsDbContext : DbContext, Knot.Modules.Conversations.Appli
{ {
modelBuilder.HasDefaultSchema("chats"); modelBuilder.HasDefaultSchema("chats");
modelBuilder.Entity<Chat>(builder => modelBuilder.Entity<DomainChat>(builder =>
{ {
builder.ToTable("Chats"); builder.ToTable("Chats");
builder.HasKey(c => c.Id); builder.HasKey(c => c.Id);
builder.Property(c => c.Type).HasConversion<string>(); builder.Property(c => c.Type).HasConversion<string>();
builder.OwnsMany(c => c.Members, mb => builder.OwnsMany(c => c.Members, mb =>
{ {
mb.ToTable("ChatMembers"); mb.ToTable("ChatMembers");
mb.HasKey(m => m.Id); mb.HasKey(m => m.Id);
mb.WithOwner().HasForeignKey(m => m.ChatId); mb.WithOwner().HasForeignKey(m => m.ChatId);
mb.HasIndex(m => new { m.ChatId, m.UserId }).IsUnique(); mb.HasIndex(m => new { m.ChatId, m.UserId }).IsUnique();
}).Navigation(c => c.Members).UsePropertyAccessMode(PropertyAccessMode.Field); }).Navigation(c => c.Members).UsePropertyAccessMode(PropertyAccessMode.Field);
}); });
modelBuilder.Entity<Folder>(builder => modelBuilder.Entity<Folder>(builder =>
{ {
@@ -7,7 +7,7 @@ using Knot.Modules.Klipy.Application.Abstractions;
namespace Knot.Modules.Klipy.Infrastructure.External; namespace Knot.Modules.Klipy.Infrastructure.External;
public sealed class KlipyClient : IKlipyClient public sealed class KlipyClient : Knot.Modules.Klipy.Application.Abstractions.IKlipyClient
{ {
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
@@ -20,7 +20,6 @@ public sealed class KlipyClient : IKlipyClient
{ {
try try
{ {
// According to docs: https://api.klipy.com/api/v1/{app_key}/gifs/trending
var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.klipy.com/api/v1/{appName}/gifs/trending?page=1&per_page=1&customer_id=knot_admin_test"); var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.klipy.com/api/v1/{appName}/gifs/trending?page=1&per_page=1&customer_id=knot_admin_test");
request.Headers.Add("X-KLIPY-API-KEY", apiKey); request.Headers.Add("X-KLIPY-API-KEY", apiKey);
@@ -35,7 +34,6 @@ public sealed class KlipyClient : IKlipyClient
public async Task<List<string>> SearchVideosAsync(string apiKey, string appName, string query, int limit, CancellationToken ct = default) public async Task<List<string>> SearchVideosAsync(string apiKey, string appName, string query, int limit, CancellationToken ct = default)
{ {
// To be implemented as needed
return new List<string>(); return new List<string>();
} }
} }
@@ -7,6 +7,8 @@ using Knot.Shared.Kernel;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver; using MongoDB.Driver;
using ContractUserStatsService = Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService;
using ModuleUserStatsService = Knot.Modules.Messaging.Application.Abstractions.IUserStatsService;
namespace Knot.Modules.Messaging; namespace Knot.Modules.Messaging;
@@ -27,7 +29,8 @@ public static class DependencyInjection
services.AddScoped<IMessageRepository, MessageRepository>(); services.AddScoped<IMessageRepository, MessageRepository>();
services.AddScoped<IMessageReactionRepository, MessageReactionRepository>(); services.AddScoped<IMessageReactionRepository, MessageReactionRepository>();
services.AddScoped<IUserStatsService, UserStatsService>(); services.AddScoped<ModuleUserStatsService, UserStatsService>();
services.AddScoped<ContractUserStatsService>(sp => (ContractUserStatsService)sp.GetRequiredService<ModuleUserStatsService>());
services.AddMediatR(config => services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly)); config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
@@ -4,15 +4,17 @@ using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Contracts.Messaging.Abstractions; using Knot.Contracts.Messaging.Abstractions;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain; using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Messaging.Application.Abstractions; using Knot.Modules.Messaging.Application.Abstractions;
using MongoDB.Bson; using MongoDB.Bson;
using MongoDB.Driver; using MongoDB.Driver;
using ContractUserStatsService = Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService;
using ModuleUserStatsService = Knot.Modules.Messaging.Application.Abstractions.IUserStatsService; using ModuleUserStatsService = Knot.Modules.Messaging.Application.Abstractions.IUserStatsService;
namespace Knot.Modules.Messaging.Infrastructure.Persistence.Mongo; namespace Knot.Modules.Messaging.Infrastructure.Persistence.Mongo;
public sealed class UserStatsService : ModuleUserStatsService public sealed class UserStatsService : ModuleUserStatsService, ContractUserStatsService
{ {
private readonly IMongoCollection<Message> _messages; private readonly IMongoCollection<Message> _messages;
@@ -23,11 +25,10 @@ public sealed class UserStatsService : ModuleUserStatsService
public async Task<Dictionary<Guid, Knot.Modules.Messaging.Application.Abstractions.UserStats>> GetStatsForUsersAsync(IEnumerable<Guid> userIds, CancellationToken ct = default) public async Task<Dictionary<Guid, Knot.Modules.Messaging.Application.Abstractions.UserStats>> GetStatsForUsersAsync(IEnumerable<Guid> userIds, CancellationToken ct = default)
{ {
var userIdList = userIds.ToList(); if (!userIds.Any()) return new Dictionary<Guid, Knot.Modules.Messaging.Application.Abstractions.UserStats>();
if (!userIdList.Any()) return new Dictionary<Guid, Knot.Modules.Messaging.Application.Abstractions.UserStats>();
var stats = await _messages.Aggregate() var stats = await _messages.Aggregate()
.Match(Builders<Message>.Filter.In(m => m.SenderId, userIdList)) .Match(Builders<Message>.Filter.In(m => m.SenderId, userIds))
.Group(new BsonDocument { .Group(new BsonDocument {
{ "_id", "$SenderId" }, { "_id", "$SenderId" },
{ "Count", new BsonDocument("$sum", 1) }, { "Count", new BsonDocument("$sum", 1) },
@@ -44,6 +45,28 @@ public sealed class UserStatsService : ModuleUserStatsService
); );
} }
async Task<Dictionary<Guid, Knot.Contracts.Messaging.Application.Abstractions.UserStats>> Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService.GetStatsForUsersAsync(List<Guid> userIds, CancellationToken cancellationToken = default)
{
if (!userIds.Any()) return new Dictionary<Guid, Knot.Contracts.Messaging.Application.Abstractions.UserStats>();
var stats = await _messages.Aggregate()
.Match(Builders<Message>.Filter.In(m => m.SenderId, userIds))
.Group(new BsonDocument {
{ "_id", "$SenderId" },
{ "Count", new BsonDocument("$sum", 1) },
{ "MediaSize", new BsonDocument("$sum", new BsonDocument("$sum", "$Media.Size")) }
})
.ToListAsync(cancellationToken);
return stats.ToDictionary(
doc => doc["_id"].AsGuid,
doc => new Knot.Contracts.Messaging.Application.Abstractions.UserStats(
doc["Count"].AsInt32,
doc.Contains("MediaSize") && !doc["MediaSize"].IsBsonNull ? (long)(doc["MediaSize"].IsInt64 ? doc["MediaSize"].AsInt64 : doc["MediaSize"].AsInt32) : 0L
)
);
}
public async Task<long> GetTotalStorageSizeAsync(CancellationToken ct = default) public async Task<long> GetTotalStorageSizeAsync(CancellationToken ct = default)
{ {
var result = await _messages.Aggregate() var result = await _messages.Aggregate()
@@ -1,10 +1,11 @@
using System.Net.Http;
using Knot.Contracts.Stories.Infrastructure.Persistence;
using Knot.Modules.Stories.Application.Abstractions;
using Knot.Modules.Stories.Infrastructure.Persistence.Mongo;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Knot.Modules.Stories.Application.Abstractions;
using Knot.Modules.Stories.Infrastructure.Persistence.Mongo;
using MongoDB.Driver; using MongoDB.Driver;
using System.Net.Http;
namespace Knot.Modules.Stories; namespace Knot.Modules.Stories;
@@ -13,12 +14,12 @@ public static class DependencyInjection
public static IServiceCollection AddStoriesModule(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddStoriesModule(this IServiceCollection services, IConfiguration configuration)
{ {
services.AddScoped<IStoryRepository, StoryRepository>(); services.AddScoped<IStoryRepository, StoryRepository>();
services.AddScoped<Knot.Contracts.Stories.Infrastructure.Persistence.IStoryCollection, StoryCollection>();
var connectionString = configuration.GetConnectionString("DefaultConnection"); var connectionString = configuration.GetConnectionString("DefaultConnection");
services.AddDbContext<Infrastructure.Database.StoriesDbContext>(options => services.AddDbContext<Infrastructure.Database.StoriesDbContext>(options =>
options.UseNpgsql(connectionString)); options.UseNpgsql(connectionString));
// MongoDB configuration happens in StoriesMongoMapConfigurator (called in Program.cs when EncryptionService is ready, or we can just call it here)
StoriesMongoMapConfigurator.Configure(); StoriesMongoMapConfigurator.Configure();
services.AddMediatR(config => services.AddMediatR(config =>
@@ -0,0 +1,33 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Stories.Infrastructure.Persistence;
using MongoDB.Driver;
using DomainStory = Knot.Modules.Stories.Domain.Story;
namespace Knot.Modules.Stories.Infrastructure.Persistence.Mongo;
public class StoryCollection : Knot.Contracts.Stories.Infrastructure.Persistence.IStoryCollection
{
private readonly IMongoCollection<DomainStory> _stories;
public StoryCollection(IMongoDatabase database)
{
_stories = database.GetCollection<DomainStory>("stories");
}
public async Task<List<Knot.Contracts.Stories.Infrastructure.Persistence.StoryContract>> GetAllAsync(CancellationToken cancellationToken)
{
var stories = await _stories.Find(_ => true).ToListAsync(cancellationToken);
return stories.Select(s => new Knot.Contracts.Stories.Infrastructure.Persistence.StoryContract
{
Id = s.Id,
UserId = s.UserId,
MediaUrl = s.MediaUrl,
MediaType = s.Type.ToString(),
CreatedAt = s.CreatedAt,
ExpiresAt = null
}).ToList();
}
}