60 Commits
Author SHA1 Message Date
Халимов Рустам 67d5764f6e Убрал аватары и имена внутри чата личных чатов 2026-04-20 23:23:29 +03:00
Халимов Рустам 786eaffb33 Fix unread divider: update sessionUnreadRef when messages are marked as read 2026-04-20 23:21:50 +03:00
Халимов Рустам 4940f1212f Fix read receipts: mark visible messages as read immediately 2026-04-20 23:20:39 +03:00
Халимов Рустам c166f1d186 Fix read receipts: join chat group when activeChat changes 2026-04-20 23:14:31 +03:00
Халимов Рустам 71b3d2b491 Fix IMessageRepository import in LeaveOrDeleteChat.cs 2026-04-20 23:04:42 +03:00
Халимов Рустам a5d40f28c8 Fix MessageBubble.tsx JSX syntax errors 2026-04-20 23:00:30 +03:00
Халимов Рустам 9bfc5555bc Правки 2026-04-20 22:58:23 +03:00
Халимов Рустам 7eea8ff6d1 Правка уведомления 2026-04-20 22:48:25 +03:00
Халимов Рустам 72325f48e5 Убрал аватары и имена внутри чата личных чатов 2026-04-20 22:37:51 +03:00
Халимов Рустам 2b51375fbf Отступы в баблах 2026-04-20 22:37:25 +03:00
Халимов Рустам c92289f074 Вставка и перетаскивание в поле ввода 2026-04-20 22:35:00 +03:00
Халимов Рустам 33ea792941 Дубликат печати, разметка 2026-04-20 22:34:27 +03:00
Халимов Рустам 63fc0e197b Уведомление об удалении чата 2026-04-20 22:31:17 +03:00
Халимов Рустам 83ed328dd5 Исправлен механизм прочтения сообщений 2026-04-20 21:44:39 +03:00
Халимов Рустам 0f593e52e0 Восстановление подключения, бэк 2026-04-20 10:46:25 +03:00
Халимов Рустам d9462069e2 Миграции 2026-04-19 22:22:43 +03:00
Халимов Рустам 9e715fe3ab Токены, правильная работа 2026-04-19 22:13:20 +03:00
Халимов Рустам 70acad56fb Удаление сообщений 2026-04-19 21:05:56 +03:00
Халимов Рустам 8399d32490 Редактирование и плеер 2026-04-08 15:23:10 +03:00
Халимов Рустам 3905094ff4 Правка миграций 2026-04-07 21:22:38 +03:00
Халимов Рустам 9e8625aea1 Миграции 2026-04-07 21:20:06 +03:00
Халимов Рустам 5b905c94da Компоуз 2026-04-07 21:12:34 +03:00
Халимов Рустам 0eecc01374 Сборка 2026-04-07 21:05:23 +03:00
Халимов Рустам c37e1723d4 Фикс .env 2026-04-07 17:29:22 +03:00
Халимов Рустам 02043d4d97 Минимальная адаптация под мобилку 2026-04-07 12:29:41 +03:00
Халимов Рустам 852efa090e Опросы 2026-04-07 11:11:35 +03:00
Халимов Рустам c45f4db61c Заготовка опросов 2026-04-07 01:01:29 +03:00
Халимов Рустам 02a85fc587 Прокрутка 2026-04-06 23:42:46 +03:00
Халимов Рустам e09860700c Удален мусор 2026-04-06 23:36:42 +03:00
Халимов Рустам 32c9bc43cf Починка импорта, плеер для аудио 2026-04-06 23:35:45 +03:00
Халимов Рустам d96e4ec7d4 Правка импорта 2026-04-06 22:35:54 +03:00
Халимов Рустам 1558b20470 Импорт 2026-04-06 22:20:13 +03:00
Халимов Рустам fa185afc73 Информация о звонках в чате 2026-04-06 01:51:48 +03:00
Халимов Рустам 90096ce2bc Звонки 2026-04-06 01:27:27 +03:00
Халимов Рустам 65d5f5fee9 Звонки 2026-04-05 23:41:55 +03:00
Халимов Рустам e11240f78f Аватар 2026-04-05 01:27:58 +03:00
Халимов Рустам 53f193970c Локализация 2026-04-05 00:34:25 +03:00
Халимов Рустам f41ad0bcf8 Мелкие правки 2026-04-05 00:11:10 +03:00
Халимов Рустам b49ff47762 Истории, обрезка 2026-04-03 23:28:00 +03:00
Халимов Рустам 182bbe2ad8 Обрезка видео 2026-04-03 21:32:02 +03:00
Халимов Рустам a04f04a448 Фиксы по историям 2026-04-03 21:13:28 +03:00
Халимов Рустам 2ab5b295e8 Прокрутка дерганая 2026-04-03 16:57:06 +03:00
Халимов Рустам 1b8abbc995 Переделаны истории 2026-04-03 01:03:32 +03:00
Халимов Рустам 943699139b Разделение сообщения 2026-04-02 22:11:03 +03:00
Халимов Рустам 757142eda7 Аватары заглушки 2026-04-02 15:26:05 +03:00
Халимов Рустам a42007df2d Дизайн 2026-04-02 14:57:32 +03:00
Халимов Рустам 9df7d7aaf1 Анимации, профиль, медиа 2026-04-02 02:37:03 +03:00
Халимов Рустам 4ae7dd60ce Рабочий чат 2026-04-01 23:18:55 +03:00
Халимов Рустам 249c344df8 Файлы, правки отображения 2026-04-01 17:42:29 +03:00
Халимов Рустам 2c6d6f831f Правки админки, вход 2026-03-31 23:55:38 +03:00
Халимов Рустам bce8f92101 Восстановление очистки админки 2026-03-31 21:31:22 +03:00
Халимов Рустам aa69c44a64 Новый стиль 2026-03-31 21:11:05 +03:00
Халимов Рустам 4faa7561a0 Админка рабочая 2026-03-31 15:43:24 +03:00
Халимов Рустам 8025340e45 Админка 2026-03-31 01:54:35 +03:00
Халимов Рустам 60d3cf12ae Сборка 2026-03-31 00:38:34 +03:00
Халимов Рустам b09e197626 Сборка 2026-03-31 00:16:18 +03:00
Халимов Рустам f1f8f6e012 Апи 2026-03-30 23:57:34 +03:00
Халимов Рустам d3f1e3f361 Эндпоинты 2026-03-30 23:41:01 +03:00
Халимов Рустам ce212c11c1 Конфиг 2026-03-30 22:54:31 +03:00
Халимов Рустам 143c0b7dc4 Правки 2026-03-30 22:48:35 +03:00
307 changed files with 14820 additions and 5904 deletions
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\IntegrationTests.Shared\Knot.IntegrationTests.Shared.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Admin\Knot.Modules.Admin.csproj" />
<ProjectReference Include="..\..\..\src\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Modules\Admin\Knot.Modules.Admin.csproj" />
<ProjectReference Include="..\..\..\src\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
<ProjectReference Include="..\..\..\src\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,58 @@
using FluentAssertions;
using Knot.Modules.Admin.Application.Admin.Commands;
using Knot.Modules.Auth.Application.Abstractions;
using Knot.Modules.Auth.Domain;
using Knot.Shared.Kernel;
using NSubstitute;
using Xunit;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Admin.UnitTests;
public class ResetUserPasswordCommandHandlerTests
{
private readonly IAuthUnitOfWork _authUnitOfWork;
private readonly IUserRepository _userRepository;
private readonly ResetUserPasswordCommandHandler _handler;
public ResetUserPasswordCommandHandlerTests()
{
_authUnitOfWork = Substitute.For<IAuthUnitOfWork>();
_userRepository = Substitute.For<IUserRepository>();
_handler = new ResetUserPasswordCommandHandler(_userRepository, _authUnitOfWork);
}
[Fact]
public async Task Handle_ShouldReturnError_WhenUserNotFound()
{
// Arrange
var command = new ResetUserPasswordCommand(Guid.NewGuid(), "NewP@ssw0rd");
_userRepository.GetByIdAsync(command.UserId, Arg.Any<CancellationToken>()).Returns((User?)null);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Be(AuthErrors.UserNotFound);
}
[Fact]
public async Task Handle_ShouldSucceed_WhenUserFound()
{
// Arrange
var user = User.Create("User", "pass", "salt", "admin");
var command = new ResetUserPasswordCommand(user.Id, "NewP@ssw0rd");
_userRepository.GetByIdAsync(command.UserId, Arg.Any<CancellationToken>()).Returns(user);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
await _authUnitOfWork.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="FluentAssertions" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Modules\Auth\Knot.Modules.Auth.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,88 @@
using FluentAssertions;
using NSubstitute;
using Knot.Modules.Auth.Application.Abstractions;
using Knot.Modules.Auth.Application.Users.Login;
using Knot.Modules.Auth.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Auth.Application.Users.Auth;
using Xunit;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Auth.UnitTests;
public class LoginUserCommandHandlerTests
{
private readonly IUserRepository _userRepository;
private readonly IJwtTokenProvider _tokenProvider;
private readonly LoginUserCommandHandler _handler;
public LoginUserCommandHandlerTests()
{
_userRepository = Substitute.For<IUserRepository>();
_tokenProvider = Substitute.For<IJwtTokenProvider>();
_handler = new LoginUserCommandHandler(_userRepository, _tokenProvider);
}
[Fact]
public async Task Handle_ShouldReturnToken_WhenCredentialsAreValid()
{
// Arrange
var password = "password123";
var passwordHash = BCrypt.Net.BCrypt.HashPassword(password);
var user = User.Create("testuser", passwordHash, "Test User", null, null);
var command = new LoginUserCommand("testuser", password);
_userRepository.GetByUsernameAsync(command.Username, Arg.Any<CancellationToken>())
.Returns(user);
_tokenProvider.Generate(user).Returns("valid-jwt-token");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Token.Should().Be("valid-jwt-token");
result.Value.User.Username.Should().Be("testuser");
}
[Fact]
public async Task Handle_ShouldReturnFailure_WhenUserDoesNotExist()
{
// Arrange
var command = new LoginUserCommand("nonexistent", "password123");
_userRepository.GetByUsernameAsync(command.Username, Arg.Any<CancellationToken>())
.Returns((User)null!);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Be(AuthErrors.IdentityInvalidCredentials.Code);
}
[Fact]
public async Task Handle_ShouldReturnFailure_WhenPasswordIsInvalid()
{
// Arrange
var correctPassword = "correctPassword";
var passwordHash = BCrypt.Net.BCrypt.HashPassword(correctPassword);
var user = User.Create("testuser", passwordHash, "Test User", null, null);
var command = new LoginUserCommand("testuser", "wrongPassword");
_userRepository.GetByUsernameAsync(command.Username, Arg.Any<CancellationToken>())
.Returns(user);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Be(AuthErrors.IdentityInvalidCredentials.Code);
}
}
@@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using NSubstitute;
using Xunit;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.Chats.Create;
namespace Knot.Modules.Conversations.UnitTests.Chats;
public class CreateChatCommandHandlerTests
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly CreateChatCommandHandler _handler;
public CreateChatCommandHandlerTests()
{
_chatRepository = Substitute.For<IChatRepository>();
_unitOfWork = Substitute.For<IChatsUnitOfWork>();
_handler = new CreateChatCommandHandler(_chatRepository, _unitOfWork);
}
[Fact]
public async Task Handle_ShouldCreateChatAndAddMembers()
{
// Arrange
var request = new CreateChatCommand("Test Group", ChatType.Group, new List<Guid> { Guid.NewGuid(), Guid.NewGuid() });
// Act
var result = await _handler.Handle(request, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBeEmpty();
_chatRepository.Received(1).Add(Arg.Is<Chat>(c =>
c.Name == "Test Group" &&
c.Type == ChatType.Group &&
c.Members.Count == 2 &&
c.Members.First().Role == ChatRole.Owner &&
c.Members.Last().Role == ChatRole.Member));
await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using NSubstitute;
using Xunit;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.Chats.GetChats;
using Knot.Modules.Conversations.Application.DTOs;
namespace Knot.Modules.Conversations.UnitTests.Chats;
public class GetChatsQueryHandlerTests
{
private readonly IChatRepository _chatRepository;
private readonly IUserDisplayNameProvider _userProvider;
private readonly IMessageRepository _messageRepository;
private readonly IMessageReactionRepository _reactionRepository;
private readonly GetChatsQueryHandler _handler;
public GetChatsQueryHandlerTests()
{
_chatRepository = Substitute.For<IChatRepository>();
_userProvider = Substitute.For<IUserDisplayNameProvider>();
_messageRepository = Substitute.For<IMessageRepository>();
_reactionRepository = Substitute.For<IMessageReactionRepository>();
_handler = new GetChatsQueryHandler(_chatRepository, _userProvider, _messageRepository, _reactionRepository);
}
[Fact]
public async Task Handle_ShouldReturnUserChats_WhenTheyExist()
{
// Arrange
var userId = Guid.NewGuid();
var request = new GetChatsQuery(userId);
var chat1 = Chat.Create("Test Chat", ChatType.Group);
chat1.GetType().GetProperty("Id")?.SetValue(chat1, Guid.NewGuid());
chat1.AddMember(userId, ChatRole.Owner);
var chat2 = Chat.Create("Personal", ChatType.Personal);
chat2.GetType().GetProperty("Id")?.SetValue(chat2, Guid.NewGuid());
chat2.AddMember(userId, ChatRole.Member);
_chatRepository.GetUserChatsAsync(userId, Arg.Any<CancellationToken>())
.Returns(new List<Chat> { chat1, chat2 });
_messageRepository.GetChatMessagesAsync(Arg.Any<Guid>(), 1, 0, Arg.Any<CancellationToken>())
.Returns(new List<Message>());
_userProvider.GetUsersInfoAsync(Arg.Any<IEnumerable<Guid>>(), Arg.Any<CancellationToken>())
.Returns(new Dictionary<Guid, UserInfo>());
// Act
var result = await _handler.Handle(request, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBeNull();
// It always appends synthetic "favorites" chat at the end if not found
result.Value.Count.Should().Be(2);
result.Value.Any(c => c.Name == "Test Chat").Should().BeTrue();
result.Value.Any(c => c.Type == "favorites").Should().BeTrue();
}
}
@@ -0,0 +1,74 @@
using FluentAssertions;
using NSubstitute;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.Chats.GetOrCreateFavorites;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Messaging.Domain;
using Knot.Shared.Kernel;
using Xunit;
namespace Knot.Modules.Conversations.UnitTests;
public class GetOrCreateFavoritesCommandHandlerTests
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly GetOrCreateFavoritesCommandHandler _handler;
public GetOrCreateFavoritesCommandHandlerTests()
{
_chatRepository = Substitute.For<IChatRepository>();
_unitOfWork = Substitute.For<IChatsUnitOfWork>();
_handler = new GetOrCreateFavoritesCommandHandler(_chatRepository, _unitOfWork);
}
[Fact]
public async Task Handle_ShouldReturnExistingChat_WhenFavoritesAlreadyExists()
{
// Arrange
var userId = Guid.NewGuid();
var existingChat = Chat.Create("Избранное", ChatType.Favorites);
_chatRepository.GetFavoritesAsync(userId, Arg.Any<CancellationToken>())
.Returns(existingChat);
var command = new GetOrCreateFavoritesCommand(userId);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Should().Be(existingChat.Id);
_chatRepository.DidNotReceive().Add(Arg.Any<Chat>());
await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_ShouldCreateNewChat_WhenFavoritesDoesNotExist()
{
// Arrange
var userId = Guid.NewGuid();
_chatRepository.GetFavoritesAsync(userId, Arg.Any<CancellationToken>())
.Returns((Chat)null!);
var command = new GetOrCreateFavoritesCommand(userId);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBeEmpty();
_chatRepository.Received(1).Add(Arg.Is<Chat>(c =>
c.Type == ChatType.Favorites &&
c.Name == "Избранное" &&
c.Members.Any(m => m.UserId == userId)));
await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="FluentAssertions" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Modules\Chats\Knot.Modules.Conversations.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Messaging\Knot.Modules.Messaging.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Conversations\Knot.Modules.Conversations.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using MediatR;
using NSubstitute;
using Xunit;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.Messages.Send;
using Knot.Modules.Settings.Application.Settings.Abstractions;
using Knot.Modules.Settings.Application.Settings.DTOs;
namespace Knot.Modules.Conversations.UnitTests.Messages;
public class SendMessageCommandHandlerTests
{
private readonly IChatRepository _chatRepository;
private readonly IMessageRepository _messageRepository;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly IMediator _mediator;
private readonly IMessagesSettings _messagesSettings;
private readonly SendMessageCommandHandler _handler;
public SendMessageCommandHandlerTests()
{
_chatRepository = Substitute.For<IChatRepository>();
_messageRepository = Substitute.For<IMessageRepository>();
_unitOfWork = Substitute.For<IChatsUnitOfWork>();
_mediator = Substitute.For<IMediator>();
_messagesSettings = Substitute.For<IMessagesSettings>();
var config = new Knot.Modules.Settings.Application.Settings.DTOs.MessagesConfig();
_messagesSettings.Current.Returns(config);
_handler = new SendMessageCommandHandler(_chatRepository, _messageRepository, _unitOfWork, _mediator, _messagesSettings);
}
[Fact]
public async Task Handle_ShouldReturnFailure_WhenChatNotFound()
{
// Arrange
var command = new SendMessageCommand(Guid.NewGuid(), Guid.NewGuid(), "Hello", "text");
_chatRepository.GetByIdAsync(command.ChatId, Arg.Any<CancellationToken>())
.Returns((Chat)null!);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Be(ChatErrors.ChatsNotFound.Code);
}
[Fact]
public async Task Handle_ShouldReturnFailure_WhenSenderIsNotMember()
{
// Arrange
var chat = Chat.Create("Test", ChatType.Group);
var command = new SendMessageCommand(chat.Id, Guid.NewGuid(), "Hello", "text");
_chatRepository.GetByIdAsync(command.ChatId, Arg.Any<CancellationToken>())
.Returns(chat);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Be(ChatErrors.ChatsForbidden.Code);
}
[Fact]
public async Task Handle_ShouldCreateMessage_WhenAuthorized()
{
// Arrange
var senderId = Guid.NewGuid();
var chat = Chat.Create("Test", ChatType.Group);
chat.AddMember(senderId, ChatRole.Member);
var command = new SendMessageCommand(chat.Id, senderId, "Hello", "text");
_chatRepository.GetByIdAsync(command.ChatId, Arg.Any<CancellationToken>())
.Returns(chat);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBeEmpty();
_messageRepository.Received(1).Add(Arg.Is<Message>(m =>
m.ChatId == chat.Id &&
m.SenderId == senderId &&
m.Content == "Hello" &&
m.Type == "text"));
await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Modules\Federation\Knot.Modules.Federation.csproj" />
<ProjectReference Include="..\..\..\src\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Modules\Klipy\Knot.Modules.Klipy.csproj" />
<ProjectReference Include="..\..\..\src\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Modules\Relations\Knot.Modules.Relations.csproj" />
<ProjectReference Include="..\..\..\src\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,88 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System.Net.Http;
using Testcontainers.PostgreSql;
using Testcontainers.MongoDb;
using Xunit;
using System.Threading.Tasks;
using System.Collections.Generic;
using Knot.Shared.Kernel;
using NSubstitute;
namespace Knot.IntegrationTests;
public abstract class BaseIntegrationTest : IAsyncLifetime
{
protected readonly PostgreSqlContainer _postgresContainer = new PostgreSqlBuilder()
.WithImage("postgres:15-alpine")
.Build();
protected readonly MongoDbContainer _mongoContainer = new MongoDbBuilder()
.WithImage("mongo:6.0")
.Build();
protected HttpClient _client;
protected WebApplicationFactory<Program> _factory;
protected readonly Guid _userId = Guid.NewGuid();
protected readonly IUserContext _userContextMock = Substitute.For<IUserContext>();
public virtual async Task InitializeAsync()
{
await _postgresContainer.StartAsync();
await _mongoContainer.StartAsync();
_userContextMock.UserId.Returns(_userId);
_userContextMock.IsAuthenticated.Returns(true);
_factory = new IntegrationTestWebApplicationFactory(
_postgresContainer.GetConnectionString(),
_mongoContainer.GetConnectionString(),
_userContextMock);
_client = _factory.CreateClient();
}
public virtual async Task DisposeAsync()
{
await _postgresContainer.StopAsync();
await _mongoContainer.StopAsync();
_factory?.Dispose();
_client?.Dispose();
}
private class IntegrationTestWebApplicationFactory : WebApplicationFactory<Program>
{
private readonly string _pgConnectionString;
private readonly string _mongoConnectionString;
private readonly IUserContext _userContext;
public IntegrationTestWebApplicationFactory(string pg, string mongo, IUserContext userContext)
{
_pgConnectionString = pg;
_mongoConnectionString = mongo;
_userContext = userContext;
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((context, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] = _pgConnectionString,
["ConnectionStrings:MongoConnection"] = _mongoConnectionString,
["DATABASE_URL"] = _pgConnectionString,
["MONGO_CONNECTION"] = _mongoConnectionString,
["KNOT_MASTER_ENCRYPTION_KEY"] = "TestEncryptionKey_32CharactersLong!"
});
});
builder.ConfigureTestServices(services => {
services.AddScoped(_ => _userContext);
});
}
}
}
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.9.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="10.0.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Respawn" Version="7.0.0" />
<PackageReference Include="Testcontainers.MongoDb" Version="4.11.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.11.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Host\Host.csproj" />
<ProjectReference Include="..\..\..\src\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\..\src\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Modules\Storage\Knot.Modules.Storage.csproj" />
<ProjectReference Include="..\..\..\src\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\IntegrationTests.Shared\Knot.IntegrationTests.Shared.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Stories\Knot.Modules.Stories.csproj" />
<ProjectReference Include="..\..\..\src\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,34 @@
using Knot.IntegrationTests;
using FluentAssertions;
using Knot.Modules.Stories.Application.Stories.Commands.CreateStory;
using Knot.Shared.Kernel;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using System.Net;
using System.Net.Http.Json;
using Xunit;
using System;
using System.Threading.Tasks;
namespace Knot.IntegrationTests.Stories;
public class StoriesTests : BaseIntegrationTest
{
[Fact]
public async Task CreateStory_ShouldReturnOk_WhenValidRequest()
{
// Arrange
var request = new CreateStoryRequest("Text", null, "Hello Integration Test", null);
// Act
var response = await _client.PostAsJsonAsync("api/stories", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadFromJsonAsync<dynamic>();
}
}
public record CreateStoryRequest(string Type, string? MediaUrl, string? Content, string? BgColor);
@@ -0,0 +1,76 @@
using FluentAssertions;
using Knot.Modules.Stories.Application.Stories.Commands.CreateStory;
using Knot.Modules.Stories.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Settings.Application.Settings.Abstractions;
using Knot.Modules.Settings.Application.Settings.DTOs;
using Knot.Modules.Stories.Application.Abstractions;
using NSubstitute;
using Xunit;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Stories.UnitTests;
public class CreateStoryCommandHandlerTests
{
private readonly IStoryRepository _storyRepository;
private readonly ISettingsService _settingsService;
private readonly CreateStoryCommandHandler _handler;
public CreateStoryCommandHandlerTests()
{
_storyRepository = Substitute.For<IStoryRepository>();
_settingsService = Substitute.For<ISettingsService>();
_handler = new CreateStoryCommandHandler(_storyRepository, _settingsService);
}
[Fact]
public async Task Handle_ShouldSucceed_WhenValidRequest()
{
// Arrange
var command = new CreateStoryCommand(Guid.NewGuid(), "Text", null, "Hello Story", null);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
await _storyRepository.Received(1).AddAsync(Arg.Any<Story>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_ShouldFail_WhenKlipyDisabledAndKlipyType()
{
// Arrange
var command = new CreateStoryCommand(Guid.NewGuid(), "Klipy", "http://klipy.com/gif", null, null);
var settings = new SystemSettingsDto { Klipy = new KlipyConfig { Enabled = false } };
_settingsService.GetSettingsAsync(Arg.Any<CancellationToken>()).Returns(settings);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Be("Stories.KlipyDisabled");
await _storyRepository.DidNotReceive().AddAsync(Arg.Any<Story>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_ShouldSucceed_WhenKlipyEnabledAndKlipyType()
{
// Arrange
var command = new CreateStoryCommand(Guid.NewGuid(), "Klipy", "http://klipy.com/gif", null, null);
var settings = new SystemSettingsDto { Klipy = new KlipyConfig { Enabled = true } };
_settingsService.GetSettingsAsync(Arg.Any<CancellationToken>()).Returns(settings);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
await _storyRepository.Received(1).AddAsync(Arg.Any<Story>(), Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,76 @@
using FluentAssertions;
using Knot.Modules.Stories.Application.Stories.Commands.DeleteStory;
using Knot.Modules.Stories.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Stories.Application.Abstractions;
using NSubstitute;
using Xunit;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Stories.UnitTests;
public class DeleteStoryCommandHandlerTests
{
private readonly IStoryRepository _storyRepository;
private readonly DeleteStoryCommandHandler _handler;
public DeleteStoryCommandHandlerTests()
{
_storyRepository = Substitute.For<IStoryRepository>();
_handler = new DeleteStoryCommandHandler(_storyRepository);
}
[Fact]
public async Task Handle_ShouldReturnError_WhenStoryNotFound()
{
// Arrange
var command = new DeleteStoryCommand(Guid.NewGuid(), Guid.NewGuid());
_storyRepository.GetByIdAsync(command.StoryId, Arg.Any<CancellationToken>()).Returns((Story?)null);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Be(StoryErrors.StoryNotFound);
}
[Fact]
public async Task Handle_ShouldReturnError_WhenUserIsNotOwner()
{
// Arrange
var ownerId = Guid.NewGuid();
var viewerId = Guid.NewGuid();
var story = Story.Create(ownerId, StoryType.Text, null, "Content", null);
var command = new DeleteStoryCommand(viewerId, story.Id);
_storyRepository.GetByIdAsync(command.StoryId, Arg.Any<CancellationToken>()).Returns(story);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Be(StoryErrors.Unauthorized);
}
[Fact]
public async Task Handle_ShouldSucceed_WhenOwnerDeletes()
{
// Arrange
var ownerId = Guid.NewGuid();
var story = Story.Create(ownerId, StoryType.Text, null, "Content", null);
var command = new DeleteStoryCommand(ownerId, story.Id);
_storyRepository.GetByIdAsync(command.StoryId, Arg.Any<CancellationToken>()).Returns(story);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
await _storyRepository.Received(1).DeleteAsync(story, Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Modules\Stories\Knot.Modules.Stories.csproj" />
<ProjectReference Include="..\..\..\src\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Modules\TelegramImport\Knot.Modules.TelegramImport.csproj" />
<ProjectReference Include="..\..\..\src\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="FluentAssertions" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Modules\Profiles\Knot.Modules.Profiles.csproj" />
<ProjectReference Include="..\..\..\src\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,38 @@
using FluentAssertions;
using Knot.Modules.Profiles.Application.Profiles.UpdateProfile;
using Knot.Modules.Profiles.Domain;
using Knot.Shared.Kernel;
using NSubstitute;
using Xunit;
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Profiles.Application.Abstractions;
namespace Knot.Modules.Profiles.UnitTests;
public class UpdateProfileCommandHandlerTests
{
private readonly IProfileRepository _profileRepository;
private readonly UpdateProfileCommandHandler _handler;
public UpdateProfileCommandHandlerTests()
{
_profileRepository = Substitute.For<IProfileRepository>();
_handler = new UpdateProfileCommandHandler(_profileRepository);
}
[Fact]
public async Task Handle_ShouldReturnError_WhenProfileNotFound()
{
// Arrange
var command = new UpdateProfileCommand(Guid.NewGuid(), "FirstName", "Bio", null);
_profileRepository.GetByIdAsync(command.UserId, Arg.Any<CancellationToken>()).Returns((ProfileDocument?)null);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsFailure.Should().BeTrue();
}
}
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Modules\WebRtc\Knot.Modules.WebRtc.csproj" />
<ProjectReference Include="..\..\..\src\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,7 @@
using Knot.Shared.Kernel;
using System;
using System.Collections.Generic;
namespace Knot.Contracts.Auth.Application.Abstractions;
public record GetUsersExistenceQuery(List<Guid> UserIds) : IQuery<List<Guid>>;
@@ -4,6 +4,7 @@ public interface IJwtTokenProvider
{
string GenerateAccessToken(Guid userId, string username);
string GenerateRefreshToken();
DateTime GetRefreshTokenExpiry();
string Generate(Guid userId, string username, string displayName, string? avatar);
string Generate(Domain.UserContract user);
}
@@ -7,5 +7,7 @@ public static class AuthErrors
public static Error IdentityInvalidCredentials => new("Auth.InvalidCredentials", "Invalid credentials");
public static Error IdentityRegistrationDisabled => new("Auth.RegistrationDisabled", "Registration is disabled");
public static Error IdentityUsernameNotUnique => new("Auth.UsernameNotUnique", "Username is already taken");
public static Error IdentityRegistrationFailed => new("Auth.RegistrationFailed", "Failed to register user");
public static Error RefreshTokenExpired => new("Auth.RefreshTokenExpired", "Refresh token has expired. Please login again.");
public static Error UserNotFound => new("Auth.UserNotFound", "User not found");
}
@@ -1,4 +1,4 @@
namespace Knot.Contracts.Auth.Application.Auth.DTOs;
namespace Knot.Contracts.Auth.Application.Auth.DTOs;
public class AuthResponseDto
{
@@ -7,6 +7,7 @@ public class AuthResponseDto
public Guid UserId { get; set; }
public string Username { get; set; } = string.Empty;
public string? DisplayName { get; set; }
public string? Avatar { get; set; }
}
public class ResetPasswordDto
@@ -19,4 +19,12 @@ public class UserContract
public bool IsExternal { get; set; }
public string? Domain { get; set; }
public DateTime? LastSeen { get; set; }
public string? RefreshToken { get; set; }
public DateTime? RefreshTokenExpiry { get; set; }
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
{
RefreshToken = refreshToken;
RefreshTokenExpiry = expiry;
}
}
@@ -10,9 +10,4 @@ public interface IChatQueryService
Task<List<ChatInfo>> GetAllChatsAsync(CancellationToken cancellationToken);
}
public interface IUserDeleterService
{
Task DeleteUserAsync(Guid userId, CancellationToken cancellationToken);
}
public record ChatInfo(Guid Id, string? Avatar);
@@ -3,9 +3,9 @@ using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
namespace Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Contracts.Conversations.Abstractions;
public interface IUserDeleterService
{
Task<Result> DeleteUserAsync(Guid userId, CancellationToken cancellationToken);
}
}
@@ -36,17 +36,21 @@ public sealed class Chat : AggregateRoot<Guid>
public string? Avatar { get; private set; }
public DateTime CreatedAt { get; private set; }
public long LastMessageSequenceId { get; private set; }
public bool IsImporting { get; private set; }
public Guid? ImportJobId { get; private set; }
private readonly List<ChatMember> _members = new();
public IReadOnlyCollection<ChatMember> Members => _members.AsReadOnly();
private Chat(Guid id, ChatType type, string? name, string? avatar, string? description = null) : base(id)
private Chat(Guid id, ChatType type, string? name, string? avatar, string? description = null, bool isImporting = false, Guid? importJobId = null) : base(id)
{
Type = type;
Name = name;
Avatar = avatar;
Description = description;
CreatedAt = DateTime.UtcNow;
IsImporting = isImporting;
ImportJobId = importJobId;
}
public static Chat CreatePersonal()
@@ -63,13 +67,18 @@ public sealed class Chat : AggregateRoot<Guid>
return chat;
}
public static Chat Create(string? name, ChatType type, string? avatar = null, string? description = null)
public static Chat Create(string? name, ChatType type, string? avatar = null, string? description = null, bool isImporting = false, Guid? importJobId = null)
{
var chat = new Chat(Guid.NewGuid(), type, name, avatar, description);
var chat = new Chat(Guid.NewGuid(), type, name, avatar, description, isImporting, importJobId);
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
return chat;
}
public void CompleteImport()
{
IsImporting = false;
}
public void AddMember(Guid userId, string role = "member")
{
if (_members.Any(m => m.UserId == userId))
@@ -0,0 +1,8 @@
namespace Knot.Contracts.Conversations.Domain;
public static class ChatConstants
{
public const int DefaultMessageQueryLimit = 50;
public const int MaxSharedMediaQueryLimit = 1000;
public const int MaxGroupNameLength = 100;
}
@@ -0,0 +1,19 @@
using Knot.Shared.Kernel;
namespace Knot.Contracts.Conversations.Domain;
public static class ChatErrors
{
public static readonly Error ChatNotFound = new Error("Chat.NotFound", "Чат не найден");
public static readonly Error OnlyOwnerCanUpdate = new Error("Chat.OnlyOwnerCanUpdate", "Только владелец может редактировать чат");
public static readonly Error Unauthorized = new Error("Chat.Unauthorized", "Нет доступа к этому чату");
public static readonly Error FoldersDisabled = new Error("Chat.FoldersDisabled", "Папки отключены");
public static readonly Error FileEmpty = new Error("Chat.FileEmpty", "Файл пуст");
public static Error FileTooLarge(long maxMb) => new Error("Chat.FileTooLarge", $"Файл слишком большой (максимум {maxMb} МБ)");
public static readonly Error ChatsNotFound = new Error("Chat.NotFound", "Чат не найден");
public static readonly Error ChatsForbidden = new Error("Chat.Forbidden", "Доступ запрещен");
public static readonly Error MediaDisabled = new Error("Chat.MediaDisabled", "Медиафайлы отключены");
public static readonly Error PollsDisabled = new Error("Chat.PollsDisabled", "Опросы отключены");
public static readonly Error NotFound = new Error("Chat.NotFound", "Не найдено");
public static readonly Error NotMember = new Error("Chat.NotMember", "Вы не являетесь участником чата");
}
@@ -3,4 +3,5 @@ namespace Knot.Contracts.Messaging.Application.Abstractions;
public interface IMessageNotifier
{
Task NotifyNewMessageAsync(Guid chatId, object messagePayload, CancellationToken cancellationToken);
Task NotifyMessageUpdateAsync(Guid chatId, string updateType, object updatePayload, CancellationToken cancellationToken);
}
@@ -8,9 +8,12 @@ public interface IMessageRepository
Task<Message?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
Task<List<Message>> GetChatMessagesAsync(Guid chatId, int limit, int offset, CancellationToken cancellationToken);
Task<Message?> GetLatestChatMessageAsync(Guid chatId, CancellationToken cancellationToken);
Task<List<Message>> GetPinnedMessagesAsync(Guid chatId, CancellationToken cancellationToken);
Task<List<Message>> SearchMessagesAsync(string query, Guid? chatId, Guid requestingUserId, CancellationToken cancellationToken);
Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, int limit, CancellationToken cancellationToken);
Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, long? sequenceId, int limit, CancellationToken cancellationToken);
Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken);
Task<List<Message>> GetChatMessagesAfterAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken);
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
Task UpdateAsync(Message message, CancellationToken cancellationToken);
@@ -0,0 +1,33 @@
using System;
namespace Knot.Contracts.Messaging.Domain;
public class CallMessage : Message
{
public override string Type => "call";
public override string? Content { get; protected set; }
public string CallType { get; protected set; }
public string CallStatus { get; protected set; }
public int? Duration { get; protected set; }
public CallMessage() : base() { }
public CallMessage(
Guid id,
Guid chatId,
Guid senderId,
string callType,
string callStatus,
int? duration,
Guid? replyToId,
Guid? forwardedFromId,
DateTime createdAt,
bool isImported = false)
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported)
{
CallType = callType;
CallStatus = callStatus;
Duration = duration;
Content = $"Call {callStatus}";
}
}
@@ -0,0 +1,17 @@
using System;
namespace Knot.Contracts.Messaging.Domain;
public class Media
{
public Guid Id { get; set; }
public string Type { get; set; } = string.Empty;
public string? Url { get; set; }
public string? ThumbnailUrl { get; set; }
public long? Size { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public string? FileId { get; set; }
public string? Filename { get; set; }
public string? Duration { get; set; }
}
@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
namespace Knot.Contracts.Messaging.Domain;
public class MediaMessage : Message
{
public override string Type => MediaType.ToString().ToLower();
public override string? Content { get; protected set; }
public string? Caption { get => Content; private set => Content = value; }
public MediaType MediaType { get; private set; }
private List<Media> _media = new();
public IReadOnlyCollection<Media> Media => _media.AsReadOnly();
private MediaMessage() : base() { MediaType = MediaType.File; }
public MediaMessage(Guid id, Guid chatId, Guid senderId, MediaType mediaType, string? caption, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported) { MediaType = mediaType; Caption = caption; }
public MediaMessage(Guid id, Guid chatId, Guid senderId, string mediaType, string? caption, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
: this(id, chatId, senderId, Enum.TryParse<MediaType>(mediaType, true, out var mt) ? mt : MediaType.File, caption, replyToId, forwardedFromId, createdAt, isImported) { }
public void AddMedia(string type, string url, string? filename, long? size, string? duration = null) => _media.Add(new Media { Type = type, Url = url, Filename = filename, FileId = filename, Size = size, Duration = duration });
public override void Edit(string newCaption) => base.Edit(newCaption);
public override void Delete() { Caption = null; base.Delete(); }
}
@@ -16,18 +16,20 @@ public abstract class Message : AggregateRoot<Guid>
public MessageState State { get; protected set; }
public abstract string Type { get; }
public abstract string? Content { get; protected set; }
public virtual string? Quote { get; protected set; } = null;
public virtual Guid? StoryId => null;
public virtual string? StoryMediaUrl => null;
public virtual string? StoryMediaType => null;
public virtual IReadOnlyCollection<Media> Media => Array.Empty<Media>();
public bool IsEdited => HasState(MessageState.IsEdited);
public bool IsDeleted => HasState(MessageState.IsDeleted);
public bool IsDeletedForUser(Guid userId) => _deletedFor.Exists(d => d.UserId == userId);
protected List<DeletedMessage> _deletedFor = new();
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
protected List<Guid> _readByUsers = new();
public IReadOnlyCollection<Guid> ReadByUsers => _readByUsers.AsReadOnly();
protected Message() : base(Guid.Empty) { }
protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported) : base(id)
protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
: base(id)
{
ChatId = chatId;
SenderId = senderId;
@@ -36,91 +38,32 @@ public abstract class Message : AggregateRoot<Guid>
CreatedAt = createdAt;
if (isImported) AddState(MessageState.IsImported);
}
public void AddState(MessageState state) => State |= state;
public void RemoveState(MessageState state) => State &= ~state;
public bool HasState(MessageState state) => (State & state) == state;
public bool IsDeletedForUser(Guid userId) => _deletedFor.Exists(d => d.UserId == userId);
public virtual void Delete() => AddState(MessageState.IsDeleted);
public virtual void Edit(string newContent) { Content = newContent; AddState(MessageState.IsEdited); }
public void DeleteForUser(Guid userId) { if (!_deletedFor.Exists(x => x.UserId == userId)) _deletedFor.Add(new DeletedMessage(Id, userId)); }
}
public class Media
{
public Guid Id { get; set; }
public string Type { get; set; } = string.Empty;
public string? Url { get; set; }
public string? ThumbnailUrl { get; set; }
public long? Size { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public string? FileId { get; set; }
public string? Filename { get; set; }
public string? Duration { get; set; }
}
public class TextMessage : Message
{
public override string Type => "text";
public override string? Content { get; protected set; }
public TextMessage() : base() { }
public TextMessage(Guid id, Guid chatId, Guid senderId, string content, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported = false)
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported) => Content = content;
public TextMessage(Guid id, Guid chatId, Guid senderId, string content, Guid? replyToId, string? quote, Guid? forwardedFromId, DateTime createdAt, bool isImported = false)
: this(id, chatId, senderId, content, replyToId, forwardedFromId, createdAt, isImported) => Quote = quote;
}
public class MediaMessage : Message
{
public override string Type => MediaType.ToString().ToLower();
public override string? Content { get; protected set; }
public string? Caption { get => Content; private set => Content = value; }
public MediaType MediaType { get; private set; }
private List<Media> _media = new();
public override IReadOnlyCollection<Media> Media => _media.AsReadOnly();
private MediaMessage() : base() { MediaType = MediaType.File; }
public MediaMessage(Guid id, Guid chatId, Guid senderId, MediaType mediaType, string? caption, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported) { MediaType = mediaType; Caption = caption; }
public MediaMessage(Guid id, Guid chatId, Guid senderId, string mediaType, string? caption, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
: this(id, chatId, senderId, Enum.TryParse<MediaType>(mediaType, true, out var mt) ? mt : MediaType.File, caption, replyToId, forwardedFromId, createdAt, isImported) { }
public void AddMedia(string type, string url, string? filename, long? size) => _media.Add(new Media { Type = type, Url = url, FileId = filename, Size = size });
public override void Edit(string newCaption) => base.Edit(newCaption);
public override void Delete() { Caption = null; base.Delete(); }
}
public class StoryMessage : Message
{
public override string Type => "story";
public override string? Content { get; protected set; }
public override Guid? StoryId { get; }
public string? InternalStoryMediaUrl { get; private set; }
public override string? StoryMediaUrl => InternalStoryMediaUrl;
public override string? StoryMediaType { get; }
public StoryMessage() : base() { }
public StoryMessage(Guid id, Guid chatId, Guid senderId, Guid storyId, string? storyMediaUrl, string? storyMediaType, DateTime createdAt)
: base(id, chatId, senderId, null, null, createdAt, false) { StoryId = storyId; InternalStoryMediaUrl = storyMediaUrl; StoryMediaType = storyMediaType; }
public StoryMessage(Guid id, Guid chatId, Guid senderId, Guid storyId, string? storyMediaUrl, string? storyMediaType, string? content, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
: this(id, chatId, senderId, storyId, storyMediaUrl, storyMediaType, createdAt) { Content = content; ReplyToId = replyToId; ForwardedFromId = forwardedFromId; if (isImported) AddState(MessageState.IsImported); }
}
public class PollMessage : Message
{
public override string Type => "poll";
public override string? Content { get; protected set; }
public List<PollOption> Options { get; } = new();
public List<PollVote> Votes { get; } = new();
public bool IsMultipleChoice { get; set; }
public DateTime? ExpiresAt { get; set; }
public bool IsClosed { get; set; }
public PollMessage() : base() { }
public PollMessage(Guid id, Guid chatId, Guid senderId, string? question, List<string>? options, bool isAnonymous, bool isMultiple, DateTime? expiresAt, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported)
public virtual void Edit(string newContent)
{
Content = question ?? "Poll";
if (options != null) foreach (var opt in options) Options.Add(new PollOption { Text = opt });
IsMultipleChoice = isMultiple;
ExpiresAt = expiresAt;
Content = newContent;
AddState(MessageState.IsEdited);
}
}
public class PollOption { public string Text { get; set; } = string.Empty; public int VoteCount { get; set; } }
public class PollVote { public Guid OptionIndex { get; set; } public Guid UserId { get; set; } public DateTime VotedAt { get; set; } }
public void DeleteForUser(Guid userId)
{
if (!_deletedFor.Exists(x => x.UserId == userId))
_deletedFor.Add(new DeletedMessage(Id, userId));
}
public void MarkAsRead(Guid userId)
{
if (!_readByUsers.Contains(userId))
{
_readByUsers.Add(userId);
}
}
public bool IsReadBy(Guid userId) => _readByUsers.Contains(userId);
}
@@ -0,0 +1,38 @@
using System;
using System.Collections.Generic;
namespace Knot.Contracts.Messaging.Domain;
public class PollMessage : Message
{
public override string Type => "poll";
public override string? Content { get; protected set; }
public List<PollOption> Options { get; set; } = new();
public List<PollVote> Votes { get; set; } = new();
public bool IsMultipleChoice { get; set; }
public bool IsAnonymous { get; set; }
public DateTime? ExpiresAt { get; set; }
public bool IsClosed { get; set; }
public PollMessage() : base() { }
public PollMessage(Guid id, Guid chatId, Guid senderId, string? question, List<PollOption>? options, bool isAnonymous, bool isMultiple, DateTime? expiresAt, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported)
{
Content = question ?? "Poll";
Options = options ?? new List<PollOption>();
IsAnonymous = isAnonymous;
IsMultipleChoice = isMultiple;
ExpiresAt = expiresAt;
}
public static PollMessage Create(Guid id, Guid chatId, Guid senderId, string? question, List<string> options, bool isAnonymous, bool isMultiple, DateTime? expiresAt, Guid? replyToId, Guid? forwardedFromId)
{
var poll = new PollMessage(id, chatId, senderId, question, null, isAnonymous, isMultiple, expiresAt, replyToId, forwardedFromId, DateTime.UtcNow, false);
foreach (var opt in options)
{
poll.Options.Add(new PollOption { Text = opt });
}
return poll;
}
}
@@ -0,0 +1,8 @@
namespace Knot.Contracts.Messaging.Domain;
public class PollOption
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Text { get; set; } = string.Empty;
public int VoteCount { get; set; }
}
@@ -0,0 +1,10 @@
using System;
namespace Knot.Contracts.Messaging.Domain;
public class PollVote
{
public Guid OptionId { get; set; }
public Guid UserId { get; set; }
public DateTime VotedAt { get; set; }
}
@@ -0,0 +1,31 @@
using System;
namespace Knot.Contracts.Messaging.Domain;
public class StoryMessage : Message
{
public override string Type => "story";
public override string? Content { get; protected set; }
public Guid? StoryId { get; private set; }
public string? StoryMediaUrl { get; private set; }
public string? StoryMediaType { get; private set; }
public StoryMessage() : base() { }
public StoryMessage(Guid id, Guid chatId, Guid senderId, Guid storyId, string? storyMediaUrl, string? storyMediaType, DateTime createdAt)
: base(id, chatId, senderId, null, null, createdAt, false)
{
StoryId = storyId;
StoryMediaUrl = storyMediaUrl;
StoryMediaType = storyMediaType;
}
public StoryMessage(Guid id, Guid chatId, Guid senderId, Guid storyId, string? storyMediaUrl, string? storyMediaType, string? content, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
: this(id, chatId, senderId, storyId, storyMediaUrl, storyMediaType, createdAt)
{
Content = content;
ReplyToId = replyToId;
ForwardedFromId = forwardedFromId;
if (isImported) AddState(MessageState.IsImported);
}
}
@@ -0,0 +1,18 @@
using System;
namespace Knot.Contracts.Messaging.Domain;
public class TextMessage : Message
{
public override string Type => "text";
public override string? Content { get; protected set; }
public string? Quote { get; protected set; }
public TextMessage() : base() { }
public TextMessage(Guid id, Guid chatId, Guid senderId, string content, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported = false)
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported) => Content = content;
public TextMessage(Guid id, Guid chatId, Guid senderId, string content, Guid? replyToId, string? quote, Guid? forwardedFromId, DateTime createdAt, bool isImported = false)
: this(id, chatId, senderId, content, replyToId, forwardedFromId, createdAt, isImported) => Quote = quote;
}
@@ -1,14 +1,16 @@
namespace Knot.Contracts.Profiles.Application.DTOs;
namespace Knot.Contracts.Profiles.Application.DTOs;
public class UserProfileDto
{
public Guid UserId { get; set; }
public Guid Id { get => UserId; set => UserId = value; }
public string? DisplayName { get; set; }
public string? Username { get; set; }
public string? About { get; set; }
public string? Avatar { get; set; }
public bool IsBot { get; set; }
public DateTime? LastSeen { get; set; }
public DateTime? Birthday { get; set; }
public bool IsPremium { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -1,4 +1,4 @@
using Knot.Contracts.Profiles.Application.DTOs;
using Knot.Contracts.Profiles.Application.DTOs;
using Knot.Shared.Kernel;
namespace Knot.Contracts.Profiles.Domain;
@@ -12,4 +12,5 @@ public interface IProfileRepository
Task<Result<UserProfileDto>> CreateAsync(UserProfileDto dto, CancellationToken cancellationToken = default);
Task<Result<UserProfileDto>> UpdateAsync(UserProfileDto dto, CancellationToken cancellationToken = default);
Task<Result> DeleteAsync(Guid userId, CancellationToken cancellationToken = default);
Task<Result> UpdateStatusAsync(Guid userId, bool isBanned, bool isDeleted, CancellationToken ct = default);
}
@@ -0,0 +1,7 @@
using Knot.Shared.Kernel;
using System;
using System.Collections.Generic;
namespace Knot.Contracts.Relations.Application.Contacts;
public record CheckBlockedStatusQuery(Guid UserId, List<Guid> CandidateIds) : IQuery<List<Guid>>;
@@ -0,0 +1,7 @@
using Knot.Shared.Kernel;
using System;
using System.Collections.Generic;
namespace Knot.Contracts.Relations.Application.Contacts;
public record GetBlockedUserIdsQuery(Guid UserId) : IQuery<List<Guid>>;
@@ -1,37 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Contracts.Settings.Abstractions;
public interface IStatisticsService
{
Task<DashboardStatsDto> GetDashboardStatsAsync(CancellationToken cancellationToken = default);
}
public class DashboardStatsDto
{
public long StorageUsedBytes { get; set; }
public long StorageLimitBytes { get; set; }
public long OnlineUsers { get; set; }
public long OfflineUsers { get; set; }
public long TotalUsers { get; set; }
public List<ActivityStatDto> ActivityTimeline { get; set; } = new();
public List<TopUserDto> TopUsersByMessages { get; set; } = new();
public List<TopUserDto> TopUsersByStorage { get; set; } = new();
}
public class ActivityStatDto
{
public DateTime Date { get; set; }
public long Messages { get; set; }
public long FilesSize { get; set; }
}
public class TopUserDto
{
public Guid UserId { get; set; }
public string Username { get; set; } = string.Empty;
public long Value { get; set; } // messages count or bytes
}
@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
namespace Knot.Contracts.Settings.Application.DTOs;
@@ -53,6 +53,7 @@ public class MessagesConfig
public class WebRtcConfig
{
public bool Enabled { get; set; } = false;
public bool EnableVoiceCalls { get; set; } = true;
public bool EnableVideoCalls { get; set; } = true;
public bool EnableScreenSharing { get; set; } = true;
public string TurnHost { get; set; } = string.Empty;
-1
View File
@@ -8,7 +8,6 @@
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.4.0" />
<PackageReference Include="Carter" Version="10.0.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="Mapster" Version="7.4.0" />
+72 -32
View File
@@ -1,31 +1,47 @@
using Knot.Modules.Messaging;
using Carter;
using Knot.Shared.Infrastructure;
using Knot.Modules.Auth;
using Knot.Modules.Profiles;
using Knot.Modules.Settings;
using Knot.Modules.Admin;
using Knot.Modules.Conversations;
using Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Storage;
using Knot.Modules.Stories;
using Knot.Modules.Klipy;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.FileProviders;
using Knot.Modules.Admin;
using Knot.Modules.Admin.Presentation.Endpoints;
using Knot.Modules.Auth;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Modules.Auth.Presentation.Endpoints;
using Knot.Modules.Conversations;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Modules.Conversations.Presentation.Endpoints;
using Knot.Modules.Federation;
using Knot.Modules.Federation.Presentation.Endpoints;
using Knot.Modules.Klipy;
using Knot.Modules.Klipy.Presentation.Endpoints;
using Knot.Modules.Messaging;
using Knot.Modules.Profiles;
using Knot.Modules.Profiles.Presentation.Endpoints;
using Knot.Modules.Relations;
using Knot.Modules.Relations.Presentation.Endpoints;
using Knot.Modules.Settings;
using Knot.Modules.Settings.Presentation.Endpoints;
using Knot.Modules.Storage;
using Knot.Modules.Storage.Presentation.Endpoints;
using Knot.Modules.Stories;
using Knot.Modules.Stories.Presentation.Endpoints;
using Knot.Modules.TelegramImport;
using Knot.Modules.TelegramImport.Presentation.Endpoints;
using Knot.Modules.WebRtc;
using Knot.Modules.WebRtc.Presentation.Endpoints;
using Knot.Shared.Infrastructure;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.SignalR;
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using MediatR;
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var builder = WebApplication.CreateBuilder(args);
@@ -33,7 +49,7 @@ var builder = WebApplication.CreateBuilder(args);
// Маппинг стандартных переменных окружения в иерархию .NET
var envMappings = new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] = builder.Configuration["DATABASE_URL"] ?? "Host=localhost;Database=knot;Username=postgres;Password=postgres",
["ConnectionStrings:DefaultConnection"] = builder.Configuration["DATABASE_URL"] ?? builder.Configuration.GetConnectionString("DefaultConnection") ?? "Host=localhost;Database=knot;Username=postgres;Password=postgres",
["ConnectionStrings:MongoConnection"] = builder.Configuration["MONGO_CONNECTION"],
["Jwt:Secret"] = builder.Configuration["JWT_SECRET"],
["Jwt:Issuer"] = builder.Configuration["JWT_ISSUER"],
@@ -60,10 +76,13 @@ builder.Services.AddSettingsModule(builder.Configuration);
builder.Services.AddMessagingModule(builder.Configuration);
builder.Services.AddConversationsModule(builder.Configuration);
builder.Services.AddProfilesModule(builder.Configuration);
builder.Services.AddRelationsModule(builder.Configuration);
builder.Services.AddStorageModule(builder.Configuration);
builder.Services.AddStoriesModule(builder.Configuration);
builder.Services.AddKlipyModule();
builder.Services.AddAdminModule();
builder.Services.AddWebRtcModule();
builder.Services.AddTelegramImportModule();
builder.Services.AddSharedInfrastructure(builder.Configuration);
// CQRS / MediatR для команд в Host (например, AdminController)
@@ -74,12 +93,14 @@ builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(
typeof(Knot.Modules.Messaging.DependencyInjection).Assembly,
typeof(Knot.Modules.Conversations.DependencyInjection).Assembly,
typeof(Knot.Modules.Stories.DependencyInjection).Assembly,
typeof(Knot.Modules.Klipy.DependencyInjection).Assembly
typeof(Knot.Modules.Klipy.DependencyInjection).Assembly,
typeof(Knot.Modules.Relations.DependencyInjection).Assembly,
typeof(Knot.Modules.WebRtc.DependencyInjection).Assembly,
typeof(Knot.Modules.TelegramImport.DependencyInjection).Assembly,
typeof(Knot.Modules.Auth.Infrastructure.Persistence.AuthDbContext).Assembly,
typeof(Knot.Modules.Profiles.DependencyInjection).Assembly
));
// Carter для вызова Minimal APIs (Endpoints)
builder.Services.AddCarter();
// Настройка CORS
builder.Services.AddCors(options =>
{
@@ -87,9 +108,9 @@ builder.Services.AddCors(options =>
{
var originsFromConfig = builder.Configuration["Cors:Origins"];
var domain = builder.Configuration["DOMAIN"];
var origins = !string.IsNullOrEmpty(originsFromConfig)
? originsFromConfig.Split(',')
var origins = !string.IsNullOrEmpty(originsFromConfig)
? originsFromConfig.Split(',')
: (!string.IsNullOrEmpty(domain) ? new[] { $"https://{domain}" } : new[] { "*" });
var corsBuilder = policy.WithOrigins(origins)
@@ -111,7 +132,7 @@ builder.Services.AddCors(options =>
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Настройка маршрутизации
builder.Services.AddRouting(options =>
builder.Services.AddRouting(options =>
{
options.LowercaseUrls = true;
options.LowercaseQueryStrings = true;
@@ -175,7 +196,7 @@ using (var scope = app.Services.CreateScope())
{
var identityDb = scope.ServiceProvider.GetRequiredService<AuthDbContext>();
await identityDb.Database.MigrateAsync();
var chatsDb = scope.ServiceProvider.GetRequiredService<Knot.Modules.Conversations.Infrastructure.Persistence.ChatsDbContext>();
await chatsDb.Database.MigrateAsync();
@@ -185,6 +206,9 @@ using (var scope = app.Services.CreateScope())
var storiesDb = scope.ServiceProvider.GetRequiredService<Knot.Modules.Stories.Infrastructure.Database.StoriesDbContext>();
await storiesDb.Database.MigrateAsync();
var relationsDb = scope.ServiceProvider.GetRequiredService<Knot.Modules.Relations.Infrastructure.Persistence.RelationsDbContext>();
await relationsDb.Database.MigrateAsync();
// Set Encryption Service for MongoDB serializers
var encryptionService = scope.ServiceProvider.GetRequiredService<Knot.Shared.Kernel.Security.IEncryptionService>();
Knot.Modules.Messaging.Infrastructure.Persistence.Mongo.EncryptedStringSerializer.EncryptionService = encryptionService;
@@ -195,6 +219,10 @@ using (var scope = app.Services.CreateScope())
{
concreteSettings.Initialize();
}
// Sync user replicas for Relations module on startup
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
await mediator.Send(new Knot.Modules.Relations.Application.Contacts.SyncReplicasCommand());
}
// Настройка конвейера запросов
@@ -216,8 +244,20 @@ if (app.Environment.IsDevelopment())
app.UseAuthentication();
app.UseAuthorization();
// Добавляем контроллеры и Carter (Minimal APIs)
app.MapCarter();
// Регистрация эндпоинтов
app.MapAuthEndpoints();
app.MapStoriesEndpoints();
app.MapContactsEndpoints();
app.MapSettingsEndpoints();
app.MapProfilesEndpoints();
app.MapFederationEndpoints();
app.MapKlipyEndpoints();
app.MapChatsEndpoints();
app.MapMessagesEndpoints();
app.MapAdminEndpoints();
app.MapFilesEndpoints();
app.MapWebRtcEndpoints();
app.MapTelegramImportEndpoints();
// Добавляем SignalR хабы
app.MapHub<ChatHub>("/hubs/chat");
+3 -2
View File
@@ -13,7 +13,8 @@
"Secret": "knot_super_secret_key_1234567890_knot",
"Issuer": "Knot",
"Audience": "KnotUsers",
"ExpiryInMinutes": 1440
"ExpiryInMinutes": 1440,
"RefreshExpiryInDays": 30
},
"KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot"
}
}
@@ -8,7 +8,7 @@ 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.Shared.Kernel.Storage;
using Knot.Contracts.Stories.Infrastructure.Persistence;
using Knot.Modules.Admin.Application.Admin.DTOs;
using MediatR;
@@ -51,7 +51,7 @@ internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand,
var orphanMessages = await _messageQueryService.GetOrphanedMessagesAsync(activeChatIds, cancellationToken);
var keptMessages = allMessages
.Where(m => !orphanMessages.Any(om => om.Id == m.Id))
.Where(m => !orphanMessages.Any(om => om.Id == m.Id) && !m.IsDeleted)
.ToList();
var allMinioFiles = (await _fileStorage.ListFilesAsync()).ToList();
@@ -0,0 +1,68 @@
using BCrypt.Net;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Domain;
using Knot.Modules.Admin.Application.Admin.DTOs;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Admin.Application.Admin.Commands.CreateUser;
public record CreateUserCommand(
string Username,
string Password,
string DisplayName,
string? Email = null,
string? Bio = null
) : ICommand<AdminUserDto>;
internal sealed class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, AdminUserDto>
{
private readonly IUserRepository _userRepository;
private readonly IAuthUnitOfWork _unitOfWork;
public CreateUserCommandHandler(IUserRepository userRepository, IAuthUnitOfWork unitOfWork)
{
_userRepository = userRepository;
_unitOfWork = unitOfWork;
}
public async Task<Result<AdminUserDto>> Handle(CreateUserCommand request, CancellationToken cancellationToken)
{
// Проверка уникальности username
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
{
return Result.Failure<AdminUserDto>(new Error("Admin.CreateUser.UsernameNotUnique", "Username is already taken"));
}
// Хеширование пароля
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
// Создание контракта пользователя
var userContract = new UserContract
{
Id = Guid.NewGuid(),
Username = request.Username,
PasswordHash = passwordHash,
DisplayName = request.DisplayName,
Email = request.Email,
Bio = request.Bio,
CreatedAt = DateTime.UtcNow,
IsBanned = false,
IsOnline = false
};
_unitOfWork.Add(userContract);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(new AdminUserDto(
userContract.Id,
userContract.Username,
userContract.DisplayName,
userContract.Email,
userContract.Avatar,
userContract.CreatedAt,
userContract.IsOnline,
userContract.LastSeen ?? DateTime.UtcNow,
userContract.IsBanned));
}
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@@ -8,6 +8,7 @@ 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.Shared.Kernel.Storage;
using Knot.Contracts.Stories.Infrastructure.Persistence;
using Knot.Modules.Admin.Application.Admin.DTOs;
using MediatR;
@@ -23,17 +24,20 @@ internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery,
private readonly IAuthDbContext _authDbContext;
private readonly IChatsDbContext _chatsDbContext;
private readonly IStoryCollection _storyCollection;
private readonly IFileStorageService _fileStorage;
public CleanDryRunQueryHandler(
Knot.Contracts.Messaging.Application.Abstractions.IMessageQueryService messageService,
IAuthDbContext authDbContext,
IChatsDbContext chatsDbContext,
IStoryCollection storyCollection)
IStoryCollection storyCollection,
IFileStorageService fileStorage)
{
_messageService = messageService;
_authDbContext = authDbContext;
_chatsDbContext = chatsDbContext;
_storyCollection = storyCollection;
_fileStorage = fileStorage;
}
public async Task<Result<CleanDryRunResult>> Handle(CleanDryRunQuery request, CancellationToken ct)
@@ -46,25 +50,42 @@ internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery,
var orphanedMediaCount = orphanedMessages.Count(m => m.MediaUrl != null);
var orphanedMessageCount = orphanedMessages.Count;
var validIds = new HashSet<string>();
foreach (var msg in orphanedMessages.Where(m => m.MediaUrl != null))
{
var parts = msg.MediaUrl.Split('/');
var fileId = parts.LastOrDefault();
if (!string.IsNullOrEmpty(fileId))
{
validIds.Add(fileId);
}
}
var allMessages = await _messageService.GetAllMessagesAsync(ct);
var keptMessages = allMessages
.Where(m => !orphanedMessages.Any(om => om.Id == m.Id) && !m.IsDeleted)
.ToList();
var allUsers = await _authDbContext.Users.ToListAsync(ct);
var stories = await _storyCollection.GetAllAsync(ct);
var validUrls = new HashSet<string>();
var activeMessageUrls = keptMessages.Where(m => m.Media != null).SelectMany(m => m.Media!).Select(x => x.Url).Where(u => !string.IsNullOrEmpty(u));
var activeChatUrls = _chatsDbContext.Chats.Select(c => c.Avatar).Where(u => !string.IsNullOrEmpty(u));
var activeUserUrls = allUsers.Select(u => u.Avatar).Where(u => !string.IsNullOrEmpty(u));
var activeStoryUrls = stories.Select(s => s.MediaUrl).Where(u => !string.IsNullOrEmpty(u));
foreach (var u in activeMessageUrls) validUrls.Add(u!);
foreach (var u in activeChatUrls) validUrls.Add(u!);
foreach (var u in activeUserUrls) validUrls.Add(u!);
foreach (var u in activeStoryUrls) validUrls.Add(u!);
var validFileIds = validUrls
.Where(u => u.Contains("/api/files/"))
.Select(u => u.Split('/').Last())
.ToHashSet();
var allMinioFiles = await _fileStorage.ListFilesAsync();
long orphanedFileSize = allMinioFiles
.Where(f => !validFileIds.Contains(f.FileId))
.Sum(f => f.Size);
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 CleanDryRunResult(
orphanedMessageCount,
orphanedMediaCount,
0,
orphanedFileSize,
expiredStoriesCount,
expiredStoriesSize
));
@@ -1,8 +1,8 @@
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 Knot.Shared.Kernel.Services;
using MediatR;
namespace Knot.Modules.Admin.Application.Admin.Queries;
@@ -1,11 +1,9 @@
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.Conversations.Abstractions;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Modules.Admin.Application.Admin.DTOs;
using MediatR;
@@ -18,12 +16,12 @@ internal sealed class GetUserDetailsQueryHandler : IQueryHandler<GetUserDetailsQ
{
private readonly IUserRepository _userRepository;
private readonly Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService _statsService;
private readonly Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService _statusService;
private readonly IUserStatusService _statusService;
public GetUserDetailsQueryHandler(
IUserRepository userRepository,
Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService statsService,
Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService statusService)
IUserStatusService statusService)
{
_userRepository = userRepository;
_statsService = statsService;
@@ -5,7 +5,7 @@ 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.Conversations.Abstractions;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Modules.Admin.Application.Admin.DTOs;
using MediatR;
@@ -18,12 +18,12 @@ internal sealed class SearchUsersQueryHandler : IQueryHandler<SearchUsersQuery,
{
private readonly IUserRepository _userRepository;
private readonly Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService _statsService;
private readonly Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService _statusService;
private readonly IUserStatusService _statusService;
public SearchUsersQueryHandler(
IUserRepository userRepository,
Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService statsService,
Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService statusService)
IUserStatusService statusService)
{
_userRepository = userRepository;
_statsService = statsService;
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
@@ -18,8 +18,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Carter" Version="10.0.0" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="BCrypt.Net-Next" Version="4.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
</ItemGroup>
</Project>
@@ -1,7 +1,9 @@
using Carter;
using System.Text.Json.Serialization;
using Knot.Contracts.Conversations.Abstractions;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs;
using Knot.Modules.Admin.Application.Admin.Commands;
using Knot.Modules.Admin.Application.Admin.Commands.CreateUser;
using Knot.Modules.Admin.Application.Admin.Commands.TestKlipy;
using Knot.Modules.Admin.Application.Admin.Queries;
using MediatR;
@@ -10,14 +12,16 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
namespace Knot.Host.Presentation.Endpoints;
namespace Knot.Modules.Admin.Presentation.Endpoints;
public record KlipyTestDto(string ApiKey, string AppName);
public record KlipyTestDto(
[property: JsonPropertyName("apiKey")] string ApiKey,
[property: JsonPropertyName("appName")] string AppName);
public record ResetPasswordRequest(string NewPassword);
public sealed class AdminEndpoints : ICarterModule
public static class AdminEndpoints
{
public void AddRoutes(IEndpointRouteBuilder app)
public static void MapAdminEndpoints(this WebApplication app)
{
var group = app.MapGroup("api/admin"); // Middleware handles auth
@@ -35,7 +39,16 @@ public sealed class AdminEndpoints : ICarterModule
group.MapPost("settings/test-klipy", async ([FromBody] KlipyTestDto dto, ISender sender, CancellationToken ct) =>
{
Console.WriteLine($"[Admin] TestKlipy received: ApiKey={(string.IsNullOrEmpty(dto.ApiKey) ? "EMPTY" : "present")}, AppName={(string.IsNullOrEmpty(dto.AppName) ? "EMPTY" : dto.AppName)}");
if (string.IsNullOrEmpty(dto.ApiKey) || string.IsNullOrEmpty(dto.AppName))
{
Console.WriteLine($"[Admin] TestKlipy: Missing required fields - ApiKey={dto?.ApiKey}, AppName={dto?.AppName}");
return Results.BadRequest(new { error = "ApiKey and AppName are required" });
}
var result = await sender.Send(new TestKlipyConnectionCommand(dto.ApiKey, dto.AppName), ct);
Console.WriteLine($"[Admin] TestKlipy result: IsSuccess={result.IsSuccess}, Error={result.Error?.Description}");
return result.IsSuccess ? Results.Ok(new { success = true }) : Results.BadRequest(new { error = result.Error.Description });
});
@@ -45,7 +58,13 @@ public sealed class AdminEndpoints : ICarterModule
return Results.Ok(result.Value);
});
group.MapPost("users/{userId:guid}/reset-password", async (Guid userId, [FromBody] ResetPasswordRequest dto, ISender sender, CancellationToken ct) =>
group.MapPost("users", async ([FromBody] CreateUserCommand command, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(command, ct);
return result.IsSuccess ? Results.Created($"/api/admin/users/{result.Value.Id}", result.Value) : Results.BadRequest(new { error = result.Error.Description });
});
group.MapPost("users/{userId:guid}/reset-password", async ([FromRoute] Guid userId, [FromBody] ResetPasswordRequest dto, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new ResetUserPasswordCommand(userId, dto.NewPassword), ct);
if (result.IsFailure)
@@ -56,34 +75,51 @@ public sealed class AdminEndpoints : ICarterModule
return Results.Ok(result.Value);
});
group.MapPost("users/{userId:guid}/ban", async (Guid userId, ISender sender, CancellationToken ct) =>
group.MapPost("users/{userId:guid}/ban", async ([FromRoute] Guid userId, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new BanUserCommand(userId), ct);
return result.IsSuccess ? Results.Ok() : Results.BadRequest(new { error = result.Error.Description });
});
group.MapPost("users/{userId:guid}/unban", async (Guid userId, ISender sender, CancellationToken ct) =>
group.MapPost("users/{userId:guid}/unban", async ([FromRoute] Guid userId, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new UnbanUserCommand(userId), ct);
return result.IsSuccess ? Results.Ok() : Results.BadRequest(new { error = result.Error.Description });
});
group.MapGet("users", async (ISender sender, [FromQuery] string query = "", CancellationToken ct = default) =>
group.MapGet("users", async (ISender sender, [FromQuery] string? query, CancellationToken ct = default) =>
{
var result = await sender.Send(new SearchUsersQuery(query), ct);
if (!result.IsSuccess)
// При пустом или отсутствующем query возвращаем всех пользователей
var searchQuery = string.IsNullOrWhiteSpace(query) ? "" : query;
try
{
return Results.BadRequest(new { error = result.Error.Description });
var result = await sender.Send(new SearchUsersQuery(searchQuery), ct);
if (!result.IsSuccess)
{
Console.WriteLine($"[Admin] SearchUsers error: {result.Error.Code} - {result.Error.Description}");
return Results.BadRequest(new { error = result.Error.Description });
}
return Results.Ok(result.Value);
}
catch (Exception ex)
{
Console.WriteLine($"[Admin] SearchUsers exception: {ex}");
return Results.BadRequest(new { error = ex.Message });
}
return Results.Ok(result.Value);
});
group.MapGet("users/{id:guid}", async (Guid id, ISender sender, CancellationToken ct) =>
group.MapGet("users/{id:guid}", async ([FromRoute] Guid id, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new GetUserDetailsQuery(id), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound("User not found");
});
group.MapDelete("users/{userId:guid}", async ([FromRoute] Guid userId, IUserDeleterService userDeleter, CancellationToken ct) =>
{
var result = await userDeleter.DeleteUserAsync(userId, ct);
return result.IsSuccess ? Results.Ok() : Results.BadRequest(new { error = result.Error.Description });
});
group.MapGet("clean/dry-run", async (ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new CleanDryRunQuery(), ct);
@@ -95,6 +131,17 @@ public sealed class AdminEndpoints : ICarterModule
return Results.Ok(result.Value);
});
group.MapPost("clean/run", async (ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new CleanRunCommand(), ct);
if (!result.IsSuccess)
{
Console.WriteLine($"[Admin] Cleanup Run Error: {result.Error.Description}");
return Results.BadRequest(new { error = result.Error.Description });
}
return Results.Ok(result.Value);
});
group.MapGet("timezones", () =>
{
// Получаем все системные часовые пояса и формируем удобный для фронтенда формат
@@ -0,0 +1,14 @@
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Auth.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace Knot.Modules.Auth.Application.Abstractions;
public interface IAuthDbContext
{
DbSet<User> Users { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,11 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Auth.Application.Abstractions;
/// <summary>
/// Unit of Work специфичный для модуля Identity.
/// </summary>
public interface IAuthUnitOfWork : IUnitOfWork
{
}
@@ -0,0 +1,12 @@
using Knot.Modules.Auth.Domain;
namespace Knot.Modules.Auth.Application.Abstractions;
public interface IJwtTokenProvider
{
string Generate(User user);
string Generate(Guid userId, string username, string displayName, string? avatar);
string GenerateRefreshToken();
DateTime GetRefreshTokenExpiry();
}
@@ -0,0 +1,21 @@
using System;
namespace Knot.Modules.Auth.Application.Auth.DTOs;
public record AuthResponseDto(
string Token,
AuthUserDto User
);
public record AuthUserDto(
Guid Id,
string Username,
string DisplayName,
string? Email,
string? Bio,
string? Avatar,
DateTime? Birthday,
bool IsOnline,
DateTime CreatedAt
);
@@ -0,0 +1,21 @@
using System;
namespace Knot.Modules.Auth.Application.Users.Auth;
public record AuthResponseDto(
string Token,
AuthUserDto User
);
public record AuthUserDto(
Guid Id,
string Username,
string DisplayName,
string? Email,
string? Bio,
string? Avatar,
DateTime? Birthday,
bool IsOnline,
DateTime CreatedAt
);
@@ -1,6 +1,8 @@
using Knot.Shared.Kernel;
using Knot.Contracts.Auth.Domain;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Shared.Kernel;
namespace Knot.Modules.Auth.Application.Users.GetMe;
@@ -9,10 +11,12 @@ public sealed record GetMeQuery(Guid UserId) : IQuery<AuthResponseDto>;
internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponseDto>
{
private readonly IUserRepository _userRepository;
private readonly IJwtTokenProvider _tokenProvider;
public GetMeQueryHandler(IUserRepository userRepository)
public GetMeQueryHandler(IUserRepository userRepository, IJwtTokenProvider tokenProvider)
{
_userRepository = userRepository;
_tokenProvider = tokenProvider;
}
public async Task<Result<AuthResponseDto>> Handle(GetMeQuery request, CancellationToken cancellationToken)
@@ -23,13 +27,23 @@ internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponse
return Result.Failure<AuthResponseDto>(AuthErrors.UserNotFound);
}
// Check if access token needs to be refreshed (less than 1 hour remaining)
string? newAccessToken = null;
// We can't directly check the current token's expiry here, but we can
// always issue a new token if the user is authenticated
// For now, let's issue a new token on every request (simplified approach)
// A better approach would be to parse the incoming token and check expiry
newAccessToken = _tokenProvider.Generate(user);
var response = new AuthResponseDto
{
AccessToken = string.Empty,
AccessToken = newAccessToken,
RefreshToken = string.Empty,
UserId = user.Id,
Username = user.Username,
DisplayName = user.DisplayName
DisplayName = user.DisplayName,
Avatar = user.Avatar
};
return Result.Success(response);
@@ -0,0 +1,31 @@
using Knot.Shared.Kernel;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Domain;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Auth.Application.Users;
internal sealed class GetUsersExistenceQueryHandler : IQueryHandler<GetUsersExistenceQuery, List<Guid>>
{
private readonly IAuthDbContext _context;
public GetUsersExistenceQueryHandler(IAuthDbContext context)
{
_context = context;
}
public async Task<Result<List<Guid>>> Handle(GetUsersExistenceQuery request, CancellationToken cancellationToken)
{
var existingIds = await _context.Set<Knot.Modules.Auth.Domain.User>()
.Where(u => request.UserIds.Contains(u.Id))
.Select(u => u.Id)
.ToListAsync(cancellationToken);
return Result.Success(existingIds);
}
}
@@ -1,14 +1,10 @@
using BCrypt.Net;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Contracts.Auth.Domain;
using Knot.Shared.Kernel;
namespace Knot.Modules.Auth.Application.Users.Login;
/// <summary>
/// . AuthResponseDto.
/// </summary>
public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>;
public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, AuthResponseDto>
@@ -32,14 +28,21 @@ public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand,
}
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
string refreshToken = _tokenProvider.GenerateRefreshToken();
DateTime refreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
// Save refresh token to database
user.SetRefreshToken(refreshToken, refreshExpiry);
await _userRepository.UpdateAsync(user, cancellationToken);
return Result.Success(new AuthResponseDto
{
AccessToken = token,
RefreshToken = string.Empty,
RefreshToken = refreshToken,
UserId = user.Id,
Username = user.Username,
DisplayName = user.DisplayName
});
}
}
@@ -0,0 +1,7 @@
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Auth.Application.Users.RefreshToken;
public record RefreshTokenCommand(string RefreshToken) : ICommand<AuthResponseDto>;
@@ -0,0 +1,65 @@
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Contracts.Auth.Domain;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Auth.Application.Users.RefreshToken;
internal sealed class RefreshTokenCommandHandler : ICommandHandler<RefreshTokenCommand, AuthResponseDto>
{
private readonly IUserRepository _userRepository;
private readonly IJwtTokenProvider _tokenProvider;
public RefreshTokenCommandHandler(
IUserRepository userRepository,
IJwtTokenProvider tokenProvider)
{
_userRepository = userRepository;
_tokenProvider = tokenProvider;
}
public async Task<Result<AuthResponseDto>> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.RefreshToken))
{
return Result.Failure<AuthResponseDto>(
new Error("Auth.InvalidRefreshToken", "Refresh token is required"));
}
var user = await _userRepository.GetByRefreshTokenAsync(request.RefreshToken, cancellationToken);
if (user == null)
{
return Result.Failure<AuthResponseDto>(
new Error("Auth.InvalidRefreshToken", "Invalid or expired refresh token"));
}
// Check if refresh token has expired
if (user.RefreshTokenExpiry.HasValue && user.RefreshTokenExpiry.Value < DateTime.UtcNow)
{
// Clear expired refresh token
user.SetRefreshToken(null, null);
await _userRepository.UpdateAsync(user, cancellationToken);
return Result.Failure<AuthResponseDto>(
new Error("Auth.RefreshTokenExpired", "Refresh token has expired. Please login again."));
}
var newAccessToken = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
var newRefreshToken = _tokenProvider.GenerateRefreshToken();
var newRefreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
user.SetRefreshToken(newRefreshToken, newRefreshExpiry);
await _userRepository.UpdateAsync(user, cancellationToken);
return Result.Success(new AuthResponseDto
{
AccessToken = newAccessToken,
RefreshToken = newRefreshToken,
UserId = user.Id,
Username = user.Username,
DisplayName = user.DisplayName,
Avatar = user.Avatar
});
}
}
@@ -1,17 +1,13 @@
using Knot.Contracts.Auth.Domain;
using BCrypt.Net;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Modules.Auth.Domain;
using Knot.Shared.Kernel;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs;
using BCrypt.Net;
using Knot.Modules.Auth.Domain;
using Knot.Shared.Kernel;
namespace Knot.Modules.Auth.Application.Users.Register;
/// <summary>
/// Êîìàíäà äëÿ ðåãèñòðàöèè íîâîãî ïîëüçîâàòåëÿ.
/// </summary>
public sealed record RegisterUserCommand(
string Username,
string Password,
@@ -19,9 +15,6 @@ public sealed record RegisterUserCommand(
string? Email,
string? Bio) : ICommand<AuthResponseDto>;
/// <summary>
/// Îáðàáîò÷èê êîìàíäû ðåãèñòðàöèè.
/// </summary>
internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto>
{
private readonly IUserRepository _userRepository;
@@ -48,16 +41,13 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationDisabled);
}
// 1. Ïðîâåðêà óíèêàëüíîñòè username
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
{
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityUsernameNotUnique);
}
// 2. Õåøèðîâàíèå ïàðîëÿ
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
// 3. Ñîçäàíèå ñóùíîñòè
var user = User.Create(
request.Username,
passwordHash,
@@ -65,21 +55,33 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
request.Email,
request.Bio);
// 4. Ñîõðàíåíèå - èñïîëüçóåì ìåòîä ñ Domain User
var repoWithDomainUserAdd = _userRepository as Infrastructure.Persistence.UserRepository;
repoWithDomainUserAdd?.Add(user);
var repoImpl = _userRepository as Infrastructure.Persistence.UserRepository;
repoImpl?.Add(user);
await _unitOfWork.SaveChangesAsync(cancellationToken);
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
// Get the saved user as contract
var userContract = await _userRepository.GetByUsernameAsync(request.Username, cancellationToken);
if (userContract == null)
{
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationFailed);
}
string token = _tokenProvider.Generate(userContract.Id, userContract.Username, userContract.DisplayName, userContract.Avatar);
string refreshToken = _tokenProvider.GenerateRefreshToken();
DateTime refreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
userContract.SetRefreshToken(refreshToken, refreshExpiry);
await _userRepository.UpdateAsync(userContract, cancellationToken);
return Result.Success(new AuthResponseDto
{
AccessToken = token,
RefreshToken = string.Empty,
UserId = user.Id,
Username = user.Username,
DisplayName = user.DisplayName
RefreshToken = refreshToken,
UserId = userContract.Id,
Username = userContract.Username,
DisplayName = userContract.DisplayName
});
}
}
@@ -24,7 +24,7 @@ public static class DependencyInjection
services.AddScoped<IAuthUnitOfWork>(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<Knot.Contracts.Auth.Domain.IUserRepository, UserRepository>();
services.AddScoped<IJwtTokenProvider, JwtTokenProvider>();
services.AddScoped<IUserDisplayNameProvider, Knot.Modules.Auth.Infrastructure.Services.UserDisplayNameProvider>();
@@ -0,0 +1,15 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Auth.Domain;
public static class AuthErrors
{
public static readonly Error FriendsNotFound = new Error("Friends.NotFound", "Friendship not found");
public static readonly Error FriendsSelf = new Error("Friends.Self", "Cannot add yourself");
public static readonly Error FriendsExists = new Error("Friends.Exists", "Friendship already exists");
public static readonly Error UserNotFound = new Error("User.NotFound", "User not found");
public static readonly Error IdentityInvalidCredentials = new Error("Identity.InvalidCredentials", "Неверное имя пользователя или пароль.");
public static readonly Error IdentityRegistrationDisabled = new Error("Identity.RegistrationDisabled", "Registration is disabled by the administrator.");
public static readonly Error IdentityUsernameNotUnique = new Error("Identity.UsernameNotUnique", "Это имя пользователя уже занято.");
}
+19 -4
View File
@@ -26,10 +26,17 @@ public sealed class User : AggregateRoot<Guid>
public bool IsBanned { get; private set; }
public string? PhoneNumber { get; private set; }
public string? RefreshToken { get; private set; }
public DateTime? RefreshTokenExpiry { get; private set; }
public DateTime? BannedUntil { get; private set; }
public void Ban() => IsBanned = true;
public void Unban() => IsBanned = false;
public void Ban() {
IsBanned = true;
RaiseDomainEvent(new UserBannedDomainEvent(Id, true));
}
public void Unban() {
IsBanned = false;
RaiseDomainEvent(new UserBannedDomainEvent(Id, false));
}
public void SetOnline(bool isOnline, DateTime? lastSeen = null)
{
@@ -42,9 +49,10 @@ public sealed class User : AggregateRoot<Guid>
PhoneNumber = phoneNumber;
}
public void SetRefreshToken(string? refreshToken)
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
{
RefreshToken = refreshToken;
RefreshTokenExpiry = expiry;
}
public void SetBannedUntil(DateTime? bannedUntil)
@@ -66,10 +74,15 @@ public sealed class User : AggregateRoot<Guid>
Username = contract.Username;
DisplayName = contract.DisplayName;
PhoneNumber = contract.PhoneNumber;
Bio = contract.Bio;
Avatar = contract.Avatar;
Birthday = contract.Birthday;
IsBanned = contract.IsBanned;
BannedUntil = contract.BannedUntil;
SetOnline(contract.IsOnline, contract.LastSeen);
UserDomain = contract.Domain;
RefreshToken = contract.RefreshToken;
RefreshTokenExpiry = contract.RefreshTokenExpiry;
}
private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null)
@@ -172,7 +185,9 @@ public sealed class User : AggregateRoot<Guid>
IsOnline = IsOnline,
IsExternal = IsExternal,
Domain = _domain,
LastSeen = LastSeen
LastSeen = LastSeen,
RefreshToken = RefreshToken,
RefreshTokenExpiry = RefreshTokenExpiry
};
}
}
+4
View File
@@ -0,0 +1,4 @@
// Глобальные алиасы для модуля Auth
// Используем контракты вместо дублирующихся интерфейсов из модуля
global using AuthErrors = Knot.Contracts.Auth.Application.Auth.DTOs.AuthErrors;
global using IUserRepository = Knot.Contracts.Auth.Domain.IUserRepository;
@@ -50,6 +50,12 @@ internal sealed class JwtTokenProvider : IJwtTokenProvider
return Convert.ToBase64String(randomBytes);
}
public DateTime GetRefreshTokenExpiry()
{
var expiryInDays = int.Parse(_configuration["Jwt:RefreshExpiryInDays"] ?? "30");
return DateTime.UtcNow.AddDays(expiryInDays);
}
public string Generate(Guid userId, string username, string displayName, string? avatar)
{
var claims = new Claim[]
@@ -65,6 +65,7 @@ internal sealed class UserRepository : IUserRepository
{
domainUser.UpdateFromContract(user);
_context.Users.Update(domainUser);
await _context.SaveChangesAsync(cancellationToken);
}
}
@@ -1,4 +1,3 @@
using Knot.Contracts.Auth.Domain;
using Knot.Shared.Kernel;
namespace Knot.Modules.Auth.Infrastructure.Services;
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -15,7 +15,6 @@
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.1.0" />
<PackageReference Include="Carter" Version="10.0.0" />
<PackageReference Include="MediatR" Version="12.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.4" />
@@ -0,0 +1,106 @@
// <auto-generated />
using System;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Modules.Auth.Migrations
{
[DbContext(typeof(AuthDbContext))]
[Migration("20260407181656_AddUserInfoFields")]
partial class AddUserInfoFields
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("identity")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Auth.Domain.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime?>("BannedUntil")
.HasColumnType("timestamp with time zone");
b.Property<string>("Bio")
.HasColumnType("text");
b.Property<DateTime?>("Birthday")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Domain")
.HasColumnType("text");
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("HideStatus")
.HasColumnType("boolean");
b.Property<bool>("HideStoryViews")
.HasColumnType("boolean");
b.Property<bool>("IsBanned")
.HasColumnType("boolean");
b.Property<bool>("IsExternal")
.HasColumnType("boolean");
b.Property<bool>("IsOnline")
.HasColumnType("boolean");
b.Property<DateTime?>("LastSeen")
.HasColumnType("timestamp with time zone");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<string>("RefreshToken")
.HasColumnType("text");
b.Property<string>("UserDomain")
.HasColumnType("text");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users", "identity");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,37 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Auth.Migrations
{
/// <inheritdoc />
public partial class AddUserInfoFields : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='identity' AND table_name='Users' AND column_name='BannedUntil') THEN
ALTER TABLE identity.""Users"" ADD ""BannedUntil"" timestamp with time zone;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='identity' AND table_name='Users' AND column_name='PhoneNumber') THEN
ALTER TABLE identity.""Users"" ADD ""PhoneNumber"" text;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='identity' AND table_name='Users' AND column_name='RefreshToken') THEN
ALTER TABLE identity.""Users"" ADD ""RefreshToken"" text;
END IF;
END $$;");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "BannedUntil", schema: "identity", table: "Users");
migrationBuilder.DropColumn(name: "PhoneNumber", schema: "identity", table: "Users");
migrationBuilder.DropColumn(name: "RefreshToken", schema: "identity", table: "Users");
}
}
}
@@ -0,0 +1,104 @@
// <auto-generated />
using System;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Modules.Auth.Migrations
{
[DbContext(typeof(AuthDbContext))]
[Migration("20270328000000_AddUserUserDomainField")]
partial class AddUserUserDomainField
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("identity")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Auth.Domain.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime?>("BannedUntil")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Birthday")
.HasColumnType("timestamp with time zone");
b.Property<string>("Bio")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Domain")
.HasColumnType("text");
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("HideStatus")
.HasColumnType("boolean");
b.Property<bool>("HideStoryViews")
.HasColumnType("boolean");
b.Property<bool>("IsBanned")
.HasColumnType("boolean");
b.Property<bool>("IsExternal")
.HasColumnType("boolean");
b.Property<bool>("IsOnline")
.HasColumnType("boolean");
b.Property<DateTime?>("LastSeen")
.HasColumnType("timestamp with time zone");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<string>("RefreshToken")
.HasColumnType("text");
b.Property<string>("UserDomain")
.HasColumnType("text");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users", "identity");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,31 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Auth.Migrations
{
/// <inheritdoc />
public partial class AddUserUserDomainField : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "UserDomain",
schema: "identity",
table: "Users",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "UserDomain",
schema: "identity",
table: "Users");
}
}
}
@@ -0,0 +1,100 @@
using System;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Modules.Auth.Migrations
{
/// <inheritdoc />
[DbContext(typeof(AuthDbContext))]
[Migration("20270419220000_AddRefreshTokenExpiry")]
partial class AddRefreshTokenExpiry
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0-rc.1.25451.105")
.HasAnnotation("Relational:DefaultSchema", "identity");
modelBuilder.Entity("Knot.Modules.Auth.Domain.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime?>("BannedUntil")
.HasColumnType("timestamp with time zone");
b.Property<string>("Bio")
.HasColumnType("text");
b.Property<DateTime?>("Birthday")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.HasColumnType("text");
b.Property<string>("Domain")
.HasColumnType("text");
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("HideStatus")
.HasColumnType("boolean");
b.Property<bool>("HideStoryViews")
.HasColumnType("boolean");
b.Property<bool>("IsBanned")
.HasColumnType("boolean");
b.Property<bool>("IsExternal")
.HasColumnType("boolean");
b.Property<bool>("IsOnline")
.HasColumnType("boolean");
b.Property<DateTime?>("LastSeen")
.HasColumnType("timestamp with time zone");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<string>("RefreshToken")
.HasColumnType("text");
b.Property<DateTime?>("RefreshTokenExpiry")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users", "identity");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Auth.Migrations
{
/// <inheritdoc />
public partial class AddRefreshTokenExpiry : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "RefreshTokenExpiry",
schema: "identity",
table: "Users",
type: "timestamp with time zone",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "RefreshTokenExpiry",
schema: "identity",
table: "Users");
}
}
}
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -23,7 +23,7 @@ namespace Knot.Modules.Auth.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Contracts.Auth.Domain.User", b =>
modelBuilder.Entity("Knot.Modules.Auth.Domain.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -32,6 +32,9 @@ namespace Knot.Modules.Auth.Migrations
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime?>("BannedUntil")
.HasColumnType("timestamp with time zone");
b.Property<string>("Bio")
.HasColumnType("text");
@@ -57,10 +60,10 @@ namespace Knot.Modules.Auth.Migrations
b.Property<bool>("HideStoryViews")
.HasColumnType("boolean");
b.Property<bool>("IsExternal")
b.Property<bool>("IsBanned")
.HasColumnType("boolean");
b.Property<bool>("IsBanned")
b.Property<bool>("IsExternal")
.HasColumnType("boolean");
b.Property<bool>("IsOnline")
@@ -73,6 +76,15 @@ namespace Knot.Modules.Auth.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<string>("RefreshToken")
.HasColumnType("text");
b.Property<string>("UserDomain")
.HasColumnType("text");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
@@ -1,19 +1,19 @@
using Carter;
using Knot.Shared.Kernel;
using Knot.Modules.Auth.Application.Users.Login;
using Knot.Modules.Auth.Application.Users.Register;
using Knot.Modules.Auth.Application.Users.GetMe;
using Knot.Modules.Auth.Application.Users.Login;
using Knot.Modules.Auth.Application.Users.RefreshToken;
using Knot.Modules.Auth.Application.Users.Register;
using Knot.Shared.Kernel;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
namespace Knot.Modules.Auth.Presentation.Endpoints;
public sealed class AuthEndpoints : ICarterModule
public static class AuthEndpoints
{
public void AddRoutes(IEndpointRouteBuilder app)
public static void MapAuthEndpoints(this WebApplication app)
{
var group = app.MapGroup("api/auth");
@@ -29,6 +29,12 @@ public sealed class AuthEndpoints : ICarterModule
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
});
group.MapPost("refresh", async ([FromBody] RefreshTokenCommand command, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(command, ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
});
group.MapGet("me", async (ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new GetMeQuery(userContext.UserId), ct);
@@ -1,11 +0,0 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Conversations.Application.Abstractions;
/// <summary>
/// Unit of Work специфичный для модуля Chats.
/// </summary>
public interface IChatsUnitOfWork : IUnitOfWork
{
}
@@ -1,8 +0,0 @@
using System;
namespace Knot.Modules.Conversations.Application.Abstractions;
public interface IUserStatusService
{
bool IsUserOnline(string userId);
}
@@ -4,8 +4,8 @@ using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using MediatR;
using System.Linq;
using SixLabors.ImageSharp;
@@ -2,8 +2,8 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using MediatR;
using System.Linq;

Some files were not shown because too many files have changed in this diff Show More