Перепиливание под чистый DDD

This commit is contained in:
Халимов Рустам
2026-03-22 23:59:33 +03:00
parent 5da1a2f45d
commit 6e532b021d
302 changed files with 3595 additions and 3679 deletions

View File

@@ -0,0 +1,102 @@
using Knot.Modules.Admin.Application.Admin.DTOs;
using Knot.Modules.Messaging.Domain;
using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Domain;
using MongoDB.Driver;
using Microsoft.EntityFrameworkCore;
namespace Host.Application.Admin.Commands;
public record CleanRunCommand(IFileStorageService FileStorage, AuthDbContext IdentityDb) : ICommand<MessageResponse>;
internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand, MessageResponse>
{
private readonly ChatsDbContext _chatsDbContext;
private readonly IMongoCollection<Message> _messages;
public CleanRunCommandHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb)
{
_chatsDbContext = chatsDbContext;
_messages = mongoDb.GetCollection<Message>("messages");
}
public async Task<Result<MessageResponse>> Handle(CleanRunCommand request, CancellationToken cancellationToken)
{
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken);
var activeChatIds = allChats.Select(c => c.Id).ToHashSet();
var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken);
var orphanMessages = allMessages
.Where(m => !activeChatIds.Contains(m.ChatId))
.ToList();
var keptMessages = allMessages
.Where(m => activeChatIds.Contains(m.ChatId))
.ToList();
var allMinioFiles = (await request.FileStorage.ListFilesAsync()).ToList();
var allUsers = await request.IdentityDb.Users.AsNoTracking().ToListAsync(cancellationToken);
var validUrls = new HashSet<string>();
var activeMessageUrls = keptMessages.OfType<MediaMessage>()
.Where(m => m.Media != null)
.SelectMany(m => m.Media)
.Select(me => me.Url)
.Where(u => !string.IsNullOrEmpty(u));
var activeChatUrls = allChats
.Where(c => !string.IsNullOrEmpty(c.Avatar))
.Select(c => c.Avatar!);
var activeUserUrls = allUsers
.Where(u => !string.IsNullOrEmpty(u.Avatar))
.Select(u => u.Avatar!);
foreach (var u in activeMessageUrls)
{
validUrls.Add(u!);
}
foreach (var u in activeChatUrls)
{
validUrls.Add(u);
}
foreach (var u in activeUserUrls)
{
validUrls.Add(u);
}
var validFileIds = validUrls
.Where(u => u.Contains("/api/files/"))
.Select(u => u.Split('/').Last())
.ToHashSet();
foreach (var file in allMinioFiles)
{
if (!validFileIds.Contains(file.FileId))
{
await request.FileStorage.DeleteFileAsync(file.FileId);
}
}
if (orphanMessages.Any())
{
var orphanIds = orphanMessages.Select(m => m.Id).ToList();
var filter = Builders<Message>.Filter.In(m => m.Id, orphanIds);
await _messages.DeleteManyAsync(filter, cancellationToken);
}
return Result.Success(new MessageResponse("Cleanup completed successfully"));
}
}

View File

@@ -0,0 +1,47 @@
using Knot.Modules.Auth.Domain;
using Knot.Modules.Admin.Application.Admin.DTOs;
using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Auth.Application.Auth.DTOs;
using Knot.Modules.Auth.Application.Users;
using Knot.Modules.Auth.Domain;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Modules.Auth.Application.Abstractions;
namespace Host.Application.Admin.Commands;
public record ResetUserPasswordCommand(Guid UserId, string NewPassword) : ICommand<SuccessResponse>;
internal sealed class ResetUserPasswordCommandHandler : ICommandHandler<ResetUserPasswordCommand, SuccessResponse>
{
private readonly IUserRepository _userRepository;
private readonly IAuthUnitOfWork _identityUnitOfWork;
public ResetUserPasswordCommandHandler(IUserRepository userRepository, IAuthUnitOfWork identityUnitOfWork)
{
_userRepository = userRepository;
_identityUnitOfWork = identityUnitOfWork;
}
public async Task<Result<SuccessResponse>> Handle(ResetUserPasswordCommand request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null)
{
return Result.Failure<SuccessResponse>(AuthErrors.UserNotFound);
}
if (string.IsNullOrWhiteSpace(request.NewPassword))
return Result.Failure<SuccessResponse>(DomainErrors.InvalidPassword);
var hash = BCrypt.Net.BCrypt.HashPassword(request.NewPassword);
user.ChangePassword(hash);
await _identityUnitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(new SuccessResponse(true));
}
}

View File

@@ -0,0 +1,27 @@
using Knot.Modules.Admin.Application.Admin.DTOs;
using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
namespace Host.Application.Admin.Commands;
public record UpdateSettingsCommand(SystemSettingsDto Settings) : ICommand<SystemSettingsDto>;
internal sealed class UpdateSettingsCommandHandler : ICommandHandler<UpdateSettingsCommand, SystemSettingsDto>
{
private readonly ISettingsService _settingsService;
public UpdateSettingsCommandHandler(ISettingsService settingsService)
{
_settingsService = settingsService;
}
public async Task<Result<SystemSettingsDto>> Handle(UpdateSettingsCommand request, CancellationToken cancellationToken)
{
await _settingsService.UpdateSettingsAsync(request.Settings, cancellationToken);
return Result.Success(request.Settings);
}
}

View File

@@ -0,0 +1,15 @@
using System;
namespace Knot.Modules.Admin.Application.Admin.DTOs;
public record AdminUserDetailsDto(
Guid Id,
string Username,
string DisplayName,
string? Bio,
string? Avatar,
DateTime CreatedAt,
bool IsOnline,
DateTime LastOnlineAt,
AdminUserStatsDto Stats
);

View File

@@ -0,0 +1,14 @@
using System;
namespace Knot.Modules.Admin.Application.Admin.DTOs;
public record AdminUserDto(
Guid Id,
string Username,
string DisplayName,
string? Email,
string? Avatar,
DateTime CreatedAt,
bool IsOnline,
DateTime LastOnlineAt
);

View File

@@ -0,0 +1,9 @@
namespace Knot.Modules.Admin.Application.Admin.DTOs;
public record AdminUserStatsDto(
int MessagesCount,
int MediaCount,
int FilesCount,
int LinksCount,
long StorageUsedBytes
);

View File

@@ -0,0 +1,7 @@
namespace Knot.Modules.Admin.Application.Admin.DTOs;
public class CleanupDryRunResultDto
{
public int OrphanedMessagesCount { get; set; }
public long OrphanedMediaBytes { get; set; }
}

View File

@@ -0,0 +1,105 @@
using Knot.Modules.Admin.Application.Admin.DTOs;
using Knot.Modules.Messaging.Domain;
using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Domain;
using MongoDB.Driver;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Auth.Application.Auth.DTOs;
using Knot.Modules.Auth.Application.Users;
namespace Host.Application.Admin.Queries;
public record CleanDryRunQuery(IFileStorageService FileStorage, AuthDbContext IdentityDb) : IQuery<CleanupDryRunResultDto>;
internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery, CleanupDryRunResultDto>
{
private readonly ChatsDbContext _chatsDbContext;
private readonly IMongoCollection<Message> _messages;
public CleanDryRunQueryHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb)
{
_chatsDbContext = chatsDbContext;
_messages = mongoDb.GetCollection<Message>("messages");
}
public async Task<Result<CleanupDryRunResultDto>> Handle(CleanDryRunQuery request, CancellationToken cancellationToken)
{
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken);
var activeChatIds = allChats.Select(c => c.Id).ToHashSet();
var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken);
var orphanedMessages = allMessages
.Where(m => !activeChatIds.Contains(m.ChatId))
.ToList();
var keptMessages = allMessages
.Where(m => activeChatIds.Contains(m.ChatId))
.ToList();
var orphanedMessagesCount = orphanedMessages.Count;
var allMinioFiles = (await request.FileStorage.ListFilesAsync()).ToList();
var allUsers = await request.IdentityDb.Users.AsNoTracking().ToListAsync(cancellationToken);
var validUrls = new HashSet<string>();
var activeMessageUrls = keptMessages.OfType<MediaMessage>()
.Where(m => m.Media != null)
.SelectMany(m => m.Media)
.Select(me => me.Url)
.Where(u => !string.IsNullOrEmpty(u));
var activeChatUrls = allChats
.Where(c => !string.IsNullOrEmpty(c.Avatar))
.Select(c => c.Avatar!);
var activeUserUrls = allUsers
.Where(u => !string.IsNullOrEmpty(u.Avatar))
.Select(u => u.Avatar!);
foreach (var u in activeMessageUrls)
{
validUrls.Add(u!);
}
foreach (var u in activeChatUrls)
{
validUrls.Add(u);
}
foreach (var u in activeUserUrls)
{
validUrls.Add(u);
}
var validFileIds = validUrls
.Where(u => u.Contains("/api/files/"))
.Select(u => u.Split('/').Last())
.ToHashSet();
long safeBytes = 0;
foreach (var file in allMinioFiles)
{
if (!validFileIds.Contains(file.FileId))
{
safeBytes += file.Size;
}
}
return Result.Success(new CleanupDryRunResultDto
{
OrphanedMessagesCount = orphanedMessagesCount,
OrphanedMediaBytes = safeBytes
});
}
}

