Reorganize root folder structure: Remove apps layer layer

This commit is contained in:
Халимов Рустам
2026-03-19 22:22:45 +03:00
parent fb252f9d87
commit 11ebbc853b
296 changed files with 139 additions and 139 deletions

View File

@@ -0,0 +1,100 @@
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.Identity.Infrastructure.Persistence;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Knot.Modules.Chats.Domain;
using MongoDB.Driver;
using Microsoft.EntityFrameworkCore;
namespace Host.Application.Admin.Commands;
public record CleanRunCommand(IFileStorageService FileStorage, IdentityDbContext 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,43 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Host.Models;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Identity.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 IIdentityUnitOfWork _identityUnitOfWork;
public ResetUserPasswordCommandHandler(IUserRepository userRepository, IIdentityUnitOfWork 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>(IdentityErrors.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,26 @@
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,101 @@
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.Identity.Infrastructure.Persistence;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Knot.Modules.Chats.Domain;
using MongoDB.Driver;
using Microsoft.EntityFrameworkCore;
using Host.Models;
namespace Host.Application.Admin.Queries;
public record CleanDryRunQuery(IFileStorageService FileStorage, IdentityDbContext 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,25 @@
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,26 @@
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,72 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Host.Models;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Chats.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>(IdentityErrors.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.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()),
Knot.Modules.Chats.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,43 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Host.Models;
using Knot.Modules.Identity.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.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()),
Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()) ? DateTime.UtcNow : u.CreatedAt
)).ToList();
return Result.Success(result);
}
}

View File

@@ -0,0 +1,25 @@
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using MediatR;
using Host.Models;
using System.Threading;
using System.Threading.Tasks;
namespace Host.Application.Config.Queries;
public record GetPublicConfigQuery : IQuery<PublicConfigDto>;
internal sealed class GetPublicConfigQueryHandler : IQueryHandler<GetPublicConfigQuery, PublicConfigDto>
{
private readonly ISettingsService _settings;
public GetPublicConfigQueryHandler(ISettingsService settings)
{
_settings = settings;
}
public Task<Result<PublicConfigDto>> Handle(GetPublicConfigQuery request, CancellationToken cancellationToken)
{
return Task.FromResult(Result.Success(PublicConfigDto.FromSettings(_settings.Current)));
}
}

View File

@@ -0,0 +1,52 @@
using Host.Application.Stories;
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,63 @@
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using Knot.Shared.Kernel.Constants;
using Microsoft.Extensions.Caching.Memory;
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
namespace Host.Application.Klipy.Queries;
public record GetTrendingGifsQuery : IQuery<JsonElement?>;
internal sealed class GetTrendingGifsQueryHandler : IQueryHandler<GetTrendingGifsQuery, JsonElement?>
{
private readonly ISettingsService _settings;
private readonly IMemoryCache _cache;
private readonly IHttpClientFactory _httpClientFactory;
public GetTrendingGifsQueryHandler(ISettingsService settings, IMemoryCache cache, IHttpClientFactory httpClientFactory)
{
_settings = settings;
_cache = cache;
_httpClientFactory = httpClientFactory;
}
public async Task<Result<JsonElement?>> Handle(GetTrendingGifsQuery request, CancellationToken cancellationToken)
{
var conf = _settings.Current;
if (!conf.EnableKlipy || string.IsNullOrEmpty(conf.KlipyApiKey))
return Result.Failure<JsonElement?>(new Error(Errors.KlipyNotConfigured, "Klipy is not configured"));
var cacheKeyTrending = $"klipy_trending_{conf.KlipyApiKey}";
if (_cache.TryGetValue(cacheKeyTrending, out JsonElement cachedResult))
return Result.Success<JsonElement?>(cachedResult);
var customerId = string.IsNullOrWhiteSpace(conf.KlipyCustomerId) ? Knot.Shared.Kernel.Constants.Klipy.DefaultCustomerId : conf.KlipyCustomerId;
var client = _httpClientFactory.CreateClient();
var urlCo = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCo, conf.KlipyApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceTrending, "", customerId);
var response = await client.GetAsync(urlCo, cancellationToken);
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
var urlCom = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCom, conf.KlipyApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceTrending, "", customerId);
response = await client.GetAsync(urlCom, cancellationToken);
}
if (!response.IsSuccessStatusCode)
return Result.Failure<JsonElement?>(new Error(Errors.KlipyApiError, "Klipy API Error"));
}
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: cancellationToken);
_cache.Set(cacheKeyTrending, result, TimeSpan.FromMinutes(Knot.Shared.Kernel.Constants.Klipy.TrendingCacheMinutes));
return Result.Success<JsonElement?>(result);
}
}

View File

@@ -0,0 +1,67 @@
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using Knot.Shared.Kernel.Constants;
using Microsoft.Extensions.Caching.Memory;
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
namespace Host.Application.Klipy.Queries;
public record SearchGifsQuery(string Query) : IQuery<JsonElement?>;
internal sealed class SearchGifsQueryHandler : IQueryHandler<SearchGifsQuery, JsonElement?>
{
private readonly ISettingsService _settings;
private readonly IMemoryCache _cache;
private readonly IHttpClientFactory _httpClientFactory;
public SearchGifsQueryHandler(ISettingsService settings, IMemoryCache cache, IHttpClientFactory httpClientFactory)
{
_settings = settings;
_cache = cache;
_httpClientFactory = httpClientFactory;
}
public async Task<Result<JsonElement?>> Handle(SearchGifsQuery request, CancellationToken cancellationToken)
{
var conf = _settings.Current;
if (!conf.EnableKlipy || string.IsNullOrEmpty(conf.KlipyApiKey))
return Result.Failure<JsonElement?>(new Error(Errors.KlipyNotConfigured, "Klipy is not configured"));
if (string.IsNullOrWhiteSpace(request.Query))
return Result.Failure<JsonElement?>(new Error(Errors.InvalidQuery, "Invalid query parameter"));
var cacheKey = $"klipy_search_{conf.KlipyApiKey}_{request.Query.ToLowerInvariant()}";
if (_cache.TryGetValue(cacheKey, out JsonElement cachedResult))
return Result.Success<JsonElement?>(cachedResult);
var customerId = string.IsNullOrWhiteSpace(conf.KlipyCustomerId) ? Knot.Shared.Kernel.Constants.Klipy.DefaultCustomerId : conf.KlipyCustomerId;
var client = _httpClientFactory.CreateClient();
var queryParam = $"q={Uri.EscapeDataString(request.Query)}";
var urlCo = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCo, conf.KlipyApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceSearch, queryParam, customerId);
var response = await client.GetAsync(urlCo, cancellationToken);
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
var urlCom = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCom, conf.KlipyApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceSearch, queryParam, customerId);
response = await client.GetAsync(urlCom, cancellationToken);
}
if (!response.IsSuccessStatusCode)
return Result.Failure<JsonElement?>(new Error(Errors.KlipySearchError, "Klipy Search API Error"));
}
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: cancellationToken);
_cache.Set(cacheKey, result, TimeSpan.FromMinutes(Knot.Shared.Kernel.Constants.Klipy.SearchCacheMinutes));
return Result.Success<JsonElement?>(result);
}
}

View File

@@ -0,0 +1,141 @@
using Host.Application.Stories;
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.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Messages.Send;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.SignalR;
using Knot.Modules.Chats.Infrastructure.SignalR;
using Host.Models;
namespace Host.Application.Stories.Commands.AddStoryReaction;
public record AddStoryReactionCommand(Guid UserId, Guid StoryId, string Emoji) : ICommand<MessageResponse>;
internal sealed class AddStoryReactionCommandHandler : ICommandHandler<AddStoryReactionCommand, MessageResponse>
{
private readonly IdentityDbContext _context;
private readonly IUserRepository _userRepository;
private readonly IChatRepository _chatRepository;
private readonly IMessageRepository _messageRepository;
private readonly ISender _sender;
private readonly IHubContext<ChatHub> _hubContext;
public AddStoryReactionCommandHandler(
IdentityDbContext context,
IUserRepository userRepository,
IChatRepository chatRepository,
IMessageRepository messageRepository,
ISender sender,
IHubContext<ChatHub> hubContext)
{
_context = context;
_userRepository = userRepository;
_chatRepository = chatRepository;
_messageRepository = messageRepository;
_sender = sender;
_hubContext = hubContext;
}
public async Task<Result<MessageResponse>> Handle(AddStoryReactionCommand request, CancellationToken cancellationToken)
{
var story = await _context.Stories.FindAsync(new object[] { request.StoryId }, cancellationToken);
if (story == null)
{
return Result.Failure<MessageResponse>(StoryErrors.StoryNotFound);
}
var existing = await _context.StoryReactions
.FirstOrDefaultAsync(r => r.StoryId == request.StoryId && r.UserId == request.UserId && r.Emoji == request.Emoji, cancellationToken);
if (existing != null)
{
return Result.Success(new MessageResponse("Reaction already exists"));
}
var reaction = new StoryReaction(request.StoryId, request.UserId, request.Emoji);
_context.StoryReactions.Add(reaction);
await _context.SaveChangesAsync(cancellationToken);
var chatId = await GetOrCreatePersonalChatIdAsync(request.UserId, story.UserId, cancellationToken);
var lastStoryMessage = await _messageRepository.GetLastStoryMessageAsync(chatId, story.Id, cancellationToken);
if (lastStoryMessage != null)
{
var addReactionCommand = new Knot.Modules.Chats.Application.Messages.React.AddReactionCommand(
lastStoryMessage.Id, request.UserId, request.Emoji, chatId);
await _sender.Send(addReactionCommand, cancellationToken);
}
else
{
var storyQuote = GetStoryQuote(story);
var messageCommand = new SendMessageCommand(
ChatId: chatId,
SenderId: request.UserId,
Content: request.Emoji,
Type: "text",
Quote: storyQuote,
StoryId: story.Id,
StoryMediaUrl: story.MediaUrl,
StoryMediaType: story.Type,
Attachments: null,
ReplyToId: null,
ForwardedFromId: null);
await _sender.Send(messageCommand, cancellationToken);
}
var reactor = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
await _hubContext.Clients.All.SendAsync("story_reaction", new
{
storyId = story.Id,
userId = request.UserId,
username = reactor?.Username,
displayName = reactor?.DisplayName,
avatar = reactor?.Avatar,
emoji = request.Emoji,
createdAt = DateTime.UtcNow,
ownerId = story.UserId
}, cancellationToken);
return Result.Success(new MessageResponse("Reaction added"));
}
private string GetStoryQuote(Story story)
{
if (!string.IsNullOrEmpty(story.Content))
{
return story.Content;
}
return story.Type.ToLower() switch
{
"image" => "🖼 Фото",
"video" => "🎬 Видео",
_ => "История"
};
}
private async Task<Guid> GetOrCreatePersonalChatIdAsync(Guid userId1, Guid userId2, CancellationToken cancellationToken)
{
var userChats = await _chatRepository.GetUserChatsAsync(userId1, cancellationToken);
var personalChat = userChats.FirstOrDefault(c =>
c.Type == ChatType.Personal &&
c.Members.Any(m => m.UserId == userId2));
if (personalChat != null)
{
return personalChat.Id;
}
var command = new Knot.Modules.Chats.Application.Chats.Create.CreateChatCommand(string.Empty, ChatType.Personal, new List<Guid> { userId1, userId2 });
var result = await _sender.Send(command, cancellationToken);
return result.Value;
}
}

