This commit is contained in:
Халимов Рустам
2026-03-19 00:59:01 +03:00
parent 900ca6f8b8
commit 59e0cf7667
142 changed files with 4557 additions and 2593 deletions

View File

@@ -0,0 +1,85 @@
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 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;
public CleanRunCommandHandler(ChatsDbContext chatsDbContext)
{
_chatsDbContext = chatsDbContext;
}
public async Task<Result<MessageResponse>> Handle(CleanRunCommand request, CancellationToken cancellationToken)
{
var orphanMessages = await _chatsDbContext.Messages
.Include(m => m.Media)
.Where(m => m.IsDeleted || !_chatsDbContext.Chats.Any(c => c.Id == m.ChatId))
.ToListAsync(cancellationToken);
var allMinioFiles = (await request.FileStorage.ListFilesAsync()).ToList();
var keptMessages = await _chatsDbContext.Messages
.AsNoTracking()
.Include(m => m.Media)
.Where(m => !m.IsDeleted && _chatsDbContext.Chats.Any(c => c.Id == m.ChatId))
.ToListAsync(cancellationToken);
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken);
var allUsers = await request.IdentityDb.Users.AsNoTracking().ToListAsync(cancellationToken);
var validUrls = new HashSet<string>();
var activeMessageUrls = keptMessages
.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())
{
_chatsDbContext.Messages.RemoveRange(orphanMessages);
await _chatsDbContext.SaveChangesAsync(cancellationToken);
}
return Result.Success(new MessageResponse("Cleanup completed successfully"));
}
}

View File

@@ -0,0 +1,40 @@
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>(new Error("User.NotFound", "User not found"));
if (string.IsNullOrWhiteSpace(request.NewPassword))
return Result.Failure<SuccessResponse>(new Error("InvalidPassword", "Password cannot be empty"));
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,87 @@
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 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;
public CleanDryRunQueryHandler(ChatsDbContext chatsDbContext)
{
_chatsDbContext = chatsDbContext;
}
public async Task<Result<CleanupDryRunResultDto>> Handle(CleanDryRunQuery request, CancellationToken cancellationToken)
{
var orphanedMessages = await _chatsDbContext.Messages
.Include(m => m.Media)
.Where(m => m.IsDeleted || !_chatsDbContext.Chats.Any(c => c.Id == m.ChatId))
.ToListAsync(cancellationToken);
var orphanedMessagesCount = orphanedMessages.Count;
var allMinioFiles = (await request.FileStorage.ListFilesAsync()).ToList();
var keptMessages = await _chatsDbContext.Messages
.AsNoTracking()
.Include(m => m.Media)
.Where(m => !m.IsDeleted && _chatsDbContext.Chats.Any(c => c.Id == m.ChatId))
.ToListAsync(cancellationToken);
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken);
var allUsers = await request.IdentityDb.Users.AsNoTracking().ToListAsync(cancellationToken);
var validUrls = new HashSet<string>();
var activeMessageUrls = keptMessages
.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.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
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 ChatsDbContext _chatsDbContext;
public GetUserDetailsQueryHandler(IUserRepository userRepository, ChatsDbContext chatsDbContext)
{
_userRepository = userRepository;
_chatsDbContext = chatsDbContext;
}
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>(new Error("User.NotFound", "User not found"));
var messagesCount = await _chatsDbContext.Messages.CountAsync(m => m.SenderId == request.UserId, cancellationToken);
var allUserMedia = await _chatsDbContext.Messages
.AsNoTracking()
.Where(m => m.SenderId == request.UserId)
.SelectMany(m => m.Media)
.ToListAsync(cancellationToken);
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 = await _chatsDbContext.Messages
.AsNoTracking()
.Where(m => m.SenderId == request.UserId)
.Select(m => m.Content)
.ToListAsync(cancellationToken);
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,51 @@
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>(new Error("Request.Invalid", "Domain and PublicKey are required")));
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>(new Error("Unauthorized", "Domain is not in the whitelist")));
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,128 @@
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>(new Error("Story.NotFound", "Story not found"));
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,114 @@
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>(new Error("Story.NotFound", "Story not found"));
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,33 @@
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>(new Error("Story.NotFound", "Story not found"));
if (story.UserId != request.UserId) return Result.Failure<MessageResponse>(new Error("Unauthorized", "Unauthorized"));
_context.Stories.Remove(story);
await _context.SaveChangesAsync(cancellationToken);
return Result.Success(new MessageResponse("Story deleted"));
}
}

View File

@@ -0,0 +1,36 @@
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,73 @@
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>(new Error("Story.NotFound", "Story not found"));
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,90 @@
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,56 @@
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>>(new Error("Story.NotFound", "Story not found"));
if (story.UserId != request.UserId) return Result.Failure<List<StoryReplyDto>>(new Error("Unauthorized", "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,58 @@
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>>(new Error("Story.NotFound", "Story not found"));
if (story.UserId != request.UserId) return Result.Failure<List<StoryViewerDto>>(new Error("Unauthorized", "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,62 @@
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>(new Error("User.NotFound", "User not found"));
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,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

@@ -1,11 +1,15 @@
using Knot.Modules.Identity.Domain;
using Knot.Shared.Kernel.Configuration;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Chats.Infrastructure.Persistence;
using MediatR;
using Host.Models;
using Knot.Shared.Kernel.Configuration;
using Knot.Modules.Identity.Application.Users.Register;
using Knot.Modules.Identity.Application.Abstractions;
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;
namespace Host.Controllers;
@@ -13,294 +17,88 @@ namespace Host.Controllers;
[Route("api/[controller]")]
public class AdminController : ControllerBase
{
private readonly ISettingsService _settingsService;
private readonly Knot.Shared.Kernel.Services.IStatisticsService _statisticsService;
private readonly IUserRepository _userRepository;
private readonly ChatsDbContext _chatsDbContext;
private readonly ISender _sender;
private readonly IIdentityUnitOfWork _identityUnitOfWork;
public AdminController(
ISettingsService settingsService,
Knot.Shared.Kernel.Services.IStatisticsService statisticsService,
IUserRepository userRepository,
ChatsDbContext chatsDbContext,
ISender sender,
IIdentityUnitOfWork identityUnitOfWork)
public AdminController(ISender sender)
{
_settingsService = settingsService;
_statisticsService = statisticsService;
_userRepository = userRepository;
_chatsDbContext = chatsDbContext;
_sender = sender;
_identityUnitOfWork = identityUnitOfWork;
}
[HttpGet("settings")]
public async Task<IActionResult> GetSettings(CancellationToken ct)
{
var settings = await _settingsService.GetSettingsAsync(ct);
return Ok(settings);
var result = await _sender.Send(new GetSettingsQuery(), ct);
return Ok(result.Value);
}
[HttpPut("settings")]
public async Task<IActionResult> UpdateSettings([FromBody] SystemSettingsDto settings, CancellationToken ct)
{
await _settingsService.UpdateSettingsAsync(settings, ct);
return Ok(settings);
var result = await _sender.Send(new UpdateSettingsCommand(settings), ct);
return Ok(result.Value);
}
[HttpGet("dashboard")]
public async Task<IActionResult> GetDashboardStats(CancellationToken ct)
{
var stats = await _statisticsService.GetDashboardStatsAsync(ct);
return Ok(stats);
var result = await _sender.Send(new GetDashboardStatsQuery(), ct);
return Ok(result.Value);
}
[HttpPost("users")]
public async Task<IActionResult> CreateUser([FromBody] RegisterUserCommand command)
public async Task<IActionResult> CreateUser([FromBody] RegisterUserCommand command, CancellationToken ct)
{
var result = await _sender.Send(command);
if (result.IsFailure)
{
return BadRequest(new { error = result.Error.Description });
}
var result = await _sender.Send(command, ct);
if (result.IsFailure) return BadRequest(new { error = result.Error.Description });
var user = await _userRepository.GetByIdAsync(result.Value, default);
return Ok(new {
user.Id,
user.Username,
user.DisplayName,
user.Email,
user.CreatedAt
});
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 user = await _userRepository.GetByIdAsync(userId, ct);
if (user == null) return NotFound(new { error = "User not found" });
if (string.IsNullOrWhiteSpace(dto.NewPassword)) return BadRequest(new { error = "Password cannot be empty" });
var hash = BCrypt.Net.BCrypt.HashPassword(dto.NewPassword);
user.ChangePassword(hash);
await _identityUnitOfWork.SaveChangesAsync(ct);
return Ok(new { success = true });
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 users = string.IsNullOrWhiteSpace(query)
? await _userRepository.SearchUsersAsync("", ct)
: await _userRepository.SearchUsersAsync(query, ct);
return Ok(users.Select(u => new
{
Id = u.Id,
Username = u.Username,
DisplayName = u.DisplayName,
Avatar = u.Avatar,
CreatedAt = u.CreatedAt,
IsOnline = Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()),
LastOnlineAt = Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()) ? DateTime.UtcNow : u.CreatedAt
}));
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 targetUser = await _userRepository.GetByIdAsync(id, ct);
if (targetUser == null) return NotFound("User not found");
var messagesCount = await _chatsDbContext.Messages.CountAsync(m => m.SenderId == id, ct);
var allUserMedia = await _chatsDbContext.Messages
.AsNoTracking()
.Where(m => m.SenderId == id)
.SelectMany(m => m.Media)
.ToListAsync(ct);
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);
// Fetch contents first, then count links to avoid EF Core ValueConverter translation error
var userContents = await _chatsDbContext.Messages
.AsNoTracking()
.Where(m => m.SenderId == id)
.Select(m => m.Content)
.ToListAsync(ct);
var linksCount = userContents.Count(c => !string.IsNullOrEmpty(c) && c.Contains("http"));
return Ok(new
{
Id = targetUser.Id,
Username = targetUser.Username,
DisplayName = targetUser.DisplayName,
Bio = targetUser.Bio,
Avatar = targetUser.Avatar,
CreatedAt = targetUser.CreatedAt,
IsOnline = Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()),
LastOnlineAt = Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()) ? DateTime.UtcNow : targetUser.CreatedAt,
Stats = new
{
MessagesCount = messagesCount,
MediaCount = mediaCount,
FilesCount = filesCount,
LinksCount = linksCount,
StorageUsedBytes = storageUsed
}
});
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] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage,
[FromServices] Knot.Modules.Identity.Infrastructure.Persistence.IdentityDbContext identityDb,
[FromServices] IFileStorageService fileStorage,
[FromServices] IdentityDbContext identityDb,
CancellationToken ct)
{
var orphanedMessages = await _chatsDbContext.Messages
.Include(m => m.Media)
.Where(m => m.IsDeleted || !_chatsDbContext.Chats.Any(c => c.Id == m.ChatId))
.ToListAsync(ct);
var orphanedMessagesCount = orphanedMessages.Count;
// Fetch all current physical files from MinIO
var allMinioFiles = (await fileStorage.ListFilesAsync()).ToList();
// Collect ALL active URLs that we must preserve
var keptMessages = await _chatsDbContext.Messages
.AsNoTracking()
.Include(m => m.Media)
.Where(m => !m.IsDeleted && _chatsDbContext.Chats.Any(c => c.Id == m.ChatId))
.ToListAsync(ct);
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(ct);
var allUsers = await identityDb.Users.AsNoTracking().ToListAsync(ct);
var validUrls = new HashSet<string>();
var activeMessageUrls = keptMessages
.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 Ok(new CleanupDryRunResultDto
{
OrphanedMessagesCount = orphanedMessagesCount,
OrphanedMediaBytes = safeBytes
});
var result = await _sender.Send(new CleanDryRunQuery(fileStorage, identityDb), ct);
return Ok(result.Value);
}
[HttpPost("clean/run")]
public async Task<IActionResult> CleanRun(
[FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage,
[FromServices] Knot.Modules.Identity.Infrastructure.Persistence.IdentityDbContext identityDb,
[FromServices] IFileStorageService fileStorage,
[FromServices] IdentityDbContext identityDb,
CancellationToken ct)
{
var orphanMessages = await _chatsDbContext.Messages
.Include(m => m.Media)
.Where(m => m.IsDeleted || !_chatsDbContext.Chats.Any(c => c.Id == m.ChatId))
.ToListAsync(ct);
// Fetch all current physical files from MinIO
var allMinioFiles = (await fileStorage.ListFilesAsync()).ToList();
// Collect ALL active URLs that we must preserve
var keptMessages = await _chatsDbContext.Messages
.AsNoTracking()
.Include(m => m.Media)
.Where(m => !m.IsDeleted && _chatsDbContext.Chats.Any(c => c.Id == m.ChatId))
.ToListAsync(ct);
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(ct);
var allUsers = await identityDb.Users.AsNoTracking().ToListAsync(ct);
var validUrls = new HashSet<string>();
var activeMessageUrls = keptMessages
.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();
// Physically delete from Storage
foreach (var file in allMinioFiles)
{
if (!validFileIds.Contains(file.FileId))
{
await fileStorage.DeleteFileAsync(file.FileId);
}
}
if(orphanMessages.Any()) {
_chatsDbContext.Messages.RemoveRange(orphanMessages);
await _chatsDbContext.SaveChangesAsync(ct);
}
return Ok(new { message = "Cleanup completed successfully" });
var result = await _sender.Send(new CleanRunCommand(fileStorage, identityDb), ct);
return Ok(result.Value);
}
}
public class ResetPasswordDto
{
public string NewPassword { get; set; } = string.Empty;
}
public class CleanupDryRunResultDto
{
public int OrphanedMessagesCount { get; set; }
public long OrphanedMediaBytes { get; set; }
}