View File

@@ -0,0 +1,26 @@
using Knot.Modules.Admin.Application.Admin.DTOs;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Services;
namespace Host.Application.Admin.Queries;
public record GetDashboardStatsQuery() : IQuery<DashboardStatsDto>;
internal sealed class GetDashboardStatsQueryHandler : IQueryHandler<GetDashboardStatsQuery, DashboardStatsDto>
{
private readonly IStatisticsService _statisticsService;
public GetDashboardStatsQueryHandler(IStatisticsService statisticsService)
{
_statisticsService = statisticsService;
}
public async Task<Result<DashboardStatsDto>> Handle(GetDashboardStatsQuery request, CancellationToken cancellationToken)
{
var stats = await _statisticsService.GetDashboardStatsAsync(cancellationToken);
return Result.Success(stats);
}
}

View File

@@ -0,0 +1,27 @@
using Knot.Modules.Admin.Application.Admin.DTOs;
using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
namespace Host.Application.Admin.Queries;
public record GetSettingsQuery() : IQuery<SystemSettingsDto>;
internal sealed class GetSettingsQueryHandler : IQueryHandler<GetSettingsQuery, SystemSettingsDto>
{
private readonly ISettingsService _settingsService;
public GetSettingsQueryHandler(ISettingsService settingsService)
{
_settingsService = settingsService;
}
public async Task<Result<SystemSettingsDto>> Handle(GetSettingsQuery request, CancellationToken cancellationToken)
{
var settings = await _settingsService.GetSettingsAsync(cancellationToken);
return Result.Success(settings);
}
}

View File

@@ -0,0 +1,77 @@
using Knot.Modules.Auth.Domain;
using Knot.Modules.Admin.Application.Admin.DTOs;
using Knot.Modules.Messaging.Domain;
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Auth.Application.Auth.DTOs;
using Knot.Modules.Auth.Application.Users;
using Knot.Modules.Auth.Domain;
using Knot.Modules.Conversations.Domain;
using MongoDB.Driver;
namespace Host.Application.Admin.Queries;
public record GetUserDetailsQuery(Guid UserId) : IQuery<AdminUserDetailsDto>;
internal sealed class GetUserDetailsQueryHandler : IQueryHandler<GetUserDetailsQuery, AdminUserDetailsDto>
{
private readonly IUserRepository _userRepository;
private readonly IMongoCollection<Message> _messages;
public GetUserDetailsQueryHandler(IUserRepository userRepository, IMongoDatabase mongoDatabase)
{
_userRepository = userRepository;
_messages = mongoDatabase.GetCollection<Message>("Messages");
}
public async Task<Result<AdminUserDetailsDto>> Handle(GetUserDetailsQuery request, CancellationToken cancellationToken)
{
var targetUser = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (targetUser == null)
{
return Result.Failure<AdminUserDetailsDto>(AuthErrors.UserNotFound);
}
var filter = Builders<Message>.Filter.Eq(m => m.SenderId, request.UserId);
var userMessages = await _messages.Find(filter).ToListAsync(cancellationToken);
var messagesCount = userMessages.Count;
var allUserMedia = userMessages.OfType<MediaMessage>().SelectMany(m => m.Media).ToList();
var mediaCount = allUserMedia.Count(m => m.Type == "image" || m.Type == "video");
var filesCount = allUserMedia.Count(m => m.Type == "file" || m.Type == "audio");
var storageUsed = allUserMedia.Sum(m => m.Size ?? 0);
var userContents = userMessages.OfType<TextMessage>().Select(m => m.Content)
.Concat(userMessages.OfType<MediaMessage>().Where(m => m.Caption != null).Select(m => m.Caption))
.ToList();
var linksCount = userContents.Count(c => !string.IsNullOrEmpty(c) && c.Contains("http"));
var result = new AdminUserDetailsDto(
targetUser.Id,
targetUser.Username,
targetUser.DisplayName,
targetUser.Bio,
targetUser.Avatar,
targetUser.CreatedAt,
Knot.Modules.Conversations.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()),
Knot.Modules.Conversations.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()) ? DateTime.UtcNow : targetUser.CreatedAt,
new AdminUserStatsDto(
messagesCount,
mediaCount,
filesCount,
linksCount,
storageUsed
)
);
return Result.Success(result);
}
}

View File

@@ -0,0 +1,46 @@
using Knot.Modules.Admin.Application.Admin.DTOs;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Auth.Application.Auth.DTOs;
using Knot.Modules.Auth.Application.Users;
using Knot.Modules.Auth.Domain;
namespace Host.Application.Admin.Queries;
public record SearchUsersQuery(string Query) : IQuery<List<AdminUserDto>>;
internal sealed class SearchUsersQueryHandler : IQueryHandler<SearchUsersQuery, List<AdminUserDto>>
{
private readonly IUserRepository _userRepository;
public SearchUsersQueryHandler(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task<Result<List<AdminUserDto>>> Handle(SearchUsersQuery request, CancellationToken cancellationToken)
{
var users = string.IsNullOrWhiteSpace(request.Query)
? await _userRepository.SearchUsersAsync("", cancellationToken)
: await _userRepository.SearchUsersAsync(request.Query, cancellationToken);
var result = users.Select(u => new AdminUserDto(
u.Id,
u.Username,
u.DisplayName,
u.Email,
u.Avatar,
u.CreatedAt,
Knot.Modules.Conversations.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()),
Knot.Modules.Conversations.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()) ? DateTime.UtcNow : u.CreatedAt
)).ToList();
return Result.Success(result);
}
}

View File

@@ -0,0 +1,7 @@
namespace Knot.Modules.Admin;
using Microsoft.Extensions.DependencyInjection;
public static class DependencyInjection {
public static IServiceCollection AddAdminModule(this IServiceCollection services) {
return services;
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
<ProjectReference Include="..\Conversations\Knot.Modules.Conversations.csproj" />
<ProjectReference Include="..\Auth\Knot.Modules.Auth.csproj" />
<ProjectReference Include="..\Stories\Knot.Modules.Stories.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Carter" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>Knot.Modules.Admin.UnitTests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,86 @@
using Carter;
using Knot.Modules.Auth.Application.Auth.DTOs;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using Knot.Modules.Auth.Application.Users.Register;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Auth.Infrastructure.Persistence;
using MediatR;
using Host.Application.Admin.Queries;
using Host.Application.Admin.Commands;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc;
namespace Knot.Host.Presentation.Endpoints;
public sealed class AdminEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup("api/admin"); // Middleware handles auth
group.MapGet("settings", async (ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new GetSettingsQuery(), ct);
return Results.Ok(result.Value);
});
group.MapPut("settings", async ([FromBody] SystemSettingsDto settings, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new UpdateSettingsCommand(settings), ct);
return Results.Ok(result.Value);
});
group.MapGet("dashboard", async (ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new GetDashboardStatsQuery(), ct);
return Results.Ok(result.Value);
});
group.MapPost("users", async ([FromBody] RegisterUserCommand command, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(command, ct);
if (result.IsFailure) return Results.BadRequest(new { error = result.Error.Description });
var userDetails = await sender.Send(new GetUserDetailsQuery(result.Value.User.Id), ct);
return Results.Ok(userDetails.Value);
});
group.MapPost("users/{userId:guid}/reset-password", async (Guid userId, [FromBody] ResetPasswordDto dto, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new ResetUserPasswordCommand(userId, dto.NewPassword), ct);
if (result.IsFailure)
{
if (result.Error.Code == "User.NotFound") return Results.NotFound(new { error = "User not found" });
return Results.BadRequest(new { error = result.Error.Description });
}
return Results.Ok(result.Value);
});
group.MapGet("users", async (ISender sender, [FromQuery] string query = "", CancellationToken ct = default) =>
{
var result = await sender.Send(new SearchUsersQuery(query), ct);
return Results.Ok(result.Value);
});
group.MapGet("users/{id:guid}", async (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.MapGet("clean/dry-run", async (IFileStorageService fileStorage, AuthDbContext identityDb, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new CleanDryRunQuery(fileStorage, identityDb), ct);
return Results.Ok(result.Value);
});
group.MapPost("clean/run", async (IFileStorageService fileStorage, AuthDbContext identityDb, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new CleanRunCommand(fileStorage, identityDb), ct);
return Results.Ok(result.Value);
});
}
}