View File

@@ -0,0 +1,124 @@
using Host.Application.Stories;
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.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Messages.Send;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.SignalR;
using Knot.Modules.Chats.Infrastructure.SignalR;
using Host.Models;
namespace Host.Application.Stories.Commands.AddStoryReply;
public record AddStoryReplyCommand(Guid UserId, Guid StoryId, string Content) : ICommand<MessageResponse>;
internal sealed class AddStoryReplyCommandHandler : ICommandHandler<AddStoryReplyCommand, MessageResponse>
{
private readonly IdentityDbContext _context;
private readonly IUserRepository _userRepository;
private readonly IChatRepository _chatRepository;
private readonly IMessageRepository _messageRepository;
private readonly ISender _sender;
private readonly IHubContext<ChatHub> _hubContext;
public AddStoryReplyCommandHandler(
IdentityDbContext context,
IUserRepository userRepository,
IChatRepository chatRepository,
IMessageRepository messageRepository,
ISender sender,
IHubContext<ChatHub> hubContext)
{
_context = context;
_userRepository = userRepository;
_chatRepository = chatRepository;
_messageRepository = messageRepository;
_sender = sender;
_hubContext = hubContext;
}
public async Task<Result<MessageResponse>> Handle(AddStoryReplyCommand request, CancellationToken cancellationToken)
{
var story = await _context.Stories.FindAsync(new object[] { request.StoryId }, cancellationToken);
if (story == null)
{
return Result.Failure<MessageResponse>(StoryErrors.StoryNotFound);
}
var reply = new StoryReply(request.StoryId, request.UserId, request.Content);
_context.StoryReplies.Add(reply);
await _context.SaveChangesAsync(cancellationToken);
var chatId = await GetOrCreatePersonalChatIdAsync(request.UserId, story.UserId, cancellationToken);
var lastStoryMessage = await _messageRepository.GetLastStoryMessageAsync(chatId, story.Id, cancellationToken);
var storyQuote = GetStoryQuote(story);
var messageCommand = new SendMessageCommand(
ChatId: chatId,
SenderId: request.UserId,
Content: request.Content,
Type: "text",
Quote: storyQuote,
ReplyToId: lastStoryMessage?.Id,
StoryId: story.Id,
StoryMediaUrl: story.MediaUrl,
StoryMediaType: story.Type,
Attachments: null,
ForwardedFromId: null);
await _sender.Send(messageCommand, cancellationToken);
var replier = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
await _hubContext.Clients.All.SendAsync("story_reply", new
{
storyId = story.Id,
userId = request.UserId,
username = replier?.Username,
displayName = replier?.DisplayName,
avatar = replier?.Avatar,
content = request.Content,
createdAt = DateTime.UtcNow,
ownerId = story.UserId
}, cancellationToken);
return Result.Success(new MessageResponse("Reply added"));
}
private string GetStoryQuote(Story story)
{
if (!string.IsNullOrEmpty(story.Content))
{
return story.Content;
}
return story.Type.ToLower() switch
{
"image" => "<22><> Фото",
"video" => "🎬 Видео",
_ => "История"
};
}
private async Task<Guid> GetOrCreatePersonalChatIdAsync(Guid userId1, Guid userId2, CancellationToken cancellationToken)
{
var userChats = await _chatRepository.GetUserChatsAsync(userId1, cancellationToken);
var personalChat = userChats.FirstOrDefault(c =>
c.Type == ChatType.Personal &&
c.Members.Any(m => m.UserId == userId2));
if (personalChat != null)
{
return personalChat.Id;
}
var command = new Knot.Modules.Chats.Application.Chats.Create.CreateChatCommand(string.Empty, ChatType.Personal, new List<Guid> { userId1, userId2 });
var result = await _sender.Send(command, cancellationToken);
return result.Value;
}
}

View File

@@ -0,0 +1,36 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Identity.Domain;
namespace Host.Application.Stories.Commands.CreateStory;
public record CreateStoryCommand(Guid UserId, string Type, string? MediaUrl, string? Content, string? BgColor) : ICommand<Guid>;
internal sealed class CreateStoryCommandHandler : ICommandHandler<CreateStoryCommand, Guid>
{
private readonly IdentityDbContext _context;
public CreateStoryCommandHandler(IdentityDbContext context)
{
_context = context;
}
public async Task<Result<Guid>> Handle(CreateStoryCommand request, CancellationToken cancellationToken)
{
var story = Story.Create(
request.UserId,
request.Type,
request.MediaUrl,
request.Content,
request.BgColor);
_context.Stories.Add(story);
await _context.SaveChangesAsync(cancellationToken);
return Result.Success(story.Id);
}
}

View File

@@ -0,0 +1,40 @@
using Host.Application.Stories;
using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Host.Models;
namespace Host.Application.Stories.Commands.DeleteStory;
public record DeleteStoryCommand(Guid UserId, Guid StoryId) : ICommand<MessageResponse>;
internal sealed class DeleteStoryCommandHandler : ICommandHandler<DeleteStoryCommand, MessageResponse>
{
private readonly IdentityDbContext _context;
public DeleteStoryCommandHandler(IdentityDbContext context)
{
_context = context;
}
public async Task<Result<MessageResponse>> Handle(DeleteStoryCommand request, CancellationToken cancellationToken)
{
var story = await _context.Stories.FindAsync(new object[] { request.StoryId }, cancellationToken);
if (story == null)
{
return Result.Failure<MessageResponse>(StoryErrors.StoryNotFound);
}
if (story.UserId != request.UserId)
{
return Result.Failure<MessageResponse>(StoryErrors.Unauthorized);
}
_context.Stories.Remove(story);
await _context.SaveChangesAsync(cancellationToken);
return Result.Success(new MessageResponse("Story deleted"));
}
}

View File

@@ -0,0 +1,39 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Host.Models;
namespace Host.Application.Stories.Commands.RemoveStoryReaction;
public record RemoveStoryReactionCommand(Guid UserId, Guid StoryId, string Emoji) : ICommand<MessageResponse>;
internal sealed class RemoveStoryReactionCommandHandler : ICommandHandler<RemoveStoryReactionCommand, MessageResponse>
{
private readonly IdentityDbContext _context;
public RemoveStoryReactionCommandHandler(IdentityDbContext context)
{
_context = context;
}
public async Task<Result<MessageResponse>> Handle(RemoveStoryReactionCommand request, CancellationToken cancellationToken)
{
var reaction = await _context.StoryReactions
.FirstOrDefaultAsync(r => r.StoryId == request.StoryId && r.UserId == request.UserId && r.Emoji == request.Emoji, cancellationToken);
if (reaction == null)
{
return Result.Success(new MessageResponse("Reaction not found"));
}
_context.StoryReactions.Remove(reaction);
await _context.SaveChangesAsync(cancellationToken);
return Result.Success(new MessageResponse("Reaction removed"));
}
}

View File

@@ -0,0 +1,77 @@
using Host.Application.Stories;
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.SignalR;
using Knot.Modules.Chats.Infrastructure.SignalR;
using Host.Models;
namespace Host.Application.Stories.Commands.ViewStory;
public record ViewStoryCommand(Guid UserId, Guid StoryId) : ICommand<MessageResponse>;
internal sealed class ViewStoryCommandHandler : ICommandHandler<ViewStoryCommand, MessageResponse>
{
private readonly IdentityDbContext _context;
private readonly IUserRepository _userRepository;
private readonly IHubContext<ChatHub> _hubContext;
public ViewStoryCommandHandler(IdentityDbContext context, IUserRepository userRepository, IHubContext<ChatHub> hubContext)
{
_context = context;
_userRepository = userRepository;
_hubContext = hubContext;
}
public async Task<Result<MessageResponse>> Handle(ViewStoryCommand request, CancellationToken cancellationToken)
{
try
{
var story = await _context.Stories
.Include(s => s.Viewers)
.FirstOrDefaultAsync(s => s.Id == request.StoryId, cancellationToken);
if (story == null)
{
return Result.Failure<MessageResponse>(StoryErrors.StoryNotFound);
}
if (story.UserId == request.UserId)
{
return Result.Success(new MessageResponse("Owner view"));
}
if (!story.Viewers.Any(v => v.UserId == request.UserId))
{
story.AddViewer(request.UserId);
await _context.SaveChangesAsync(cancellationToken);
var viewer = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
var updatedStory = await _context.Stories.Include(s => s.Viewers).FirstAsync(s => s.Id == story.Id, cancellationToken);
await _hubContext.Clients.All.SendAsync("story_viewed", new
{
storyId = story.Id,
userId = request.UserId,
username = viewer?.Username,
displayName = viewer?.DisplayName,
avatar = viewer?.Avatar,
viewedAt = DateTime.UtcNow,
viewCount = updatedStory.Viewers.Count,
ownerId = story.UserId
}, cancellationToken);
}
return Result.Success(new MessageResponse("Story viewed"));
}
catch (DbUpdateException)
{
return Result.Success(new MessageResponse("Story already viewed"));
}
}
}

View File

@@ -0,0 +1,99 @@
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.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Messages.Send;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.SignalR;
using Knot.Modules.Chats.Infrastructure.SignalR;
using Host.Models;
namespace Host.Application.Stories.Queries.GetStories;
public record GetStoriesQuery(Guid UserId) : IQuery<List<StoryGroupDto>>;
internal sealed class GetStoriesQueryHandler : IQueryHandler<GetStoriesQuery, List<StoryGroupDto>>
{
private readonly IdentityDbContext _context;
private readonly IUserRepository _userRepository;
public GetStoriesQueryHandler(IdentityDbContext context, IUserRepository userRepository)
{
_context = context;
_userRepository = userRepository;
}
public async Task<Result<List<StoryGroupDto>>> Handle(GetStoriesQuery request, CancellationToken cancellationToken)
{
var friendships = await _context.Set<Friendship>()
.Where(f => f.Status == FriendshipStatus.Accepted && (f.UserId == request.UserId || f.FriendId == request.UserId))
.ToListAsync(cancellationToken);
var friendIds = friendships.Select(f => f.UserId == request.UserId ? f.FriendId : f.UserId).ToList();
friendIds.Add(request.UserId);
var stories = await _context.Stories
.Include(s => s.Viewers)
.Include(s => s.Reactions)
.Include(s => s.Replies)
.Where(s => s.ExpiresAt > DateTime.UtcNow && friendIds.Contains(s.UserId))
.OrderByDescending(s => s.CreatedAt)
.ToListAsync(cancellationToken);
var groups = stories.GroupBy(s => s.UserId).ToList();
var result = new List<StoryGroupDto>();
var userIds = groups.Select(g => g.Key).ToList();
var userMap = new Dictionary<Guid, dynamic>();
foreach (var uid in userIds)
{
var u = await _userRepository.GetByIdAsync(uid, cancellationToken);
if (u != null)
{
userMap[uid] = u;
}
}
foreach (var group in groups)
{
if (!userMap.TryGetValue(group.Key, out var user))
{
continue;
}
result.Add(new StoryGroupDto(
new StoryUserDto(user.Id, user.Username, user.DisplayName, user.Avatar),
group.Select(s => new StoryDto(
s.Id,
s.Type,
s.MediaUrl,
s.Content,
s.BgColor,
s.CreatedAt,
s.ExpiresAt,
s.Viewers.Count,
s.Viewers.Any(v => v.UserId == request.UserId),
s.Reactions.Select(r => new StoryReactionDto(r.Id, r.UserId, r.Emoji, r.CreatedAt)).ToList(),
s.Replies.Count
)).OrderBy(s => s.CreatedAt).ToList(),
group.Any(s => !s.Viewers.Any(v => v.UserId == request.UserId))
));
}
return Result.Success(result.OrderBy(r =>
{
if (r.User.Id == request.UserId)
{
return 0;
}
return 1;
}).ToList());
}
}

