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

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

View File

@@ -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<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 IAuthUnitOfWork _unitOfWork;
@@ -21,16 +21,16 @@ internal sealed class BanUserCommandHandler : ICommandHandler<BanUserCommand, Re
_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);
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.BannedUntil = null; // Permanent ban
await _userRepository.UpdateAsync(user, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(Result.Success());
return Result.Success(new SuccessResponse(true));
}
}

View File

@@ -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<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 IAuthUnitOfWork _unitOfWork;
@@ -21,16 +21,16 @@ internal sealed class UnbanUserCommandHandler : ICommandHandler<UnbanUserCommand
_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);
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.BannedUntil = null;
await _userRepository.UpdateAsync(user, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(Result.Success());
return Result.Success(new SuccessResponse(true));
}
}

View File

@@ -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<MessageResponse>;
internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand, MessageResponse>
{
private readonly ChatsDbContext _chatsDbContext;
private readonly IMongoCollection<Message> _messages;
private readonly IMongoCollection<Story> _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<Message>("messages");
_stories = mongoDb.GetCollection<Story>("stories");
_messageQueryService = messageQueryService;
_storyCollection = storyCollection;
_fileStorage = fileStorage;
_identityDb = identityDb;
}
@@ -40,30 +44,27 @@ internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand,
{
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 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<string>();
var activeMessageUrls = keptMessages.OfType<MediaMessage>()
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<CleanRunCommand,
if (orphanMessages.Any())
{
var orphanIds = orphanMessages.Select(m => m.Id).ToList();
var filter = Builders<Message>.Filter.In(m => m.Id, orphanIds);
await _messages.DeleteManyAsync(filter, cancellationToken);
await _messageQueryService.DeleteMessagesAsync(orphanIds, cancellationToken);
}
return Result.Success(new MessageResponse("Cleanup completed successfully"));

View File

@@ -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<ResetUse
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
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))
return Result.Failure<SuccessResponse>(new Error("Admin.InvalidPassword", "Invalid password"));
var hash = BCrypt.Net.BCrypt.HashPassword(request.NewPassword);
@@ -42,3 +42,4 @@ internal sealed class ResetUserPasswordCommandHandler : ICommandHandler<ResetUse
return Result.Success(new SuccessResponse(true));
}
}

View File

@@ -1,10 +1,10 @@
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Contracts.Admin.Abstractions;
using Knot.Contracts.Klipy.Application.Abstractions;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs;
using Knot.Modules.Klipy.Application.Abstractions;
using MediatR;
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>
{
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;

View File

@@ -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<UpdateSetti
public async Task<Result<SystemSettingsDto>> 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);

View File

@@ -0,0 +1,9 @@
namespace Knot.Modules.Admin.Application.Admin.DTOs;
public record CleanDryRunResult(
int OrphanedMessagesCount,
int OrphanedMediaCount,
long OrphanedMediaBytes,
int ExpiredStoriesCount,
long ExpiredStoriesSize
);

View File

@@ -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<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 IMongoCollection<Message> _messages;
private readonly IMongoCollection<Story> _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<Message>("messages");
_stories = mongoDb.GetCollection<Story>("stories");
_fileStorage = fileStorage;
_identityDb = identityDb;
_storyCollection = storyCollection;
}
public async Task<Result<CleanupDryRunResultDto>> Handle(CleanDryRunQuery request, CancellationToken ct)
public async Task<Result<CleanDryRunResult>> 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<Message>.Filter.Or(
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))
var validIds = new HashSet<string>();
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<CleanupDryRunResultDto>(new Error("Cleanup.Error", $"Ошибка при анализе данных: {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);
}
return Result.Failure<CleanDryRunResult>(new Error("Admin.CleanDryRun.Error", $"Error during dry run: {ex.Message}"));
}
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<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 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;
_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)
try
{
return Result.Failure<AdminUserDetailsDto>(AuthErrors.UserNotFound);
}
var user = await _userRepository.GetByIdAsync(request.UserId, ct);
if (user == null)
return Result.Failure<AdminUserDto>(new Error("Admin.GetUserDetails.NotFound", "User not found"));
List<Message> userMessages;
try
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 ex)
{
var filter = Builders<Message>.Filter.Eq(m => m.SenderId, request.UserId);
userMessages = await _messages.Find(filter).ToListAsync(cancellationToken);
return Result.Failure<AdminUserDto>(new Error("Admin.GetUserDetails.Error", $"Error getting user details: {ex.Message}"));
}
catch (Exception)
{
userMessages = new List<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);
}
}

View File

@@ -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<List<AdminUserDto>>;
internal sealed class SearchUsersQueryHandler : IQueryHandler<SearchUsersQuery, List<AdminUserDto>>
{
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<SearchUsersQuery,
{
try
{
// 1. Ïîèñê ïîëüçîâàòåëåé â ðåëÿöèîííîé ÁÄ
var users = await _userRepository.SearchUsersAsync(request.Query ?? "", ct);
if (!users.Any()) return Result.Success(new List<AdminUserDto>());
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<SearchUsersQuery,
}
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}"));
}
}
}

View File

@@ -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;
/// </summary>
public sealed record SystemSettingsUpdatedDomainEvent(SystemSettingsDto Settings) : IDomainEvent;