View File

@@ -1,14 +1,14 @@
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Auth.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace Knot.Modules.Identity.Application.Abstractions;
namespace Knot.Modules.Auth.Application.Abstractions;
public interface IIdentityDbContext
public interface IAuthDbContext
{
DbSet<User> Users { get; }
DbSet<Friendship> Friendships { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}

View File

@@ -1,10 +1,11 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Identity.Application.Abstractions;
namespace Knot.Modules.Auth.Application.Abstractions;
/// <summary>
/// Unit of Work специфичный для модуля Identity.
/// </summary>
public interface IIdentityUnitOfWork : IUnitOfWork
public interface IAuthUnitOfWork : IUnitOfWork
{
}

View File

@@ -0,0 +1,9 @@
using Knot.Modules.Auth.Domain;
namespace Knot.Modules.Auth.Application.Abstractions;
public interface IJwtTokenProvider
{
string Generate(User user);
}

View File

@@ -1,6 +1,6 @@
using System;
namespace Knot.Modules.Identity.Application.Users.Auth;
namespace Knot.Modules.Auth.Application.Auth.DTOs;
public record AuthResponseDto(
string Token,
@@ -18,3 +18,4 @@ public record AuthUserDto(
bool IsOnline,
DateTime CreatedAt
);

View File

@@ -0,0 +1,7 @@
namespace Knot.Modules.Auth.Application.Auth.DTOs;
public class ResetPasswordDto
{
public string NewPassword { get; set; } = string.Empty;
}

View File

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

View File

@@ -1,9 +1,9 @@
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Application.Users.Auth;
using Knot.Modules.Auth.Domain;
namespace Knot.Modules.Identity.Application.Users.GetMe;
using Knot.Modules.Auth.Application.Users.Auth;
namespace Knot.Modules.Auth.Application.Users.GetMe;
public sealed record GetMeQuery(Guid UserId) : IQuery<AuthResponseDto>;
@@ -21,7 +21,7 @@ internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponse
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null)
{
return Result.Failure<AuthResponseDto>(IdentityErrors.UserNotFound);
return Result.Failure<AuthResponseDto>(AuthErrors.UserNotFound);
}
var response = new AuthResponseDto(
@@ -42,3 +42,4 @@ internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponse
return Result.Success(response);
}
}

View File

@@ -1,10 +1,11 @@
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Auth.Domain;
using Knot.Modules.Auth.Application.Abstractions;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Application.Users.Auth;
using Knot.Modules.Auth.Application.Users.Auth;
using BCrypt.Net;
namespace Knot.Modules.Identity.Application.Users.Login;
namespace Knot.Modules.Auth.Application.Users.Login;
/// <summary>
/// Команда для входа пользователя. Возвращает AuthResponseDto.
@@ -28,7 +29,7 @@ public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand,
if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
{
return Result.Failure<AuthResponseDto>(IdentityErrors.IdentityInvalidCredentials);
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityInvalidCredentials);
}
string token = _tokenProvider.Generate(user);
@@ -49,3 +50,4 @@ public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand,
));
}
}

View File

@@ -1,11 +1,12 @@
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Auth.Domain;
using Knot.Modules.Auth.Application.Abstractions;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using Knot.Modules.Identity.Application.Users.Auth;
using Knot.Modules.Auth.Application.Users.Auth;
using BCrypt.Net;
namespace Knot.Modules.Identity.Application.Users.Register;
namespace Knot.Modules.Auth.Application.Users.Register;
/// <summary>
/// Команда для регистрации нового пользователя.
@@ -23,13 +24,13 @@ public sealed record RegisterUserCommand(
public sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto>
{
private readonly IUserRepository _userRepository;
private readonly IIdentityUnitOfWork _unitOfWork;
private readonly IAuthUnitOfWork _unitOfWork;
private readonly ISettingsService _settings;
private readonly IJwtTokenProvider _tokenProvider;
public RegisterUserCommandHandler(
IUserRepository userRepository,
IIdentityUnitOfWork unitOfWork,
IAuthUnitOfWork unitOfWork,
ISettingsService settings,
IJwtTokenProvider tokenProvider)
{
@@ -43,13 +44,13 @@ public sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCom
{
if (!_settings.Current.EnableRegistration)
{
return Result.Failure<AuthResponseDto>(IdentityErrors.IdentityRegistrationDisabled);
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationDisabled);
}
// 1. Проверка уникальности username
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
{
return Result.Failure<AuthResponseDto>(IdentityErrors.IdentityUsernameNotUnique);
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityUsernameNotUnique);
}
// 2. Хеширование пароля (здесь будет вызов сервиса, пока заглушка)
@@ -85,3 +86,4 @@ public sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCom
));
}
}

View File

@@ -1,35 +1,35 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Infrastructure.Authentication;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Modules.Auth.Domain;
using Knot.Modules.Auth.Application.Abstractions;
using Knot.Modules.Auth.Infrastructure.Authentication;
using Knot.Shared.Kernel;
namespace Knot.Modules.Identity;
namespace Knot.Modules.Auth;
public static class DependencyInjection
{
/// <summary>
/// Регистрация сервисов модуля Identity.
/// </summary>
public static IServiceCollection AddIdentityModule(
public static IServiceCollection AddAuthModule(
this IServiceCollection services,
IConfiguration configuration)
{
// Настройка базы данных
string connectionString = configuration.GetConnectionString("DefaultConnection")!;
services.AddDbContext<IdentityDbContext>(options =>
services.AddDbContext<AuthDbContext>(options =>
options.UseNpgsql(connectionString));
// Регистрация Unit of Work и Репозиториев
services.AddScoped<IIdentityUnitOfWork>(sp => sp.GetRequiredService<IdentityDbContext>());
services.AddScoped<IIdentityDbContext>(sp => sp.GetRequiredService<IdentityDbContext>());
services.AddScoped<IAuthUnitOfWork>(sp => sp.GetRequiredService<AuthDbContext>());
services.AddScoped<IAuthDbContext>(sp => sp.GetRequiredService<AuthDbContext>());
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IJwtTokenProvider, JwtTokenProvider>();
services.AddScoped<IUserDisplayNameProvider, Knot.Modules.Identity.Infrastructure.Services.UserDisplayNameProvider>();
services.AddScoped<IUserDisplayNameProvider, Knot.Modules.Auth.Infrastructure.Services.UserDisplayNameProvider>();
// Регистрация MediatR для этого модуля
services.AddMediatR(config =>
@@ -38,3 +38,4 @@ public static class DependencyInjection
return services;
}
}

View File

@@ -1,8 +1,8 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Identity.Domain;
namespace Knot.Modules.Auth.Domain;
public static class IdentityErrors
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");
@@ -12,3 +12,4 @@ public static class IdentityErrors
public static readonly Error IdentityRegistrationDisabled = new Error("Identity.RegistrationDisabled", "Registration is disabled by the administrator.");
public static readonly Error IdentityUsernameNotUnique = new Error("Identity.UsernameNotUnique", "Это имя пользователя уже занято.");
}

View File

@@ -1,6 +1,6 @@
using Knot.Modules.Identity.Domain;
using Knot.Modules.Auth.Domain;
namespace Knot.Modules.Identity.Domain;
namespace Knot.Modules.Auth.Domain;
/// <summary>
/// Интерфейс репозитория для работы с пользователями.
@@ -15,3 +15,4 @@ public interface IUserRepository
void Add(User user);
void Update(User user);
}

View File

@@ -1,6 +1,6 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Identity.Domain;
namespace Knot.Modules.Auth.Domain;
/// <summary>
/// Сущность пользователя в контексте идентификации (Identity).
@@ -58,3 +58,4 @@ public sealed class User : AggregateRoot<Guid>
PasswordHash = newPasswordHash;
}
}

View File