View File

@@ -0,0 +1,66 @@
using Host.Application.Stories;
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.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Host.Models;
namespace Host.Application.Stories.Queries.GetStoryReplies;
public record GetStoryRepliesQuery(Guid UserId, Guid StoryId) : IQuery<List<StoryReplyDto>>;
internal sealed class GetStoryRepliesQueryHandler : IQueryHandler<GetStoryRepliesQuery, List<StoryReplyDto>>
{
private readonly IdentityDbContext _context;
private readonly IUserRepository _userRepository;
public GetStoryRepliesQueryHandler(IdentityDbContext context, IUserRepository userRepository)
{
_context = context;
_userRepository = userRepository;
}
public async Task<Result<List<StoryReplyDto>>> Handle(GetStoryRepliesQuery request, CancellationToken cancellationToken)
{
var story = await _context.Stories
.Include(s => s.Replies)
.FirstOrDefaultAsync(s => s.Id == request.StoryId, cancellationToken);
if (story == null)
{
return Result.Failure<List<StoryReplyDto>>(StoryErrors.StoryNotFound);
}
if (story.UserId != request.UserId)
{
return Result.Failure<List<StoryReplyDto>>(StoryErrors.Unauthorized);
}
var replies = new List<StoryReplyDto>();
foreach (var reply in story.Replies.OrderBy(r => r.CreatedAt))
{
var user = await _userRepository.GetByIdAsync(reply.UserId, cancellationToken);
if (user == null)
{
continue;
}
replies.Add(new StoryReplyDto(
reply.Id,
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
reply.Content,
reply.CreatedAt
));
}
return Result.Success(replies);
}
}

View File

@@ -0,0 +1,68 @@
using Host.Application.Stories;
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.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Host.Models;
namespace Host.Application.Stories.Queries.GetStoryViewers;
public record GetStoryViewersQuery(Guid UserId, Guid StoryId) : IQuery<List<StoryViewerDto>>;
internal sealed class GetStoryViewersQueryHandler : IQueryHandler<GetStoryViewersQuery, List<StoryViewerDto>>
{
private readonly IdentityDbContext _context;
private readonly IUserRepository _userRepository;
public GetStoryViewersQueryHandler(IdentityDbContext context, IUserRepository userRepository)
{
_context = context;
_userRepository = userRepository;
}
public async Task<Result<List<StoryViewerDto>>> Handle(GetStoryViewersQuery request, CancellationToken cancellationToken)
{
var story = await _context.Stories
.Include(s => s.Viewers)
.FirstOrDefaultAsync(s => s.Id == request.StoryId, cancellationToken);
if (story == null)
{
return Result.Failure<List<StoryViewerDto>>(StoryErrors.StoryNotFound);
}
if (story.UserId != request.UserId)
{
return Result.Failure<List<StoryViewerDto>>(StoryErrors.Unauthorized);
}
var viewerIds = story.Viewers.Select(v => v.UserId).ToList();
var viewers = new List<StoryViewerDto>();
foreach (var viewerId in viewerIds)
{
var user = await _userRepository.GetByIdAsync(viewerId, cancellationToken);
if (user == null)
{
continue;
}
var viewerRecord = story.Viewers.First(v => v.UserId == viewerId);
viewers.Add(new StoryViewerDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
viewerRecord.ViewedAt
));
}
return Result.Success(viewers);
}
}

View File

@@ -0,0 +1,65 @@
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.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Identity.Application.Abstractions;
using Microsoft.EntityFrameworkCore;
using Host.Models;
namespace Host.Application.Stories.Queries.GetUserStories;
public record GetUserStoriesQuery(Guid CurrentUserId, Guid TargetUserId) : IQuery<StoryGroupDto>;
internal sealed class GetUserStoriesQueryHandler : IQueryHandler<GetUserStoriesQuery, StoryGroupDto>
{
private readonly IdentityDbContext _context;
private readonly IUserRepository _userRepository;
public GetUserStoriesQueryHandler(IdentityDbContext context, IUserRepository userRepository)
{
_context = context;
_userRepository = userRepository;
}
public async Task<Result<StoryGroupDto>> Handle(GetUserStoriesQuery request, CancellationToken cancellationToken)
{
var stories = await _context.Stories
.Include(s => s.Viewers)
.Include(s => s.Reactions)
.Include(s => s.Replies)
.Where(s => s.UserId == request.TargetUserId)
.OrderByDescending(s => s.CreatedAt)
.ToListAsync(cancellationToken);
var user = await _userRepository.GetByIdAsync(request.TargetUserId, cancellationToken);
if (user == null)
{
return Result.Failure<StoryGroupDto>(IdentityErrors.UserNotFound);
}
var result = new StoryGroupDto(
new StoryUserDto(user.Id, user.Username, user.DisplayName, user.Avatar),
stories.Select(s => new StoryDto(
s.Id,
s.Type,
s.MediaUrl,
s.Content,
s.BgColor,
s.CreatedAt,
s.ExpiresAt,
s.Viewers.Count,
s.Viewers.Any(v => v.UserId == request.CurrentUserId),
s.Reactions.Select(r => new StoryReactionDto(r.Id, r.UserId, r.Emoji, r.CreatedAt)).ToList(),
s.Replies.Count
)).ToList(),
stories.Any(s => !s.Viewers.Any(v => v.UserId == request.CurrentUserId))
);
return Result.Success(result);
}
}

View File

@@ -0,0 +1,9 @@
using Knot.Shared.Kernel;
namespace Host.Application.Stories;
public static class StoryErrors
{
public static readonly Error Unauthorized = new Error("Unauthorized", "Access denied");
public static readonly Error StoryNotFound = new Error("Story.NotFound", "Story not found");
}

View File

@@ -0,0 +1,73 @@
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using Microsoft.Extensions.Configuration;
using MediatR;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Host.Application.WebRtc.Queries;
public record IceServerDto(string[] Urls, string? Username = null, string? Credential = null);
public record IceServersResultDto(List<IceServerDto> IceServers);
public record GetIceServersQuery : IQuery<IceServersResultDto>;
internal sealed class GetIceServersQueryHandler : IQueryHandler<GetIceServersQuery, IceServersResultDto>
{
private readonly IConfiguration _configuration;
private readonly ISettingsService _settingsService;
public GetIceServersQueryHandler(IConfiguration configuration, ISettingsService settingsService)
{
_configuration = configuration;
_settingsService = settingsService;
}
public Task<Result<IceServersResultDto>> Handle(GetIceServersQuery request, CancellationToken cancellationToken)
{
var settings = _settingsService.Current;
if (!settings.EnableCalls)
{
return Task.FromResult(Result.Failure<IceServersResultDto>(new Error(
Knot.Shared.Kernel.Constants.Errors.DisabledByAdmin,
"Сервис отключен администратором."
)));
}
var turnUrl = !string.IsNullOrEmpty(settings.TurnHost)
? $"turn:{settings.TurnHost}:{settings.TurnPort}"
: _configuration["WebRtc:TurnUrl"];
var turnUsername = !string.IsNullOrEmpty(settings.TurnUser)
? settings.TurnUser
: _configuration["WebRtc:TurnUsername"];
var turnSecret = !string.IsNullOrEmpty(settings.TurnSecret)
? settings.TurnSecret
: _configuration["WebRtc:TurnPassword"];
var iceServers = new List<IceServerDto>();
if (!string.IsNullOrEmpty(turnUrl))
{
var stunUrl = turnUrl.Replace("turn:", "stun:");
iceServers.Add(new IceServerDto(new[] { stunUrl }));
if (!string.IsNullOrEmpty(turnUsername))
{
iceServers.Add(new IceServerDto(
new[] { turnUrl, turnUrl + "?transport=tcp" },
turnUsername,
!string.IsNullOrEmpty(turnSecret) ? turnSecret : turnUsername
));
}
else
{
iceServers.Add(new IceServerDto(new[] { turnUrl, turnUrl + "?transport=tcp" }));
}
}
return Task.FromResult(Result.Success(new IceServersResultDto(iceServers)));
}
}

View File

@@ -0,0 +1,110 @@
using Microsoft.AspNetCore.Mvc;
using Host.Models;
using Knot.Shared.Kernel.Configuration;
using Knot.Modules.Identity.Application.Users.Register;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Identity.Infrastructure.Persistence;
using System.Threading;
using System.Threading.Tasks;
using System;
using MediatR;
using Host.Application.Admin.Queries;
using Host.Application.Admin.Commands;
using Microsoft.AspNetCore.Authorization;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class AdminController : ControllerBase
{
private readonly ISender _sender;
public AdminController(ISender sender)
{
_sender = sender;
}
[HttpGet("settings")]
public async Task<IActionResult> GetSettings(CancellationToken ct)
{
var result = await _sender.Send(new GetSettingsQuery(), ct);
return Ok(result.Value);
}
[HttpPut("settings")]
public async Task<IActionResult> UpdateSettings([FromBody] SystemSettingsDto settings, CancellationToken ct)
{
var result = await _sender.Send(new UpdateSettingsCommand(settings), ct);
return Ok(result.Value);
}
[HttpGet("dashboard")]
public async Task<IActionResult> GetDashboardStats(CancellationToken ct)
{
var result = await _sender.Send(new GetDashboardStatsQuery(), ct);
return Ok(result.Value);
}
[HttpPost("users")]
public async Task<IActionResult> CreateUser([FromBody] RegisterUserCommand command, CancellationToken ct)
{
var result = await _sender.Send(command, ct);
if (result.IsFailure) return BadRequest(new { error = result.Error.Description });
var userDetails = await _sender.Send(new GetUserDetailsQuery(result.Value.User.Id), ct);
return Ok(userDetails.Value);
}
[HttpPost("users/{userId:guid}/reset-password")]
public async Task<IActionResult> ResetUserPassword(Guid userId, [FromBody] ResetPasswordDto dto, CancellationToken ct)
{
var result = await _sender.Send(new ResetUserPasswordCommand(userId, dto.NewPassword), ct);
if (result.IsFailure)
{
if (result.Error.Code == "User.NotFound") return NotFound(new { error = "User not found" });
return BadRequest(new { error = result.Error.Description });
}
return Ok(result.Value);
}
[HttpGet("users")]
public async Task<IActionResult> SearchUsers([FromQuery] string query = "", CancellationToken ct = default)
{
var result = await _sender.Send(new SearchUsersQuery(query), ct);
return Ok(result.Value);
}
[HttpGet("users/{id:guid}")]
public async Task<IActionResult> GetUserDetails(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new GetUserDetailsQuery(id), ct);
if (result.IsFailure)
{
return NotFound("User not found");
}
return Ok(result.Value);
}
[HttpGet("clean/dry-run")]
public async Task<IActionResult> CleanDryRun(
[FromServices] IFileStorageService fileStorage,
[FromServices] IdentityDbContext identityDb,
CancellationToken ct)
{
var result = await _sender.Send(new CleanDryRunQuery(fileStorage, identityDb), ct);
return Ok(result.Value);
}
[HttpPost("clean/run")]
public async Task<IActionResult> CleanRun(
[FromServices] IFileStorageService fileStorage,
[FromServices] IdentityDbContext identityDb,
CancellationToken ct)
{
var result = await _sender.Send(new CleanRunCommand(fileStorage, identityDb), ct);
return Ok(result.Value);
}
}

