diff --git a/backend/src/Contracts/Admin/Abstractions/AggregateRoot.cs b/backend/src/Contracts/Admin/Abstractions/AggregateRoot.cs
new file mode 100644
index 0000000..1c30236
--- /dev/null
+++ b/backend/src/Contracts/Admin/Abstractions/AggregateRoot.cs
@@ -0,0 +1,22 @@
+namespace Knot.Contracts.Admin.Abstractions;
+
+///
+/// Базовый класс для агрегатов - сущностей с бизнес-логикой и доменными событиями.
+///
+public abstract class AggregateRoot : Entity where TId : notnull
+{
+ private readonly List _domainEvents = new();
+ public IReadOnlyList DomainEvents => _domainEvents.AsReadOnly();
+
+ protected AggregateRoot(TId id) : base(id) { }
+
+ protected void RaiseDomainEvent(IDomainEvent domainEvent)
+ {
+ _domainEvents.Add(domainEvent);
+ }
+
+ public void ClearDomainEvents()
+ {
+ _domainEvents.Clear();
+ }
+}
diff --git a/backend/src/Contracts/Admin/Abstractions/Entity.cs b/backend/src/Contracts/Admin/Abstractions/Entity.cs
new file mode 100644
index 0000000..47c46b6
--- /dev/null
+++ b/backend/src/Contracts/Admin/Abstractions/Entity.cs
@@ -0,0 +1,43 @@
+namespace Knot.Contracts.Admin.Abstractions;
+
+///
+/// Базовый класс для сущностей с уникальным идентификатором.
+///
+public abstract class Entity 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 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? a, Entity? 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? a, Entity? b) => !(a == b);
+}
diff --git a/backend/src/Contracts/Admin/Abstractions/ICommand.cs b/backend/src/Contracts/Admin/Abstractions/ICommand.cs
new file mode 100644
index 0000000..eeb8e1d
--- /dev/null
+++ b/backend/src/Contracts/Admin/Abstractions/ICommand.cs
@@ -0,0 +1,15 @@
+using MediatR;
+
+namespace Knot.Contracts.Admin.Abstractions;
+
+public interface ICommand : IRequest { }
+
+public interface ICommand : IRequest> { }
+
+public interface ICommandHandler : IRequestHandler
+ where TCommand : ICommand
+{ }
+
+public interface ICommandHandler : IRequestHandler>
+ where TCommand : ICommand
+{ }
diff --git a/backend/src/Contracts/Admin/Abstractions/IDomainEvent.cs b/backend/src/Contracts/Admin/Abstractions/IDomainEvent.cs
new file mode 100644
index 0000000..ceec674
--- /dev/null
+++ b/backend/src/Contracts/Admin/Abstractions/IDomainEvent.cs
@@ -0,0 +1,8 @@
+using MediatR;
+
+namespace Knot.Contracts.Admin.Abstractions;
+
+///
+/// Интерфейс для доменных событий.
+///
+public interface IDomainEvent : INotification { }
diff --git a/backend/src/Contracts/Admin/Abstractions/IQuery.cs b/backend/src/Contracts/Admin/Abstractions/IQuery.cs
new file mode 100644
index 0000000..81ed2f3
--- /dev/null
+++ b/backend/src/Contracts/Admin/Abstractions/IQuery.cs
@@ -0,0 +1,13 @@
+using MediatR;
+
+namespace Knot.Contracts.Admin.Abstractions;
+
+public interface IQuery : IRequest>
+{
+}
+
+public interface IQueryHandler
+ : IRequestHandler>
+ where TQuery : IQuery
+{
+}
diff --git a/backend/src/Contracts/Admin/Abstractions/MessageResponse.cs b/backend/src/Contracts/Admin/Abstractions/MessageResponse.cs
new file mode 100644
index 0000000..e532e8f
--- /dev/null
+++ b/backend/src/Contracts/Admin/Abstractions/MessageResponse.cs
@@ -0,0 +1,3 @@
+namespace Knot.Contracts.Admin.Abstractions;
+
+public record MessageResponse(string Message);
diff --git a/backend/src/Contracts/Admin/Abstractions/Result.cs b/backend/src/Contracts/Admin/Abstractions/Result.cs
new file mode 100644
index 0000000..e0a4a6c
--- /dev/null
+++ b/backend/src/Contracts/Admin/Abstractions/Result.cs
@@ -0,0 +1,63 @@
+namespace Knot.Contracts.Admin.Abstractions;
+
+///
+/// Представляет ошибку в доменной логике.
+///
+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);
+}
+
+///
+/// Общая обертка для результата операции. Позволяет избегать использования исключений для управления потоком.
+///
+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 Success(TValue value) => Result.Success(value);
+ public static Result Failure(Error error) => Result.Failure(error);
+}
+
+///
+/// Результ операции, содержащий значение.
+///
+public class Result : 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 Success(TValue value) => new(value, true, Error.None);
+ public new static Result Failure(Error error) => new(default, false, error);
+}
diff --git a/backend/src/Contracts/Admin/Abstractions/SuccessResponse.cs b/backend/src/Contracts/Admin/Abstractions/SuccessResponse.cs
new file mode 100644
index 0000000..9e256a3
--- /dev/null
+++ b/backend/src/Contracts/Admin/Abstractions/SuccessResponse.cs
@@ -0,0 +1,3 @@
+namespace Knot.Contracts.Admin.Abstractions;
+
+public record SuccessResponse(bool Success);
diff --git a/backend/src/Contracts/Admin/Knot.Contracts.Admin.csproj b/backend/src/Contracts/Admin/Knot.Contracts.Admin.csproj
index 4bd9c60..ee7380a 100644
--- a/backend/src/Contracts/Admin/Knot.Contracts.Admin.csproj
+++ b/backend/src/Contracts/Admin/Knot.Contracts.Admin.csproj
@@ -6,10 +6,6 @@
enable
-
-
-
-
diff --git a/backend/src/Contracts/Auth/Infrastructure/Persistence/IAuthDbContext.cs b/backend/src/Contracts/Auth/Infrastructure/Persistence/IAuthDbContext.cs
new file mode 100644
index 0000000..cd456d2
--- /dev/null
+++ b/backend/src/Contracts/Auth/Infrastructure/Persistence/IAuthDbContext.cs
@@ -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 Users { get; }
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
\ No newline at end of file
diff --git a/backend/src/Contracts/Conversations/Infrastructure/Persistence/IChatsDbContext.cs b/backend/src/Contracts/Conversations/Infrastructure/Persistence/IChatsDbContext.cs
index ccfba8b..4e1db8d 100644
--- a/backend/src/Contracts/Conversations/Infrastructure/Persistence/IChatsDbContext.cs
+++ b/backend/src/Contracts/Conversations/Infrastructure/Persistence/IChatsDbContext.cs
@@ -1,20 +1,18 @@
using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using Knot.Contracts.Conversations.Application.Abstractions;
-using Knot.Shared.Kernel;
+using Knot.Contracts.Conversations.Domain;
namespace Knot.Contracts.Conversations.Infrastructure.Persistence;
-public interface IChatsDbContext : IChatsUnitOfWork
+public interface IChatsDbContext
{
IQueryable Chats { get; }
}
-public class Chat : AggregateRoot
+///
+/// Упрощенная проекция чата для запросов (не доменная сущность).
+///
+public class Chat
{
- public Chat() : base(Guid.NewGuid()) { }
-
-
+ public Guid Id { get; set; }
public string? Avatar { get; set; }
-}
\ No newline at end of file
+}
diff --git a/backend/src/Contracts/Klipy/Application/Abstractions/IKlipyClient.cs b/backend/src/Contracts/Klipy/Application/Abstractions/IKlipyClient.cs
new file mode 100644
index 0000000..67ad5a1
--- /dev/null
+++ b/backend/src/Contracts/Klipy/Application/Abstractions/IKlipyClient.cs
@@ -0,0 +1,9 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Knot.Contracts.Klipy.Application.Abstractions;
+
+public interface IKlipyClient
+{
+ Task TestConnectionAsync(string apiKey, string appName, CancellationToken cancellationToken = default);
+}
\ No newline at end of file
diff --git a/backend/src/Contracts/Messaging/Application/Abstractions/IMessageQueryService.cs b/backend/src/Contracts/Messaging/Application/Abstractions/IMessageQueryService.cs
new file mode 100644
index 0000000..6dcbeaa
--- /dev/null
+++ b/backend/src/Contracts/Messaging/Application/Abstractions/IMessageQueryService.cs
@@ -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> GetAllMessagesAsync(CancellationToken cancellationToken);
+ Task> GetOrphanedMessagesAsync(HashSet activeChatIds, CancellationToken cancellationToken);
+ Task DeleteMessagesAsync(List messageIds, CancellationToken cancellationToken);
+}
+
+public record MessageInfo(Guid Id, Guid ChatId, bool IsDeleted, string? MediaUrl, List? Media);
+
+public record MediaInfo(string Url);
\ No newline at end of file
diff --git a/backend/src/Contracts/Messaging/Application/Abstractions/IUserStatsService.cs b/backend/src/Contracts/Messaging/Application/Abstractions/IUserStatsService.cs
new file mode 100644
index 0000000..64694b5
--- /dev/null
+++ b/backend/src/Contracts/Messaging/Application/Abstractions/IUserStatsService.cs
@@ -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> GetStatsForUsersAsync(List userIds, CancellationToken cancellationToken = default);
+}
+
+public record UserStats(int MessageCount, long StorageSize);
\ No newline at end of file
diff --git a/backend/src/Contracts/Settings/Abstractions/IStatisticsService.cs b/backend/src/Contracts/Settings/Abstractions/IStatisticsService.cs
new file mode 100644
index 0000000..da97d46
--- /dev/null
+++ b/backend/src/Contracts/Settings/Abstractions/IStatisticsService.cs
@@ -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 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 ActivityTimeline { get; set; } = new();
+ public List TopUsersByMessages { get; set; } = new();
+ public List 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
+}
diff --git a/backend/src/Contracts/Storage/Abstractions/IFileStorageService.cs b/backend/src/Contracts/Storage/Abstractions/IFileStorageService.cs
new file mode 100644
index 0000000..414937b
--- /dev/null
+++ b/backend/src/Contracts/Storage/Abstractions/IFileStorageService.cs
@@ -0,0 +1,25 @@
+using System.IO;
+using System.Threading.Tasks;
+
+namespace Knot.Contracts.Storage.Abstractions;
+
+public interface IFileStorageService
+{
+ // Загружает поток файла и возвращает его уникальный идентификатор (SHA256 хеш или GUID).
+ Task UploadFileAsync(Stream stream, string fileName, string contentType);
+
+ // Скачивает файл и возвращает его расшифрованный поток и тип содержимого.
+ Task<(Stream Stream, string ContentType, string FileName)> DownloadFileAsync(string fileId);
+
+ // Удаляет файл из хранилища.
+ Task DeleteFileAsync(string fileId);
+
+ // Получает список всех файлов в хранилище с их размерами.
+ Task> ListFilesAsync();
+
+ // Получает размер конкретного файла
+ Task GetFileSizeAsync(string fileId, CancellationToken ct = default);
+
+ // Получает размеры списка файлов
+ Task> GetFileSizesAsync(IEnumerable fileIds, CancellationToken ct = default);
+}
diff --git a/backend/src/Contracts/Stories/Infrastructure/Persistence/IStoryCollection.cs b/backend/src/Contracts/Stories/Infrastructure/Persistence/IStoryCollection.cs
new file mode 100644
index 0000000..b9341c9
--- /dev/null
+++ b/backend/src/Contracts/Stories/Infrastructure/Persistence/IStoryCollection.cs
@@ -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> 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; }
+}
\ No newline at end of file
diff --git a/backend/src/Modules/Admin/Application/Admin/Commands/BanUser/BanUserCommand.cs b/backend/src/Modules/Admin/Application/Admin/Commands/BanUser/BanUserCommand.cs
index 16fef17..bf7ea30 100644
--- a/backend/src/Modules/Admin/Application/Admin/Commands/BanUser/BanUserCommand.cs
+++ b/backend/src/Modules/Admin/Application/Admin/Commands/BanUser/BanUserCommand.cs
@@ -1,16 +1,16 @@
-using Knot.Contracts.Auth.Domain;
-using Knot.Contracts.Auth.Application.Abstractions;
-using Knot.Shared.Kernel;
using System;
using System.Threading;
using System.Threading.Tasks;
+using Knot.Contracts.Admin.Abstractions;
+using Knot.Contracts.Auth.Application.Abstractions;
+using Knot.Contracts.Auth.Domain;
using MediatR;
namespace Knot.Modules.Admin.Application.Admin.Commands;
-public record BanUserCommand(Guid UserId) : ICommand;
+public record BanUserCommand(Guid UserId) : ICommand;
-internal sealed class BanUserCommandHandler : ICommandHandler
+internal sealed class BanUserCommandHandler : ICommandHandler
{
private readonly IUserRepository _userRepository;
private readonly IAuthUnitOfWork _unitOfWork;
@@ -21,16 +21,16 @@ internal sealed class BanUserCommandHandler : ICommandHandler> Handle(BanUserCommand request, CancellationToken cancellationToken)
+ public async Task> Handle(BanUserCommand request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
- if (user == null) return Result.Failure(Error.NotFound("User.NotFound", "User not found"));
+ if (user == null) return Result.Failure(Error.NotFound("User.NotFound", "User not found"));
user.IsBanned = true;
user.BannedUntil = null; // Permanent ban
await _userRepository.UpdateAsync(user, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
- return Result.Success(Result.Success());
+ return Result.Success(new SuccessResponse(true));
}
}
diff --git a/backend/src/Modules/Admin/Application/Admin/Commands/BanUser/UnbanUserCommand.cs b/backend/src/Modules/Admin/Application/Admin/Commands/BanUser/UnbanUserCommand.cs
index 48ef80e..4e49465 100644
--- a/backend/src/Modules/Admin/Application/Admin/Commands/BanUser/UnbanUserCommand.cs
+++ b/backend/src/Modules/Admin/Application/Admin/Commands/BanUser/UnbanUserCommand.cs
@@ -1,16 +1,16 @@
-using Knot.Contracts.Auth.Domain;
-using Knot.Contracts.Auth.Application.Abstractions;
-using Knot.Shared.Kernel;
using System;
using System.Threading;
using System.Threading.Tasks;
+using Knot.Contracts.Admin.Abstractions;
+using Knot.Contracts.Auth.Application.Abstractions;
+using Knot.Contracts.Auth.Domain;
using MediatR;
namespace Knot.Modules.Admin.Application.Admin.Commands;
-public record UnbanUserCommand(Guid UserId) : ICommand;
+public record UnbanUserCommand(Guid UserId) : ICommand;
-internal sealed class UnbanUserCommandHandler : ICommandHandler
+internal sealed class UnbanUserCommandHandler : ICommandHandler
{
private readonly IUserRepository _userRepository;
private readonly IAuthUnitOfWork _unitOfWork;
@@ -21,16 +21,16 @@ internal sealed class UnbanUserCommandHandler : ICommandHandler> Handle(UnbanUserCommand request, CancellationToken cancellationToken)
+ public async Task> Handle(UnbanUserCommand request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
- if (user == null) return Result.Failure(Error.NotFound("User.NotFound", "User not found"));
+ if (user == null) return Result.Failure(Error.NotFound("User.NotFound", "User not found"));
user.IsBanned = false;
user.BannedUntil = null;
await _userRepository.UpdateAsync(user, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
- return Result.Success(Result.Success());
+ return Result.Success(new SuccessResponse(true));
}
}
diff --git a/backend/src/Modules/Admin/Application/Admin/Commands/CleanRun/CleanRunCommand.cs b/backend/src/Modules/Admin/Application/Admin/Commands/CleanRun/CleanRunCommand.cs
index 41c05ba..01f1db6 100644
--- a/backend/src/Modules/Admin/Application/Admin/Commands/CleanRun/CleanRunCommand.cs
+++ b/backend/src/Modules/Admin/Application/Admin/Commands/CleanRun/CleanRunCommand.cs
@@ -3,17 +3,16 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
-using Knot.Contracts.Auth.Application.Abstractions;
-using Knot.Contracts.Messaging.Domain;
+using Knot.Contracts.Admin.Abstractions;
+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.Auth.Infrastructure.Persistence;
-using Knot.Modules.Conversations.Infrastructure.Persistence;
-using Knot.Modules.Stories.Domain;
-using Knot.Shared.Kernel;
-using Knot.Shared.Kernel.Storage;
using MediatR;
using Microsoft.EntityFrameworkCore;
-using MongoDB.Driver;
namespace Knot.Modules.Admin.Application.Admin.Commands;
@@ -21,17 +20,22 @@ public record CleanRunCommand() : ICommand;
internal sealed class CleanRunCommandHandler : ICommandHandler
{
- private readonly ChatsDbContext _chatsDbContext;
- private readonly IMongoCollection _messages;
- private readonly IMongoCollection _stories;
+ private readonly Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext _chatsDbContext;
+ private readonly Knot.Contracts.Messaging.Application.Abstractions.IMessageQueryService _messageQueryService;
+ private readonly Knot.Contracts.Stories.Infrastructure.Persistence.IStoryCollection _storyCollection;
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;
- _messages = mongoDb.GetCollection("messages");
- _stories = mongoDb.GetCollection("stories");
+ _messageQueryService = messageQueryService;
+ _storyCollection = storyCollection;
_fileStorage = fileStorage;
_identityDb = identityDb;
}
@@ -40,30 +44,27 @@ internal sealed class CleanRunCommandHandler : ICommandHandler c.Id).ToHashSet();
- var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken);
-
- var orphanMessages = allMessages
- .Where(m => !activeChatIds.Contains(m.ChatId) || m.IsDeleted)
- .ToList();
+ var allMessages = await _messageQueryService.GetAllMessagesAsync(cancellationToken);
+ var orphanMessages = await _messageQueryService.GetOrphanedMessagesAsync(activeChatIds, cancellationToken);
var keptMessages = allMessages
- .Where(m => activeChatIds.Contains(m.ChatId) && !m.IsDeleted)
+ .Where(m => !orphanMessages.Any(om => om.Id == m.Id))
.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();
- var activeMessageUrls = keptMessages.OfType()
+ var activeMessageUrls = keptMessages
.Where(m => m.Media != null)
- .SelectMany(m => m.Media)
+ .SelectMany(m => m.Media!)
.Select(me => me.Url)
.Where(u => !string.IsNullOrEmpty(u));
@@ -100,8 +101,7 @@ internal sealed class CleanRunCommandHandler : ICommandHandler m.Id).ToList();
- var filter = Builders.Filter.In(m => m.Id, orphanIds);
- await _messages.DeleteManyAsync(filter, cancellationToken);
+ await _messageQueryService.DeleteMessagesAsync(orphanIds, cancellationToken);
}
return Result.Success(new MessageResponse("Cleanup completed successfully"));
diff --git a/backend/src/Modules/Admin/Application/Admin/Commands/ResetUserPassword/ResetUserPasswordCommand.cs b/backend/src/Modules/Admin/Application/Admin/Commands/ResetUserPassword/ResetUserPasswordCommand.cs
index 9a2ff3d..14c7e23 100644
--- a/backend/src/Modules/Admin/Application/Admin/Commands/ResetUserPassword/ResetUserPasswordCommand.cs
+++ b/backend/src/Modules/Admin/Application/Admin/Commands/ResetUserPassword/ResetUserPasswordCommand.cs
@@ -6,7 +6,7 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
-using Knot.Shared.Kernel;
+using Knot.Contracts.Admin.Abstractions;
namespace Knot.Modules.Admin.Application.Admin.Commands;
@@ -28,10 +28,10 @@ internal sealed class ResetUserPasswordCommandHandler : ICommandHandler(AuthErrors.UserNotFound);
+ return Result.Failure(Error.NotFound("Auth.UserNotFound", "User not found"));
}
- if (string.IsNullOrWhiteSpace(request.NewPassword))
+ if (string.IsNullOrWhiteSpace(request.NewPassword))
return Result.Failure(new Error("Admin.InvalidPassword", "Invalid password"));
var hash = BCrypt.Net.BCrypt.HashPassword(request.NewPassword);
@@ -42,3 +42,4 @@ internal sealed class ResetUserPasswordCommandHandler : ICommandHandler
{
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;
_klipyClient = klipyClient;
diff --git a/backend/src/Modules/Admin/Application/Admin/Commands/UpdateSettings/UpdateSettingsCommand.cs b/backend/src/Modules/Admin/Application/Admin/Commands/UpdateSettings/UpdateSettingsCommand.cs
index d6e3a06..8987b02 100644
--- a/backend/src/Modules/Admin/Application/Admin/Commands/UpdateSettings/UpdateSettingsCommand.cs
+++ b/backend/src/Modules/Admin/Application/Admin/Commands/UpdateSettings/UpdateSettingsCommand.cs
@@ -1,12 +1,12 @@
-using Knot.Modules.Admin.Application.Admin.DTOs;
using System;
+using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
-using MediatR;
-using Knot.Shared.Kernel;
+using Knot.Contracts.Admin.Abstractions;
using Knot.Contracts.Settings.Application.Abstractions;
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;
@@ -26,19 +26,19 @@ internal sealed class UpdateSettingsCommandHandler : ICommandHandler> Handle(UpdateSettingsCommand request, CancellationToken cancellationToken)
{
// Если федерация включена, но ключи еще не сгенерированы — создаем их
- if (request.Settings.Federation.Enabled &&
- (string.IsNullOrEmpty(request.Settings.Federation.PrivateKey) ||
+ if (request.Settings.Federation.Enabled &&
+ (string.IsNullOrEmpty(request.Settings.Federation.PrivateKey) ||
string.IsNullOrEmpty(request.Settings.Federation.PublicKey)))
{
using var rsa = RSA.Create(2048);
-
+
// Экспорт в формате PKCS#8 (Private) и PKCS#1 (Public) в Base64 PEM-строки
request.Settings.Federation.PrivateKey = Convert.ToBase64String(rsa.ExportPkcs8PrivateKey());
request.Settings.Federation.PublicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey());
}
await _settingsService.UpdateSettingsAsync(request.Settings, cancellationToken);
-
+
// Уведомляем другие модули (в т.ч. Федерацию) через Доменное Событие
await _mediator.Publish(new Knot.Modules.Admin.Domain.Events.SystemSettingsUpdatedDomainEvent(request.Settings), cancellationToken);
diff --git a/backend/src/Modules/Admin/Application/Admin/DTOs/CleanDryRunResult.cs b/backend/src/Modules/Admin/Application/Admin/DTOs/CleanDryRunResult.cs
new file mode 100644
index 0000000..e714abd
--- /dev/null
+++ b/backend/src/Modules/Admin/Application/Admin/DTOs/CleanDryRunResult.cs
@@ -0,0 +1,9 @@
+namespace Knot.Modules.Admin.Application.Admin.DTOs;
+
+public record CleanDryRunResult(
+ int OrphanedMessagesCount,
+ int OrphanedMediaCount,
+ long OrphanedMediaBytes,
+ int ExpiredStoriesCount,
+ long ExpiredStoriesSize
+);
diff --git a/backend/src/Modules/Admin/Application/Admin/Queries/CleanDryRun/CleanDryRunQuery.cs b/backend/src/Modules/Admin/Application/Admin/Queries/CleanDryRun/CleanDryRunQuery.cs
index 4c24fe5..e0cc42c 100644
--- a/backend/src/Modules/Admin/Application/Admin/Queries/CleanDryRun/CleanDryRunQuery.cs
+++ b/backend/src/Modules/Admin/Application/Admin/Queries/CleanDryRun/CleanDryRunQuery.cs
@@ -1,136 +1,77 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
-using Knot.Contracts.Auth.Application.Abstractions;
-using Knot.Contracts.Auth.Application.Auth.DTOs;
-using Knot.Contracts.Messaging.Domain;
+using Knot.Contracts.Admin.Abstractions;
+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.Stories.Infrastructure.Persistence;
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 Microsoft.EntityFrameworkCore;
-using MongoDB.Bson;
-using MongoDB.Driver;
namespace Knot.Modules.Admin.Application.Admin.Queries;
-public record CleanDryRunQuery() : IQuery;
+public record CleanDryRunQuery() : IQuery;
-internal sealed class CleanDryRunQueryHandler : IQueryHandler
+internal sealed class CleanDryRunQueryHandler : IQueryHandler
{
- private readonly ChatsDbContext _chatsDbContext;
- private readonly IMongoCollection _messages;
- private readonly IMongoCollection _stories;
- private readonly IFileStorageService _fileStorage;
- private readonly AuthDbContext _identityDb;
+ private readonly Knot.Contracts.Messaging.Application.Abstractions.IMessageQueryService _messageService;
+ private readonly IAuthDbContext _authDbContext;
+ private readonly IChatsDbContext _chatsDbContext;
+ private readonly IStoryCollection _storyCollection;
- 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;
- _messages = mongoDb.GetCollection("messages");
- _stories = mongoDb.GetCollection("stories");
- _fileStorage = fileStorage;
- _identityDb = identityDb;
+ _storyCollection = storyCollection;
}
- public async Task> Handle(CleanDryRunQuery request, CancellationToken ct)
+ public async Task> Handle(CleanDryRunQuery request, CancellationToken ct)
{
try
{
- // 1. Получаем ID активных чатов (SQL)
- var activeChats = await _chatsDbContext.Chats
- .AsNoTracking()
- .Select(c => new { c.Id, c.Avatar })
- .ToListAsync(ct);
+ var activeChatIds = _chatsDbContext.Chats.Select(c => c.Id).ToHashSet();
- 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 orphanedFilter = Builders.Filter.Or(
- Builders.Filter.BitsAnySet(m => m.State, (long)MessageState.IsDeleted),
- Builders.Filter.Nin(m => m.ChatId, activeChatIds)
- );
- var orphanedMessagesCount = await _messages.CountDocumentsAsync(orphanedFilter, cancellationToken: ct);
-
- // 3. Собираем ID всех используемых файлов
- var validFileIds = new HashSet();
-
- // Аватары чатов и пользователей
- 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.Filter.And(
- Builders.Filter.BitsAllClear(m => m.State, (long)MessageState.IsDeleted),
- Builders.Filter.In(m => m.ChatId, activeChatIds)
- );
-
- var projection = Builders.Projection.Include("Media");
-
- using (var cursor = await _messages.Find(activeFilter).Project(projection).ToCursorAsync(ct))
+ var validIds = new HashSet();
+ foreach (var msg in orphanedMessages.Where(m => m.MediaUrl != null))
{
- while (await cursor.MoveNextAsync(ct))
+ var parts = msg.MediaUrl.Split('/');
+ var fileId = parts.LastOrDefault();
+ if (!string.IsNullOrEmpty(fileId))
{
- foreach (var doc in cursor.Current)
- {
- 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);
- }
- }
- }
+ validIds.Add(fileId);
}
}
- // 4. Анализ физического хранилища
- var allStoredFiles = await _fileStorage.ListFilesAsync();
- long orphanedBytes = allStoredFiles
- .Where(file => !validFileIds.Contains(file.FileId))
- .Sum(file => file.Size);
+ var stories = await _storyCollection.GetAllAsync(ct);
+ var expiredStoriesCount = stories.Count(s => s.ExpiresAt.HasValue && s.ExpiresAt.Value < DateTime.UtcNow);
+ var expiredStoriesSize = stories.Where(s => s.ExpiresAt.HasValue && s.ExpiresAt.Value < DateTime.UtcNow).Sum(s => s.MediaUrl?.Length ?? 0);
- return Result.Success(new CleanupDryRunResultDto
- {
- OrphanedMessagesCount = (int)orphanedMessagesCount,
- OrphanedMediaBytes = orphanedBytes
- });
+ return Result.Success(new CleanDryRunResult(
+ orphanedMessageCount,
+ orphanedMediaCount,
+ 0,
+ expiredStoriesCount,
+ expiredStoriesSize
+ ));
}
catch (Exception ex)
{
- return Result.Failure(new Error("Cleanup.Error", $"Ошибка при анализе данных: {ex.Message}"));
- }
- }
-
- private void AddFileIdIfValid(string? url, HashSet 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);
- }
+ return Result.Failure(new Error("Admin.CleanDryRun.Error", $"Error during dry run: {ex.Message}"));
}
}
}
diff --git a/backend/src/Modules/Admin/Application/Admin/Queries/GetDashboardStats/GetDashboardStatsQuery.cs b/backend/src/Modules/Admin/Application/Admin/Queries/GetDashboardStats/GetDashboardStatsQuery.cs
index c5b12c0..009ec59 100644
--- a/backend/src/Modules/Admin/Application/Admin/Queries/GetDashboardStats/GetDashboardStatsQuery.cs
+++ b/backend/src/Modules/Admin/Application/Admin/Queries/GetDashboardStats/GetDashboardStatsQuery.cs
@@ -1,9 +1,9 @@
-using Knot.Modules.Admin.Application.Admin.DTOs;
using System.Threading;
using System.Threading.Tasks;
+using Knot.Contracts.Admin.Abstractions;
+using Knot.Contracts.Settings.Abstractions;
+using Knot.Modules.Admin.Application.Admin.DTOs;
using MediatR;
-using Knot.Shared.Kernel;
-using Knot.Shared.Kernel.Services;
namespace Knot.Modules.Admin.Application.Admin.Queries;
diff --git a/backend/src/Modules/Admin/Application/Admin/Queries/GetSettings/GetSettingsQuery.cs b/backend/src/Modules/Admin/Application/Admin/Queries/GetSettings/GetSettingsQuery.cs
index 7bbfac4..6b6f545 100644
--- a/backend/src/Modules/Admin/Application/Admin/Queries/GetSettings/GetSettingsQuery.cs
+++ b/backend/src/Modules/Admin/Application/Admin/Queries/GetSettings/GetSettingsQuery.cs
@@ -1,11 +1,11 @@
-using Knot.Modules.Admin.Application.Admin.DTOs;
using System;
using System.Threading;
using System.Threading.Tasks;
-using MediatR;
-using Knot.Shared.Kernel;
+using Knot.Contracts.Admin.Abstractions;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs;
+using Knot.Modules.Admin.Application.Admin.DTOs;
+using MediatR;
namespace Knot.Modules.Admin.Application.Admin.Queries;
diff --git a/backend/src/Modules/Admin/Application/Admin/Queries/GetUserDetails/GetUserDetailsQuery.cs b/backend/src/Modules/Admin/Application/Admin/Queries/GetUserDetails/GetUserDetailsQuery.cs
index eb82f1b..bc2ede1 100644
--- a/backend/src/Modules/Admin/Application/Admin/Queries/GetUserDetails/GetUserDetailsQuery.cs
+++ b/backend/src/Modules/Admin/Application/Admin/Queries/GetUserDetails/GetUserDetailsQuery.cs
@@ -1,91 +1,64 @@
-using Knot.Contracts.Auth.Domain;
-using Knot.Contracts.Auth.Application.Auth.DTOs;
-using Knot.Modules.Admin.Application.Admin.DTOs;
-using Knot.Modules.Messaging.Domain;
-using System;
+using System;
+using System.Collections.Generic;
using System.Linq;
using System.Threading;
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 Knot.Shared.Kernel;
-using Knot.Modules.Conversations.Domain;
-using MongoDB.Driver;
namespace Knot.Modules.Admin.Application.Admin.Queries;
-public record GetUserDetailsQuery(Guid UserId) : IQuery;
+public record GetUserDetailsQuery(Guid UserId) : IQuery;
-internal sealed class GetUserDetailsQueryHandler : IQueryHandler
+internal sealed class GetUserDetailsQueryHandler : IQueryHandler
{
private readonly IUserRepository _userRepository;
- private readonly IMongoCollection _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;
- _messages = mongoDatabase.GetCollection("Messages");
+ _statsService = statsService;
+ _statusService = statusService;
}
- public async Task> Handle(GetUserDetailsQuery request, CancellationToken cancellationToken)
+ public async Task> Handle(GetUserDetailsQuery request, CancellationToken ct)
{
- var targetUser = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
- if (targetUser == null)
+ try
{
- return Result.Failure(AuthErrors.UserNotFound);
- }
+ var user = await _userRepository.GetByIdAsync(request.UserId, ct);
+ if (user == null)
+ return Result.Failure(new Error("Admin.GetUserDetails.NotFound", "User not found"));
- List userMessages;
- try
+ var statsMap = await _statsService.GetStatsForUsersAsync(new List { 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 ex)
{
- var filter = Builders.Filter.Eq(m => m.SenderId, request.UserId);
- userMessages = await _messages.Find(filter).ToListAsync(cancellationToken);
+ return Result.Failure(new Error("Admin.GetUserDetails.Error", $"Error getting user details: {ex.Message}"));
}
- catch (Exception)
- {
- userMessages = new List();
- }
-
- var messagesCount = userMessages.Count;
-
- var mediaMessages = userMessages.OfType().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()
- .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);
}
}
diff --git a/backend/src/Modules/Admin/Application/Admin/Queries/SearchUsers/SearchUsersQuery.cs b/backend/src/Modules/Admin/Application/Admin/Queries/SearchUsers/SearchUsersQuery.cs
index ea80d91..dee4aa8 100644
--- a/backend/src/Modules/Admin/Application/Admin/Queries/SearchUsers/SearchUsersQuery.cs
+++ b/backend/src/Modules/Admin/Application/Admin/Queries/SearchUsers/SearchUsersQuery.cs
@@ -1,14 +1,14 @@
-using Knot.Contracts.Auth.Domain;
-using Knot.Modules.Admin.Application.Admin.DTOs;
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
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 Knot.Shared.Kernel;
-using Knot.Modules.Messaging.Application.Abstractions;
-using Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Admin.Application.Admin.Queries;
@@ -17,13 +17,13 @@ public record SearchUsersQuery(string Query) : IQuery>;
internal sealed class SearchUsersQueryHandler : IQueryHandler>
{
private readonly IUserRepository _userRepository;
- private readonly IUserStatsService _statsService;
- private readonly IUserStatusService _statusService;
+ private readonly Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService _statsService;
+ private readonly Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService _statusService;
public SearchUsersQueryHandler(
- IUserRepository userRepository,
- IUserStatsService statsService,
- IUserStatusService statusService)
+ IUserRepository userRepository,
+ Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService statsService,
+ Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService statusService)
{
_userRepository = userRepository;
_statsService = statsService;
@@ -34,20 +34,19 @@ internal sealed class SearchUsersQueryHandler : IQueryHandler());
var userIds = users.Select(u => u.Id).ToList();
- // 2. NoSQL
var statsMap = await _statsService.GetStatsForUsersAsync(userIds, ct);
- // 3. DTO
- var result = users.Select(u => {
- var stats = statsMap.GetValueOrDefault(u.Id, new UserStats(0, 0L));
+ var result = users.Select(u =>
+ {
+ var stats = statsMap.GetValueOrDefault(u.Id, new Knot.Contracts.Messaging.Application.Abstractions.UserStats(0, 0L));
var isOnline = _statusService.IsUserOnline(u.Id.ToString());
-
+
+
return new AdminUserDto(
u.Id,
u.Username ?? "unknown",
@@ -67,7 +66,7 @@ internal sealed class SearchUsersQueryHandler : IQueryHandler>(new Error("Admin.SearchUsers.Error", $" : {ex.Message}"));
+ return Result.Failure>(new Error("Admin.SearchUsers.Error", $"Error searching users: {ex.Message}"));
}
}
}
diff --git a/backend/src/Modules/Admin/Domain/Events/SystemSettingsUpdatedDomainEvent.cs b/backend/src/Modules/Admin/Domain/Events/SystemSettingsUpdatedDomainEvent.cs
index 91a8f74..5c42dfa 100644
--- a/backend/src/Modules/Admin/Domain/Events/SystemSettingsUpdatedDomainEvent.cs
+++ b/backend/src/Modules/Admin/Domain/Events/SystemSettingsUpdatedDomainEvent.cs
@@ -1,5 +1,5 @@
using System.Collections.Generic;
-using Knot.Shared.Kernel;
+using Knot.Contracts.Admin.Abstractions;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs;
@@ -10,3 +10,4 @@ namespace Knot.Modules.Admin.Domain.Events;
///
public sealed record SystemSettingsUpdatedDomainEvent(SystemSettingsDto Settings) : IDomainEvent;
+
diff --git a/backend/src/Modules/Admin/Knot.Modules.Admin.csproj b/backend/src/Modules/Admin/Knot.Modules.Admin.csproj
index 534ad08..97466f0 100644
--- a/backend/src/Modules/Admin/Knot.Modules.Admin.csproj
+++ b/backend/src/Modules/Admin/Knot.Modules.Admin.csproj
@@ -4,9 +4,9 @@
enable
enable
+
-
-
+
@@ -14,14 +14,12 @@
-
-
-
-
-
-
+
+
+
+
\ No newline at end of file
diff --git a/backend/src/Modules/Admin/Presentation/Endpoints/AdminEndpoints.cs b/backend/src/Modules/Admin/Presentation/Endpoints/AdminEndpoints.cs
index 337f68a..38a1d3d 100644
--- a/backend/src/Modules/Admin/Presentation/Endpoints/AdminEndpoints.cs
+++ b/backend/src/Modules/Admin/Presentation/Endpoints/AdminEndpoints.cs
@@ -1,22 +1,19 @@
using Carter;
-using Knot.Contracts.Auth.Application.Auth.DTOs;
-using Knot.Shared.Kernel;
using Knot.Contracts.Settings.Application.Abstractions;
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.TestKlipy;
+using Knot.Modules.Admin.Application.Admin.Queries;
+using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc;
-using Knot.Modules.Admin.Application.Admin.Commands.TestKlipy;
-using Knot.Modules.Conversations.Application.Users.Commands.DeleteUser;
+using Microsoft.AspNetCore.Routing;
namespace Knot.Host.Presentation.Endpoints;
public record KlipyTestDto(string ApiKey, string AppName);
+public record ResetPasswordRequest(string NewPassword);
public sealed class AdminEndpoints : ICarterModule
{
@@ -47,20 +44,11 @@ public sealed class AdminEndpoints : ICarterModule
var result = await sender.Send(new GetDashboardStatsQuery(), ct);
return Results.Ok(result.Value);
});
-
- group.MapPost("users", async ([FromBody] RegisterUserCommand command, 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) =>
+ group.MapPost("users/{userId:guid}/reset-password", async (Guid userId, [FromBody] ResetPasswordRequest dto, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new ResetUserPasswordCommand(userId, dto.NewPassword), ct);
- if (result.IsFailure)
+ if (result.IsFailure)
{
if (result.Error.Code == "User.NotFound") return Results.NotFound(new { error = "User not found" });
return Results.BadRequest(new { error = result.Error.Description });
@@ -80,12 +68,6 @@ public sealed class AdminEndpoints : ICarterModule
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) =>
{
var result = await sender.Send(new SearchUsersQuery(query), ct);
@@ -117,7 +99,8 @@ public sealed class AdminEndpoints : ICarterModule
{
// Получаем все системные часовые пояса и формируем удобный для фронтенда формат
var zones = TimeZoneInfo.GetSystemTimeZones()
- .Select(tz => new {
+ .Select(tz => new
+ {
id = tz.Id,
displayName = tz.DisplayName,
standardName = tz.StandardName,
diff --git a/backend/src/Modules/Auth/DependencyInjection.cs b/backend/src/Modules/Auth/DependencyInjection.cs
index 6ed18e5..b4ecbeb 100644
--- a/backend/src/Modules/Auth/DependencyInjection.cs
+++ b/backend/src/Modules/Auth/DependencyInjection.cs
@@ -1,5 +1,6 @@
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Domain;
+using Knot.Contracts.Auth.Infrastructure.Persistence;
using Knot.Modules.Auth.Infrastructure.Authentication;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Shared.Kernel;
@@ -11,27 +12,22 @@ namespace Knot.Modules.Auth;
public static class DependencyInjection
{
- ///
- /// Регистрация сервисов модуля Identity.
- ///
public static IServiceCollection AddAuthModule(
this IServiceCollection services,
IConfiguration configuration)
{
- // Настройка базы данных
string connectionString = configuration.GetConnectionString("DefaultConnection")!;
services.AddDbContext(options =>
options.UseNpgsql(connectionString));
- // Регистрация Unit of Work и Репозиториев
services.AddScoped(sp => sp.GetRequiredService());
- services.AddScoped(sp => sp.GetRequiredService());
+ services.AddScoped(sp => sp.GetRequiredService());
+ services.AddScoped(sp => sp.GetRequiredService());
services.AddScoped();
services.AddScoped();
services.AddScoped();
- // Регистрация MediatR для этого модуля
services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
diff --git a/backend/src/Modules/Auth/Infrastructure/Persistence/AuthDbContext.cs b/backend/src/Modules/Auth/Infrastructure/Persistence/AuthDbContext.cs
index 439186a..40dbaf9 100644
--- a/backend/src/Modules/Auth/Infrastructure/Persistence/AuthDbContext.cs
+++ b/backend/src/Modules/Auth/Infrastructure/Persistence/AuthDbContext.cs
@@ -1,5 +1,6 @@
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Domain;
+using Knot.Contracts.Auth.Infrastructure.Persistence;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Security;
using MediatR;
@@ -9,7 +10,7 @@ using DomainUser = Knot.Modules.Auth.Domain.User;
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 IEncryptionService _encryptionService;
@@ -31,6 +32,21 @@ public sealed class AuthDbContext : DbContext, IAuthUnitOfWork, IAuthDbContext
Set().Add(domainUser);
}
+ IQueryable Knot.Contracts.Auth.Infrastructure.Persistence.IAuthDbContext.Users =>
+ Set().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)
{
base.OnConfiguring(optionsBuilder);
diff --git a/backend/src/Modules/Conversations/DependencyInjection.cs b/backend/src/Modules/Conversations/DependencyInjection.cs
index 9753682..bcd56bc 100644
--- a/backend/src/Modules/Conversations/DependencyInjection.cs
+++ b/backend/src/Modules/Conversations/DependencyInjection.cs
@@ -1,4 +1,5 @@
using Knot.Contracts.Conversations.Application.Abstractions;
+using Knot.Contracts.Conversations.Infrastructure.Persistence;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
@@ -29,6 +30,7 @@ public static class DependencyInjection
var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017";
services.AddScoped(sp => sp.GetRequiredService());
+ services.AddScoped(sp => sp.GetRequiredService());
services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/backend/src/Modules/Conversations/Infrastructure/Persistence/ConversationsDbContext.cs b/backend/src/Modules/Conversations/Infrastructure/Persistence/ConversationsDbContext.cs
index e1f4782..540d299 100644
--- a/backend/src/Modules/Conversations/Infrastructure/Persistence/ConversationsDbContext.cs
+++ b/backend/src/Modules/Conversations/Infrastructure/Persistence/ConversationsDbContext.cs
@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Conversations.Application.Abstractions;
+using Knot.Contracts.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
@@ -11,13 +12,11 @@ using Knot.Shared.Kernel.Security;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
+using DomainChat = Knot.Modules.Conversations.Domain.Chat;
namespace Knot.Modules.Conversations.Infrastructure.Persistence;
-///
-/// Контекст базы данных для модуля чатов.
-///
-public sealed class ChatsDbContext : DbContext, Knot.Modules.Conversations.Application.Abstractions.IChatsUnitOfWork
+public sealed class ChatsDbContext : DbContext, Knot.Modules.Conversations.Application.Abstractions.IChatsUnitOfWork, Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext
{
private readonly IMediator _mediator;
private readonly IEncryptionService _encryptionService;
@@ -29,10 +28,13 @@ public sealed class ChatsDbContext : DbContext, Knot.Modules.Conversations.Appli
_encryptionService = encryptionService;
}
- public DbSet Chats => Set();
+ public DbSet Chats => Set();
public DbSet Folders => Set();
public DbSet UserChatSettings => Set();
+ IQueryable Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext.Chats =>
+ Set().Select(c => new Knot.Contracts.Conversations.Infrastructure.Persistence.Chat { Avatar = c.Avatar });
+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
@@ -43,20 +45,20 @@ public sealed class ChatsDbContext : DbContext, Knot.Modules.Conversations.Appli
{
modelBuilder.HasDefaultSchema("chats");
- modelBuilder.Entity(builder =>
- {
- builder.ToTable("Chats");
- builder.HasKey(c => c.Id);
- builder.Property(c => c.Type).HasConversion();
+ modelBuilder.Entity(builder =>
+ {
+ builder.ToTable("Chats");
+ builder.HasKey(c => c.Id);
+ builder.Property(c => c.Type).HasConversion();
- 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);
- });
+ 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);
+ });
modelBuilder.Entity(builder =>
{
diff --git a/backend/src/Modules/Klipy/Infrastructure/External/KlipyClient.cs b/backend/src/Modules/Klipy/Infrastructure/External/KlipyClient.cs
index 74e6fac..fb7c711 100644
--- a/backend/src/Modules/Klipy/Infrastructure/External/KlipyClient.cs
+++ b/backend/src/Modules/Klipy/Infrastructure/External/KlipyClient.cs
@@ -7,7 +7,7 @@ using Knot.Modules.Klipy.Application.Abstractions;
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;
@@ -20,10 +20,9 @@ public sealed class KlipyClient : IKlipyClient
{
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");
request.Headers.Add("X-KLIPY-API-KEY", apiKey);
-
+
using var response = await _httpClient.SendAsync(request, ct);
return response.IsSuccessStatusCode;
}
@@ -35,7 +34,6 @@ public sealed class KlipyClient : IKlipyClient
public async Task> SearchVideosAsync(string apiKey, string appName, string query, int limit, CancellationToken ct = default)
{
- // To be implemented as needed
return new List();
}
}
diff --git a/backend/src/Modules/Messaging/DependencyInjection.cs b/backend/src/Modules/Messaging/DependencyInjection.cs
index d03aa2c..0ed12df 100644
--- a/backend/src/Modules/Messaging/DependencyInjection.cs
+++ b/backend/src/Modules/Messaging/DependencyInjection.cs
@@ -7,6 +7,8 @@ using Knot.Shared.Kernel;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
+using ContractUserStatsService = Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService;
+using ModuleUserStatsService = Knot.Modules.Messaging.Application.Abstractions.IUserStatsService;
namespace Knot.Modules.Messaging;
@@ -27,7 +29,8 @@ public static class DependencyInjection
services.AddScoped();
services.AddScoped();
- services.AddScoped();
+ services.AddScoped();
+ services.AddScoped(sp => (ContractUserStatsService)sp.GetRequiredService());
services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
diff --git a/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/UserStatsService.cs b/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/UserStatsService.cs
index 332dc6e..2e7e836 100644
--- a/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/UserStatsService.cs
+++ b/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/UserStatsService.cs
@@ -4,15 +4,17 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Messaging.Abstractions;
+using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Messaging.Application.Abstractions;
using MongoDB.Bson;
using MongoDB.Driver;
+using ContractUserStatsService = Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService;
using ModuleUserStatsService = Knot.Modules.Messaging.Application.Abstractions.IUserStatsService;
namespace Knot.Modules.Messaging.Infrastructure.Persistence.Mongo;
-public sealed class UserStatsService : ModuleUserStatsService
+public sealed class UserStatsService : ModuleUserStatsService, ContractUserStatsService
{
private readonly IMongoCollection _messages;
@@ -23,11 +25,10 @@ public sealed class UserStatsService : ModuleUserStatsService
public async Task> GetStatsForUsersAsync(IEnumerable userIds, CancellationToken ct = default)
{
- var userIdList = userIds.ToList();
- if (!userIdList.Any()) return new Dictionary();
+ if (!userIds.Any()) return new Dictionary();
var stats = await _messages.Aggregate()
- .Match(Builders.Filter.In(m => m.SenderId, userIdList))
+ .Match(Builders.Filter.In(m => m.SenderId, userIds))
.Group(new BsonDocument {
{ "_id", "$SenderId" },
{ "Count", new BsonDocument("$sum", 1) },
@@ -44,6 +45,28 @@ public sealed class UserStatsService : ModuleUserStatsService
);
}
+ async Task> Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService.GetStatsForUsersAsync(List userIds, CancellationToken cancellationToken = default)
+ {
+ if (!userIds.Any()) return new Dictionary();
+
+ var stats = await _messages.Aggregate()
+ .Match(Builders.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 GetTotalStorageSizeAsync(CancellationToken ct = default)
{
var result = await _messages.Aggregate()
diff --git a/backend/src/Modules/Stories/DependencyInjection.cs b/backend/src/Modules/Stories/DependencyInjection.cs
index 8a599f3..646c207 100644
--- a/backend/src/Modules/Stories/DependencyInjection.cs
+++ b/backend/src/Modules/Stories/DependencyInjection.cs
@@ -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.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
-using Knot.Modules.Stories.Application.Abstractions;
-using Knot.Modules.Stories.Infrastructure.Persistence.Mongo;
using MongoDB.Driver;
-using System.Net.Http;
namespace Knot.Modules.Stories;
@@ -13,12 +14,12 @@ public static class DependencyInjection
public static IServiceCollection AddStoriesModule(this IServiceCollection services, IConfiguration configuration)
{
services.AddScoped();
-
+ services.AddScoped();
+
var connectionString = configuration.GetConnectionString("DefaultConnection");
services.AddDbContext(options =>
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();
services.AddMediatR(config =>
diff --git a/backend/src/Modules/Stories/Infrastructure/Persistence/Mongo/StoryCollection.cs b/backend/src/Modules/Stories/Infrastructure/Persistence/Mongo/StoryCollection.cs
new file mode 100644
index 0000000..4e28631
--- /dev/null
+++ b/backend/src/Modules/Stories/Infrastructure/Persistence/Mongo/StoryCollection.cs
@@ -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 _stories;
+
+ public StoryCollection(IMongoDatabase database)
+ {
+ _stories = database.GetCollection("stories");
+ }
+
+ public async Task> 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();
+ }
+}
\ No newline at end of file