@@ -3,10 +3,10 @@ using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Auth.Application.Abstractions;
using Knot.Modules.Auth.Domain;
namespace Knot.Modules.Identity.Infrastructure.Authentication;
namespace Knot.Modules.Auth.Infrastructure.Authentication;
public sealed class JwtTokenProvider : IJwtTokenProvider
{
@@ -43,3 +43,4 @@ public sealed class JwtTokenProvider : IJwtTokenProvider
return new JwtSecurityTokenHandler().WriteToken(token);
}
}

View File

@@ -0,0 +1,81 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Metadata;
using Knot.Modules.Auth.Application.Abstractions;
using Knot.Modules.Auth.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Security;
namespace Knot.Modules.Auth.Infrastructure.Persistence;
/// <summary>
/// Контекст базы данных для модуля Identity.
/// </summary>
public sealed class AuthDbContext : DbContext, IAuthUnitOfWork, Knot.Modules.Auth.Application.Abstractions.IAuthDbContext
{
private readonly IMediator _mediator;
private readonly IEncryptionService _encryptionService;
public AuthDbContext(DbContextOptions<AuthDbContext> options, IMediator mediator, IEncryptionService encryptionService)
: base(options)
{
_mediator = mediator;
_encryptionService = encryptionService;
}
public DbSet<User> Users => Set<User>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
optionsBuilder.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning));
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("identity");
modelBuilder.Entity<User>(builder =>
{
builder.ToTable("Users");
builder.HasKey(u => u.Id);
builder.Property(u => u.Username).IsRequired().HasMaxLength(50);
builder.HasIndex(u => u.Username).IsUnique();
builder.Property(u => u.PasswordHash).IsRequired();
});
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var domainEvents = ChangeTracker
.Entries<IAggregateRoot>()
.SelectMany(x =>
{
if (x.Entity is AggregateRoot<Guid> root)
{
var events = root.GetDomainEvents().ToList();
root.ClearDomainEvents();
return events;
}
return Enumerable.Empty<IDomainEvent>();
})
.ToList();
int result = await base.SaveChangesAsync(cancellationToken);
foreach (var domainEvent in domainEvents)
{
await _mediator.Publish(domainEvent, cancellationToken);
}
return result;
}
}

View File

@@ -1,16 +1,16 @@
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Auth.Domain;
namespace Knot.Modules.Identity.Infrastructure.Persistence;
namespace Knot.Modules.Auth.Infrastructure.Persistence;
/// <summary>
/// Реализация репозитория пользователей с использованием EF Core.
/// </summary>
public sealed class UserRepository : IUserRepository
{
private readonly IdentityDbContext _context;
private readonly AuthDbContext _context;
public UserRepository(IdentityDbContext context)
public UserRepository(AuthDbContext context)
{
_context = context;
}
@@ -54,3 +54,4 @@ public sealed class UserRepository : IUserRepository
_context.Users.Update(user);
}
}

View File

@@ -1,7 +1,7 @@
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Auth.Domain;
namespace Knot.Modules.Identity.Infrastructure.Services;
namespace Knot.Modules.Auth.Infrastructure.Services;
public sealed class UserDisplayNameProvider : IUserDisplayNameProvider
{
@@ -40,3 +40,4 @@ public sealed class UserDisplayNameProvider : IUserDisplayNameProvider
return result;
}
}

View File

@@ -8,10 +8,12 @@
<ItemGroup>
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\Profiles\Knot.Modules.Profiles.csproj" />
</ItemGroup>
<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" />

View File

@@ -1,19 +1,19 @@
// <auto-generated />
// <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;
using Knot.Modules.Identity.Infrastructure.Persistence;
#nullable disable
namespace Knot.Modules.Identity.Infrastructure.Persistence.Migrations
namespace Knot.Modules.Auth.Migrations
{
[DbContext(typeof(IdentityDbContext))]
[Migration("20260311180816_InitialIdentity")]
partial class InitialIdentity
[DbContext(typeof(AuthDbContext))]
[Migration("20260322191927_InitialAuth")]
partial class InitialAuth
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -26,20 +26,33 @@ namespace Knot.Modules.Identity.Infrastructure.Persistence.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Identity.Domain.User", b =>
modelBuilder.Entity("Knot.Modules.Auth.Domain.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
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()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
.HasColumnType("text");
b.Property<bool>("HideStoryViews")
.HasColumnType("boolean");
b.Property<string>("PasswordHash")
.IsRequired()

View File

@@ -1,12 +1,12 @@
using System;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Identity.Infrastructure.Persistence.Migrations
namespace Knot.Modules.Auth.Migrations
{
/// <inheritdoc />
public partial class InitialIdentity : Migration
public partial class InitialAuth : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
@@ -22,8 +22,13 @@ namespace Knot.Modules.Identity.Infrastructure.Persistence.Migrations
Id = table.Column<Guid>(type: "uuid", nullable: false),
Username = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: false),
DisplayName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Email = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true)
DisplayName = table.Column<string>(type: "text", nullable: false),
Email = table.Column<string>(type: "text", nullable: true),
Bio = table.Column<string>(type: "text", nullable: true),
Avatar = table.Column<string>(type: "text", nullable: true),
Birthday = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
HideStoryViews = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{

View File

@@ -0,0 +1,73 @@
// <auto-generated />
using System;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Modules.Auth.Migrations
{
[DbContext(typeof(AuthDbContext))]
partial class AuthDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(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<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>("Email")
.HasColumnType("text");
b.Property<bool>("HideStoryViews")
.HasColumnType("boolean");
b.Property<string>("PasswordHash")
.IsRequired()
.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
}
}
}

View File

@@ -0,0 +1,39 @@
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 MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc;
namespace Knot.Modules.Auth.Presentation.Endpoints;
public sealed class AuthEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup("api/auth");
group.MapPost("register", async ([FromBody] RegisterUserCommand command, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(command, ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Code ?? result.Error.Description });
});
group.MapPost("login", async ([FromBody] LoginUserCommand 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);
return result.IsSuccess ? Results.Ok(new { User = result.Value.User }) : Results.NotFound();
}).RequireAuthorization();
}
}

View File

@@ -1,5 +0,0 @@
using System;
namespace Knot.Modules.Chats.Application.DTOs;
public sealed record CreatePersonalChatRequest(Guid UserId);

View File

@@ -1,3 +0,0 @@
namespace Knot.Modules.Chats.Application.DTOs;
public record TogglePinResponse(bool IsPinned);

View File

@@ -1,99 +0,0 @@
// <auto-generated />
using System;
using Knot.Modules.Chats.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.Chats.Migrations
{
[DbContext(typeof(ChatsDbContext))]
[Migration("20260319124845_InitialChats")]
partial class InitialChats
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("chats")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("ChatId")
.HasColumnType("uuid");
b1.Property<bool>("IsMuted")
.HasColumnType("boolean");
b1.Property<bool>("IsPinned")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b1.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.HasKey("Id");
b1.HasIndex("ChatId", "UserId")
.IsUnique();
b1.ToTable("ChatMembers", "chats");
b1.WithOwner()
.HasForeignKey("ChatId");
});
b.Navigation("Members");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,69 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Chats.Migrations
{
/// <inheritdoc />
public partial class AddHighWaterMark : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "LastMessageSequenceId",
schema: "chats",
table: "Chats",
type: "bigint",
nullable: false,
defaultValue: 0L);
migrationBuilder.AddColumn<Guid>(
name: "LastDeliveredMessageId",
schema: "chats",
table: "ChatMembers",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "LastReadMessageId",
schema: "chats",
table: "ChatMembers",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "LastReadSequenceId",
schema: "chats",
table: "ChatMembers",
type: "bigint",
nullable: false,
defaultValue: 0L);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LastMessageSequenceId",
schema: "chats",
table: "Chats");
migrationBuilder.DropColumn(
name: "LastDeliveredMessageId",
schema: "chats",
table: "ChatMembers");
migrationBuilder.DropColumn(
name: "LastReadMessageId",
schema: "chats",
table: "ChatMembers");
migrationBuilder.DropColumn(
name: "LastReadSequenceId",
schema: "chats",
table: "ChatMembers");
}
}
}

View File

@@ -1,6 +1,6 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Application.Abstractions;
namespace Knot.Modules.Conversations.Application.Abstractions;
/// <summary>
/// Unit of Work специфичный для модуля Chats.
@@ -8,3 +8,4 @@ namespace Knot.Modules.Chats.Application.Abstractions;
public interface IChatsUnitOfWork : IUnitOfWork
{
}

View File