View File

@@ -0,0 +1,50 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Application.Users.Auth;
using Knot.Modules.Identity.Application.Users.Register;
using Knot.Modules.Identity.Application.Users.Login;
using Knot.Modules.Identity.Application.Users.GetMe;
using MediatR;
namespace Host.Controllers;
[ApiController]
[Route("api/[controller]")]
public sealed class AuthController : ControllerBase
{
private readonly ISender _sender;
public AuthController(ISender sender)
{
_sender = sender;
}
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterUserCommand command, CancellationToken ct)
{
var result = await _sender.Send(command, ct);
if (result.IsFailure) return BadRequest(new { error = result.Error.Code ?? result.Error.Description });
return Ok(result.Value);
}
[HttpGet("me")]
[Authorize]
public async Task<IActionResult> GetMe([FromServices] IUserContext userContext, CancellationToken ct)
{
var result = await _sender.Send(new GetMeQuery(userContext.UserId), ct);
if (result.IsFailure)
{
return NotFound();
}
return Ok(new { User = result.Value.User });
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginUserCommand command, CancellationToken ct)
{
var result = await _sender.Send(command, ct);
if (result.IsFailure) return Unauthorized(new { error = result.Error.Code ?? result.Error.Description });
return Ok(result.Value);
}
}

View File

