DDD
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
15
apps/server-net/src/Host/Models/Admin/AdminUserDetailsDto.cs
Normal file
15
apps/server-net/src/Host/Models/Admin/AdminUserDetailsDto.cs
Normal 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
|
||||
);
|
||||
14
apps/server-net/src/Host/Models/Admin/AdminUserDto.cs
Normal file
14
apps/server-net/src/Host/Models/Admin/AdminUserDto.cs
Normal 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
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Host.Models;
|
||||
|
||||
public record AdminUserStatsDto(
|
||||
int MessagesCount,
|
||||
int MediaCount,
|
||||
int FilesCount,
|
||||
int LinksCount,
|
||||
long StorageUsedBytes
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Host.Models;
|
||||
|
||||
public class CleanupDryRunResultDto
|
||||
{
|
||||
public int OrphanedMessagesCount { get; set; }
|
||||
public long OrphanedMediaBytes { get; set; }
|
||||
}
|
||||
20
apps/server-net/src/Host/Models/Auth/AuthResponseDto.cs
Normal file
20
apps/server-net/src/Host/Models/Auth/AuthResponseDto.cs
Normal 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
|
||||
);
|
||||
6
apps/server-net/src/Host/Models/Auth/ResetPasswordDto.cs
Normal file
6
apps/server-net/src/Host/Models/Auth/ResetPasswordDto.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Host.Models;
|
||||
|
||||
public class ResetPasswordDto
|
||||
{
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
26
apps/server-net/src/Host/Models/Config/PublicConfigDto.cs
Normal file
26
apps/server-net/src/Host/Models/Config/PublicConfigDto.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
13
apps/server-net/src/Host/Models/Friends/FriendDto.cs
Normal file
13
apps/server-net/src/Host/Models/Friends/FriendDto.cs
Normal 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
|
||||
);
|
||||
16
apps/server-net/src/Host/Models/Friends/FriendRequestDto.cs
Normal file
16
apps/server-net/src/Host/Models/Friends/FriendRequestDto.cs
Normal 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
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
using System;
|
||||
|
||||
namespace Host.Models;
|
||||
|
||||
public record SendFriendRequest(Guid FriendId);
|
||||
@@ -0,0 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Host.Models;
|
||||
|
||||
public record AnalyzeImportResponseDto(
|
||||
Guid Token,
|
||||
List<string> Names
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Host.Models;
|
||||
|
||||
public record ExecuteImportRequest(
|
||||
Guid Token,
|
||||
Dictionary<string, Guid> Mapping,
|
||||
string? GroupName
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
using System;
|
||||
|
||||
namespace Host.Models;
|
||||
|
||||
public record ExecuteImportResponseDto(
|
||||
bool Success,
|
||||
int MessagesImported,
|
||||
Guid ChatId
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
using System;
|
||||
|
||||
namespace Host.Models;
|
||||
|
||||
public record AddStoryReactionRequest(string Emoji);
|
||||
@@ -0,0 +1,5 @@
|
||||
using System;
|
||||
|
||||
namespace Host.Models;
|
||||
|
||||
public record AddStoryReplyRequest(string Content);
|
||||
@@ -0,0 +1,5 @@
|
||||
using System;
|
||||
|
||||
namespace Host.Models;
|
||||
|
||||
public record CreateStoryRequest(string Type, string? MediaUrl, string? Content, string? BgColor);
|
||||
@@ -0,0 +1,5 @@
|
||||
using System;
|
||||
|
||||
namespace Host.Models;
|
||||
|
||||
public record RemoveStoryReactionRequest(string Emoji);
|
||||
10
apps/server-net/src/Host/Models/Stories/StoriesDtos.cs
Normal file
10
apps/server-net/src/Host/Models/Stories/StoriesDtos.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Host.Models;
|
||||
|
||||
public record StoryGroupDto(
|
||||
StoryUserDto User,
|
||||
List<StoryDto> Stories,
|
||||
bool HasUnviewed
|
||||
);
|
||||
18
apps/server-net/src/Host/Models/Stories/StoryDto.cs
Normal file
18
apps/server-net/src/Host/Models/Stories/StoryDto.cs
Normal 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
|
||||
);
|
||||
10
apps/server-net/src/Host/Models/Stories/StoryReactionDto.cs
Normal file
10
apps/server-net/src/Host/Models/Stories/StoryReactionDto.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace Host.Models;
|
||||
|
||||
public record StoryReactionDto(
|
||||
Guid Id,
|
||||
Guid UserId,
|
||||
string Emoji,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
13
apps/server-net/src/Host/Models/Stories/StoryReplyDto.cs
Normal file
13
apps/server-net/src/Host/Models/Stories/StoryReplyDto.cs
Normal 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
|
||||
);
|
||||
10
apps/server-net/src/Host/Models/Stories/StoryUserDto.cs
Normal file
10
apps/server-net/src/Host/Models/Stories/StoryUserDto.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace Host.Models;
|
||||
|
||||
public record StoryUserDto(
|
||||
Guid Id,
|
||||
string Username,
|
||||
string DisplayName,
|
||||
string? Avatar
|
||||
);
|
||||
11
apps/server-net/src/Host/Models/Stories/StoryViewerDto.cs
Normal file
11
apps/server-net/src/Host/Models/Stories/StoryViewerDto.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System;
|
||||
|
||||
namespace Host.Models;
|
||||
|
||||
public record StoryViewerDto(
|
||||
Guid UserId,
|
||||
string Username,
|
||||
string DisplayName,
|
||||
string? Avatar,
|
||||
DateTime ViewedAt
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
using System;
|
||||
|
||||
namespace Host.Models;
|
||||
|
||||
public sealed record UpdateProfileRequest(string? DisplayName, string? Bio, DateTime? Birthday);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Host.Models;
|
||||
|
||||
public sealed record UpdateSettingsRequest(bool? HideStoryViews);
|
||||
12
apps/server-net/src/Host/Models/Users/UserDto.cs
Normal file
12
apps/server-net/src/Host/Models/Users/UserDto.cs
Normal 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
|
||||
);
|
||||
16
apps/server-net/src/Host/Models/Users/UserProfileDto.cs
Normal file
16
apps/server-net/src/Host/Models/Users/UserProfileDto.cs
Normal 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
|
||||
);
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
using Knot.Shared.Kernel.Configuration;
|
||||
|
||||
namespace Host.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Сервис конфигурации приложения.
|
||||
/// </summary>
|
||||
public interface IConfigService
|
||||
{
|
||||
IPublicConfig GetPublicConfig();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.DTOs;
|
||||
|
||||
public sealed record AddMembersRequest(List<Guid> UserIds);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,5 @@
|
||||
using System;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.DTOs;
|
||||
|
||||
public sealed record CreatePersonalChatRequest(Guid UserId);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.DTOs;
|
||||
|
||||
public record MessageSenderDto(
|
||||
Guid Id,
|
||||
string Username,
|
||||
string DisplayName,
|
||||
string? Avatar
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.DTOs;
|
||||
|
||||
public record ReactionDto(
|
||||
Guid Id,
|
||||
string Emoji,
|
||||
Guid UserId,
|
||||
MessageSenderDto User
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
using System;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.DTOs;
|
||||
|
||||
public record ReadByDto(
|
||||
Guid UserId
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Knot.Modules.Chats.Application.DTOs;
|
||||
|
||||
public record TogglePinResponse(bool IsPinned);
|
||||
@@ -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
Reference in New Issue
Block a user