@@ -4,14 +4,14 @@ using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using MediatR;
using System.Linq;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
namespace Knot.Modules.Chats.Application.Chats.Avatar;
namespace Knot.Modules.Conversations.Application.Chats.Avatar;
public record UploadGroupAvatarCommand(Guid ChatId, Guid UserId, string FileName, string ContentType, Stream FileStream) : ICommand<Guid>;
@@ -127,3 +127,4 @@ internal sealed class RemoveGroupAvatarCommandHandler : ICommandHandler<RemoveGr
return Result.Success(request.ChatId);
}
}

View File

@@ -2,12 +2,12 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using MediatR;
using System.Linq;
namespace Knot.Modules.Chats.Application.Chats.Clear;
namespace Knot.Modules.Conversations.Application.Chats.Clear;
public record ClearChatCommand(Guid ChatId, Guid UserId) : ICommand<MessageResponse>;
@@ -32,3 +32,4 @@ internal sealed class ClearChatCommandHandler : ICommandHandler<ClearChatCommand
return Result.Success(new MessageResponse("Cleared"));
}
}

View File

@@ -1,9 +1,9 @@
using Knot.Modules.Chats.Domain;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Chats.Create;
namespace Knot.Modules.Conversations.Application.Chats.Create;
/// <summary>
/// Команда для создания чата.
@@ -41,3 +41,4 @@ public sealed class CreateChatCommandHandler : ICommandHandler<CreateChatCommand
return Result.Success(chat.Id);
}
}

View File

@@ -1,3 +1,5 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -5,11 +7,11 @@ using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.DTOs;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Chats.GetChatById;
namespace Knot.Modules.Conversations.Application.Chats.GetChatById;
public record GetChatByIdQuery(Guid UserId, Guid ChatId) : IQuery<ChatDto?>;
@@ -153,3 +155,5 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
}
}

View File

@@ -1,3 +1,5 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -5,12 +7,12 @@ using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.DTOs;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Chats.GetChats;
namespace Knot.Modules.Conversations.Application.Chats.GetChats;
public record GetChatsQuery(Guid UserId) : IQuery<List<ChatDto>>;
@@ -147,3 +149,5 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
}
}

View File

@@ -1,8 +1,8 @@
using Knot.Modules.Chats.Domain;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Chats.GetOrCreateFavorites;
namespace Knot.Modules.Conversations.Application.Chats.GetOrCreateFavorites;
public sealed record GetOrCreateFavoritesCommand(Guid UserId) : ICommand<Guid>;
@@ -36,3 +36,4 @@ public sealed class GetOrCreateFavoritesCommandHandler : ICommandHandler<GetOrCr
return Result.Success(chat.Id);
}
}

View File

@@ -3,12 +3,12 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using MediatR;
using System.Linq;
namespace Knot.Modules.Chats.Application.Chats.LeaveOrDelete;
namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete;
public record LeaveOrDeleteChatCommand(Guid ChatId, Guid UserId) : ICommand<SuccessResponse>;
@@ -51,3 +51,4 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
return Result.Success(new SuccessResponse(true));
}
}

View File

@@ -3,12 +3,12 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using MediatR;
using System.Linq;
namespace Knot.Modules.Chats.Application.Chats.Members;
namespace Knot.Modules.Conversations.Application.Chats.Members;
public record AddMembersCommand(Guid ChatId, Guid UserId, List<Guid> UserIdsToAdd) : ICommand<Guid>;
@@ -70,3 +70,4 @@ internal sealed class RemoveMemberCommandHandler : ICommandHandler<RemoveMemberC
return Result.Success(request.ChatId);
}
}

View File

@@ -2,13 +2,13 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Chats.Application.DTOs;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.DTOs;
using MediatR;
using System.Linq;
namespace Knot.Modules.Chats.Application.Chats.TogglePin;
namespace Knot.Modules.Conversations.Application.Chats.TogglePin;
public record TogglePinCommand(Guid ChatId, Guid UserId) : ICommand<TogglePinResponse>;
@@ -44,3 +44,4 @@ internal sealed class TogglePinCommandHandler : ICommandHandler<TogglePinCommand
return Result.Success(new TogglePinResponse(member.IsPinned));
}
}

View File

@@ -2,12 +2,12 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using MediatR;
using System.Linq;
namespace Knot.Modules.Chats.Application.Chats.Update;
namespace Knot.Modules.Conversations.Application.Chats.Update;
public record UpdateChatCommand(Guid ChatId, Guid UserId, string? Name, string? Description) : ICommand<Guid>;
@@ -46,3 +46,4 @@ internal sealed class UpdateChatCommandHandler : ICommandHandler<UpdateChatComma
return Result.Success(chat.Id);
}
}

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
namespace Knot.Modules.Conversations.Application.DTOs;
public sealed record AddMembersRequest(List<Guid> UserIds);

View File

@@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
namespace Knot.Modules.Conversations.Application.DTOs;
public record ChatDto(
Guid Id,
@@ -14,3 +14,4 @@ public record ChatDto(
List<ChatMessageDto> Messages,
int UnreadCount
);

View File

@@ -1,7 +1,7 @@
using Knot.Modules.Chats.Domain;
using Knot.Modules.Conversations.Domain;
using System;
namespace Knot.Modules.Chats.Application.DTOs;
namespace Knot.Modules.Conversations.Application.DTOs;
public record ChatMemberDto(
Guid Id,
@@ -10,3 +10,4 @@ public record ChatMemberDto(
bool IsPinned,
ChatUserDto? User
);

View File

@@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
namespace Knot.Modules.Conversations.Application.DTOs;
public record ChatMessageDto(
Guid Id,
@@ -23,3 +23,4 @@ public record ChatMessageDto(
List<ReactionDto> Reactions,
List<ReadByDto> ReadBy
);

View File

@@ -1,6 +1,6 @@
using System;
namespace Knot.Modules.Chats.Application.DTOs;
namespace Knot.Modules.Conversations.Application.DTOs;
public record ChatUserDto(
Guid Id,
@@ -10,3 +10,4 @@ public record ChatUserDto(
bool IsOnline,
DateTime LastSeen
);

View File

@@ -1,7 +1,8 @@
using System;
using System.Collections.Generic;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Conversations.Domain;
namespace Knot.Modules.Chats.Application.DTOs;
namespace Knot.Modules.Conversations.Application.DTOs;
public sealed record CreateChatRequest(string Name, ChatType Type, List<Guid> MemberIds);

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
namespace Knot.Modules.Conversations.Application.DTOs;
public sealed record CreateGroupChatRequest(string Name, List<Guid> MemberIds);

View File

@@ -0,0 +1,4 @@
namespace Knot.Modules.Conversations.Application.DTOs;
using System;
public record CreatePersonalChatRequest(Guid UserId);

View File

@@ -1,6 +1,6 @@
using System;
namespace Knot.Modules.Chats.Application.DTOs;
namespace Knot.Modules.Conversations.Application.DTOs;
public record MediaDto(
Guid Id,
@@ -9,3 +9,4 @@ public record MediaDto(
string? Filename,
long? Size
);

View File

@@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
namespace Knot.Modules.Conversations.Application.DTOs;
public record MessageDetailDto(
Guid Id,
@@ -41,3 +41,4 @@ public record MessageReactionDto(
Guid UserId,
MessageSenderDto? User
);

View File

@@ -1,6 +1,6 @@
using System;
namespace Knot.Modules.Chats.Application.DTOs;
namespace Knot.Modules.Conversations.Application.DTOs;
public record MessageSenderDto(
Guid Id,
@@ -8,3 +8,4 @@ public record MessageSenderDto(
string DisplayName,
string? Avatar
);

View File

@@ -1,6 +1,6 @@
using System;
namespace Knot.Modules.Chats.Application.DTOs;
namespace Knot.Modules.Conversations.Application.DTOs;
public record ReactionDto(
Guid Id,
@@ -8,3 +8,4 @@ public record ReactionDto(
Guid UserId,
MessageSenderDto User
);

View File

@@ -1,7 +1,8 @@
using System;
namespace Knot.Modules.Chats.Application.DTOs;
namespace Knot.Modules.Conversations.Application.DTOs;
public record ReadByDto(
Guid UserId
);

View File

@@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
namespace Knot.Modules.Conversations.Application.DTOs;
public record SearchMessageDto(
Guid Id,
@@ -30,3 +30,4 @@ public record SimpleReactionDto(
Guid UserId,
string Emoji
);

View File

@@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
namespace Knot.Modules.Conversations.Application.DTOs;
public sealed record SendMessageRequest(
string? Content,
@@ -12,3 +12,4 @@ public sealed record SendMessageRequest(
Guid? ForwardedFromId = null);
public sealed record AttachmentDto(string Type, string Url, string? FileName, long? FileSize);

View File

@@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
namespace Knot.Modules.Conversations.Application.DTOs;
public record SharedMediaDto(
Guid Id,
@@ -18,3 +18,4 @@ public record SharedMediaDto(
string? Type,
List<MediaDto>? Media
);

View File

@@ -0,0 +1,4 @@
namespace Knot.Modules.Conversations.Application.DTOs;
public record TogglePinResponse(bool IsPinned);

View File

@@ -1,3 +1,4 @@
namespace Knot.Modules.Chats.Application.DTOs;
namespace Knot.Modules.Conversations.Application.DTOs;
public sealed record UpdateChatRequest(string? Name, string? Description);

View File

@@ -1,10 +1,11 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
namespace Knot.Modules.Conversations.Application.DTOs;
public record UploadFileResponseDto(
string Url,
string Filename,
long Size
);

View File

@@ -1,11 +1,13 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using MediatR;
using Microsoft.AspNetCore.SignalR;
using global::Knot.Modules.Chats.Domain;
using global::Knot.Modules.Chats.Infrastructure.SignalR;
using global::Knot.Modules.Conversations.Domain;
using global::Knot.Modules.Conversations.Infrastructure.SignalR;
using global::Knot.Shared.Kernel;
using global::Knot.Modules.Chats.Application.Abstractions;
using global::Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Messages.Delete;
namespace Knot.Modules.Conversations.Application.Messages.Delete;
public sealed record DeleteMessagesCommand(
Guid ChatId,
@@ -77,3 +79,5 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
return global::Knot.Shared.Kernel.Result.Success();
}
}

View File

@@ -1,3 +1,5 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -5,12 +7,12 @@ using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.DTOs;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Messages.GetMessages;
namespace Knot.Modules.Conversations.Application.Messages.GetMessages;
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor) : IQuery<List<MessageDetailDto>>;
@@ -145,3 +147,5 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
}
}

View File

@@ -1,3 +1,5 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -6,12 +8,12 @@ using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.DTOs;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Messages.GetSharedMedia;
namespace Knot.Modules.Conversations.Application.Messages.GetSharedMedia;
public record GetSharedMediaQuery(Guid UserId, Guid ChatId, string? Type) : IQuery<List<SharedMediaDto>>;
@@ -125,3 +127,5 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
}
}

View File

@@ -1,12 +1,14 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using MediatR;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Infrastructure.SignalR;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Application.Messages.React;
namespace Knot.Modules.Conversations.Application.Messages.React;
public sealed record AddReactionCommand(
Guid MessageId,
@@ -70,3 +72,5 @@ public sealed class AddReactionCommandHandler : ICommandHandler<AddReactionComma
return Result.Success();
}
}

View File

@@ -1,12 +1,14 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using MediatR;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Infrastructure.SignalR;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Application.Messages.React;
namespace Knot.Modules.Conversations.Application.Messages.React;
public sealed record RemoveReactionCommand(
Guid MessageId,
@@ -61,3 +63,5 @@ public sealed class RemoveReactionCommandHandler : ICommandHandler<RemoveReactio
return Result.Success();
}
}

View File

@@ -1,9 +1,9 @@
using MediatR;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Messages.Read;
namespace Knot.Modules.Conversations.Application.Messages.Read;
public sealed record ReadMessagesCommand(Guid ChatId, Guid UserId, Guid LastReadMessageId, long LastReadSequenceId) : ICommand;
@@ -33,3 +33,4 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCom
return Result.Success();
}
}

View File

@@ -1,3 +1,5 @@
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -5,10 +7,10 @@ using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.DTOs;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Domain;
namespace Knot.Modules.Chats.Application.Messages.SearchMessages;
namespace Knot.Modules.Conversations.Application.Messages.SearchMessages;
public record SearchMessagesQuery(Guid UserId, string Query, Guid? ChatId) : IQuery<List<SearchMessageDto>>;
@@ -66,3 +68,5 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
}
}