@@ -0,0 +1,241 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.DTOs;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Chats.Application.Chats.GetChats;
using Knot.Modules.Chats.Application.Chats.GetChatById;
using Knot.Modules.Chats.Application.Chats.Create;
using Knot.Modules.Chats.Application.Chats.GetOrCreateFavorites;
using Knot.Modules.Chats.Application.Chats.Update;
using Knot.Modules.Chats.Application.Chats.LeaveOrDelete;
using Knot.Modules.Chats.Application.Chats.Clear;
using Knot.Modules.Chats.Application.Chats.TogglePin;
using Knot.Modules.Chats.Application.Chats.Members;
using Knot.Modules.Chats.Application.Chats.Avatar;
using Knot.Modules.Chats.Domain;
using MediatR;
using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/chats")]
public sealed class ChatsController : ControllerBase
{
private readonly ISender _sender;
private readonly IUserContext _userContext;
public ChatsController(ISender sender, IUserContext userContext)
{
_sender = sender;
_userContext = userContext;
}
[HttpGet]
public async Task<IActionResult> GetChats(CancellationToken ct)
{
var result = await _sender.Send(new GetChatsQuery(_userContext.UserId), ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
return Ok(result.Value);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateChatRequest request, CancellationToken ct)
{
var command = new CreateChatCommand(request.Name, request.Type, request.MemberIds);
var result = await _sender.Send(command, ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpPost("personal")]
public async Task<IActionResult> CreatePersonal([FromBody] CreatePersonalChatRequest request, 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 BadRequest(result.Error.Description);
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpPost("group")]
public async Task<IActionResult> CreateGroup([FromBody] CreateGroupChatRequest request, 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 BadRequest(result.Error.Description);
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpPost("favorites")]
public async Task<IActionResult> GetOrCreateFavorites(CancellationToken ct)
{
var result = await _sender.Send(new GetOrCreateFavoritesCommand(_userContext.UserId), ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> UpdateChat(Guid id, [FromBody] UpdateChatRequest request, CancellationToken ct)
{
var result = await _sender.Send(new UpdateChatCommand(id, _userContext.UserId, request.Name, request.Description), ct);
if (result.IsFailure)
{
return NotFound();
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> LeaveOrDeleteChat(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new LeaveOrDeleteChatCommand(id, _userContext.UserId), ct);
if (result.IsFailure)
{
if (result.Error.Code == "Unauthorized")
{
return Forbid();
}
return NotFound();
}
return Ok(result.Value);
}
[HttpPost("{id:guid}/clear")]
public async Task<IActionResult> ClearChat(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new ClearChatCommand(id, _userContext.UserId), ct);
if (result.IsFailure)
{
return NotFound();
}
return Ok(result.Value);
}
[HttpPost("{id:guid}/pin")]
public async Task<IActionResult> TogglePin(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new TogglePinCommand(id, _userContext.UserId), ct);
if (result.IsFailure)
{
return NotFound();
}
return Ok(result.Value);
}
[HttpPost("{id:guid}/members")]
public async Task<IActionResult> AddMembers(Guid id, [FromBody] AddMembersRequest request, CancellationToken ct)
{
var result = await _sender.Send(new AddMembersCommand(id, _userContext.UserId, request.UserIds.ToList()), ct);
if (result.IsFailure)
{
return NotFound();
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpDelete("{id:guid}/members/{userId:guid}")]
public async Task<IActionResult> RemoveMember(Guid id, Guid userId, CancellationToken ct)
{
var result = await _sender.Send(new RemoveMemberCommand(id, _userContext.UserId, userId), ct);
if (result.IsFailure)
{
return NotFound();
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpPost("{id:guid}/avatar")]
public async Task<IActionResult> UploadGroupAvatar(Guid id, Microsoft.AspNetCore.Http.IFormFile avatar, CancellationToken ct)
{
if (avatar == null || avatar.Length == 0)
{
return 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 NotFound();
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpPost("{id:guid}/avatar/crop")]
public async Task<IActionResult> CropGroupAvatar(Guid id, [FromForm] Microsoft.AspNetCore.Http.IFormFile avatar, [FromForm] int x, [FromForm] int y, [FromForm] int width, [FromForm] int height, CancellationToken ct)
{
if (avatar == null || avatar.Length == 0)
{
return BadRequest("No file");
}
using var stream = avatar.OpenReadStream();
var result = await _sender.Send(new CropGroupAvatarCommand(id, _userContext.UserId, avatar.FileName, avatar.ContentType, stream, x, y, width, height), ct);
if (result.IsFailure)
{
return NotFound();
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
[HttpDelete("{id:guid}/avatar")]
public async Task<IActionResult> RemoveGroupAvatar(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new RemoveGroupAvatarCommand(id, _userContext.UserId), ct);
if (result.IsFailure)
{
return NotFound();
}
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
return Ok(chatResult.Value);
}
}

View File

@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Knot.Shared.Kernel.Storage;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/files")]
public sealed class FilesController : ControllerBase
{
private readonly IFileStorageService _fileStorage;
public FilesController(IFileStorageService fileStorage)
{
_fileStorage = fileStorage;
}
[HttpGet("{id}")]
[AllowAnonymous]
public async Task<IActionResult> DownloadFile(string id)
{
try
{
var result = await _fileStorage.DownloadFileAsync(id);
// Обратите внимание, что мы возвращаем поток с автоматическим освобождением памяти.
return File(result.Stream, result.ContentType, result.FileName);
}
catch (Exception ex)
{
return NotFound(new { error = "Файл не найден или ошибка доступа.", message = ex.Message });
}
}
}

View File

@@ -0,0 +1,113 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Identity.Application.Friends;
using Knot.Shared.Kernel;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/friends")]
public sealed class FriendsController : ControllerBase
{
private readonly ISender _sender;
private readonly IUserContext _userContext;
public FriendsController(ISender sender, IUserContext userContext)
{
_sender = sender;
_userContext = userContext;
}
[HttpGet]
public async Task<IActionResult> GetFriends(CancellationToken ct)
{
var result = await _sender.Send(new GetFriendsQuery(_userContext.UserId), ct);
if (result.IsFailure)
{
return BadRequest(result.Error);
}
return Ok(result.Value);
}
[HttpGet("requests")]
public async Task<IActionResult> GetRequests(CancellationToken ct)
{
var result = await _sender.Send(new GetIncomingRequestsQuery(_userContext.UserId), ct);
if (result.IsFailure)
{
return BadRequest(result.Error);
}
return Ok(result.Value);
}
[HttpGet("outgoing")]
public async Task<IActionResult> GetOutgoingRequests(CancellationToken ct)
{
var result = await _sender.Send(new GetOutgoingRequestsQuery(_userContext.UserId), ct);
if (result.IsFailure)
{
return BadRequest(result.Error);
}
return Ok(result.Value);
}
[HttpPost("request")]
public async Task<IActionResult> SendRequest([FromBody] Host.Models.SendFriendRequest request, CancellationToken ct)
{
var result = await _sender.Send(new SendFriendRequestCommand(_userContext.UserId, request.FriendId), ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
return Ok(new { status = "pending" });
}
[HttpPost("{id:guid}/accept")]
public async Task<IActionResult> AcceptRequest(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new AcceptFriendRequestCommand(_userContext.UserId, id), ct);
if (result.IsFailure)
{
return NotFound(result.Error.Description);
}
return Ok(new { id = result.Value });
}
[HttpPost("{id:guid}/decline")]
public async Task<IActionResult> DeclineRequest(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new DeclineFriendRequestCommand(_userContext.UserId, id), ct);
if (result.IsFailure)
{
return NotFound(result.Error.Description);
}
return Ok(new Knot.Shared.Kernel.SuccessResponse(true));
}
[HttpGet("status/{userId:guid}")]
public async Task<IActionResult> GetStatus(Guid userId, CancellationToken ct)
{
var result = await _sender.Send(new GetFriendshipStatusQuery(_userContext.UserId, userId), ct);
if (result.IsFailure)
{
return BadRequest(result.Error);
}
return Ok(result.Value);
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> RemoveFriend(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new RemoveFriendCommand(_userContext.UserId, id), ct);
if (result.IsFailure)
{
return NotFound(result.Error.Description);
}
return Ok(new Knot.Shared.Kernel.SuccessResponse(true));
}
}

View File

@@ -0,0 +1,114 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Knot.Modules.Chats.Application.DTOs;
using Knot.Shared.Kernel;
using System.Threading;
using System.Threading.Tasks;
using System;
using Microsoft.AspNetCore.Http;
using MediatR;
using Knot.Modules.Chats.Application.Messages.GetMessages;
using Knot.Modules.Chats.Application.Messages.SearchMessages;
using Knot.Modules.Chats.Application.Messages.UploadFile;
using Knot.Modules.Chats.Application.Messages.GetSharedMedia;
using Knot.Modules.Chats.Application.Messages.Send;
using System.Linq;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/messages")]
public sealed class MessagesController : ControllerBase
{
private readonly ISender _sender;
private readonly IUserContext _userContext;
public MessagesController(ISender sender, IUserContext userContext)
{
_sender = sender;
_userContext = userContext;
}
[HttpGet("chat/{chatId:guid}")]
public async Task<IActionResult> GetMessages(Guid chatId, [FromQuery] string? cursor, CancellationToken ct = default)
{
var result = await _sender.Send(new GetMessagesQuery(_userContext.UserId, chatId, cursor), ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
return Ok(result.Value);
}
[HttpGet("search")]
public async Task<IActionResult> GetSearch([FromQuery] string q, [FromQuery] Guid? chatId, CancellationToken ct)
{
var result = await _sender.Send(new SearchMessagesQuery(_userContext.UserId, q, chatId), ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
return Ok(result.Value);
}
[HttpPost("upload")]
public async Task<IActionResult> UploadFile(IFormFile file, CancellationToken ct)
{
if (file == null || file.Length == 0)
{
return 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 StatusCode(413, result.Error.Description);
}
return BadRequest(result.Error.Description);
}
return Ok(result.Value);
}
[HttpGet("chat/{chatId:guid}/shared")]
public async Task<IActionResult> GetSharedMedia(Guid chatId, [FromQuery] string? type, CancellationToken ct)
{
var result = await _sender.Send(new GetSharedMediaQuery(_userContext.UserId, chatId, type), ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
return Ok(result.Value);
}
[HttpPost("chat/{chatId:guid}")]
public async Task<IActionResult> SendMessage(Guid chatId, [FromBody] SendMessageRequest request, 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);
if (result.IsFailure)
{
return BadRequest(result.Error.Description);
}
return Ok(result.Value);
}
}

View File

@@ -0,0 +1,149 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Host.Models;
using Knot.Shared.Kernel;
using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Host.Application.Stories.Queries.GetStories;
using Host.Application.Stories.Commands.CreateStory;
using Host.Application.Stories.Queries.GetUserStories;
using Host.Application.Stories.Commands.ViewStory;
using Host.Application.Stories.Queries.GetStoryViewers;
using Host.Application.Stories.Commands.AddStoryReaction;
using Host.Application.Stories.Commands.RemoveStoryReaction;
using Host.Application.Stories.Commands.AddStoryReply;
using Host.Application.Stories.Queries.GetStoryReplies;
using Host.Application.Stories.Commands.DeleteStory;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/stories")]
public sealed class StoriesController : ControllerBase
{
private readonly ISender _sender;
private readonly IUserContext _userContext;
public StoriesController(ISender sender, IUserContext userContext)
{
_sender = sender;
_userContext = userContext;
}
[HttpGet]
public async Task<IActionResult> GetStories(CancellationToken ct)
{
var result = await _sender.Send(new GetStoriesQuery(_userContext.UserId), ct);
return Ok(result.Value);
}
[HttpPost]
public async Task<IActionResult> CreateStory([FromBody] CreateStoryRequest request, CancellationToken ct)
{
var result = await _sender.Send(new CreateStoryCommand(_userContext.UserId, request.Type, request.MediaUrl, request.Content, request.BgColor), ct);
return Ok(new { id = result.Value });
}
[HttpGet("user/{userId}")]
public async Task<IActionResult> GetUserStories(Guid userId, CancellationToken ct)
{
var result = await _sender.Send(new GetUserStoriesQuery(_userContext.UserId, userId), ct);
if (result.IsFailure)
{
return NotFound();
}
return Ok(result.Value);
}
[HttpPost("{id}/view")]
public async Task<IActionResult> ViewStory(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new ViewStoryCommand(_userContext.UserId, id), ct);
if (result.IsFailure)
{
return NotFound();
}
return Ok(result.Value);
}
[HttpGet("{id}/viewers")]
public async Task<IActionResult> GetStoryViewers(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new GetStoryViewersQuery(_userContext.UserId, id), ct);
if (result.IsFailure)
{
if (result.Error.Code == "Unauthorized")
{
return Forbid();
}
return NotFound();
}
return Ok(result.Value);
}
[HttpPost("{id}/reaction")]
public async Task<IActionResult> AddReaction(Guid id, [FromBody] AddStoryReactionRequest request, CancellationToken ct)
{
var result = await _sender.Send(new AddStoryReactionCommand(_userContext.UserId, id, request.Emoji), ct);
if (result.IsFailure)
{
return NotFound();
}
return Ok(result.Value);
}
[HttpDelete("{id}/reaction")]
public async Task<IActionResult> RemoveReaction(Guid id, [FromBody] RemoveStoryReactionRequest request, CancellationToken ct)
{
var result = await _sender.Send(new RemoveStoryReactionCommand(_userContext.UserId, id, request.Emoji), ct);
if (result.IsFailure)
{
return NotFound();
}
return Ok(result.Value);
}
[HttpPost("{id}/reply")]
public async Task<IActionResult> AddReply(Guid id, [FromBody] AddStoryReplyRequest request, CancellationToken ct)
{
var result = await _sender.Send(new AddStoryReplyCommand(_userContext.UserId, id, request.Content), ct);
if (result.IsFailure)
{
return NotFound();
}
return Ok(result.Value);
}
[HttpGet("{id}/replies")]
public async Task<IActionResult> GetReplies(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new GetStoryRepliesQuery(_userContext.UserId, id), ct);
if (result.IsFailure)
{
if (result.Error.Code == "Unauthorized")
{
return Forbid();
}
return NotFound();
}
return Ok(result.Value);
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteStory(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new DeleteStoryCommand(_userContext.UserId, id), ct);
if (result.IsFailure)
{
if (result.Error.Code == "Unauthorized")
{
return Forbid();
}
return NotFound();
}
return Ok(result.Value);
}
}

View File

@@ -0,0 +1,64 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using MediatR;
using Host.Models;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.TelegramImport;
using System.Threading;
using System.Threading.Tasks;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/import/telegram")]
public sealed class TelegramImportController : ControllerBase
{
private readonly ISender _sender;
private readonly IUserContext _userContext;
public TelegramImportController(ISender sender, IUserContext userContext)
{
_sender = sender;
_userContext = userContext;
}
[HttpPost("analyze")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 10L * 1024 * 1024 * 1024)] // 10GB for big exports
public async Task<IActionResult> Analyze(IFormFile file, CancellationToken ct)
{
if (file == null)
{
return BadRequest("No file uploaded.");
}
using var stream = file.OpenReadStream();
var command = new AnalyzeImportCommand(stream, file.FileName);
var result = await _sender.Send(command, ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description ?? result.Error.Code);
}
return Ok(result.Value);
}
[HttpPost("execute")]
public async Task<IActionResult> Execute([FromBody] ExecuteImportRequest req, CancellationToken ct)
{
var command = new ExecuteImportCommand(_userContext.UserId, req.Token, req.Mapping, req.GroupName);
var result = await _sender.Send(command, ct);
if (result.IsFailure)
{
return BadRequest(result.Error.Description ?? result.Error.Code);
}
return Ok(result.Value);
}
}

View File

@@ -0,0 +1,134 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Knot.Shared.Kernel;
using Host.Models;
using Knot.Shared.Kernel.Storage;
using MediatR;
using Knot.Modules.Identity.Application.Users;
using Knot.Modules.Identity.Application.Users.Avatar;
using Knot.Modules.Identity.Application.Users.GetUser;
using Knot.Modules.Identity.Application.Users.Search;
using Knot.Modules.Identity.Application.Users.UpdateProfile;
using Knot.Modules.Identity.Application.Users.UpdateSettings;
namespace Host.Controllers;
[Authorize]
[ApiController]
[Route("api/users")]
public sealed class UsersController : ControllerBase
{
private readonly ISender _sender;
private readonly IUserContext _userContext;
public UsersController(ISender sender, IUserContext userContext)
{
_sender = sender;
_userContext = userContext;
}
[HttpGet("search")]
public async Task<IActionResult> Search([FromQuery] string q, CancellationToken ct)
{
var result = await _sender.Send(new SearchUsersQuery(q), ct);
if (result.IsFailure)
{
return BadRequest(result.Error);
}
return Ok(result.Value);
}
[HttpPut("settings")]
public async Task<IActionResult> UpdateSettings([FromBody] UpdateSettingsRequest request, CancellationToken ct)
{
var result = await _sender.Send(new UpdateSettingsCommand(_userContext.UserId, request.HideStoryViews), ct);
if (result.IsFailure)
{
return NotFound(result.Error);
}
return Ok(result.Value);
}
[HttpPost("avatar")]
public async Task<IActionResult> UploadAvatar(IFormFile avatar, CancellationToken ct)
{
var fileToUpload = avatar ?? Request.Form.Files.FirstOrDefault();
if (fileToUpload == null || fileToUpload.Length == 0)
{
return BadRequest("No file uploaded");
}
using var stream = fileToUpload.OpenReadStream();
var command = new UploadAvatarCommand(
_userContext.UserId,
stream,
fileToUpload.FileName,
fileToUpload.ContentType
);
var result = await _sender.Send(command, ct);
if (result.IsFailure)
{
return NotFound(result.Error);
}
return Ok(result.Value);
}
[HttpPost("avatar/crop")]
public async Task<IActionResult> CropAvatar([FromForm] IFormFile avatar, [FromForm] int x, [FromForm] int y, [FromForm] int width, [FromForm] int height, CancellationToken ct)
{
if (avatar == null || avatar.Length == 0)
{
return BadRequest("No file uploaded");
}
using var stream = avatar.OpenReadStream();
var command = new CropAvatarCommand(
_userContext.UserId,
stream,
avatar.FileName ?? "avatar.jpg",
avatar.ContentType,
x, y, width, height
);
var result = await _sender.Send(command, ct);
if (result.IsFailure)
{
return NotFound(result.Error);
}
return Ok(result.Value);
}
[HttpDelete("avatar")]
public async Task<IActionResult> DeleteAvatar(CancellationToken ct)
{
var result = await _sender.Send(new DeleteAvatarCommand(_userContext.UserId), ct);
if (result.IsFailure)
{
return NotFound(result.Error);
}
return Ok(result.Value);
}
[HttpPut("profile")]
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request, CancellationToken ct)
{
var result = await _sender.Send(new UpdateProfileCommand(_userContext.UserId, request.DisplayName, request.Bio, request.Birthday), ct);
if (result.IsFailure)
{
return NotFound(result.Error);
}
return Ok(result.Value);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetUser(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new GetUserQuery(id), ct);
if (result.IsFailure)
{
return NotFound(result.Error);
}
return Ok(result.Value);
}
}

View File

@@ -0,0 +1,25 @@
using Carter;
using MediatR;
using Knot.Shared.Kernel.Constants;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Host.Endpoints;
/// <summary>
/// Регистрация эндпоинтов для конфигурации приложения.
/// </summary>
public sealed class ConfigEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup(Routes.ApiConfig);
group.MapGet("/", async (ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new Host.Application.Config.Queries.GetPublicConfigQuery(), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
});
}
}

View File

@@ -0,0 +1,41 @@
using Carter;
using MediatR;
using Knot.Shared.Kernel.Constants;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using System;
namespace Host.Endpoints;
/// <summary>
/// Регистрация эндпоинтов федерации.
/// </summary>
public sealed class FederationEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup(Routes.ApiFederation);
group.MapPost("/handshake", async ([FromBody] Host.Application.Federation.Commands.HandshakeRequest request, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new Host.Application.Federation.Commands.HandshakeFederationCommand(request), ct);
if (result.IsSuccess)
{
return Results.Ok(result.Value);
}
if (result.Error.Code == "Unauthorized")
{
return Results.Forbid();
}
if (result.Error.Code == Knot.Shared.Kernel.Constants.Errors.DisabledByAdmin)
{
return Results.StatusCode(503);
}
return Results.BadRequest(new { error = result.Error.Description });
});
}
}