View File

@@ -1,13 +1,11 @@
using MediatR;
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.Abstractions;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using Knot.Modules.Identity.Application.Users.GetMe;
using MediatR;
namespace Host.Controllers;
@@ -16,109 +14,34 @@ namespace Host.Controllers;
public sealed class AuthController : ControllerBase
{
private readonly ISender _sender;
private readonly IJwtTokenProvider _tokenProvider;
private readonly IUserRepository _userRepository;
private readonly ISettingsService _settings;
public AuthController(ISender sender, IJwtTokenProvider tokenProvider, IUserRepository userRepository, ISettingsService settings)
public AuthController(ISender sender)
{
_sender = sender;
_tokenProvider = tokenProvider;
_userRepository = userRepository;
_settings = settings;
}
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterUserCommand command)
public async Task<IActionResult> Register([FromBody] RegisterUserCommand command, CancellationToken ct)
{
if (!_settings.Current.EnableRegistration)
{
return BadRequest(new { error = "Registration is disabled by the administrator." });
}
Result<Guid> result = await _sender.Send(command);
if (result.IsFailure)
{
return BadRequest(new { error = result.Error.Description, code = result.Error.Code });
}
// Получаем созданного пользователя для генерации токена и возврата данных
var user = await _userRepository.GetByIdAsync(result.Value, default);
if (user is null) return BadRequest(new { error = "Не удалось получить созданного пользователя" });
string token = _tokenProvider.Generate(user);
return Ok(new
{
Token = token,
User = new
{
user.Id,
user.Username,
user.DisplayName,
user.Email,
user.Bio,
user.Avatar,
user.Birthday,
IsOnline = true,
user.CreatedAt
}
});
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)
public async Task<IActionResult> GetMe([FromServices] IUserContext userContext, CancellationToken ct)
{
var user = await _userRepository.GetByIdAsync(userContext.UserId, default);
if (user is null) return NotFound();
return Ok(new
{
User = new
{
user.Id,
user.Username,
user.DisplayName,
user.Email,
user.Bio,
user.Avatar,
user.Birthday,
IsOnline = true,
user.CreatedAt
}
});
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)
public async Task<IActionResult> Login([FromBody] LoginUserCommand command, CancellationToken ct)
{
Result<string> result = await _sender.Send(command);
if (result.IsFailure)
{
return Unauthorized(new { error = result.Error.Description, code = result.Error.Code });
}
// Получаем данные пользователя
var user = await _userRepository.GetByUsernameAsync(command.Username, default);
if (user is null) return Unauthorized();
return Ok(new
{
Token = result.Value,
User = new
{
user.Id,
user.Username,
user.DisplayName,
user.Email,
user.Bio,
user.Avatar,
user.Birthday,
IsOnline = true,
user.CreatedAt
}
});
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

@@ -1,19 +1,25 @@
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Chats.Application.Chats.Create;
using Knot.Modules.Chats.Application.Messages.Send;
using Knot.Modules.Chats.Application.Chats.GetOrCreateFavorites;
using Knot.Modules.Chats.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Application.Abstractions;
using Knot.Modules.Chats.Infrastructure.Persistence;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
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;
@@ -24,151 +30,41 @@ public sealed class ChatsController : ControllerBase
{
private readonly ISender _sender;
private readonly IUserContext _userContext;
private readonly IUserRepository _userRepository;
public ChatsController(ISender sender, IUserContext userContext, IUserRepository userRepository)
public ChatsController(ISender sender, IUserContext userContext)
{
_sender = sender;
_userContext = userContext;
_userRepository = userRepository;
}
[HttpGet]
public async Task<IActionResult> GetChats([FromServices] IChatRepository chatRepository, [FromServices] IMessageRepository messageRepository, CancellationToken ct)
public async Task<IActionResult> GetChats(CancellationToken ct)
{
var chats = await chatRepository.GetUserChatsAsync(_userContext.UserId, ct);
var result = new List<object>();
bool hasFavorites = false;
foreach (var c in chats)
{
if (c.Type == ChatType.Favorites)
{
if (hasFavorites) continue;
hasFavorites = true;
}
var members = new List<object>();
foreach (var m in c.Members)
{
var user = await _userRepository.GetByIdAsync(m.UserId, ct);
members.Add(new
{
id = m.Id,
userId = m.UserId,
role = m.Role,
isPinned = m.IsPinned,
user = user != null ? new {
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
isOnline = false,
lastSeen = DateTime.UtcNow
} : null
});
}
var chatMessages = await messageRepository.GetChatMessagesAsync(c.Id, 1, 0, ct);
var messagesList = new List<object>();
if (chatMessages.Any())
{
var m = chatMessages.First();
var senderObj = await _userRepository.GetByIdAsync(m.SenderId, ct);
var reactionsWithUser = new List<object>();
foreach (var r in m.Reactions)
{
var rUser = await _userRepository.GetByIdAsync(r.UserId, ct);
reactionsWithUser.Add(new
{
id = r.Id,
emoji = r.Emoji,
userId = r.UserId,
user = rUser != null
? new { id = rUser.Id, username = rUser.Username, displayName = rUser.DisplayName }
: new { id = r.UserId, username = "unknown", displayName = "Unknown" }
});
}
messagesList.Add(new
{
m.Id,
m.ChatId,
m.SenderId,
m.Content,
m.Type,
m.ReplyToId,
m.Quote,
m.StoryId,
m.StoryMediaUrl,
m.StoryMediaType,
m.IsEdited,
m.IsDeleted,
m.CreatedAt,
Media = m.Media.ToList(),
Sender = senderObj != null ? new {
id = senderObj.Id,
username = senderObj.Username,
displayName = senderObj.DisplayName,
avatar = senderObj.Avatar
} : new { id = m.SenderId, username = "unknown", displayName = "Unknown", avatar = (string?)null },
reactions = reactionsWithUser,
ReadBy = m.ReadBy.Select(r => new { userId = r.UserId }).ToList()
});
}
if (c.Type == ChatType.Personal && messagesList.Count == 0)
{
var currentMember = c.Members.FirstOrDefault(m => m.UserId == _userContext.UserId);
if (currentMember == null || currentMember.Role != ChatRole.Owner)
{
continue; // Skip returning this empty personal chat to the non-initiator
}
}
result.Add(new
{
id = c.Id,
type = c.Type.ToString().ToLowerInvariant(),
name = c.Type == ChatType.Favorites ? "Избранное" : (c.Type == ChatType.Personal ? null : c.Name),
description = c.Description,
avatar = c.Avatar,
createdAt = c.CreatedAt,
members = members,
messages = messagesList,
unreadCount = 0
});
}
return Ok(result);
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)
public async Task<IActionResult> Create([FromBody] CreateChatRequest request, CancellationToken ct)
{
var command = new CreateChatCommand(request.Name, request.Type, request.MemberIds);
Result<Guid> result = await _sender.Send(command);
var result = await _sender.Send(command, ct);
if (result.IsFailure) return BadRequest(result.Error.Description);
if (result.IsFailure)
{
return BadRequest(result.Error);
}
return Ok(result.Value);
var chatResult = await _sender.Send(new GetChatByIdQuery(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 });
Result<Guid> result = await _sender.Send(command, ct);
var result = await _sender.Send(command, ct);
if (result.IsFailure) return BadRequest(result.Error.Description);
if (result.IsFailure) return BadRequest(result.Error);
return Ok(await MapChatAsync(result.Value, ct));
var chatResult = await _sender.Send(new GetChatByIdQuery(result.Value), ct);
return Ok(chatResult.Value);
}
[HttpPost("group")]
@@ -182,265 +78,116 @@ public sealed class ChatsController : ControllerBase
memberIds.Insert(0, _userContext.UserId);
var command = new CreateChatCommand(request.Name, ChatType.Group, memberIds);
Result<Guid> result = await _sender.Send(command, ct);
var result = await _sender.Send(command, ct);
if (result.IsFailure) return BadRequest(result.Error.Description);
if (result.IsFailure) return BadRequest(result.Error);
return Ok(await MapChatAsync(result.Value, ct));
var chatResult = await _sender.Send(new GetChatByIdQuery(result.Value), ct);
return Ok(chatResult.Value);
}
[HttpPost("favorites")]
public async Task<IActionResult> GetOrCreateFavorites(CancellationToken ct)
{
var command = new GetOrCreateFavoritesCommand(_userContext.UserId);
Result<Guid> result = await _sender.Send(command, ct);
var result = await _sender.Send(new GetOrCreateFavoritesCommand(_userContext.UserId), ct);
if (result.IsFailure) return BadRequest(result.Error.Description);
if (result.IsFailure) return BadRequest(result.Error);
return Ok(await MapChatAsync(result.Value, ct));
var chatResult = await _sender.Send(new GetChatByIdQuery(result.Value), ct);
return Ok(chatResult.Value);
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> UpdateChat(Guid id, [FromBody] UpdateChatRequest request, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct)
public async Task<IActionResult> UpdateChat(Guid id, [FromBody] UpdateChatRequest request, CancellationToken ct)
{
var chat = await chatRepository.GetByIdAsync(id, ct);
if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound();
var result = await _sender.Send(new UpdateChatCommand(id, _userContext.UserId, request.Name, request.Description), ct);
if (result.IsFailure) return NotFound();
if (request.Name != null) chat.UpdateName(request.Name);
if (request.Description != null) chat.UpdateDescription(request.Description);
chatRepository.Update(chat);
await uow.SaveChangesAsync(ct);
return Ok(await MapChatAsync(id, ct));
var chatResult = await _sender.Send(new GetChatByIdQuery(result.Value), ct);
return Ok(chatResult.Value);
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> LeaveOrDeleteChat(Guid id, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct)
public async Task<IActionResult> LeaveOrDeleteChat(Guid id, CancellationToken ct)
{
var chat = await chatRepository.GetByIdAsync(id, ct);
if (chat == null) return Ok(new { success = true }); // Already physically deleted, idempotent
if (!chat.Members.Any(m => m.UserId == _userContext.UserId)) return Forbid();
if (chat.Type == ChatType.Group)
var result = await _sender.Send(new LeaveOrDeleteChatCommand(id, _userContext.UserId), ct);
if (result.IsFailure)
{
chat.RemoveMember(_userContext.UserId);
chatRepository.Update(chat);
if (result.Error.Code == "Unauthorized") return Forbid();
return NotFound();
}
else
{
chatRepository.Remove(chat);
}
await uow.SaveChangesAsync(ct);
return Ok(new { success = true });
return Ok(result.Value);
}
[HttpPost("{id:guid}/clear")]
public async Task<IActionResult> ClearChat(Guid id, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct)
public async Task<IActionResult> ClearChat(Guid id, CancellationToken ct)
{
var chat = await chatRepository.GetByIdAsync(id, ct);
if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound();
// Mark clear timestamp per member — simplified: just return success
return Ok(new { message = "Cleared" });
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, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct)
public async Task<IActionResult> TogglePin(Guid id, CancellationToken ct)
{
var chat = await chatRepository.GetByIdAsync(id, ct);
if (chat == null) return NotFound();
var member = chat.Members.FirstOrDefault(m => m.UserId == _userContext.UserId);
if (member == null) return NotFound();
member.TogglePin();
chatRepository.Update(chat);
await uow.SaveChangesAsync(ct);
return Ok(new { isPinned = member.IsPinned });
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, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct)
public async Task<IActionResult> AddMembers(Guid id, [FromBody] AddMembersRequest request, CancellationToken ct)
{
var chat = await chatRepository.GetByIdAsync(id, ct);
if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound();
var result = await _sender.Send(new AddMembersCommand(id, _userContext.UserId, request.UserIds.ToList()), ct);
if (result.IsFailure) return NotFound();
foreach (var userId in request.UserIds)
{
chat.AddMember(userId);
}
chatRepository.Update(chat);
await uow.SaveChangesAsync(ct);
return Ok(await MapChatAsync(id, ct));
var chatResult = await _sender.Send(new GetChatByIdQuery(result.Value), ct);
return Ok(chatResult.Value);
}
[HttpDelete("{id:guid}/members/{userId:guid}")]
public async Task<IActionResult> RemoveMember(Guid id, Guid userId, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct)
public async Task<IActionResult> RemoveMember(Guid id, Guid userId, CancellationToken ct)
{
var chat = await chatRepository.GetByIdAsync(id, ct);
if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound();
var result = await _sender.Send(new RemoveMemberCommand(id, _userContext.UserId, userId), ct);
if (result.IsFailure) return NotFound();
chat.RemoveMember(userId);
chatRepository.Update(chat);
await uow.SaveChangesAsync(ct);
return Ok(await MapChatAsync(id, ct));
var chatResult = await _sender.Send(new GetChatByIdQuery(result.Value), ct);
return Ok(chatResult.Value);
}
[HttpPost("{id:guid}/avatar")]
public async Task<IActionResult> UploadGroupAvatar(Guid id, IFormFile avatar, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, [FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage, CancellationToken ct)
public async Task<IActionResult> UploadGroupAvatar(Guid id, Microsoft.AspNetCore.Http.IFormFile avatar, CancellationToken ct)
{
var chat = await chatRepository.GetByIdAsync(id, ct);
if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound();
if (avatar == null || avatar.Length == 0) return BadRequest("No file");
using var stream = avatar.OpenReadStream();
var fileId = await fileStorage.UploadFileAsync(stream, avatar.FileName, avatar.ContentType);
var url = $"/api/files/{fileId}";
var result = await _sender.Send(new UploadGroupAvatarCommand(id, _userContext.UserId, avatar.FileName, avatar.ContentType, stream), ct);
if (result.IsFailure) return NotFound();
chat.UpdateAvatar(url);
chatRepository.Update(chat);
await uow.SaveChangesAsync(ct);
return Ok(await MapChatAsync(id, ct));
var chatResult = await _sender.Send(new GetChatByIdQuery(result.Value), ct);
return Ok(chatResult.Value);
}
[HttpPost("{id:guid}/avatar/crop")]
public async Task<IActionResult> CropGroupAvatar(Guid id, [FromForm] IFormFile avatar, [FromForm] int x, [FromForm] int y, [FromForm] int width, [FromForm] int height, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, [FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage, CancellationToken ct)
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)
{
var chat = await chatRepository.GetByIdAsync(id, ct);
if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound();
if (avatar == null || avatar.Length == 0) return BadRequest("No file");
string url;
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();
try
{
using (var inputStream = avatar.OpenReadStream())
using (var image = await SixLabors.ImageSharp.Image.LoadAsync(inputStream))
{
int startX = Math.Max(0, Math.Min(x, image.Width - 1));
int startY = Math.Max(0, Math.Min(y, image.Height - 1));
int rectWidth = Math.Max(1, Math.Min(width, image.Width - startX));
int rectHeight = Math.Max(1, Math.Min(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, ct);
outStream.Position = 0;
var fileName = avatar.FileName ?? "avatar.jpg";
var fileId = await fileStorage.UploadFileAsync(outStream, fileName, "image/jpeg");
url = $"/api/files/{fileId}";
}
}
catch (Exception ex)
{
return StatusCode(500, "Error processing image: " + ex.Message);
}
chat.UpdateAvatar(url);
chatRepository.Update(chat);
await uow.SaveChangesAsync(ct);
return Ok(await MapChatAsync(id, ct));
var chatResult = await _sender.Send(new GetChatByIdQuery(result.Value), ct);
return Ok(chatResult.Value);
}
[HttpDelete("{id:guid}/avatar")]
public async Task<IActionResult> RemoveGroupAvatar(Guid id, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct)
public async Task<IActionResult> RemoveGroupAvatar(Guid id, CancellationToken ct)
{
var chat = await chatRepository.GetByIdAsync(id, ct);
if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound();
var result = await _sender.Send(new RemoveGroupAvatarCommand(id, _userContext.UserId), ct);
if (result.IsFailure) return NotFound();
chat.UpdateAvatar(null);
chatRepository.Update(chat);
await uow.SaveChangesAsync(ct);
return Ok(await MapChatAsync(id, ct));
}
private async Task<object> MapChatAsync(Guid chatId, CancellationToken ct)
{
var chatRepository = HttpContext.RequestServices.GetRequiredService<IChatRepository>();
var messageRepository = HttpContext.RequestServices.GetRequiredService<IMessageRepository>();
var chat = await chatRepository.GetByIdAsync(chatId, ct);
if (chat == null) return new { };
var members = new List<object>();
foreach (var m in chat.Members)
{
var user = await _userRepository.GetByIdAsync(m.UserId, ct);
members.Add(new
{
id = m.Id,
userId = m.UserId,
role = m.Role,
isPinned = m.IsPinned,
user = user != null ? new {
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
isOnline = false,
lastSeen = DateTime.UtcNow
} : null
});
}
var chatMessages = await messageRepository.GetChatMessagesAsync(chatId, 1, 0, ct);
var messagesList = new List<object>();
if (chatMessages.Any())
{
var m = chatMessages.First();
var senderObj = await _userRepository.GetByIdAsync(m.SenderId, ct);
messagesList.Add(new
{
m.Id,
m.ChatId,
m.SenderId,
m.Content,
m.Type,
m.ReplyToId,
m.Quote,
m.StoryId,
m.StoryMediaUrl,
m.StoryMediaType,
m.IsEdited,
m.IsDeleted,
m.CreatedAt,
Media = m.Media.ToList(),
Sender = senderObj != null ? new {
id = senderObj.Id,
username = senderObj.Username,
displayName = senderObj.DisplayName,
avatar = senderObj.Avatar
} : new { id = m.SenderId, username = "unknown", displayName = "Unknown", avatar = (string?)null },
ReadBy = m.ReadBy.Select(r => new { userId = r.UserId }).ToList()
});
}
return new
{
id = chat.Id,
type = chat.Type.ToString().ToLowerInvariant(),
name = chat.Type == ChatType.Favorites ? "Избранное" : (chat.Type == ChatType.Personal ? null : chat.Name),
description = chat.Description,
avatar = chat.Avatar,
createdAt = chat.CreatedAt,
members = members,
messages = messagesList,
unreadCount = 0
};
var chatResult = await _sender.Send(new GetChatByIdQuery(result.Value), ct);
return Ok(chatResult.Value);
}
}
public sealed record CreateChatRequest(string Name, ChatType Type, List<Guid> MemberIds);
public sealed record CreatePersonalChatRequest(Guid UserId);
public sealed record CreateGroupChatRequest(string Name, List<Guid> MemberIds);
public sealed record UpdateChatRequest(string? Name, string? Description);
public sealed record AddMembersRequest(List<Guid> UserIds);

View File

@@ -1,10 +1,11 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Identity.Application.Friends;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Application.Abstractions;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Host.Controllers;
@@ -13,187 +14,76 @@ namespace Host.Controllers;
[Route("api/friends")]
public sealed class FriendsController : ControllerBase
{
private readonly IdentityDbContext _context;
private readonly ISender _sender;
private readonly IUserContext _userContext;
private readonly IUserRepository _userRepository;
public FriendsController(IdentityDbContext context, IUserContext userContext, IUserRepository userRepository)
public FriendsController(ISender sender, IUserContext userContext)
{
_context = context;
_sender = sender;
_userContext = userContext;
_userRepository = userRepository;
}
[HttpGet]
public async Task<IActionResult> GetFriends(CancellationToken ct)
{
var currentUserId = _userContext.UserId;
var friendships = await _context.Friendships
.Where(f => (f.UserId == currentUserId || f.FriendId == currentUserId) && f.Status == FriendshipStatus.Accepted)
.ToListAsync(ct);
var friendIds = friendships.Select(f => f.UserId == currentUserId ? f.FriendId : f.UserId).ToList();
var friends = new List<object>();
foreach (var id in friendIds)
{
var user = await _userRepository.GetByIdAsync(id, ct);
if (user == null) continue;
var fs = friendships.First(f => f.UserId == id || f.FriendId == id);
friends.Add(new
{
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
isOnline = false,
lastSeen = DateTime.UtcNow,
friendshipId = fs.Id
});
}
return Ok(friends);
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 currentUserId = _userContext.UserId;
var friendships = await _context.Friendships
.Where(f => f.FriendId == currentUserId && f.Status == FriendshipStatus.Pending)
.ToListAsync(ct);
var requests = new List<object>();
foreach (var fs in friendships)
{
var user = await _userRepository.GetByIdAsync(fs.UserId, ct);
if (user == null) continue;
requests.Add(new
{
id = fs.Id,
user = new
{
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar
},
createdAt = fs.CreatedAt
});
}
return Ok(requests);
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 currentUserId = _userContext.UserId;
var friendships = await _context.Friendships
.Where(f => f.UserId == currentUserId && f.Status == FriendshipStatus.Pending)
.ToListAsync(ct);
var requests = new List<object>();
foreach (var fs in friendships)
{
var user = await _userRepository.GetByIdAsync(fs.FriendId, ct);
if (user == null) continue;
requests.Add(new
{
id = fs.Id,
user = new
{
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar
},
createdAt = fs.CreatedAt
});
}
return Ok(requests);
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] SendFriendRequest request, CancellationToken ct)
public async Task<IActionResult> SendRequest([FromBody] Host.Models.SendFriendRequest request, CancellationToken ct)
{
var currentUserId = _userContext.UserId;
if (currentUserId == request.FriendId) return BadRequest("Cannot add yourself");
var existing = await _context.Friendships
.FirstOrDefaultAsync(f => (f.UserId == currentUserId && f.FriendId == request.FriendId) ||
(f.UserId == request.FriendId && f.FriendId == currentUserId), ct);
if (existing != null) return BadRequest("Friendship already exists");
var friendship = Friendship.Create(currentUserId, request.FriendId);
_context.Friendships.Add(friendship);
await _context.SaveChangesAsync(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 friendship = await _context.Friendships.FindAsync(new object[] { id }, ct);
if (friendship == null || friendship.FriendId != _userContext.UserId) return NotFound();
friendship.Accept();
await _context.SaveChangesAsync(ct);
return Ok(new { id = friendship.Id });
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 friendship = await _context.Friendships.FindAsync(new object[] { id }, ct);
if (friendship == null || friendship.FriendId != _userContext.UserId) return NotFound();
friendship.Decline();
await _context.SaveChangesAsync(ct);
return Ok(new { success = true });
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 currentUserId = _userContext.UserId;
if (currentUserId == userId) return Ok(new { status = "self" });
var fs = await _context.Friendships
.FirstOrDefaultAsync(f => (f.UserId == currentUserId && f.FriendId == userId) ||
(f.UserId == userId && f.FriendId == currentUserId), ct);
if (fs == null) return Ok(new { status = "none" });
return Ok(new
{
status = fs.Status.ToString().ToLower(),
friendshipId = fs.Id,
direction = fs.UserId == currentUserId ? "outgoing" : "incoming"
});
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 currentUserId = _userContext.UserId;
var friendship = await _context.Friendships.FindAsync(new object[] { id }, ct);
if (friendship == null || (friendship.UserId != currentUserId && friendship.FriendId != currentUserId))
return NotFound();
_context.Friendships.Remove(friendship);
await _context.SaveChangesAsync(ct);
return Ok(new { success = true });
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));
}
}
public sealed record SendFriendRequest(Guid FriendId);

View File

@@ -1,15 +1,18 @@
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Knot.Modules.Chats.Domain;
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 Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Application.Abstractions;
using System.Linq;
using System.Text.RegularExpressions;
using Knot.Shared.Kernel.Configuration;
using System.Runtime.CompilerServices;
namespace Host.Controllers;
@@ -20,295 +23,59 @@ public sealed class MessagesController : ControllerBase
{
private readonly ISender _sender;
private readonly IUserContext _userContext;
private readonly IUserRepository _userRepository;
public MessagesController(ISender sender, IUserContext userContext, IUserRepository userRepository)
public MessagesController(ISender sender, IUserContext userContext)
{
_sender = sender;
_userContext = userContext;
_userRepository = userRepository;
}
[HttpGet("chat/{chatId:guid}")]
public async Task<IActionResult> GetMessages(Guid chatId, [FromServices] IMessageRepository messageRepository, [FromQuery] string? cursor, CancellationToken ct = default)
public async Task<IActionResult> GetMessages(Guid chatId, [FromQuery] string? cursor, CancellationToken ct = default)
{
DateTime? cursorDate = null;
if (!string.IsNullOrEmpty(cursor) && DateTime.TryParse(cursor, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
{
cursorDate = parsed.ToUniversalTime();
}
var messages = await messageRepository.GetChatMessagesCursorAsync(chatId, cursorDate, 100, ct);
var senders = new Dictionary<Guid, Knot.Modules.Identity.Domain.User>();
var result = new List<object>();
foreach (var m in messages)
{
if (m.DeletedByUsers.Contains(_userContext.UserId)) continue;
if (!senders.ContainsKey(m.SenderId))
{
var user = await _userRepository.GetByIdAsync(m.SenderId, ct);
if (user != null) senders[m.SenderId] = user;
}
if (m.ForwardedFromId.HasValue && !senders.ContainsKey(m.ForwardedFromId.Value))
{
var fuser = await _userRepository.GetByIdAsync(m.ForwardedFromId.Value, ct);
if (fuser != null) senders[m.ForwardedFromId.Value] = fuser;
}
object? replyToObj = null;
if (m.ReplyToId.HasValue)
{
var replyMsg = await messageRepository.GetByIdAsync(m.ReplyToId.Value, ct);
if (replyMsg != null)
{
if (!senders.ContainsKey(replyMsg.SenderId))
{
var replySender = await _userRepository.GetByIdAsync(replyMsg.SenderId, ct);
if (replySender != null) senders[replyMsg.SenderId] = replySender;
}
var senderObj = senders.TryGetValue(replyMsg.SenderId, out var rs)
? new { id = rs.Id, username = rs.Username, displayName = rs.DisplayName, avatar = rs.Avatar }
: null;
replyToObj = new
{
id = replyMsg.Id,
content = replyMsg.Content,
isDeleted = replyMsg.IsDeleted,
media = replyMsg.Media.Select(rm => new { rm.Id, rm.Type, rm.Url }).ToList(),
sender = senderObj
};
}
}
var reactionsWithUser = new List<object>();
foreach (var r in m.Reactions)
{
if (!senders.ContainsKey(r.UserId))
{
var rUser = await _userRepository.GetByIdAsync(r.UserId, ct);
if (rUser != null) senders[r.UserId] = rUser;
}
var userObj = senders.TryGetValue(r.UserId, out var ru)
? new { id = ru.Id, username = ru.Username, displayName = ru.DisplayName, avatar = ru.Avatar }
: new { id = r.UserId, username = "unknown", displayName = "Unknown", avatar = (string?)null };
reactionsWithUser.Add(new
{
id = r.Id,
emoji = r.Emoji,
userId = r.UserId,
user = userObj
});
}
result.Add(new
{
m.Id,
m.ChatId,
m.SenderId,
m.Content,
m.Type,
m.ReplyToId,
replyTo = replyToObj,
m.Quote,
m.IsEdited,
m.IsDeleted,
m.CreatedAt,
forwardedFromId = m.ForwardedFromId,
forwardedFrom = m.ForwardedFromId.HasValue && senders.TryGetValue(m.ForwardedFromId.Value, out var fwd) ? new {
id = fwd.Id,
username = fwd.Username,
displayName = fwd.DisplayName,
avatar = fwd.Avatar
} : null,
storyId = m.StoryId,
storyMediaUrl = m.StoryMediaUrl,
storyMediaType = m.StoryMediaType,
media = m.Media.Select(media => new {
media.Id,
media.Type,
media.Url,
filename = media.Filename,
size = media.Size
}).ToList(),
sender = senders.TryGetValue(m.SenderId, out var s) ? new {
id = s.Id,
username = s.Username,
displayName = s.DisplayName,
avatar = s.Avatar
} : null,
readBy = m.ReadBy.Select(r => new { userId = r.UserId }).ToList(),
reactions = reactionsWithUser
});
}
return Ok(result);
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([FromServices] IMessageRepository messageRepository, [FromQuery] string q, [FromQuery] Guid? chatId, CancellationToken ct)
public async Task<IActionResult> GetSearch([FromQuery] string q, [FromQuery] Guid? chatId, CancellationToken ct)
{
var messages = await messageRepository.SearchMessagesAsync(q, chatId, ct);
// Filter out messages deleted for this user
messages = messages.Where(m => !m.DeletedByUsers.Contains(_userContext.UserId)).ToList();
var userIds = messages.Select(m => m.SenderId).ToList();
userIds.AddRange(messages.Where(m => m.ForwardedFromId.HasValue).Select(m => m.ForwardedFromId!.Value));
var senders = new Dictionary<Guid, Knot.Modules.Identity.Domain.User>();
foreach (var id in userIds.Distinct())
{
var user = await _userRepository.GetByIdAsync(id, ct);
if (user != null) senders[id] = user;
}
var result = messages.Select(m => new
{
m.Id,
m.ChatId,
m.SenderId,
m.Content,
m.Type,
m.ReplyToId,
m.Quote,
m.IsEdited,
m.IsDeleted,
m.CreatedAt,
forwardedFromId = m.ForwardedFromId,
forwardedFrom = m.ForwardedFromId.HasValue && senders.TryGetValue(m.ForwardedFromId.Value, out var fwd) ? new {
id = fwd.Id,
username = fwd.Username,
displayName = fwd.DisplayName,
avatar = fwd.Avatar
} : null,
storyId = m.StoryId,
storyMediaUrl = m.StoryMediaUrl,
storyMediaType = m.StoryMediaType,
media = m.Media.Select(media => new {
media.Id,
media.Type,
media.Url,
filename = media.Filename,
size = media.Size
}).ToList(),
sender = senders.TryGetValue(m.SenderId, out var s) ? new {
id = s.Id,
username = s.Username,
displayName = s.DisplayName,
avatar = s.Avatar
} : new { id = m.SenderId, username = "unknown", displayName = "Unknown", avatar = (string?)null },
reactions = m.Reactions.Select(r => new { r.UserId, r.Emoji }).ToList(),
readBy = m.ReadBy.Select(r => new { userId = r.UserId }).ToList()
});
return Ok(result);
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,
[FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage,
[FromServices] ISettingsService settingsService)
public async Task<IActionResult> UploadFile(IFormFile file, CancellationToken ct)
{
if (file == null || file.Length == 0) return BadRequest("No file uploaded");
var maxMb = settingsService.Current.MaxFileSizeMb;
if (file.Length > maxMb * 1024 * 1024)
return StatusCode(413, $"File exceeds the maximum allowed size of {maxMb}MB.");
using var stream = file.OpenReadStream();
var fileId = await fileStorage.UploadFileAsync(stream, file.FileName, file.ContentType);
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(new { url = $"/api/files/{fileId}", filename = file.FileName, size = file.Length });
return Ok(result.Value);
}
[HttpGet("chat/{chatId:guid}/shared")]
public async Task<IActionResult> GetSharedMedia(Guid chatId, [FromServices] IMessageRepository messageRepository, [FromQuery] string? type, CancellationToken ct)
public async Task<IActionResult> GetSharedMedia(Guid chatId, [FromQuery] string? type, CancellationToken ct)
{
var messages = await messageRepository.GetChatMessagesAsync(chatId, 300, 0, ct);
// Filter out deleted messages
messages = messages.Where(m => !m.IsDeleted && !m.DeletedByUsers.Contains(_userContext.UserId)).ToList();
var result = new List<object>();
var filterType = type?.ToLower();
foreach (var m in messages)
{
if (filterType == "links")
{
var linkRegex = new Regex(@"https?://[^\s]+", RegexOptions.IgnoreCase);
var contentLinks = !string.IsNullOrEmpty(m.Content) ? linkRegex.Matches(m.Content).Select(match => match.Value).ToList() : new List<string>();
var mediaLinks = (m.Media ?? Enumerable.Empty<Media>()).Where(media => media.Type?.ToLower() == "link").Select(media => media.Url).ToList();
var allLinks = contentLinks.Concat(mediaLinks).Distinct().ToList();
if (allLinks.Any())
{
var sender = await _userRepository.GetByIdAsync(m.SenderId, ct);
result.Add(new
{
m.Id,
m.Content,
m.CreatedAt,
links = allLinks,
sender = sender != null ? new { sender.Id, sender.Username, sender.DisplayName, sender.Avatar } : null
});
}
continue;
}
if (m.Media == null || !m.Media.Any()) continue;
var filteredMedia = m.Media.Where(media => {
var mediaType = media.Type?.ToLower() ?? "file";
var isGif = mediaType == "image" && (media.Url.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) || media.Url.EndsWith(".gif", StringComparison.OrdinalIgnoreCase));
if (filterType == "media") return (mediaType == "image" || mediaType == "video") && !isGif;
if (filterType == "gifs") return isGif;
if (filterType == "files") return mediaType != "image" && mediaType != "video" && mediaType != "link";
return true;
}).ToList();
if (filteredMedia.Any())
{
var sender = await _userRepository.GetByIdAsync(m.SenderId, ct);
result.Add(new
{
m.Id,
m.ReplyToId,
m.Quote,
m.StoryId,
m.StoryMediaUrl,
m.StoryMediaType,
m.IsEdited,
m.Content,
m.Type,
m.CreatedAt,
media = filteredMedia.Select(media => new {
media.Id,
media.Type,
media.Url,
filename = media.Filename,
size = media.Size
}).ToList(),
sender = sender != null ? new { sender.Id, sender.Username, sender.DisplayName, sender.Avatar } : null
});
}
}
return Ok(result.OrderByDescending(x => ((dynamic)x).CreatedAt).ToList());
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)
public async Task<IActionResult> SendMessage(Guid chatId, [FromBody] SendMessageRequest request, CancellationToken ct)
{
var attachments = request.Attachments?.Select(a =>
new Knot.Modules.Chats.Application.Messages.Send.AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList();
new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList();
var command = new SendMessageCommand(
chatId,
@@ -320,23 +87,10 @@ public sealed class MessagesController : ControllerBase
request.Quote,
request.ForwardedFromId);
Result<Guid> result = await _sender.Send(command);
if (result.IsFailure)
{
return BadRequest(result.Error);
}
var result = await _sender.Send(command, ct);
if (result.IsFailure) return BadRequest(result.Error.Description);
return Ok(result.Value);
}
}
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

@@ -1,15 +1,21 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Identity.Domain;
using Knot.Modules.Identity.Infrastructure.Persistence;
using Host.Models;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Application.Abstractions;
using Microsoft.Extensions.Logging;
using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Chats.Application.Messages.Send;
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;
@@ -18,527 +24,102 @@ namespace Host.Controllers;
[Route("api/stories")]
public sealed class StoriesController : ControllerBase
{
private readonly IdentityDbContext _context;
private readonly IUserContext _userContext;
private readonly IUserRepository _userRepository;
private readonly IChatRepository _chatRepository;
private readonly ISender _sender;
private readonly IHubContext<Knot.Modules.Chats.Infrastructure.SignalR.ChatHub> _hubContext;
private readonly IMessageRepository _messageRepository;
private readonly ILogger<StoriesController> _logger;
private readonly IUserContext _userContext;
public StoriesController(
IdentityDbContext context,
IUserContext userContext,
IUserRepository userRepository,
IChatRepository chatRepository,
IMessageRepository messageRepository,
ISender sender,
IHubContext<Knot.Modules.Chats.Infrastructure.SignalR.ChatHub> hubContext,
ILogger<StoriesController> logger)
public StoriesController(ISender sender, IUserContext userContext)
{
_context = context;
_userContext = userContext;
_userRepository = userRepository;
_chatRepository = chatRepository;
_messageRepository = messageRepository;
_sender = sender;
_hubContext = hubContext;
_logger = logger;
_userContext = userContext;
}
[HttpGet]
public async Task<IActionResult> GetStories(CancellationToken ct)
{
var currentUserId = _userContext.UserId;
var friendships = await _context.Set<Friendship>()
.Where(f => f.Status == FriendshipStatus.Accepted && (f.UserId == currentUserId || f.FriendId == currentUserId))
.ToListAsync(ct);
var friendIds = friendships.Select(f => f.UserId == currentUserId ? f.FriendId : f.UserId).ToList();
friendIds.Add(currentUserId);
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(ct);
var groups = stories.GroupBy(s => s.UserId).ToList();
var result = new List<object>();
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, ct);
if (u != null) userMap[uid] = u;
}
foreach (var group in groups)
{
if (!userMap.TryGetValue(group.Key, out var user)) continue;
result.Add(new
{
user = new
{
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar
},
stories = group.Select(s => new
{
id = s.Id,
type = s.Type,
mediaUrl = s.MediaUrl,
content = s.Content,
bgColor = s.BgColor,
createdAt = s.CreatedAt,
expiresAt = s.ExpiresAt,
viewCount = s.Viewers.Count,
viewed = s.Viewers.Any(v => v.UserId == currentUserId),
reactions = s.Reactions.Select(r => new
{
id = r.Id,
userId = r.UserId,
emoji = r.Emoji,
createdAt = r.CreatedAt
}).ToList(),
replyCount = s.Replies.Count
}).OrderBy(s => s.createdAt).ToList(),
hasUnviewed = group.Any(s => !s.Viewers.Any(v => v.UserId == currentUserId))
});
}
var finalResult = result.OrderBy(r =>
{
var rDynamic = (dynamic)r;
if ((Guid)rDynamic.user.id == currentUserId) return 0;
return 1;
}).ToList();
return Ok(finalResult);
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 story = Story.Create(
_userContext.UserId,
request.Type,
request.MediaUrl,
request.Content,
request.BgColor);
_context.Stories.Add(story);
await _context.SaveChangesAsync(ct);
return Ok(new { id = story.Id });
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 currentUserId = _userContext.UserId;
var stories = await _context.Stories
.Include(s => s.Viewers)
.Include(s => s.Reactions)
.Include(s => s.Replies)
.Where(s => s.UserId == userId)
.OrderByDescending(s => s.CreatedAt)
.ToListAsync(ct);
var user = await _userRepository.GetByIdAsync(userId, ct);
if (user == null) return NotFound();
var result = new
{
user = new
{
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar
},
stories = stories.Select(s => new
{
id = s.Id,
type = s.Type,
mediaUrl = s.MediaUrl,
content = s.Content,
bgColor = s.BgColor,
createdAt = s.CreatedAt,
expiresAt = s.ExpiresAt,
viewCount = s.Viewers.Count,
viewed = s.Viewers.Any(v => v.UserId == currentUserId),
reactions = s.Reactions.Select(r => new
{
id = r.Id,
userId = r.UserId,
emoji = r.Emoji,
createdAt = r.CreatedAt
}).ToList(),
replyCount = s.Replies.Count
}).ToList()
};
return Ok(result);
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)
{
_logger.LogInformation("ViewStory called: StoryId={StoryId}, UserId={UserId}", id, _userContext.UserId);
try
{
var story = await _context.Stories
.Include(s => s.Viewers)
.FirstOrDefaultAsync(s => s.Id == id, ct);
if (story == null)
{
_logger.LogWarning("Story not found: {StoryId}", id);
return NotFound();
}
if (story.UserId == _userContext.UserId)
{
_logger.LogInformation("Owner view, skipping");
return Ok(new { message = "Owner view" });
}
if (!story.Viewers.Any(v => v.UserId == _userContext.UserId))
{
story.AddViewer(_userContext.UserId);
await _context.SaveChangesAsync(ct);
_logger.LogInformation("Viewer added. Total viewers: {Count}", story.Viewers.Count);
// Notify owner via SignalR - send to all, filter on client
var viewer = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
_logger.LogInformation("Sending story_viewed to owner {OwnerId}", story.UserId);
// Get updated story with viewers to be sure count is accurate
var updatedStory = await _context.Stories.Include(s => s.Viewers).FirstAsync(s => s.Id == story.Id, ct);
await _hubContext.Clients.All.SendAsync("story_viewed", new
{
storyId = story.Id,
userId = _userContext.UserId,
username = viewer?.Username,
displayName = viewer?.DisplayName,
avatar = viewer?.Avatar,
viewedAt = DateTime.UtcNow,
viewCount = updatedStory.Viewers.Count,
ownerId = story.UserId
}, ct);
_logger.LogInformation("story_viewed sent successfully");
}
else
{
_logger.LogInformation("User already viewed this story");
}
return Ok(new { message = "Story viewed" });
}
catch (DbUpdateException ex)
{
_logger.LogError(ex, "DbUpdateException in ViewStory");
return Ok(new { message = "Story already viewed" });
}
catch (Exception ex)
{
_logger.LogError(ex, "ViewStory error");
return StatusCode(500, ex.Message);
}
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)
{
_logger.LogInformation("GetStoryViewers called: StoryId={StoryId}, UserId={UserId}", id, _userContext.UserId);
var story = await _context.Stories
.Include(s => s.Viewers)
.FirstOrDefaultAsync(s => s.Id == id, ct);
if (story == null)
var result = await _sender.Send(new GetStoryViewersQuery(_userContext.UserId, id), ct);
if (result.IsFailure)
{
_logger.LogWarning("Story not found: {StoryId}", id);
if (result.Error.Code == "Unauthorized") return Forbid();
return NotFound();
}
if (story.UserId != _userContext.UserId)
{
_logger.LogWarning("Forbidden: User {UserId} is not owner of story {StoryId}", _userContext.UserId, id);
return Forbid();
}
_logger.LogInformation("Story has {Count} viewers", story.Viewers.Count);
var viewerIds = story.Viewers.Select(v => v.UserId).ToList();
var viewers = new List<object>();
foreach (var viewerId in viewerIds)
{
var user = await _userRepository.GetByIdAsync(viewerId, ct);
if (user == null) continue;
var viewerRecord = story.Viewers.First(v => v.UserId == viewerId);
viewers.Add(new
{
userId = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
viewedAt = viewerRecord.ViewedAt
});
}
_logger.LogInformation("Returning {Count} viewers", viewers.Count);
return Ok(viewers);
}
private string GetStoryQuote(Story story)
{
if (!string.IsNullOrEmpty(story.Content)) return story.Content;
return story.Type.ToLower() switch
{
"image" => "🖼 Фото",
"video" => "🎬 Видео",
_ => "История"
};
return Ok(result.Value);
}
[HttpPost("{id}/reaction")]
public async Task<IActionResult> AddReaction(Guid id, [FromBody] AddStoryReactionRequest request, CancellationToken ct)
{
_logger.LogInformation("AddReaction called: StoryId={StoryId}, UserId={UserId}, Emoji={Emoji}", id, _userContext.UserId, request.Emoji);
try
{
var story = await _context.Stories.FindAsync(new object[] { id }, ct);
if (story == null)
{
_logger.LogWarning("Story not found: {StoryId}", id);
return NotFound();
}
// Check if reaction already exists
var existing = await _context.StoryReactions
.FirstOrDefaultAsync(r => r.StoryId == id && r.UserId == _userContext.UserId && r.Emoji == request.Emoji, ct);
if (existing != null)
{
_logger.LogInformation("Reaction already exists");
return Ok(new { message = "Reaction already exists" });
}
// Add reaction directly via DbSet
var reaction = new StoryReaction(id, _userContext.UserId, request.Emoji);
_context.StoryReactions.Add(reaction);
await _context.SaveChangesAsync(ct);
_logger.LogInformation("Reaction saved successfully");
// 1. Create/Find chat
var chatId = await GetOrCreatePersonalChatIdAsync(_userContext.UserId, story.UserId, ct);
// 2. Threading: find last story message in this chat
var lastStoryMessage = await _messageRepository.GetLastStoryMessageAsync(chatId, story.Id, ct);
if (lastStoryMessage != null)
{
// If message already exists for this story, add a reaction to it
var addReactionCommand = new Knot.Modules.Chats.Application.Messages.React.AddReactionCommand(
lastStoryMessage.Id, _userContext.UserId, request.Emoji, chatId);
await _sender.Send(addReactionCommand, ct);
}
else
{
// Create new message for the story
var storyQuote = GetStoryQuote(story);
var messageCommand = new SendMessageCommand(
ChatId: chatId,
SenderId: _userContext.UserId,
Content: request.Emoji,
Type: "text",
Quote: storyQuote,
StoryId: story.Id,
StoryMediaUrl: story.MediaUrl,
StoryMediaType: story.Type);
await _sender.Send(messageCommand, ct);
}
// 3. Notify story owner in real-time (existing StoryViewer listeners)
var reactor = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
await _hubContext.Clients.All.SendAsync("story_reaction", new
{
storyId = story.Id,
userId = _userContext.UserId,
username = reactor?.Username,
displayName = reactor?.DisplayName,
avatar = reactor?.Avatar,
emoji = request.Emoji,
createdAt = DateTime.UtcNow,
ownerId = story.UserId
}, ct);
return Ok(new { message = "Reaction added" });
}
catch (Exception ex)
{
_logger.LogError(ex, "AddReaction error");
return StatusCode(500, ex.Message);
}
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 reaction = await _context.StoryReactions
.FirstOrDefaultAsync(r => r.StoryId == id && r.UserId == _userContext.UserId && r.Emoji == request.Emoji, ct);
if (reaction == null) return Ok(new { message = "Reaction not found" });
_context.StoryReactions.Remove(reaction);
await _context.SaveChangesAsync(ct);
return Ok(new { message = "Reaction removed" });
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)
{
_logger.LogInformation("AddReply called: StoryId={StoryId}, UserId={UserId}, Content={Content}", id, _userContext.UserId, request.Content);
try
{
var story = await _context.Stories.FindAsync(new object[] { id }, ct);
if (story == null)
{
_logger.LogWarning("Story not found: {StoryId}", id);
return NotFound();
}
// Add reply directly via DbSet
var reply = new StoryReply(id, _userContext.UserId, request.Content);
_context.StoryReplies.Add(reply);
await _context.SaveChangesAsync(ct);
_logger.LogInformation("Reply saved successfully");
// 1. Create/Find chat
var chatId = await GetOrCreatePersonalChatIdAsync(_userContext.UserId, story.UserId, ct);
// 2. Find last message for threading
var lastStoryMessage = await _messageRepository.GetLastStoryMessageAsync(chatId, story.Id, ct);
// 3. Send message to chat
var storyQuote = GetStoryQuote(story);
var messageCommand = new SendMessageCommand(
ChatId: chatId,
SenderId: _userContext.UserId,
Content: request.Content,
Type: "text",
Quote: storyQuote,
ReplyToId: lastStoryMessage?.Id,
StoryId: story.Id,
StoryMediaUrl: story.MediaUrl,
StoryMediaType: story.Type);
await _sender.Send(messageCommand, ct);
// 3. Notify story owner in real-time (existing StoryViewer listeners)
var replier = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
await _hubContext.Clients.All.SendAsync("story_reply", new
{
storyId = story.Id,
userId = _userContext.UserId,
username = replier?.Username,
displayName = replier?.DisplayName,
avatar = replier?.Avatar,
content = request.Content,
createdAt = DateTime.UtcNow,
ownerId = story.UserId
}, ct);
return Ok(new { message = "Reply added" });
}
catch (Exception ex)
{
_logger.LogError(ex, "AddReply error");
return StatusCode(500, ex.Message);
}
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 story = await _context.Stories
.Include(s => s.Replies)
.FirstOrDefaultAsync(s => s.Id == id, ct);
if (story == null) return NotFound();
if (story.UserId != _userContext.UserId) return Forbid();
var replies = new List<object>();
foreach (var reply in story.Replies.OrderBy(r => r.CreatedAt))
var result = await _sender.Send(new GetStoryRepliesQuery(_userContext.UserId, id), ct);
if (result.IsFailure)
{
var user = await _userRepository.GetByIdAsync(reply.UserId, ct);
if (user == null) continue;
replies.Add(new
{
id = reply.Id,
userId = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
content = reply.Content,
createdAt = reply.CreatedAt
});
if (result.Error.Code == "Unauthorized") return Forbid();
return NotFound();
}
return Ok(replies);
return Ok(result.Value);
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteStory(Guid id, CancellationToken ct)
{
var story = await _context.Stories.FindAsync(new object[] { id }, ct);
if (story == null) return NotFound();
if (story.UserId != _userContext.UserId) return Forbid();
_context.Stories.Remove(story);
await _context.SaveChangesAsync(ct);
return Ok(new { message = "Story deleted" });
}
private async Task<Guid> GetOrCreatePersonalChatIdAsync(Guid userId1, Guid userId2, CancellationToken ct)
{
var userChats = await _chatRepository.GetUserChatsAsync(userId1, ct);
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, ct);
return result.Value;
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);
}
}
public sealed record CreateStoryRequest(string Type, string? MediaUrl, string? Content, string? BgColor);
public sealed record AddStoryReactionRequest(string Emoji);
public sealed record RemoveStoryReactionRequest(string Emoji);
public sealed record AddStoryReplyRequest(string Content);

View File

@@ -1,24 +1,12 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using HtmlAgilityPack;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Knot.Modules.Chats.Application.Abstractions;
using Knot.Modules.Chats.Domain;
using Knot.Modules.Identity.Domain;
using MediatR;
using Host.Models;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Chats.Infrastructure.SignalR;
using Knot.Modules.Chats.Application.Chats.Create;
using Knot.Modules.Chats.Application.TelegramImport;
using System.Threading;
using System.Threading.Tasks;
namespace Host.Controllers;
@@ -29,31 +17,11 @@ public sealed class TelegramImportController : ControllerBase
{
private readonly ISender _sender;
private readonly IUserContext _userContext;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly IChatRepository _chatRepository;
private readonly IMessageRepository _messageRepository;
private readonly IFileStorageService _fileStorage;
private readonly IHubContext<ChatHub> _hubContext;
// In-memory store for uploaded zips (good enough for typical cases, ideally should be removed after use or by timer)
private static readonly ConcurrentDictionary<Guid, string> _tempZips = new();
public TelegramImportController(
ISender sender,
IUserContext userContext,
IChatsUnitOfWork unitOfWork,
IChatRepository chatRepository,
IMessageRepository messageRepository,
IFileStorageService fileStorage,
IHubContext<ChatHub> hubContext)
public TelegramImportController(ISender sender, IUserContext userContext)
{
_sender = sender;
_userContext = userContext;
_unitOfWork = unitOfWork;
_chatRepository = chatRepository;
_messageRepository = messageRepository;
_fileStorage = fileStorage;
_hubContext = hubContext;
}
[HttpPost("analyze")]
@@ -61,425 +29,36 @@ public sealed class TelegramImportController : ControllerBase
[RequestFormLimits(MultipartBodyLengthLimit = 10L * 1024 * 1024 * 1024)] // 10GB for big exports
public async Task<IActionResult> Analyze(IFormFile file, CancellationToken ct)
{
if (file == null || file.Length == 0) return BadRequest("No file uploaded");
if (!file.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) return BadRequest("Must be a ZIP archive");
var token = Guid.NewGuid();
var tempPath = Path.Combine(Path.GetTempPath(), $"{token}.zip");
await using (var fs = new FileStream(tempPath, FileMode.Create))
if (file == null)
{
await file.CopyToAsync(fs, ct);
return BadRequest("No file uploaded.");
}
var names = new HashSet<string>();
// Open zip and quickly scan messages.html
using (var archive = ZipFile.OpenRead(tempPath))
using var stream = file.OpenReadStream();
var command = new AnalyzeImportCommand(stream, file.FileName);
var result = await _sender.Send(command, ct);
if (result.IsFailure)
{
var htmlEntries = archive.Entries
.Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase));
foreach (var entry in htmlEntries)
{
using var stream = entry.Open();
var parser = new AngleSharp.Html.Parser.HtmlParser();
var doc = parser.ParseDocument(stream);
var messageNodes = doc.QuerySelectorAll(".message");
if (messageNodes == null) continue;
foreach (var node in messageNodes)
{
var fromNameNode = node.QuerySelector(".from_name");
if (fromNameNode != null)
{
var nameNodeText = (AngleSharp.Dom.IElement)fromNameNode.Clone();
var innerSpans = nameNodeText.QuerySelectorAll("span");
foreach (var span in innerSpans) span.Remove();
var name = nameNodeText.TextContent.Trim();
if (!string.IsNullOrWhiteSpace(name))
{
names.Add(name);
}
}
}
}
return BadRequest(result.Error.Description ?? result.Error.Code);
}
_tempZips[token] = tempPath;
return Ok(new
{
token,
names = names.ToList()
});
return Ok(result.Value);
}
public sealed record ExecuteImportRequest(Guid Token, Dictionary<string, Guid> Mapping, string? GroupName);
[HttpPost("execute")]
public async Task<IActionResult> Execute([FromBody] ExecuteImportRequest req, CancellationToken ct)
{
if (!_tempZips.TryGetValue(req.Token, out var tempPath))
return BadRequest("Session not found or expired");
var command = new ExecuteImportCommand(_userContext.UserId, req.Token, req.Mapping, req.GroupName);
var result = await _sender.Send(command, ct);
if (!System.IO.File.Exists(tempPath))
return BadRequest("ZIP file lost");
var myId = _userContext.UserId;
// Collect targeted users to check whose chat it is. Find the friend.
// Usually, the mapping contains MyId and FriendId.
var targetUserIds = req.Mapping.Values.Distinct().Where(id => id != Guid.Empty).ToList();
if (!targetUserIds.Contains(myId)) targetUserIds.Add(myId);
Guid chatId = Guid.Empty;
var chatMembers = targetUserIds;
if (chatMembers.Count <= 2)
if (result.IsFailure)
{
// Find existing personal chat
var existingChats = await _chatRepository.GetUserChatsAsync(myId, ct);
var personalChat = existingChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.All(m => chatMembers.Contains(m.UserId)) && c.Members.Count == chatMembers.Count);
if (personalChat != null)
{
chatId = personalChat.Id;
}
else
{
// Create personal chat
var friendId = chatMembers.FirstOrDefault(id => id != myId);
if (friendId == Guid.Empty) friendId = myId; // Notes to self
var command = new CreateChatCommand(string.Empty, ChatType.Personal, new List<Guid> { myId, friendId });
var res = await _sender.Send(command, ct);
if (res.IsFailure) return BadRequest(res.Error);
chatId = res.Value;
}
}
else
{
// Create a group
var command = new CreateChatCommand(req.GroupName ?? "Импортированный чат", ChatType.Group, chatMembers);
var res = await _sender.Send(command, ct);
if (res.IsFailure) return BadRequest(res.Error);
chatId = res.Value;
return BadRequest(result.Error.Description ?? result.Error.Code);
}
int importedCount = 0;
using (var archive = ZipFile.OpenRead(tempPath))
{
var htmlEntries = archive.Entries
.Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase))
.OrderBy(e =>
{
var name = e.Name.ToLower().Replace("messages", "").Replace(".html", "");
return string.IsNullOrEmpty(name) ? 0 : int.TryParse(name, out var num) ? num : 999999;
})
.ToList();
Guid lastSenderGuid = myId;
DateTime lastCreatedAt = DateTime.UtcNow;
Dictionary<string, Guid> messageIdMap = new();
Message? lastSavedMessage = null;
foreach (var entry in htmlEntries)
{
using var stream = entry.Open();
var parser = new AngleSharp.Html.Parser.HtmlParser();
var doc = parser.ParseDocument(stream);
var messageNodes = doc.QuerySelectorAll(".message");
if (messageNodes == null) continue;
var baseDir = Path.GetDirectoryName(entry.FullName)?.Replace("\\", "/") ?? "";
if (!string.IsNullOrEmpty(baseDir) && !baseDir.EndsWith("/")) baseDir += "/";
foreach (var node in messageNodes)
{
try
{
var fromNameNode = node.QuerySelector(".from_name");
var textNode = node.QuerySelector(".text");
var dateNode = node.QuerySelector(".date[title]")
?? node.QuerySelector(".pull_right[title]")
?? node.QuerySelector("[title]");
if (fromNameNode != null)
{
var nameNodeText = (AngleSharp.Dom.IElement)fromNameNode.Clone();
var innerSpans = nameNodeText.QuerySelectorAll("span");
foreach (var span in innerSpans) span.Remove();
var name = nameNodeText.TextContent.Trim();
if (req.Mapping.TryGetValue(name, out var mappedId) && mappedId != Guid.Empty)
lastSenderGuid = mappedId;
else
lastSenderGuid = myId;
}
Guid senderGuid = lastSenderGuid;
string content = "";
var mainBodyNode = node.QuerySelector(".body");
var isForwarded = node.QuerySelector(".forwarded") != null;
// To avoid grabbing text from inside the forwarded block as main text,
// we can look for .text that is a direct child of the main .body
// The .forwarded block has its own .text
var contentTextNode = isForwarded
? (node.QuerySelector(".body > .text") ?? node.QuerySelector(".text:not(.forwarded .text)"))
: node.QuerySelector(".text");
if (contentTextNode != null)
{
var html = contentTextNode.InnerHtml
.Replace("<br>", "\n", StringComparison.OrdinalIgnoreCase)
.Replace("<br/>", "\n", StringComparison.OrdinalIgnoreCase)
.Replace("<br />", "\n", StringComparison.OrdinalIgnoreCase);
var tempParser = new AngleSharp.Html.Parser.HtmlParser();
var tempDoc = tempParser.ParseDocument("<div>" + html + "</div>");
content = tempDoc.Body?.TextContent.Trim() ?? "";
}
DateTime createdAt = lastCreatedAt;
var titleNodes = node.QuerySelectorAll("[title]");
bool parsed = false;
if (titleNodes != null)
{
foreach (var tnode in titleNodes)
{
var dateStr = tnode.GetAttribute("title")?.Trim() ?? "";
if (dateStr.Length >= 10 && char.IsDigit(dateStr[0]) && char.IsDigit(dateStr[1]))
{
// Remove "UTC" so we can parse timezone offset generically, e.g. "17.10.2025 12:56:25 +03:00"
var cleanStr = dateStr.Replace("UTC", "", StringComparison.OrdinalIgnoreCase).Trim();
if (DateTimeOffset.TryParseExact(cleanStr, "dd.MM.yyyy HH:mm:ss zzz", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dto))
{
createdAt = dto.UtcDateTime;
parsed = true;
break;
}
else if (DateTime.TryParseExact(cleanStr, "dd.MM.yyyy HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var dt))
{
createdAt = dt;
parsed = true;
break;
}
else if (DateTime.TryParse(cleanStr, out var dFallback))
{
createdAt = dFallback.ToUniversalTime();
parsed = true;
break;
}
}
}
}
if (!parsed)
{
Console.WriteLine("Warning: Could not parse date in imported message! Using lastCreatedAt.");
}
else
{
lastCreatedAt = createdAt;
}
// Parse forwards
Guid? forwardedFromId = null;
var forwardedNode = node.QuerySelector(".forwarded.body");
if (forwardedNode != null)
{
var fwdNameNode = forwardedNode.QuerySelector(".from_name");
var fwdNameText = fwdNameNode != null ? (AngleSharp.Dom.IElement)fwdNameNode.Clone() : null;
if (fwdNameText != null)
{
var innerSpans = fwdNameText.QuerySelectorAll("span");
foreach (var s in innerSpans) s.Remove();
}
var fwdName = fwdNameText != null ? fwdNameText.TextContent.Trim() : "Неизвестного";
if (req.Mapping.TryGetValue(fwdName, out var mappedFwdId) && mappedFwdId != Guid.Empty)
{
forwardedFromId = mappedFwdId;
}
else if (fwdName == "Это я" || fwdName == req.Mapping.FirstOrDefault(x => x.Value == myId).Key)
{
forwardedFromId = myId;
}
var fwdTextNode = forwardedNode.QuerySelector(".text");
string fwdContent = "";
if (fwdTextNode != null)
{
var fHtml = fwdTextNode.InnerHtml
.Replace("<br>", "\n", StringComparison.OrdinalIgnoreCase)
.Replace("<br/>", "\n", StringComparison.OrdinalIgnoreCase)
.Replace("<br />", "\n", StringComparison.OrdinalIgnoreCase);
var tempParser = new AngleSharp.Html.Parser.HtmlParser();
var tempDoc = tempParser.ParseDocument("<div>" + fHtml + "</div>");
fwdContent = tempDoc.Body?.TextContent.Trim() ?? "";
}
if (forwardedFromId == null)
{
// We don't have this user in the app, map to generic string
content = string.IsNullOrEmpty(content)
? $"[Переслано от {fwdName}]:\n{fwdContent}"
: $"{content}\n\n[Переслано от {fwdName}]:\n{fwdContent}";
}
else if (string.IsNullOrEmpty(content))
{
content = fwdContent;
}
}
// Parse replies
Guid? replyToId = null;
var replyNode = node.QuerySelector(".reply_to a");
if (replyNode != null)
{
var href = replyNode.GetAttribute("href");
if (href != null && href.StartsWith("#go_to_"))
{
var tgId = href.Substring("#go_to_".Length);
if (messageIdMap.TryGetValue(tgId, out var mappedMsgId))
{
replyToId = mappedMsgId;
}
}
}
var mediaNodes = node.QuerySelectorAll("a.photo_wrap, a.animated_wrap, video, audio, a.document, a.media_voice_message, a.media_video, img.sticker").ToList();
if (mediaNodes.Count == 0)
{
var fallback = node.QuerySelectorAll(".media_wrap a[href]");
mediaNodes.AddRange(fallback);
}
var messageType = "text";
// Helper for categorizing files
(string mType, string cType) GetMediaTypes(string fileUrl)
{
var ext = Path.GetExtension(fileUrl)?.ToLower();
return ext switch
{
".jpg" or ".jpeg" or ".png" or ".webp" => ("image", "image/jpeg"),
".mp4" or ".mov" or ".avi" => ("video", "video/mp4"),
".ogg" or ".mp3" => ("voice", "audio/ogg"),
_ => ("file", "application/octet-stream")
};
}
if (mediaNodes != null && mediaNodes.Count > 0)
{
var firstHref = mediaNodes[0].GetAttribute("href") ?? mediaNodes[0].GetAttribute("src");
if (firstHref != null)
{
messageType = GetMediaTypes(firstHref).mType;
if (mediaNodes[0].ClassName?.Contains("animated") == true || firstHref.EndsWith(".mp4"))
{
// Treat telegram animated gifs as image format in app (we auto-loop mp4 images)
// But web app specifically handles "image" and "video" and microlink crashes on mp4 video
// Let's just keep "video" or "image". Actually, telegram exports gifs as mp4.
// We should let our app player handle it.
if (mediaNodes[0].ClassName?.Contains("animated") == true)
messageType = "image";
}
}
}
bool isJoined = fromNameNode == null;
bool isMediaOnly = string.IsNullOrEmpty(content) && forwardedNode == null && replyToId == null;
Message? targetMessage = null;
if (isJoined && isMediaOnly && lastSavedMessage != null && Math.Abs((createdAt - lastSavedMessage.CreatedAt).TotalSeconds) <= 60 && lastSavedMessage.SenderId == senderGuid)
{
targetMessage = lastSavedMessage;
}
else
{
string finalContent = content;
targetMessage = Message.Import(chatId, senderGuid, finalContent, messageType, createdAt, replyToId, forwardedFromId);
var idAttr = node.GetAttribute("id");
if (!string.IsNullOrEmpty(idAttr))
{
messageIdMap[idAttr] = targetMessage.Id;
}
}
if (mediaNodes != null)
{
foreach (var mediaNode in mediaNodes)
{
string? href = mediaNode.GetAttribute("href") ?? mediaNode.GetAttribute("src");
if (!string.IsNullOrEmpty(href) && !href.StartsWith("http"))
{
var zipPath = baseDir + href.Replace("\\", "/");
var zipEntry = archive.GetEntry(zipPath);
if (zipEntry != null)
{
using var ms = new MemoryStream();
using var zipfs = zipEntry.Open();
await zipfs.CopyToAsync(ms, ct);
ms.Position = 0;
var types = GetMediaTypes(href);
var finalMType = types.mType;
if (mediaNode.ClassName?.Contains("animated") == true) finalMType = "image";
var fileId = await _fileStorage.UploadFileAsync(ms, Path.GetFileName(href), types.cType);
targetMessage.AddMedia(finalMType, $"/api/files/{fileId}", Path.GetFileName(href), zipEntry.Length);
}
}
}
}
// Parse reactions
var reactionNodes = node.QuerySelectorAll(".reactions .reaction");
foreach (var reactionNode in reactionNodes)
{
var emojiNode = reactionNode.QuerySelector(".emoji");
if (emojiNode == null) continue;
var emoji = emojiNode.TextContent.Trim();
var userpicNodes = reactionNode.QuerySelectorAll(".userpics .userpic .initials[title]");
foreach (var userpicNode in userpicNodes)
{
var title = userpicNode.GetAttribute("title")?.Trim();
if (!string.IsNullOrEmpty(title) && req.Mapping.TryGetValue(title, out var rUserId) && rUserId != Guid.Empty)
{
targetMessage.AddReaction(rUserId, emoji);
}
}
}
if (targetMessage != lastSavedMessage && (!string.IsNullOrEmpty(content) || targetMessage.Media.Any()))
{
_messageRepository.Add(targetMessage);
lastSavedMessage = targetMessage;
importedCount++;
}
}
catch { /* ignore single message parse error */ }
}
}
}
await _unitOfWork.SaveChangesAsync(ct);
// Delete temp zip
try { System.IO.File.Delete(tempPath); _tempZips.TryRemove(req.Token, out _); } catch { }
// Notify UI for all members of the chat
await _hubContext.Clients.Users(chatMembers.Select(x => x.ToString())).SendAsync("history_updated", new { chatId });
return Ok(new { success = true, messagesImported = importedCount, chatId });
return Ok(result.Value);
}
}