View File

@@ -1,8 +1,10 @@
using Knot.Modules.Chats.Domain;
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Messaging.Domain;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Messages.Send;
namespace Knot.Modules.Conversations.Application.Messages.Send;
/// <summary>
/// Команда для отправки сообщения в чат.
@@ -133,3 +135,5 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
}
}

View File

@@ -1,4 +1,4 @@
using Knot.Modules.Chats.Domain;
using Knot.Modules.Conversations.Domain;
using System;
using System.IO;
using System.Threading;
@@ -7,9 +7,9 @@ using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Shared.Kernel.Configuration;
using Knot.Modules.Chats.Application.DTOs;
using Knot.Modules.Conversations.Application.DTOs;
namespace Knot.Modules.Chats.Application.Messages.UploadFile;
namespace Knot.Modules.Conversations.Application.Messages.UploadFile;
public record UploadFileCommand(string FileName, string ContentType, long Length, Stream FileStream) : ICommand<UploadFileResponseDto>;
@@ -42,3 +42,4 @@ internal sealed class UploadFileCommandHandler : ICommandHandler<UploadFileComma
return Result.Success(new UploadFileResponseDto("/api/files/" + fileId, request.FileName, request.Length));
}
}

View File

@@ -0,0 +1,43 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations;
public static class DependencyInjection
{
/// <summary>
/// ╨а╨╡╨│╨╕╤Б╤В╤А╨░╤Ж╨╕╤П ╤Б╨╡╤А╨▓╨╕╤Б╨╛╨▓ ╨╝╨╛╨┤╤Г╨╗╤П Chats.
/// </summary>
public static IServiceCollection AddConversationsModule(
this IServiceCollection services,
IConfiguration configuration)
{
// ╨Э╨░╤Б╤В╤А╨╛╨╣╨║╨░ ╨▒╨░╨╖╤Л ╨┤╨░╨╜╨╜╤Л╤Е
string connectionString = configuration.GetConnectionString("DefaultConnection")!;
services.AddDbContext<ChatsDbContext>(options =>
options.UseNpgsql(connectionString));
// MongoDB Setup for Messages
var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017";
// Registration
services.AddScoped<IChatsUnitOfWork>(sp => sp.GetRequiredService<ChatsDbContext>());
services.AddScoped<IChatRepository, ChatRepository>();
// MediatR
services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
services.AddScoped<Knot.Modules.Messaging.Application.Abstractions.IChatAccessProvider, Knot.Modules.Conversations.Infrastructure.Services.ChatAccessProvider>();
services.AddScoped<Knot.Modules.Messaging.Application.Abstractions.IMessageNotifier, Knot.Modules.Conversations.Infrastructure.SignalR.MessageNotifier>();
return services;
}
}

View File

@@ -1,6 +1,6 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Domain;
namespace Knot.Modules.Conversations.Domain;
public sealed record ChatCreatedDomainEvent(Chat Chat) : IDomainEvent;
public sealed record ChatMemberAddedDomainEvent(Guid ChatId, Guid UserId) : IDomainEvent;
@@ -154,3 +154,4 @@ public sealed class ChatMember : Entity<Guid>
LastDeliveredMessageId = messageId;
}
}

View File

@@ -1,4 +1,4 @@
namespace Knot.Modules.Chats.Domain;
namespace Knot.Modules.Conversations.Domain;
public static class ChatConstants
{
@@ -7,3 +7,4 @@ public static class ChatConstants
public const int SearchMessagesLimit = 50;
public const int MaxFileUploadSizeMb = 50;
}

View File

@@ -1,6 +1,6 @@
using Knot.Shared.Kernel;
namespace Knot.Modules.Chats.Domain;
namespace Knot.Modules.Conversations.Domain;
public static class ChatErrors
{
@@ -19,3 +19,4 @@ public static class ChatErrors
public static Error ImportCreateChatFailed(string msg) => new Error("Import.CreateChatFailed", msg);
public static Error FileTooLarge(int maxMb) => new Error("File.TooLarge", $"File exceeds the maximum allowed size of {maxMb}MB.");
}

View File

@@ -1,6 +1,6 @@
using Knot.Modules.Chats.Domain;
using Knot.Modules.Conversations.Domain;
namespace Knot.Modules.Chats.Domain;
namespace Knot.Modules.Conversations.Domain;
public interface IChatRepository
{
@@ -11,3 +11,4 @@ public interface IChatRepository
Task<Chat?> GetFavoritesAsync(Guid userId, CancellationToken cancellationToken);
Task<List<Chat>> GetUserChatsAsync(Guid userId, CancellationToken cancellationToken);
}

View File