View File

@@ -0,0 +1,34 @@
using Carter;
using MediatR;
using Knot.Shared.Kernel.Constants;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using System;
using System.Threading.Tasks;
namespace Host.Endpoints;
/// <summary>
/// Регистрация эндпоинтов для сервиса Klipy.
/// </summary>
public sealed class KlipyEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup(Routes.ApiKlipy).RequireAuthorization();
group.MapGet("/trending", async (ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new Host.Application.Klipy.Queries.GetTrendingGifsQuery(), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Description });
});
group.MapGet("/search", async ([FromQuery] string q, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new Host.Application.Klipy.Queries.SearchGifsQuery(q), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Description });
});
}
}

View File

@@ -0,0 +1,28 @@
using Carter;
using MediatR;
using Knot.Shared.Kernel;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Host.Endpoints;
/// <summary>
/// Регистрация эндпоинтов WebRTC
/// </summary>
public sealed class WebRtcEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup(Knot.Shared.Kernel.Constants.Routes.ApiWebRtc)
.RequireAuthorization();
group.MapGet("/ice-servers", async (ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new Host.Application.WebRtc.Queries.GetIceServersQuery(), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
})
.WithName("GetIceServers")
.WithOpenApi();
}
}

View File

@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<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" />
<PackageReference Include="MediatR" Version="12.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0-preview.1.25120.3" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0-preview.6.25358.103" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.5" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Modules\Identity\Knot.Modules.Identity.csproj" />
<ProjectReference Include="..\Modules\Chats\Knot.Modules.Chats.csproj" />
<ProjectReference Include="..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@Host_HostAddress = http://localhost:5059
GET {{Host_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,15 @@
using System;
namespace Host.Models;
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 Host.Models;
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 Host.Models;
public record AdminUserStatsDto(
int MessagesCount,
int MediaCount,
int FilesCount,
int LinksCount,
long StorageUsedBytes
);

View File

@@ -0,0 +1,7 @@
namespace Host.Models;
public class CleanupDryRunResultDto
{
public int OrphanedMessagesCount { get; set; }
public long OrphanedMediaBytes { get; set; }
}

View File

@@ -0,0 +1,20 @@
using System;
namespace Host.Models;
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

@@ -0,0 +1,6 @@
namespace Host.Models;
public class ResetPasswordDto
{
public string NewPassword { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,26 @@
using Knot.Shared.Kernel.Configuration;
namespace Host.Models;
public record PublicConfigDto
{
public bool EnableCalls { get; init; }
public bool EnableKlipy { get; init; }
public int MaxFileSizeMb { get; init; }
public int MaxGroupMembers { get; init; }
public bool EnableConfederation { get; init; }
public bool EnableRegistration { get; init; }
public static PublicConfigDto FromSettings(SystemSettingsDto settings)
{
return new PublicConfigDto
{
EnableCalls = settings.EnableCalls,
EnableKlipy = settings.EnableKlipy,
MaxFileSizeMb = settings.MaxFileSizeMb,
MaxGroupMembers = settings.MaxGroupMembers,
EnableConfederation = settings.EnableConfederation,
EnableRegistration = settings.EnableRegistration
};
}
}

View File

@@ -0,0 +1,13 @@
using System;
namespace Host.Models;
public record FriendDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar,
bool IsOnline,
DateTime LastSeen,
Guid FriendshipId
);

View File

@@ -0,0 +1,16 @@
using System;
namespace Host.Models;
public record FriendRequestDto(
Guid Id,
FriendUserDto User,
DateTime CreatedAt
);
public record FriendUserDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar
);

View File

@@ -0,0 +1,5 @@
using System;
namespace Host.Models;
public record SendFriendRequest(Guid FriendId);

View File

@@ -0,0 +1,9 @@
using System;
using System.Collections.Generic;
namespace Host.Models;
public record AnalyzeImportResponseDto(
Guid Token,
List<string> Names
);

View File

@@ -0,0 +1,10 @@
using System;
using System.Collections.Generic;
namespace Host.Models;
public record ExecuteImportRequest(
Guid Token,
Dictionary<string, Guid> Mapping,
string? GroupName
);

View File

@@ -0,0 +1,9 @@
using System;
namespace Host.Models;
public record ExecuteImportResponseDto(
bool Success,
int MessagesImported,
Guid ChatId
);

View File

@@ -0,0 +1,5 @@
using System;
namespace Host.Models;
public record AddStoryReactionRequest(string Emoji);

View File

@@ -0,0 +1,5 @@
using System;
namespace Host.Models;
public record AddStoryReplyRequest(string Content);

View File

@@ -0,0 +1,5 @@
using System;
namespace Host.Models;
public record CreateStoryRequest(string Type, string? MediaUrl, string? Content, string? BgColor);

View File

@@ -0,0 +1,5 @@
using System;
namespace Host.Models;
public record RemoveStoryReactionRequest(string Emoji);

View File

@@ -0,0 +1,10 @@
using System;
using System.Collections.Generic;
namespace Host.Models;
public record StoryGroupDto(
StoryUserDto User,
List<StoryDto> Stories,
bool HasUnviewed
);

View File

@@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
namespace Host.Models;
public record StoryDto(
Guid Id,
string Type,
string? MediaUrl,
string? Content,
string? BgColor,
DateTime CreatedAt,
DateTime ExpiresAt,
int ViewCount,
bool Viewed,
List<StoryReactionDto> Reactions,
int ReplyCount
);

View File

@@ -0,0 +1,10 @@
using System;
namespace Host.Models;
public record StoryReactionDto(
Guid Id,
Guid UserId,
string Emoji,
DateTime CreatedAt
);

View File

@@ -0,0 +1,13 @@
using System;
namespace Host.Models;
public record StoryReplyDto(
Guid Id,
Guid UserId,
string Username,
string DisplayName,
string? Avatar,
string Content,
DateTime CreatedAt
);

View File

@@ -0,0 +1,10 @@
using System;
namespace Host.Models;
public record StoryUserDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar
);

View File

@@ -0,0 +1,11 @@
using System;
namespace Host.Models;
public record StoryViewerDto(
Guid UserId,
string Username,
string DisplayName,
string? Avatar,
DateTime ViewedAt
);

View File

@@ -0,0 +1,5 @@
using System;
namespace Host.Models;
public sealed record UpdateProfileRequest(string? DisplayName, string? Bio, DateTime? Birthday);

View File

@@ -0,0 +1,3 @@
namespace Host.Models;
public sealed record UpdateSettingsRequest(bool? HideStoryViews);

View File

@@ -0,0 +1,12 @@
using System;
namespace Host.Models;
public record UserDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar,
bool IsOnline,
DateTime LastSeen
);

View File

@@ -0,0 +1,16 @@
using System;
namespace Host.Models;
public record UserProfileDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar,
string? Bio,
DateTime? Birthday,
DateTime CreatedAt,
bool? HideStoryViews = null,
bool IsOnline = false,
DateTime? LastSeen = null
);

210
backend/src/Host/Program.cs Normal file
View File

@@ -0,0 +1,210 @@
using Carter;
using Knot.Shared.Infrastructure;
using Knot.Modules.Identity;
using Knot.Modules.Chats;
using Knot.Modules.Chats.Infrastructure.SignalR;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Knot.Modules.Chats.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
using System.IdentityModel.Tokens.Jwt;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.FileProviders;
using Microsoft.AspNetCore.SignalR;
using System.Security.Claims;
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
var builder = WebApplication.CreateBuilder(args);
// Регистрация модулей
// Маппинг стандартных переменных окружения в иерархию .NET
var envMappings = new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] = builder.Configuration["DATABASE_URL"],
["ConnectionStrings:MongoConnection"] = builder.Configuration["MONGO_CONNECTION"],
["Jwt:Secret"] = builder.Configuration["JWT_SECRET"],
["Jwt:Issuer"] = builder.Configuration["JWT_ISSUER"],
["Jwt:Audience"] = builder.Configuration["JWT_AUDIENCE"],
["WebRtc:TurnUrl"] = builder.Configuration["TURN_URL"],
["WebRtc:TurnUsername"] = builder.Configuration["TURN_USERNAME"],
["WebRtc:TurnPassword"] = builder.Configuration["TURN_PASSWORD"],
["KNOT_MASTER_ENCRYPTION_KEY"] = builder.Configuration["KNOT_MASTER_ENCRYPTION_KEY"],
["S3_ENDPOINT"] = builder.Configuration["S3_ENDPOINT"],
["S3_ACCESS_KEY"] = builder.Configuration["S3_ACCESS_KEY"],
["S3_SECRET_KEY"] = builder.Configuration["S3_SECRET_KEY"],
["S3_BUCKET"] = builder.Configuration["S3_BUCKET"],
["KNOT_ADMIN_USER"] = builder.Configuration["KNOT_ADMIN_USER"],
["KNOT_ADMIN_PASSWORD"] = builder.Configuration["KNOT_ADMIN_PASSWORD"]
};
// Добавляем только те, что реально заданы в ENV
builder.Configuration.AddInMemoryCollection(
envMappings.Where(kv => !string.IsNullOrEmpty(kv.Value))
.ToDictionary(kv => kv.Key, kv => kv.Value));
builder.Services.AddIdentityModule(builder.Configuration);
builder.Services.AddChatsModule(builder.Configuration);
builder.Services.AddSharedInfrastructure(builder.Configuration);
// CQRS / MediatR для команд в Host (например, AdminController)
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
// Carter для вызова Minimal APIs (Endpoints)
builder.Services.AddCarter();
// Настройка CORS
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
var originsFromConfig = builder.Configuration["Cors:Origins"];
var domain = builder.Configuration["DOMAIN"];
var origins = !string.IsNullOrEmpty(originsFromConfig)
? originsFromConfig.Split(',')
: (!string.IsNullOrEmpty(domain) ? new[] { $"https://{domain}" } : new[] { "*" });
var corsBuilder = policy.WithOrigins(origins)
.AllowAnyHeader()
.AllowAnyMethod();
if (origins.Contains("*"))
{
// The CORS protocol does not allow specifying a wildcard (any) origin and credentials at the same time.
}
else
{
corsBuilder.AllowCredentials();
}
});
});
// Настройка Swagger/OpenAPI
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Настройка маршрутизации
builder.Services.AddRouting(options =>
{
options.LowercaseUrls = true;
options.LowercaseQueryStrings = true;
});
builder.Services.AddMemoryCache();
builder.Services.AddHttpClient();
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
builder.Services.AddSignalR()
.AddJsonProtocol(options =>
{
options.PayloadSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
builder.Services.AddSingleton<IUserIdProvider, CustomUserIdProvider>();
// Настройка JWT Аутентификации
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!))
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
// Применяем миграции при старте
using (var scope = app.Services.CreateScope())
{
var identityDb = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
await identityDb.Database.MigrateAsync();
var chatsDb = scope.ServiceProvider.GetRequiredService<ChatsDbContext>();
await chatsDb.Database.MigrateAsync();
var systemDb = scope.ServiceProvider.GetRequiredService<Knot.Shared.Infrastructure.Persistence.SystemDbContext>();
await systemDb.Database.MigrateAsync();
// Set Encryption Service for MongoDB serializers
var encryptionService = scope.ServiceProvider.GetRequiredService<Knot.Shared.Kernel.Security.IEncryptionService>();
Knot.Modules.Chats.Infrastructure.Persistence.Mongo.EncryptedStringSerializer.EncryptionService = encryptionService;
// Initialize Global Settings Cache
var settingsService = scope.ServiceProvider.GetRequiredService<Knot.Shared.Kernel.Configuration.ISettingsService>();
if (settingsService is Knot.Shared.Infrastructure.Configuration.SettingsService concreteSettings)
{
concreteSettings.Initialize();
}
}
// Настройка конвейера запросов
app.UseMiddleware<Knot.Shared.Infrastructure.Middleware.ExceptionHandlingMiddleware>();
app.UseMiddleware<Knot.Shared.Infrastructure.Middleware.AdminAuthMiddleware>();
app.UseCors();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// app.UseHttpsRedirection();
// Не раздаем статические файлы, так как теперь используем MinIO
app.UseAuthentication();
app.UseAuthorization();
// Добавляем контроллеры и Carter (Minimal APIs)
app.MapControllers();
app.MapCarter();
// Добавляем SignalR хабы
app.MapHub<ChatHub>("/hubs/chat");
app.Run();
public class CustomUserIdProvider : IUserIdProvider
{
public string? GetUserId(HubConnectionContext connection)
{
return connection.User?.FindFirstValue("sub") ?? connection.User?.FindFirstValue(ClaimTypes.NameIdentifier);
}
}

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5059",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7124;http://localhost:5059",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,19 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=knot_db;Username=knot;Password=knot_pass"
},
"Jwt": {
"Secret": "knot_super_secret_key_1234567890_knot",
"Issuer": "Knot",
"Audience": "KnotUsers",
"ExpiryInMinutes": 1440
},
"KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot"
}