View File

@@ -1,10 +1,15 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Knot.Modules.Identity.Domain;
using Knot.Shared.Kernel;
using Knot.Modules.Identity.Application.Abstractions;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
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;
@@ -13,200 +18,90 @@ namespace Host.Controllers;
[Route("api/users")]
public sealed class UsersController : ControllerBase
{
private readonly IUserRepository _userRepository;
private readonly ISender _sender;
private readonly IUserContext _userContext;
private readonly IIdentityUnitOfWork _unitOfWork;
public UsersController(IUserRepository userRepository, IUserContext userContext, IIdentityUnitOfWork unitOfWork)
public UsersController(ISender sender, IUserContext userContext)
{
_userRepository = userRepository;
_sender = sender;
_userContext = userContext;
_unitOfWork = unitOfWork;
}
[HttpGet("search")]
public async Task<IActionResult> Search([FromQuery] string q, CancellationToken ct)
{
var users = await _userRepository.SearchUsersAsync(q, ct);
var result = users.Select(user => new
{
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
isOnline = false,
lastSeen = DateTime.UtcNow
}).ToList();
return Ok(result);
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 user = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
if (user == null) return NotFound();
user.UpdateSettings(request.HideStoryViews ?? user.HideStoryViews);
await _unitOfWork.SaveChangesAsync(ct);
return Ok(new {
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
bio = user.Bio,
birthday = user.Birthday,
createdAt = user.CreatedAt,
hideStoryViews = user.HideStoryViews
});
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, [FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage, CancellationToken ct)
public async Task<IActionResult> UploadAvatar(IFormFile avatar, CancellationToken ct)
{
var user = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
if (user == null) return NotFound();
var fileToUpload = avatar ?? Request.Form.Files.FirstOrDefault();
if (fileToUpload == null || fileToUpload.Length == 0) return BadRequest("No file uploaded");
string? avatarUrl = null;
var fileToUpload = avatar != null && avatar.Length > 0 ? avatar : Request.Form.Files.Count > 0 ? Request.Form.Files[0] : null;
using var stream = fileToUpload.OpenReadStream();
var command = new UploadAvatarCommand(
_userContext.UserId,
stream,
fileToUpload.FileName,
fileToUpload.ContentType
);
if (fileToUpload != null)
{
using var stream = fileToUpload.OpenReadStream();
var fileId = await fileStorage.UploadFileAsync(stream, fileToUpload.FileName, fileToUpload.ContentType);
avatarUrl = $"/api/files/{fileId}";
}
else
{
return BadRequest("No file uploaded");
}
user.UpdateAvatar(avatarUrl);
await _unitOfWork.SaveChangesAsync(ct);
return Ok(new {
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
bio = user.Bio,
birthday = user.Birthday,
createdAt = user.CreatedAt
});
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, [FromServices] Knot.Shared.Kernel.Storage.IFileStorageService fileStorage, CancellationToken ct)
public async Task<IActionResult> CropAvatar([FromForm] IFormFile avatar, [FromForm] int x, [FromForm] int y, [FromForm] int width, [FromForm] int height, CancellationToken ct)
{
var user = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
if (user == null) return NotFound();
if (avatar == null || avatar.Length == 0) return BadRequest("No file uploaded");
if (avatar == null || avatar.Length == 0) return BadRequest("No file");
using var stream = avatar.OpenReadStream();
var command = new CropAvatarCommand(
_userContext.UserId,
stream,
avatar.FileName ?? "avatar.jpg",
avatar.ContentType,
x, y, width, height
);
string avatarUrl;
try
{
using (var inputStream = avatar.OpenReadStream())
using (var image = await SixLabors.ImageSharp.Image.LoadAsync(inputStream))
{
int startX = Math.Max(0, Math.Min(x, image.Width - 1));
int startY = Math.Max(0, Math.Min(y, image.Height - 1));
int rectWidth = Math.Max(1, Math.Min(width, image.Width - startX));
int rectHeight = Math.Max(1, Math.Min(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, ct);
outStream.Position = 0;
var fileName = avatar.FileName ?? "avatar.jpg";
var id = await fileStorage.UploadFileAsync(outStream, fileName, "image/jpeg");
avatarUrl = $"/api/files/{id}";
}
}
catch (Exception ex)
{
return StatusCode(500, "Error processing image: " + ex.Message);
}
user.UpdateAvatar(avatarUrl);
await _unitOfWork.SaveChangesAsync(ct);
return Ok(new {
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
bio = user.Bio,
birthday = user.Birthday,
createdAt = user.CreatedAt
});
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 user = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
if (user == null) return NotFound();
user.UpdateAvatar(null);
await _unitOfWork.SaveChangesAsync(ct);
return Ok(new {
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
bio = user.Bio,
birthday = user.Birthday,
createdAt = user.CreatedAt
});
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 user = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
if (user == null) return NotFound();
user.UpdateProfile(request.DisplayName ?? user.DisplayName, request.Bio, request.Birthday);
await _unitOfWork.SaveChangesAsync(ct);
return Ok(new {
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
bio = user.Bio,
birthday = user.Birthday,
createdAt = user.CreatedAt
});
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 user = await _userRepository.GetByIdAsync(id, ct);
if (user == null) return NotFound();
return Ok(new {
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
bio = user.Bio,
birthday = user.Birthday,
createdAt = user.CreatedAt,
isOnline = false,
lastSeen = DateTime.UtcNow
});
var result = await _sender.Send(new GetUserQuery(id), ct);
if (result.IsFailure) return NotFound(result.Error);
return Ok(result.Value);
}
}
public sealed record UpdateProfileRequest(string? DisplayName, string? Bio, DateTime? Birthday);
public sealed record UpdateSettingsRequest(bool? HideStoryViews);

View File

@@ -1,5 +1,5 @@
using Carter;
using Host.Services;
using MediatR;
using Knot.Shared.Kernel.Constants;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
@@ -14,12 +14,12 @@ public sealed class ConfigEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup(Routes.ApiConfig); // Без RequiresAuthorization, т.к. контроллер раньше был AllowAnonymous для GET
var group = app.MapGroup(Routes.ApiConfig);
group.MapGet("/", (IConfigService configService) =>
group.MapGet("/", async (ISender sender, CancellationToken ct) =>
{
var config = configService.GetPublicConfig();
return Results.Ok(config);
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

@@ -1,5 +1,5 @@
using Carter;
using Host.Services;
using MediatR;
using Knot.Shared.Kernel.Constants;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
@@ -16,31 +16,17 @@ public sealed class FederationEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup(Routes.ApiFederation); // Без RequireAuthorization для handshake
var group = app.MapGroup(Routes.ApiFederation);
group.MapPost("/handshake", async ([FromBody] HandshakeRequest request, IFederationService federationService) =>
group.MapPost("/handshake", async ([FromBody] Host.Application.Federation.Commands.HandshakeRequest request, ISender sender, CancellationToken ct) =>
{
try
{
var result = await federationService.HandshakeAsync(request);
return Results.Ok(result);
}
catch (InvalidOperationException ex)
{
return Results.StatusCode(503); // Service Unavailable
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
catch (UnauthorizedAccessException ex)
{
return Results.Forbid();
}
catch (Exception ex)
{
return Results.StatusCode(500);
}
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

@@ -1,4 +1,5 @@
using Carter;
using MediatR;
using Knot.Shared.Kernel.Constants;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
@@ -6,7 +7,6 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using System;
using System.Threading.Tasks;
using Host.Services;
namespace Host.Endpoints;
@@ -19,42 +19,16 @@ public sealed class KlipyEndpoints : ICarterModule
{
var group = app.MapGroup(Routes.ApiKlipy).RequireAuthorization();
group.MapGet("/trending", async (IKlipyService klipyService) =>
group.MapGet("/trending", async (ISender sender, CancellationToken ct) =>
{
try
{
var result = await klipyService.GetTrendingAsync();
return Results.Ok(result);
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
catch (Exception ex)
{
return Results.StatusCode(500); // Можно возвращать JSON с сообщением, но для продакшена лучше централизованный Exception handling
}
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, IKlipyService klipyService) =>
group.MapGet("/search", async ([FromQuery] string q, ISender sender, CancellationToken ct) =>
{
try
{
var result = await klipyService.SearchAsync(q);
return Results.Ok(result);
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
catch (Exception ex)
{
return Results.StatusCode(500);
}
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

@@ -1,9 +1,9 @@
using Carter;
using MediatR;
using Knot.Shared.Kernel;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Host.Services;
namespace Host.Endpoints;
@@ -17,8 +17,12 @@ public sealed class WebRtcEndpoints : ICarterModule
var group = app.MapGroup(Knot.Shared.Kernel.Constants.Routes.ApiWebRtc)
.RequireAuthorization();
group.MapGet("/ice-servers", (IWebRtcService service) => service.GetIceServers())
.WithName("GetIceServers")
.WithOpenApi();
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

@@ -15,7 +15,7 @@
<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.AspNetCore.SignalR.Common" Version="10.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>

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

View File

@@ -1,3 +1,4 @@
using Carter;
using Knot.Shared.Infrastructure;
using Knot.Modules.Identity;
using Knot.Modules.Chats;
@@ -50,6 +51,12 @@ 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 =>
{
@@ -180,8 +187,9 @@ if (app.Environment.IsDevelopment())
app.UseAuthentication();
app.UseAuthorization();
// Добавляем контроллеры
// Добавляем контроллеры и Carter (Minimal APIs)
app.MapControllers();
app.MapCarter();
// Добавляем SignalR хабы
app.MapHub<ChatHub>("/hubs/chat");

View File

@@ -1,18 +0,0 @@
using Knot.Shared.Kernel.Configuration;
namespace Host.Services;
public sealed class ConfigService : IConfigService
{
private readonly ISettingsService _settings;
public ConfigService(ISettingsService settings)
{
_settings = settings;
}
public IPublicConfig GetPublicConfig()
{
return new PublicConfig(_settings.Current);
}
}

View File

@@ -1,44 +0,0 @@
using Knot.Shared.Kernel.Configuration;
using Knot.Shared.Kernel.Constants;
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
namespace Host.Services;
public sealed class FederationService : IFederationService
{
private readonly ISettingsService _settings;
public FederationService(ISettingsService settings)
{
_settings = settings;
}
public Task<HandshakeResponse> HandshakeAsync(HandshakeRequest request)
{
var conf = _settings.Current;
if (!conf.EnableConfederation)
throw new InvalidOperationException(Errors.DisabledByAdmin);
if (string.IsNullOrEmpty(request.Domain) || string.IsNullOrEmpty(request.PublicKey))
throw new ArgumentException("Укажите Domain и PublicKey.");
var allowedList = conf.AllowedDomains?.Select(d => d.Trim().ToLower()).ToList() ?? new System.Collections.Generic.List<string>();
if (!allowedList.Contains(request.Domain.ToLowerInvariant()))
throw new UnauthorizedAccessException("Домен не входит в белый список.");
using var rsa = RSA.Create(2048);
var selfPublicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey());
var response = new HandshakeResponse
{
Domain = Environment.GetEnvironmentVariable("DOMAIN") ?? "knot.local",
PublicKey = selfPublicKey,
Status = "Accepted"
};
return Task.FromResult(response);
}
}

View File

@@ -1,11 +0,0 @@
using Knot.Shared.Kernel.Configuration;
namespace Host.Services;
/// <summary>
/// Сервис конфигурации приложения.
/// </summary>
public interface IConfigService
{
IPublicConfig GetPublicConfig();
}

View File

@@ -1,24 +0,0 @@
using System.Threading.Tasks;
namespace Host.Services;
/// <summary>
/// Сервис федерации (связь с другими узлами).
/// </summary>
public interface IFederationService
{
Task<HandshakeResponse> HandshakeAsync(HandshakeRequest request);
}
public class HandshakeRequest
{
public string Domain { get; set; } = string.Empty;
public string PublicKey { get; set; } = string.Empty;
}
public class HandshakeResponse
{
public string Domain { get; set; } = string.Empty;
public string PublicKey { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
}

View File

@@ -1,14 +0,0 @@
using System.Text.Json;
using System.Threading.Tasks;
namespace Host.Services;
/// <summary>
/// Сервис интеграции с Klipy GIF.
/// </summary>
public interface IKlipyService
{
Task<JsonElement?> GetTrendingAsync();
Task<JsonElement?> SearchAsync(string query);
}

View File

@@ -1,14 +0,0 @@
namespace Host.Services;
/// <summary>
/// Интерфейс публичной конфигурации приложения.
/// </summary>
public interface IPublicConfig
{
bool EnableCalls { get; }
bool EnableKlipy { get; }
int MaxFileSizeMb { get; }
int MaxGroupMembers { get; }
bool EnableConfederation { get; }
bool EnableRegistration { get; }
}

View File

@@ -1,17 +0,0 @@
using Knot.Shared.Kernel;
using Microsoft.AspNetCore.Http;
using System.Threading;
using System.Threading.Tasks;
namespace Host.Services;
/// <summary>
/// Сервис для работы с WebRTC конфигурацией.
/// </summary>
public interface IWebRtcService
{
/// <summary>
/// Получить список ICE-серверов.
/// </summary>
IResult GetIceServers();
}

View File

@@ -1,102 +0,0 @@
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.Tasks;
namespace Host.Services;
public sealed class KlipyService : IKlipyService
{
private readonly ISettingsService _settings;
private readonly IMemoryCache _cache;
private readonly IHttpClientFactory _httpClientFactory;
public KlipyService(ISettingsService settings, IMemoryCache cache, IHttpClientFactory httpClientFactory)
{
_settings = settings;
_cache = cache;
_httpClientFactory = httpClientFactory;
}
public async Task<JsonElement?> GetTrendingAsync()
{
var conf = _settings.Current;
if (!conf.EnableKlipy || string.IsNullOrEmpty(conf.KlipyApiKey))
throw new InvalidOperationException(Errors.KlipyNotConfigured);
var cacheKeyTrending = $"klipy_trending_{conf.KlipyApiKey}";
if (_cache.TryGetValue(cacheKeyTrending, out JsonElement cachedResult))
{
return cachedResult;
}
var customerId = string.IsNullOrWhiteSpace(conf.KlipyCustomerId) ? Klipy.DefaultCustomerId : conf.KlipyCustomerId;
var client = _httpClientFactory.CreateClient();
var urlCo = string.Format(Klipy.ApiUrlCo, conf.KlipyApiKey, Klipy.ResourceTrending, "", customerId);
var response = await client.GetAsync(urlCo);
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
var urlCom = string.Format(Klipy.ApiUrlCom, conf.KlipyApiKey, Klipy.ResourceTrending, "", customerId);
response = await client.GetAsync(urlCom);
}
if (!response.IsSuccessStatusCode)
throw new HttpRequestException(Errors.KlipyApiError);
}
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
_cache.Set(cacheKeyTrending, result, TimeSpan.FromMinutes(Klipy.TrendingCacheMinutes));
return result;
}
public async Task<JsonElement?> SearchAsync(string query)
{
var conf = _settings.Current;
if (!conf.EnableKlipy || string.IsNullOrEmpty(conf.KlipyApiKey))
throw new InvalidOperationException(Errors.KlipyNotConfigured);
if (string.IsNullOrWhiteSpace(query))
throw new ArgumentException(Errors.InvalidQuery, nameof(query));
var cacheKey = $"klipy_search_{conf.KlipyApiKey}_{query.ToLowerInvariant()}";
if (_cache.TryGetValue(cacheKey, out JsonElement cachedResult))
{
return cachedResult;
}
var customerId = string.IsNullOrWhiteSpace(conf.KlipyCustomerId) ? Klipy.DefaultCustomerId : conf.KlipyCustomerId;
var client = _httpClientFactory.CreateClient();
var queryParam = $"q={Uri.EscapeDataString(query)}";
var urlCo = string.Format(Klipy.ApiUrlCo, conf.KlipyApiKey, Klipy.ResourceSearch, queryParam, customerId);
var response = await client.GetAsync(urlCo);
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
var urlCom = string.Format(Klipy.ApiUrlCom, conf.KlipyApiKey, Klipy.ResourceSearch, queryParam, customerId);
response = await client.GetAsync(urlCom);
}
if (!response.IsSuccessStatusCode)
throw new HttpRequestException(Errors.KlipySearchError);
}
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
_cache.Set(cacheKey, result, TimeSpan.FromMinutes(Klipy.SearchCacheMinutes));
return result;
}
}

View File

@@ -1,16 +0,0 @@
using Knot.Shared.Kernel.Configuration;
namespace Host.Services;
/// <summary>
/// Реализация публичной конфигурации приложения.
/// </summary>
public sealed class PublicConfig(SystemSettingsDto settings) : IPublicConfig
{
public bool EnableCalls => settings.EnableCalls;
public bool EnableKlipy => settings.EnableKlipy;
public int MaxFileSizeMb => settings.MaxFileSizeMb;
public int MaxGroupMembers => settings.MaxGroupMembers;
public bool EnableConfederation => settings.EnableConfederation;
public bool EnableRegistration => settings.EnableRegistration;
}

View File

@@ -1,71 +0,0 @@
using Knot.Shared.Kernel.Configuration;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using System.Collections.Generic;
namespace Host.Services;
/// <summary>
/// Реализация сервиса управления WebRTC настройками.
/// </summary>
public sealed class WebRtcService : IWebRtcService
{
private readonly IConfiguration _configuration;
private readonly ISettingsService _settingsService;
public WebRtcService(
IConfiguration configuration,
ISettingsService settingsService)
{
_configuration = configuration;
_settingsService = settingsService;
}
public IResult GetIceServers()
{
var settings = _settingsService.Current;
if (!settings.EnableCalls)
{
return Results.StatusCode(503); // TODO: Возвращать структуру ошибки с пояснением на русском языке (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<object>();
if (!string.IsNullOrEmpty(turnUrl))
{
var stunUrl = turnUrl.Replace("turn:", "stun:");
iceServers.Add(new { urls = new[] { stunUrl } });
if (!string.IsNullOrEmpty(turnUsername))
{
iceServers.Add(new
{
urls = new[] { turnUrl, turnUrl + "?transport=tcp" },
username = turnUsername,
credential = !string.IsNullOrEmpty(turnSecret) ? turnSecret : turnUsername
});
}
else
{
iceServers.Add(new
{
urls = new[] { turnUrl, turnUrl + "?transport=tcp" }
});
}
}
return Results.Ok(new { iceServers });
}
}

View File

@@ -0,0 +1,123 @@
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>(new Error("Chat.NotFound", "Chat not found or access denied"));
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>(new Error("Chat.NotFound", "Chat not found or access denied"));
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>(new Error("Chat.NotFound", "Chat not found or access denied"));
chat.UpdateAvatar(null);
_chatRepository.Update(chat);
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(request.ChatId);
}
}

View File

@@ -0,0 +1,32 @@
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>(new Error("Chat.NotFound", "Chat not found or access denied"));
// Currently a placeholder
return Result.Success(new MessageResponse("Cleared"));
}
}

View File

@@ -0,0 +1,116 @@
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;
using Knot.Modules.Identity.Domain;
namespace Knot.Modules.Chats.Application.Chats.GetChatById;
public record GetChatByIdQuery(Guid ChatId) : IQuery<ChatDto?>;
internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery, ChatDto?>
{
private readonly IChatRepository _chatRepository;
private readonly IUserRepository _userRepository;
private readonly IMessageRepository _messageRepository;
public GetChatByIdQueryHandler(IChatRepository chatRepository, IUserRepository userRepository, IMessageRepository messageRepository)
{
_chatRepository = chatRepository;
_userRepository = userRepository;
_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);
var members = new List<ChatMemberDto>();
foreach (var m in chat.Members)
{
var user = await _userRepository.GetByIdAsync(m.UserId, cancellationToken);
members.Add(new ChatMemberDto(
m.Id,
m.UserId,
m.Role,
m.IsPinned,
user != null ? new ChatUserDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
false,
DateTime.UtcNow
) : null
));
}
var chatMessages = await _messageRepository.GetChatMessagesAsync(chat.Id, 1, 0, cancellationToken);
var messagesList = new List<ChatMessageDto>();
if (chatMessages.Any())
{
var m = chatMessages.First();
var senderObj = await _userRepository.GetByIdAsync(m.SenderId, cancellationToken);
var reactionsWithUser = new List<ReactionDto>();
foreach (var r in m.Reactions)
{
var rUser = await _userRepository.GetByIdAsync(r.UserId, cancellationToken);
reactionsWithUser.Add(new ReactionDto(
r.Id,
r.Emoji,
r.UserId,
rUser != null
? new MessageSenderDto(rUser.Id, rUser.Username, rUser.DisplayName, rUser.Avatar)
: new MessageSenderDto(r.UserId, "unknown", "Unknown", null)
));
}
messagesList.Add(new ChatMessageDto(
m.Id,
m.ChatId,
m.SenderId,
m.Content,
m.Type,
m.ReplyToId,
m.Quote,
m.StoryId,
m.StoryMediaUrl,
m.StoryMediaType,
m.IsEdited,
m.IsDeleted,
m.CreatedAt,
m.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(m.SenderId, "unknown", "Unknown", null),
reactionsWithUser,
m.ReadBy.Select(r => new ReadByDto(r.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,136 @@
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;
using Knot.Modules.Identity.Domain;
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 IUserRepository _userRepository;
private readonly IMessageRepository _messageRepository;
public GetChatsQueryHandler(IChatRepository chatRepository, IUserRepository userRepository, IMessageRepository messageRepository)
{
_chatRepository = chatRepository;
_userRepository = userRepository;
_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 c in userChats)
{
if (c.Type == ChatType.Favorites)
{
if (hasFavorites) continue;
hasFavorites = true;
}
var members = new List<ChatMemberDto>();
foreach (var m in c.Members)
{
var user = await _userRepository.GetByIdAsync(m.UserId, cancellationToken);
members.Add(new ChatMemberDto(
m.Id,
m.UserId,
m.Role,
m.IsPinned,
user != null ? new ChatUserDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
false,
DateTime.UtcNow
) : null
));
}
var chatMessages = await _messageRepository.GetChatMessagesAsync(c.Id, 1, 0, cancellationToken);
var messagesList = new List<ChatMessageDto>();
if (chatMessages.Any())
{
var m = chatMessages.First();
var senderObj = await _userRepository.GetByIdAsync(m.SenderId, cancellationToken);
var reactionsWithUser = new List<ReactionDto>();
foreach (var r in m.Reactions)
{
var rUser = await _userRepository.GetByIdAsync(r.UserId, cancellationToken);
reactionsWithUser.Add(new ReactionDto(
r.Id,
r.Emoji,
r.UserId,
rUser != null
? new MessageSenderDto(rUser.Id, rUser.Username, rUser.DisplayName, rUser.Avatar)
: new MessageSenderDto(r.UserId, "unknown", "Unknown", null)
));
}
messagesList.Add(new ChatMessageDto(
m.Id,
m.ChatId,
m.SenderId,
m.Content,
m.Type,
m.ReplyToId,
m.Quote,
m.StoryId,
m.StoryMediaUrl,
m.StoryMediaType,
m.IsEdited,
m.IsDeleted,
m.CreatedAt,
m.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(m.SenderId, "unknown", "Unknown", null),
reactionsWithUser,
m.ReadBy.Select(r => new ReadByDto(r.UserId)).ToList()
));
}
var unreadCount = await _messageRepository.GetUnreadCountAsync(c.Id, request.UserId, cancellationToken);
dtos.Add(new ChatDto(
c.Id,
c.Type.ToString().ToLowerInvariant(),
c.Type == ChatType.Favorites ? "Избранное" : (c.Type == ChatType.Personal ? null : c.Name),
c.Description,
c.Avatar,
c.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,47 @@
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>(new Error("Unauthorized", "Access denied"));
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,68 @@
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>(new Error("Chat.NotFound", "Chat not found or access denied"));
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>(new Error("Chat.NotFound", "Chat not found or access denied"));
chat.RemoveMember(request.UserIdToRemove);
_chatRepository.Update(chat);
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(request.ChatId);
}
}

View File

@@ -0,0 +1,42 @@
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>(new Error("Chat.NotFound", "Chat not found"));
var member = chat.Members.FirstOrDefault(m => m.UserId == request.UserId);
if (member == null)
return Result.Failure<TogglePinResponse>(new Error("Chat.NotFound", "Member not found"));
member.TogglePin();
_chatRepository.Update(chat);
await _uow.SaveChangesAsync(cancellationToken);
return Result.Success(new TogglePinResponse(member.IsPinned));
}
}

View File

@@ -0,0 +1,39 @@
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>(new Error("Chat.NotFound", "Chat not found or access denied"));
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);

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