@@ -1,10 +1,10 @@
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Infrastructure.SignalR;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Infrastructure.SignalR;
using MediatR;
using Microsoft.AspNetCore.SignalR;
namespace Knot.Modules.Chats.Infrastructure.Handlers;
namespace Knot.Modules.Conversations.Infrastructure.Handlers;
/// <summary>
/// Обработчик доменного события создания чата.
@@ -89,3 +89,4 @@ public sealed class ChatCreatedDomainEventHandler : INotificationHandler<ChatCre
}
}
}

View File

@@ -1,7 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Conversations.Domain;
namespace Knot.Modules.Chats.Infrastructure.Persistence;
namespace Knot.Modules.Conversations.Infrastructure.Persistence;
public sealed class ChatRepository : IChatRepository
{
@@ -53,3 +53,4 @@ public sealed class ChatRepository : IChatRepository
.ToListAsync(cancellationToken);
}
}

View File

@@ -1,12 +1,12 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Security;
namespace Knot.Modules.Chats.Infrastructure.Persistence;
namespace Knot.Modules.Conversations.Infrastructure.Persistence;
/// <summary>
/// Контекст базы данных для модуля чатов.
@@ -85,3 +85,4 @@ public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
return result;
}
}

View File

@@ -0,0 +1,11 @@
namespace Knot.Modules.Conversations.Infrastructure.Services;
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
public class ChatAccessProvider : IChatAccessProvider { private readonly ChatsDbContext _db; public ChatAccessProvider(ChatsDbContext db) { _db = db; } public Task<List<Guid>> GetValidChatIdsForUserAsync(Guid userId, CancellationToken ct) { return _db.Chats.Where(c => c.Members.Any(m => m.UserId == userId)).Select(c => c.Id).ToListAsync(ct); } }

View File

@@ -4,15 +4,15 @@ using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Knot.Modules.Chats.Application.Messages.Send;
using Knot.Modules.Chats.Application.Messages.Read;
using Knot.Modules.Chats.Application.Messages.Delete;
using Knot.Modules.Chats.Application.Messages.React;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Conversations.Application.Messages.Send;
using Knot.Modules.Conversations.Application.Messages.Read;
using Knot.Modules.Conversations.Application.Messages.Delete;
using Knot.Modules.Conversations.Application.Messages.React;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Microsoft.Extensions.Caching.Memory;
namespace Knot.Modules.Chats.Infrastructure.SignalR;
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
/// <summary>
/// Хаб SignalR для обработки сообщений и WebRTC сигналинга в реальном времени.
@@ -661,3 +661,4 @@ public sealed class ChatHub : Hub
public record GroupRenegotiateAnswerRequest(string ChatId, string TargetUserId, object Answer);
public record FriendSignalRequest(string FriendId);
}

View File

@@ -0,0 +1,7 @@
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Modules.Messaging.Application.Abstractions;
using Microsoft.AspNetCore.SignalR;
using System;
using System.Threading;
using System.Threading.Tasks;
public class MessageNotifier : IMessageNotifier { private readonly IHubContext<ChatHub> _hubContext; public MessageNotifier(IHubContext<ChatHub> hubContext) { _hubContext = hubContext; } public Task NotifyNewMessageAsync(Guid chatId, object messagePayload, CancellationToken cancellationToken) { return _hubContext.Clients.Group(chatId.ToString()).SendAsync("new_message", messagePayload, cancellationToken); } }

View File

@@ -2,10 +2,12 @@
<ItemGroup>
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\Messaging\Knot.Modules.Messaging.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.4.0" />
<PackageReference Include="Carter" Version="10.0.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="MediatR" Version="12.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
@@ -33,3 +35,4 @@
</PropertyGroup>
</Project>

View File