View File

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

View File

@@ -0,0 +1,129 @@
using System;
using System.IO;
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 MediatR;
using System.Linq;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
namespace Knot.Modules.Chats.Application.Chats.Avatar;
public record UploadGroupAvatarCommand(Guid ChatId, Guid UserId, string FileName, string ContentType, Stream FileStream) : ICommand<Guid>;
internal sealed class UploadGroupAvatarCommandHandler : ICommandHandler<UploadGroupAvatarCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
private readonly IFileStorageService _fileStorage;
public UploadGroupAvatarCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow, IFileStorageService fileStorage)
{
_chatRepository = chatRepository;
_uow = uow;
_fileStorage = fileStorage;
}
public async Task<Result<Guid>> Handle(UploadGroupAvatarCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
}
var fileId = await _fileStorage.UploadFileAsync(request.FileStream, request.FileName, request.ContentType);
var url = $"/api/files/{fileId}";
chat.UpdateAvatar(url);
_chatRepository.Update(chat);
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(request.ChatId);
}
}
public record CropGroupAvatarCommand(Guid ChatId, Guid UserId, string FileName, string ContentType, Stream FileStream, int X, int Y, int Width, int Height) : ICommand<Guid>;
internal sealed class CropGroupAvatarCommandHandler : ICommandHandler<CropGroupAvatarCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
private readonly IFileStorageService _fileStorage;
public CropGroupAvatarCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow, IFileStorageService fileStorage)
{
_chatRepository = chatRepository;
_uow = uow;
_fileStorage = fileStorage;
}
public async Task<Result<Guid>> Handle(CropGroupAvatarCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
}
string url;
using (var image = await SixLabors.ImageSharp.Image.LoadAsync(request.FileStream))
{
int startX = Math.Max(0, Math.Min(request.X, image.Width - 1));
int startY = Math.Max(0, Math.Min(request.Y, image.Height - 1));
int rectWidth = Math.Max(1, Math.Min(request.Width, image.Width - startX));
int rectHeight = Math.Max(1, Math.Min(request.Height, image.Height - startY));
image.Mutate(ctx => ctx.Crop(new SixLabors.ImageSharp.Rectangle(startX, startY, rectWidth, rectHeight)));
image.Mutate(ctx => ctx.Resize(400, 400));
using var outStream = new MemoryStream();
await image.SaveAsJpegAsync(outStream, cancellationToken);
outStream.Position = 0;
var fileName = request.FileName ?? "avatar.jpg";
var fileId = await _fileStorage.UploadFileAsync(outStream, fileName, "image/jpeg");
url = $"/api/files/{fileId}";
}
chat.UpdateAvatar(url);
_chatRepository.Update(chat);
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(request.ChatId);
}
}
public record RemoveGroupAvatarCommand(Guid ChatId, Guid UserId) : ICommand<Guid>;
internal sealed class RemoveGroupAvatarCommandHandler : ICommandHandler<RemoveGroupAvatarCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
public RemoveGroupAvatarCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
{
_chatRepository = chatRepository;
_uow = uow;
}
public async Task<Result<Guid>> Handle(RemoveGroupAvatarCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
}
chat.UpdateAvatar(null);
_chatRepository.Update(chat);
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(request.ChatId);
}
}

View File

@@ -0,0 +1,34 @@
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 MediatR;
using System.Linq;
namespace Knot.Modules.Chats.Application.Chats.Clear;
public record ClearChatCommand(Guid ChatId, Guid UserId) : ICommand<MessageResponse>;
internal sealed class ClearChatCommandHandler : ICommandHandler<ClearChatCommand, MessageResponse>
{
private readonly IChatRepository _chatRepository;
public ClearChatCommandHandler(IChatRepository chatRepository)
{
_chatRepository = chatRepository;
}
public async Task<Result<MessageResponse>> Handle(ClearChatCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<MessageResponse>(ChatErrors.ChatNotFound);
}
// Currently a placeholder
return Result.Success(new MessageResponse("Cleared"));
}
}

View File

@@ -0,0 +1,43 @@
using Knot.Modules.Chats.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Chats.Create;
/// <summary>
/// Команда для создания чата.
/// </summary>
public sealed record CreateChatCommand(
string Name,
ChatType Type,
List<Guid> MemberIds) : ICommand<Guid>;
public sealed class CreateChatCommandHandler : ICommandHandler<CreateChatCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _unitOfWork;
public CreateChatCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork)
{
_chatRepository = chatRepository;
_unitOfWork = unitOfWork;
}
public async Task<Result<Guid>> Handle(CreateChatCommand request, CancellationToken cancellationToken)
{
var chat = Chat.Create(request.Name, request.Type);
for (int i = 0; i < request.MemberIds.Count; i++)
{
var userId = request.MemberIds[i];
var role = (i == 0) ? ChatRole.Owner : ChatRole.Member;
chat.AddMember(userId, role);
}
_chatRepository.Add(chat);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(chat.Id);
}
}

View File

@@ -0,0 +1,140 @@
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.Chats.Application.DTOs;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Chats.GetChatById;
public record GetChatByIdQuery(Guid UserId, Guid ChatId) : IQuery<ChatDto?>;
internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery, ChatDto?>
{
private readonly IChatRepository _chatRepository;
private readonly IUserDisplayNameProvider _userProvider;
private readonly IMessageRepository _messageRepository;
public GetChatByIdQueryHandler(IChatRepository chatRepository, IUserDisplayNameProvider userProvider, IMessageRepository messageRepository)
{
_chatRepository = chatRepository;
_userProvider = userProvider;
_messageRepository = messageRepository;
}
public async Task<Result<ChatDto?>> Handle(GetChatByIdQuery request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null)
{
return Result.Success<ChatDto?>(null);
}
if (!chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<ChatDto?>(ChatErrors.ChatsForbidden);
}
var userIdsToFetch = new HashSet<Guid>();
foreach (var member in chat.Members)
{
userIdsToFetch.Add(member.UserId);
}
var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken);
if (latestMessage != null)
{
userIdsToFetch.Add(latestMessage.SenderId);
foreach (var reaction in latestMessage.Reactions)
{
userIdsToFetch.Add(reaction.UserId);
}
}
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
var members = new List<ChatMemberDto>();
foreach (var member in chat.Members)
{
usersInfo.TryGetValue(member.UserId, out var user);
members.Add(new ChatMemberDto(
member.Id,
member.UserId,
member.Role,
member.IsPinned,
user != null ? new ChatUserDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
false,
DateTime.UtcNow
) : null
));
}
var messagesList = new List<ChatMessageDto>();
if (latestMessage != null)
{
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
var reactionsWithUser = new List<ReactionDto>();
foreach (var reaction in latestMessage.Reactions)
{
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
reactionsWithUser.Add(new ReactionDto(
reaction.Id,
reaction.Emoji,
reaction.UserId,
reactionUser != null
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null)
));
}
messagesList.Add(new ChatMessageDto(
latestMessage.Id,
latestMessage.ChatId,
latestMessage.SenderId,
latestMessage.Content,
latestMessage.Type,
latestMessage.ReplyToId,
latestMessage.Quote,
latestMessage.StoryId,
latestMessage.StoryMediaUrl,
latestMessage.StoryMediaType,
latestMessage.IsEdited,
latestMessage.IsDeleted,
latestMessage.CreatedAt,
latestMessage.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
senderObj != null ? new MessageSenderDto(
senderObj.Id,
senderObj.Username,
senderObj.DisplayName,
senderObj.Avatar
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
reactionsWithUser,
latestMessage.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList()
));
}
var dto = new ChatDto(
chat.Id,
chat.Type.ToString().ToLowerInvariant(),
chat.Type == ChatType.Favorites ? "Избранное" : (chat.Type == ChatType.Personal ? null : chat.Name),
chat.Description,
chat.Avatar,
chat.CreatedAt,
members,
messagesList,
0
);
return Result.Success<ChatDto?>(dto);
}
}

View File