View File

@@ -4,9 +4,9 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
<ProjectReference Include="..\..\Contracts\Admin\Knot.Contracts.Admin.csproj" />
<ProjectReference Include="..\..\Contracts\Auth\Knot.Contracts.Auth.csproj" />
<ProjectReference Include="..\..\Contracts\Profiles\Knot.Contracts.Profiles.csproj" />
<ProjectReference Include="..\..\Contracts\Conversations\Knot.Contracts.Conversations.csproj" />
@@ -14,14 +14,12 @@
<ProjectReference Include="..\..\Contracts\Settings\Knot.Contracts.Settings.csproj" />
<ProjectReference Include="..\..\Contracts\Stories\Knot.Contracts.Stories.csproj" />
<ProjectReference Include="..\..\Contracts\Klipy\Knot.Contracts.Klipy.csproj" />
<!-- Admin needs direct module access for cleanup operations - PrivateAssets prevents module exposure -->
<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" />
<ProjectReference Include="..\..\Contracts\Storage\Knot.Contracts.Storage.csproj" />
</ItemGroup>
<ItemGroup>
<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>
</Project>

View File

@@ -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,

View File

@@ -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
{
/// <summary>
/// Регистрация сервисов модуля Identity.
/// </summary>
public static IServiceCollection AddAuthModule(
this IServiceCollection services,
IConfiguration configuration)
{
// Настройка базы данных
string connectionString = configuration.GetConnectionString("DefaultConnection")!;
services.AddDbContext<AuthDbContext>(options =>
options.UseNpgsql(connectionString));
// Регистрация Unit of Work и Репозиториев
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<IJwtTokenProvider, JwtTokenProvider>();
services.AddScoped<IUserDisplayNameProvider, Knot.Modules.Auth.Infrastructure.Services.UserDisplayNameProvider>();
// Регистрация MediatR для этого модуля
services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));

View File

@@ -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<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)
{
base.OnConfiguring(optionsBuilder);

View File

@@ -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<ConversationsAbstractions.IChatsUnitOfWork>(sp => sp.GetRequiredService<ChatsDbContext>());
services.AddScoped<Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext>(sp => sp.GetRequiredService<ChatsDbContext>());
services.AddScoped<IChatRepository, ChatRepository>();
services.AddScoped<IFolderRepository, FolderRepository>();
services.AddScoped<IUserChatSettingsRepository, UserChatSettingsRepository>();

View File

@@ -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;
/// <summary>
/// Контекст базы данных для модуля чатов.
/// </summary>
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<Chat> Chats => Set<Chat>();
public DbSet<DomainChat> Chats => Set<DomainChat>();
public DbSet<Folder> Folders => Set<Folder>();
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)
{
base.OnConfiguring(optionsBuilder);
@@ -43,20 +45,20 @@ public sealed class ChatsDbContext : DbContext, Knot.Modules.Conversations.Appli
{
modelBuilder.HasDefaultSchema("chats");
modelBuilder.Entity<Chat>(builder =>
{
builder.ToTable("Chats");
builder.HasKey(c => c.Id);
builder.Property(c => c.Type).HasConversion<string>();
modelBuilder.Entity<DomainChat>(builder =>
{
builder.ToTable("Chats");
builder.HasKey(c => c.Id);
builder.Property(c => c.Type).HasConversion<string>();
builder.OwnsMany(c => c.Members, mb =>
{
mb.ToTable("ChatMembers");
mb.HasKey(m => m.Id);
mb.WithOwner().HasForeignKey(m => m.ChatId);
mb.HasIndex(m => new { m.ChatId, m.UserId }).IsUnique();
}).Navigation(c => c.Members).UsePropertyAccessMode(PropertyAccessMode.Field);
});
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<Folder>(builder =>
{

View File

@@ -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<List<string>> SearchVideosAsync(string apiKey, string appName, string query, int limit, CancellationToken ct = default)
{
// To be implemented as needed
return new List<string>();
}
}

View File

@@ -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<IMessageRepository, MessageRepository>();
services.AddScoped<IMessageReactionRepository, MessageReactionRepository>();
services.AddScoped<IUserStatsService, UserStatsService>();
services.AddScoped<ModuleUserStatsService, UserStatsService>();
services.AddScoped<ContractUserStatsService>(sp => (ContractUserStatsService)sp.GetRequiredService<ModuleUserStatsService>());
services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));

View File

@@ -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<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)
{
var userIdList = userIds.ToList();
if (!userIdList.Any()) return new Dictionary<Guid, Knot.Modules.Messaging.Application.Abstractions.UserStats>();
if (!userIds.Any()) return new Dictionary<Guid, Knot.Modules.Messaging.Application.Abstractions.UserStats>();
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 {
{ "_id", "$SenderId" },
{ "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)
{
var result = await _messages.Aggregate()

View File

@@ -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<IStoryRepository, StoryRepository>();
services.AddScoped<Knot.Contracts.Stories.Infrastructure.Persistence.IStoryCollection, StoryCollection>();
var connectionString = configuration.GetConnectionString("DefaultConnection");
services.AddDbContext<Infrastructure.Database.StoriesDbContext>(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 =>

View File

@@ -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();
}
}