@@ -1,6 +1,6 @@
// <auto-generated />
using System;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
@@ -9,11 +9,11 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Modules.Chats.Migrations
namespace Knot.Modules.Conversations.Migrations
{
[DbContext(typeof(ChatsDbContext))]
[Migration("20260320185319_AddHighWaterMark")]
partial class AddHighWaterMark
[Migration("20260322191932_InitialConversations")]
partial class InitialConversations
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -26,7 +26,7 @@ namespace Knot.Modules.Chats.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
modelBuilder.Entity("Knot.Modules.Conversations.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -56,9 +56,9 @@ namespace Knot.Modules.Chats.Migrations
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
modelBuilder.Entity("Knot.Modules.Conversations.Domain.Chat", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 =>
b.OwnsMany("Knot.Modules.Conversations.Domain.ChatMember", "Members", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()

View File

@@ -3,10 +3,10 @@ using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Chats.Migrations
namespace Knot.Modules.Conversations.Migrations
{
/// <inheritdoc />
public partial class InitialChats : Migration
public partial class InitialConversations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
@@ -24,7 +24,8 @@ namespace Knot.Modules.Chats.Migrations
Name = table.Column<string>(type: "text", nullable: true),
Description = table.Column<string>(type: "text", nullable: true),
Avatar = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
LastMessageSequenceId = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
@@ -42,7 +43,10 @@ namespace Knot.Modules.Chats.Migrations
Role = table.Column<string>(type: "text", nullable: false),
JoinedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
IsPinned = table.Column<bool>(type: "boolean", nullable: false),
IsMuted = table.Column<bool>(type: "boolean", nullable: false)
IsMuted = table.Column<bool>(type: "boolean", nullable: false),
LastReadMessageId = table.Column<Guid>(type: "uuid", nullable: true),
LastReadSequenceId = table.Column<long>(type: "bigint", nullable: false),
LastDeliveredMessageId = table.Column<Guid>(type: "uuid", nullable: true)
},
constraints: table =>
{

View File

@@ -1,6 +1,6 @@
// <auto-generated />
using System;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -8,7 +8,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Modules.Chats.Migrations
namespace Knot.Modules.Conversations.Migrations
{
[DbContext(typeof(ChatsDbContext))]
partial class ChatsDbContextModelSnapshot : ModelSnapshot
@@ -23,7 +23,7 @@ namespace Knot.Modules.Chats.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
modelBuilder.Entity("Knot.Modules.Conversations.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -53,9 +53,9 @@ namespace Knot.Modules.Chats.Migrations
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
modelBuilder.Entity("Knot.Modules.Conversations.Domain.Chat", b =>
{
b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 =>
b.OwnsMany("Knot.Modules.Conversations.Domain.ChatMember", "Members", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()

View File

@@ -0,0 +1,174 @@
using Carter;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Application.Chats.GetChats;
using Knot.Modules.Conversations.Application.Chats.GetChatById;
using Knot.Modules.Conversations.Application.Chats.Create;
using Knot.Modules.Conversations.Application.Chats.GetOrCreateFavorites;
using Knot.Modules.Conversations.Application.Chats.Update;
using Knot.Modules.Conversations.Application.Chats.LeaveOrDelete;
using Knot.Modules.Conversations.Application.Chats.Clear;
using Knot.Modules.Conversations.Application.Chats.TogglePin;
using Knot.Modules.Conversations.Application.Chats.Members;
using Knot.Modules.Conversations.Application.Chats.Avatar;
using Knot.Modules.Conversations.Domain;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc;
namespace Knot.Modules.Conversations.Presentation.Endpoints;
public sealed class ChatsEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup("api/chats").RequireAuthorization();
group.MapGet("", async (ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new GetChatsQuery(userContext.UserId), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
});
group.MapPost("", async ([FromBody] CreateChatRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var command = new CreateChatCommand(request.Name, request.Type, request.MemberIds);
var result = await sender.Send(command, ct);
if (result.IsFailure) return Results.BadRequest(result.Error.Description);
var chatResult = await sender.Send(new GetChatByIdQuery(userContext.UserId, result.Value), ct);
return Results.Ok(chatResult.Value);
});
group.MapPost("personal", async ([FromBody] CreatePersonalChatRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var command = new CreateChatCommand(string.Empty, ChatType.Personal, new List<Guid> { userContext.UserId, request.UserId });
var result = await sender.Send(command, ct);
if (result.IsFailure) return Results.BadRequest(result.Error.Description);
var chatResult = await sender.Send(new GetChatByIdQuery(userContext.UserId, result.Value), ct);
return Results.Ok(chatResult.Value);
});
group.MapPost("group", async ([FromBody] CreateGroupChatRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var memberIds = request.MemberIds.ToList();
if (memberIds.Contains(userContext.UserId)) memberIds.Remove(userContext.UserId);
memberIds.Insert(0, userContext.UserId);
var command = new CreateChatCommand(request.Name, ChatType.Group, memberIds);
var result = await sender.Send(command, ct);
if (result.IsFailure) return Results.BadRequest(result.Error.Description);
var chatResult = await sender.Send(new GetChatByIdQuery(userContext.UserId, result.Value), ct);
return Results.Ok(chatResult.Value);
});
group.MapPost("favorites", async (ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new GetOrCreateFavoritesCommand(userContext.UserId), ct);
if (result.IsFailure) return Results.BadRequest(result.Error.Description);
var chatResult = await sender.Send(new GetChatByIdQuery(userContext.UserId, result.Value), ct);
return Results.Ok(chatResult.Value);
});
group.MapPut("{id:guid}", async (Guid id, [FromBody] UpdateChatRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new UpdateChatCommand(id, userContext.UserId, request.Name, request.Description), ct);
if (result.IsFailure) return Results.NotFound();
var chatResult = await sender.Send(new GetChatByIdQuery(userContext.UserId, result.Value), ct);
return Results.Ok(chatResult.Value);
});
group.MapDelete("{id:guid}", async (Guid id, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new LeaveOrDeleteChatCommand(id, userContext.UserId), ct);
if (result.IsFailure)
{
if (result.Error.Code == "Unauthorized") return Results.Forbid();
return Results.NotFound();
}
return Results.Ok(result.Value);
});
group.MapPost("{id:guid}/clear", async (Guid id, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new ClearChatCommand(id, userContext.UserId), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound();
});
group.MapPost("{id:guid}/pin", async (Guid id, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new TogglePinCommand(id, userContext.UserId), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound();
});
group.MapPost("{id:guid}/members", async (Guid id, [FromBody] AddMembersRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new AddMembersCommand(id, userContext.UserId, request.UserIds.ToList()), ct);
if (result.IsFailure) return Results.NotFound();
var chatResult = await sender.Send(new GetChatByIdQuery(userContext.UserId, result.Value), ct);
return Results.Ok(chatResult.Value);
});
group.MapDelete("{id:guid}/members/{userId:guid}", async (Guid id, Guid userId, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new RemoveMemberCommand(id, userContext.UserId, userId), ct);
if (result.IsFailure) return Results.NotFound();
var chatResult = await sender.Send(new GetChatByIdQuery(userContext.UserId, result.Value), ct);
return Results.Ok(chatResult.Value);
});
group.MapPost("{id:guid}/avatar", async (Guid id, HttpRequest req, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
if (!req.HasFormContentType) return Results.BadRequest("No file");
var form = await req.ReadFormAsync(ct);
var avatar = form.Files.FirstOrDefault();
if (avatar == null || avatar.Length == 0) return Results.BadRequest("No file");
using var stream = avatar.OpenReadStream();
var result = await sender.Send(new UploadGroupAvatarCommand(id, userContext.UserId, avatar.FileName, avatar.ContentType, stream), ct);
if (result.IsFailure) return Results.NotFound();
var chatResult = await sender.Send(new GetChatByIdQuery(userContext.UserId, result.Value), ct);
return Results.Ok(chatResult.Value);
}).DisableAntiforgery();
group.MapPost("{id:guid}/avatar/crop", async (Guid id, HttpRequest req, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
if (!req.HasFormContentType) return Results.BadRequest("No file");
var form = await req.ReadFormAsync(ct);
var avatar = form.Files.FirstOrDefault();
if (avatar == null || avatar.Length == 0) return Results.BadRequest("No file");
int.TryParse(form["x"], out int x);
int.TryParse(form["y"], out int y);
int.TryParse(form["width"], out int width);
int.TryParse(form["height"], out int height);
using var stream = avatar.OpenReadStream();
var result = await sender.Send(new CropGroupAvatarCommand(id, userContext.UserId, avatar.FileName ?? "avatar.jpg", avatar.ContentType, stream, x, y, width, height), ct);
if (result.IsFailure) return Results.NotFound();
var chatResult = await sender.Send(new GetChatByIdQuery(userContext.UserId, result.Value), ct);
return Results.Ok(chatResult.Value);
}).DisableAntiforgery();
group.MapDelete("{id:guid}/avatar", async (Guid id, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new RemoveGroupAvatarCommand(id, userContext.UserId), ct);
if (result.IsFailure) return Results.NotFound();
var chatResult = await sender.Send(new GetChatByIdQuery(userContext.UserId, result.Value), ct);
return Results.Ok(chatResult.Value);
});
}
}

View File

@@ -0,0 +1,79 @@
using Carter;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Application.Messages.GetMessages;
using Knot.Modules.Conversations.Application.Messages.SearchMessages;
using Knot.Modules.Conversations.Application.Messages.UploadFile;
using Knot.Modules.Conversations.Application.Messages.GetSharedMedia;
using Knot.Modules.Conversations.Application.Messages.Send;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc;
namespace Knot.Modules.Conversations.Presentation.Endpoints;
public sealed class MessagesEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup("api/messages").RequireAuthorization();
group.MapGet("chat/{chatId:guid}", async (Guid chatId, [FromQuery] string? cursor, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new GetMessagesQuery(userContext.UserId, chatId, cursor), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
});
group.MapGet("search", async ([FromQuery] string q, [FromQuery] Guid? chatId, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new SearchMessagesQuery(userContext.UserId, q, chatId), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
});
group.MapPost("upload", async (HttpRequest req, ISender sender, CancellationToken ct) =>
{
if (!req.HasFormContentType) return Results.BadRequest("No file uploaded");
var form = await req.ReadFormAsync(ct);
var file = form.Files.FirstOrDefault();
if (file == null || file.Length == 0) return Results.BadRequest("No file uploaded");
using var stream = file.OpenReadStream();
var result = await sender.Send(new UploadFileCommand(file.FileName, file.ContentType, file.Length, stream), ct);
if (result.IsFailure)
{
if (result.Error.Code == "File.TooLarge") return Results.StatusCode(413);
return Results.BadRequest(result.Error.Description);
}
return Results.Ok(result.Value);
}).DisableAntiforgery();
group.MapGet("chat/{chatId:guid}/shared", async (Guid chatId, [FromQuery] string? type, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new GetSharedMediaQuery(userContext.UserId, chatId, type), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
});
group.MapPost("chat/{chatId:guid}", async (Guid chatId, [FromBody] SendMessageRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var attachments = request.Attachments?.Select(a =>
new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList();
var command = new SendMessageCommand(
chatId,
userContext.UserId,
request.Content,
request.Type,
attachments,
request.ReplyToId,
request.Quote,
request.ForwardedFromId);
var result = await sender.Send(command, ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
});
}
}

View File

@@ -0,0 +1,54 @@
using Knot.Modules.Stories.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using Knot.Shared.Kernel.Constants;
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
namespace Host.Application.Federation.Commands;
public record HandshakeRequest(string Domain, string PublicKey);
public record HandshakeResponse(string Domain, string PublicKey, string Status);
public record HandshakeFederationCommand(HandshakeRequest Request) : ICommand<HandshakeResponse>;
internal sealed class HandshakeFederationCommandHandler : ICommandHandler<HandshakeFederationCommand, HandshakeResponse>
{
private readonly ISettingsService _settings;
public HandshakeFederationCommandHandler(ISettingsService settings)
{
_settings = settings;
}
public Task<Result<HandshakeResponse>> Handle(HandshakeFederationCommand request, CancellationToken cancellationToken)
{
var conf = _settings.Current;
if (!conf.EnableConfederation)
return Task.FromResult(Result.Failure<HandshakeResponse>(new Error(Errors.DisabledByAdmin, "Federation is disabled")));
if (string.IsNullOrEmpty(request.Request.Domain) || string.IsNullOrEmpty(request.Request.PublicKey))
return Task.FromResult(Result.Failure<HandshakeResponse>(DomainErrors.RequestInvalid));
var allowedList = conf.AllowedDomains?.Select(d => d.Trim().ToLower()).ToList() ?? new System.Collections.Generic.List<string>();
if (!allowedList.Contains(request.Request.Domain.ToLowerInvariant()))
return Task.FromResult(Result.Failure<HandshakeResponse>(StoryErrors.Unauthorized));
using var rsa = RSA.Create(2048);
var selfPublicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey());
var response = new HandshakeResponse(
Environment.GetEnvironmentVariable("DOMAIN") ?? "knot.local",
selfPublicKey,
"Accepted"
);
return Task.FromResult(Result.Success(response));
}
}

View File

@@ -0,0 +1,7 @@
namespace Knot.Modules.Federation;
using Microsoft.Extensions.DependencyInjection;
public static class DependencyInjection {
public static IServiceCollection AddFederationModule(this IServiceCollection services) {
return services;
}
}

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