@@ -0,0 +1,148 @@
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.Chats.Application.DTOs;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Chats.GetChats;
public record GetChatsQuery(Guid UserId) : IQuery<List<ChatDto>>;
internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<ChatDto>>
{
private readonly IChatRepository _chatRepository;
private readonly IUserDisplayNameProvider _userProvider;
private readonly IMessageRepository _messageRepository;
public GetChatsQueryHandler(IChatRepository chatRepository, IUserDisplayNameProvider userProvider, IMessageRepository messageRepository)
{
_chatRepository = chatRepository;
_userProvider = userProvider;
_messageRepository = messageRepository;
}
public async Task<Result<List<ChatDto>>> Handle(GetChatsQuery request, CancellationToken cancellationToken)
{
var userChats = await _chatRepository.GetUserChatsAsync(request.UserId, cancellationToken);
var dtos = new List<ChatDto>();
bool hasFavorites = false;
foreach (var chat in userChats)
{
var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken);
if (latestMessage == null)
{
continue;
}
var userIdsToFetch = new HashSet<Guid>();
foreach (var member in chat.Members)
{
userIdsToFetch.Add(member.UserId);
}
userIdsToFetch.Add(latestMessage.SenderId);
foreach (var r in latestMessage.Reactions)
{
userIdsToFetch.Add(r.UserId);
}
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
var members = new List<ChatMemberDto>();
foreach (var member in chat.Members)
{
usersInfo.TryGetValue(member.UserId, out var user);
members.Add(new ChatMemberDto(
member.Id,
member.UserId,
member.Role,
member.IsPinned,
user != null ? new ChatUserDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
false,
DateTime.UtcNow
) : null
));
}
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
var reactionsWithUser = new List<ReactionDto>();
foreach (var reaction in latestMessage.Reactions)
{
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
reactionsWithUser.Add(new ReactionDto(
reaction.Id,
reaction.Emoji,
reaction.UserId,
reactionUser != null
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null)
));
}
var messagesList = new List<ChatMessageDto>
{
new ChatMessageDto(
latestMessage.Id,
latestMessage.ChatId,
latestMessage.SenderId,
latestMessage.Content,
latestMessage.Type,
latestMessage.ReplyToId,
latestMessage.Quote,
latestMessage.StoryId,
latestMessage.StoryMediaUrl,
latestMessage.StoryMediaType,
latestMessage.IsEdited,
latestMessage.IsDeleted,
latestMessage.CreatedAt,
latestMessage.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
senderObj != null ? new MessageSenderDto(
senderObj.Id,
senderObj.Username,
senderObj.DisplayName,
senderObj.Avatar
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
reactionsWithUser,
latestMessage.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList()
)
};
var unreadCount = await _messageRepository.GetUnreadCountAsync(chat.Id, request.UserId, cancellationToken);
dtos.Add(new ChatDto(
chat.Id,
chat.Type.ToString().ToLowerInvariant(),
chat.Type == ChatType.Favorites ? "Избранное" : (chat.Type == ChatType.Personal ? null : chat.Name),
chat.Description,
chat.Avatar,
chat.CreatedAt,
members,
messagesList,
unreadCount
));
}
if (!hasFavorites)
{
var favs = new ChatDto(Guid.Empty, "favorites", "Избранное", null, null, DateTime.UtcNow, new List<ChatMemberDto>(), new List<ChatMessageDto>(), 0);
dtos.Add(favs);
}
var sorted = dtos.OrderByDescending(d => d.Messages.FirstOrDefault()?.CreatedAt ?? d.CreatedAt).ToList();
return Result.Success(sorted);
}
}

View File

@@ -0,0 +1,38 @@
using Knot.Modules.Chats.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Chats.Application.Abstractions;
namespace Knot.Modules.Chats.Application.Chats.GetOrCreateFavorites;
public sealed record GetOrCreateFavoritesCommand(Guid UserId) : ICommand<Guid>;
public sealed class GetOrCreateFavoritesCommandHandler : ICommandHandler<GetOrCreateFavoritesCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _unitOfWork;
public GetOrCreateFavoritesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork)
{
_chatRepository = chatRepository;
_unitOfWork = unitOfWork;
}
public async Task<Result<Guid>> Handle(GetOrCreateFavoritesCommand request, CancellationToken cancellationToken)
{
var favorites = await _chatRepository.GetFavoritesAsync(request.UserId, cancellationToken);
if (favorites != null)
{
return Result.Success(favorites.Id);
}
// Create new favorites chat
var chat = Chat.Create("Избранное", ChatType.Favorites);
chat.AddMember(request.UserId, ChatRole.Owner);
_chatRepository.Add(chat);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(chat.Id);
}
}

View File

@@ -0,0 +1,53 @@
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 MediatR;
using System.Linq;
namespace Knot.Modules.Chats.Application.Chats.LeaveOrDelete;
public record LeaveOrDeleteChatCommand(Guid ChatId, Guid UserId) : ICommand<SuccessResponse>;
internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrDeleteChatCommand, SuccessResponse>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
public LeaveOrDeleteChatCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
{
_chatRepository = chatRepository;
_uow = uow;
}
public async Task<Result<SuccessResponse>> Handle(LeaveOrDeleteChatCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null)
{
return Result.Success(new SuccessResponse(true));
}
if (!chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<SuccessResponse>(ChatErrors.Unauthorized);
}
if (chat.Type == ChatType.Group)
{
chat.RemoveMember(request.UserId);
_chatRepository.Update(chat);
}
else
{
_chatRepository.Remove(chat);
}
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(new SuccessResponse(true));
}
}

View File

@@ -0,0 +1,72 @@
using System;
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 MediatR;
using System.Linq;
namespace Knot.Modules.Chats.Application.Chats.Members;
public record AddMembersCommand(Guid ChatId, Guid UserId, List<Guid> UserIdsToAdd) : ICommand<Guid>;
internal sealed class AddMembersCommandHandler : ICommandHandler<AddMembersCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
public AddMembersCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
{
_chatRepository = chatRepository;
_uow = uow;
}
public async Task<Result<Guid>> Handle(AddMembersCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
}
foreach (var userId in request.UserIdsToAdd)
{
chat.AddMember(userId);
}
_chatRepository.Update(chat);
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(request.ChatId);
}
}
public record RemoveMemberCommand(Guid ChatId, Guid UserId, Guid UserIdToRemove) : ICommand<Guid>;
internal sealed class RemoveMemberCommandHandler : ICommandHandler<RemoveMemberCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
public RemoveMemberCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
{
_chatRepository = chatRepository;
_uow = uow;
}
public async Task<Result<Guid>> Handle(RemoveMemberCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
}
chat.RemoveMember(request.UserIdToRemove);
_chatRepository.Update(chat);
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(request.ChatId);
}
}

View File

@@ -0,0 +1,46 @@
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 MediatR;
using System.Linq;
namespace Knot.Modules.Chats.Application.Chats.TogglePin;
public record TogglePinCommand(Guid ChatId, Guid UserId) : ICommand<TogglePinResponse>;
internal sealed class TogglePinCommandHandler : ICommandHandler<TogglePinCommand, TogglePinResponse>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
public TogglePinCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
{
_chatRepository = chatRepository;
_uow = uow;
}
public async Task<Result<TogglePinResponse>> Handle(TogglePinCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null)
{
return Result.Failure<TogglePinResponse>(ChatErrors.ChatNotFound);
}
var member = chat.Members.FirstOrDefault(m => m.UserId == request.UserId);
if (member == null)
{
return Result.Failure<TogglePinResponse>(ChatErrors.ChatNotFound);
}
member.TogglePin();
_chatRepository.Update(chat);
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(new TogglePinResponse(member.IsPinned));
}
}

View File

@@ -0,0 +1,48 @@
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 MediatR;
using System.Linq;
namespace Knot.Modules.Chats.Application.Chats.Update;
public record UpdateChatCommand(Guid ChatId, Guid UserId, string? Name, string? Description) : ICommand<Guid>;
internal sealed class UpdateChatCommandHandler : ICommandHandler<UpdateChatCommand, Guid>
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _uow;
public UpdateChatCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
{
_chatRepository = chatRepository;
_uow = uow;
}
public async Task<Result<Guid>> Handle(UpdateChatCommand request, CancellationToken cancellationToken)
{
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
{
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
}
if (request.Name != null)
{
chat.UpdateName(request.Name);
}
if (request.Description != null)
{
chat.UpdateDescription(request.Description);
}
_chatRepository.Update(chat);
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(chat.Id);
}
}

View File

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

View File

@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
public record ChatDto(
Guid Id,
string Type,
string? Name,
string? Description,
string? Avatar,
DateTime CreatedAt,
List<ChatMemberDto> Members,
List<ChatMessageDto> Messages,
int UnreadCount
);

View File

@@ -0,0 +1,12 @@
using Knot.Modules.Chats.Domain;
using System;
namespace Knot.Modules.Chats.Application.DTOs;
public record ChatMemberDto(
Guid Id,
Guid UserId,
string Role,
bool IsPinned,
ChatUserDto? User
);

View File

@@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
public record ChatMessageDto(
Guid Id,
Guid ChatId,
Guid SenderId,
string? Content,
string Type,
Guid? ReplyToId,
string? Quote,
Guid? StoryId,
string? StoryMediaUrl,
string? StoryMediaType,
bool IsEdited,
bool IsDeleted,
DateTime CreatedAt,
List<MediaDto> Media,
MessageSenderDto Sender,
List<ReactionDto> Reactions,
List<ReadByDto> ReadBy
);

View File

@@ -0,0 +1,12 @@
using System;
namespace Knot.Modules.Chats.Application.DTOs;
public record ChatUserDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar,
bool IsOnline,
DateTime LastSeen
);

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
using System;
namespace Knot.Modules.Chats.Application.DTOs;
public record MediaDto(
Guid Id,
string Type,
string Url,
string? Filename,
long? Size
);

View File

@@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
public record MessageDetailDto(
Guid Id,
Guid ChatId,
Guid SenderId,
string? Content,
string Type,
Guid? ReplyToId,
ReplyToMessageDto? ReplyTo,
string? Quote,
bool IsEdited,
bool IsDeleted,
DateTime CreatedAt,
Guid? ForwardedFromId,
MessageSenderDto? ForwardedFrom,
Guid? StoryId,
string? StoryMediaUrl,
string? StoryMediaType,
List<MediaDto> Media,
MessageSenderDto? Sender,
List<ReadByDto> ReadBy,
List<MessageReactionDto> Reactions
);
public record ReplyToMessageDto(
Guid Id,
string? Content,
bool IsDeleted,
List<MediaDto> Media,
MessageSenderDto? Sender
);
public record MessageReactionDto(
Guid Id,
string Emoji,
Guid UserId,
MessageSenderDto? User
);

View File

@@ -0,0 +1,10 @@
using System;
namespace Knot.Modules.Chats.Application.DTOs;
public record MessageSenderDto(
Guid Id,
string Username,
string DisplayName,
string? Avatar
);

View File

@@ -0,0 +1,10 @@
using System;
namespace Knot.Modules.Chats.Application.DTOs;
public record ReactionDto(
Guid Id,
string Emoji,
Guid UserId,
MessageSenderDto User
);

View File

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

View File

@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
public record SearchMessageDto(
Guid Id,
Guid ChatId,
Guid SenderId,
string? Content,
string Type,
Guid? ReplyToId,
string? Quote,
bool IsEdited,
bool IsDeleted,
DateTime CreatedAt,
Guid? ForwardedFromId,
MessageSenderDto? ForwardedFrom,
Guid? StoryId,
string? StoryMediaUrl,
string? StoryMediaType,
List<MediaDto> Media,
MessageSenderDto Sender,
List<SimpleReactionDto> Reactions,
List<ReadByDto> ReadBy
);
public record SimpleReactionDto(
Guid UserId,
string Emoji
);

View File

@@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
public sealed record SendMessageRequest(
string? Content,
string Type,
List<AttachmentDto>? Attachments = null,
Guid? ReplyToId = null,
string? Quote = null,
Guid? ForwardedFromId = null);
public sealed record AttachmentDto(string Type, string Url, string? FileName, long? FileSize);

View File

@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
namespace Knot.Modules.Chats.Application.DTOs;
public record SharedMediaDto(
Guid Id,
string? Content,
DateTime CreatedAt,
List<string>? Links,
MessageSenderDto? Sender,
Guid? ReplyToId,
string? Quote,
Guid? StoryId,
string? StoryMediaUrl,
string? StoryMediaType,
bool? IsEdited,
string? Type,
List<MediaDto>? Media
);

View File

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

View File

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

View File

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

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