Reorganize root folder structure: Remove apps layer layer
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediatR;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Shared.Kernel.Storage;
|
||||
using Knot.Modules.Identity.Infrastructure.Persistence;
|
||||
using Knot.Modules.Chats.Infrastructure.Persistence;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using MongoDB.Driver;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Host.Application.Admin.Commands;
|
||||
|
||||
public record CleanRunCommand(IFileStorageService FileStorage, IdentityDbContext IdentityDb) : ICommand<MessageResponse>;
|
||||
|
||||
internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand, MessageResponse>
|
||||
{
|
||||
private readonly ChatsDbContext _chatsDbContext;
|
||||
private readonly IMongoCollection<Message> _messages;
|
||||
|
||||
public CleanRunCommandHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb)
|
||||
{
|
||||
_chatsDbContext = chatsDbContext;
|
||||
_messages = mongoDb.GetCollection<Message>("Messages");
|
||||
}
|
||||
|
||||
public async Task<Result<MessageResponse>> Handle(CleanRunCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken);
|
||||
var activeChatIds = allChats.Select(c => c.Id).ToHashSet();
|
||||
|
||||
var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken);
|
||||
|
||||
var orphanMessages = allMessages
|
||||
.Where(m => !activeChatIds.Contains(m.ChatId))
|
||||
.ToList();
|
||||
|
||||
var keptMessages = allMessages
|
||||
.Where(m => activeChatIds.Contains(m.ChatId))
|
||||
.ToList();
|
||||
|
||||
var allMinioFiles = (await request.FileStorage.ListFilesAsync()).ToList();
|
||||
|
||||
var allUsers = await request.IdentityDb.Users.AsNoTracking().ToListAsync(cancellationToken);
|
||||
|
||||
var validUrls = new HashSet<string>();
|
||||
|
||||
var activeMessageUrls = keptMessages.OfType<MediaMessage>()
|
||||
.Where(m => m.Media != null)
|
||||
.SelectMany(m => m.Media)
|
||||
.Select(me => me.Url)
|
||||
.Where(u => !string.IsNullOrEmpty(u));
|
||||
|
||||
var activeChatUrls = allChats
|
||||
.Where(c => !string.IsNullOrEmpty(c.Avatar))
|
||||
.Select(c => c.Avatar!);
|
||||
|
||||
var activeUserUrls = allUsers
|
||||
.Where(u => !string.IsNullOrEmpty(u.Avatar))
|
||||
.Select(u => u.Avatar!);
|
||||
|
||||
foreach (var u in activeMessageUrls)
|
||||
{
|
||||
validUrls.Add(u!);
|
||||
}
|
||||
foreach (var u in activeChatUrls)
|
||||
{
|
||||
validUrls.Add(u);
|
||||
}
|
||||
foreach (var u in activeUserUrls)
|
||||
{
|
||||
validUrls.Add(u);
|
||||
}
|
||||
|
||||
var validFileIds = validUrls
|
||||
.Where(u => u.Contains("/api/files/"))
|
||||
.Select(u => u.Split('/').Last())
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var file in allMinioFiles)
|
||||
{
|
||||
if (!validFileIds.Contains(file.FileId))
|
||||
{
|
||||
await request.FileStorage.DeleteFileAsync(file.FileId);
|
||||
}
|
||||
}
|
||||
|
||||
if (orphanMessages.Any())
|
||||
{
|
||||
var orphanIds = orphanMessages.Select(m => m.Id).ToList();
|
||||
var filter = Builders<Message>.Filter.In(m => m.Id, orphanIds);
|
||||
await _messages.DeleteManyAsync(filter, cancellationToken);
|
||||
}
|
||||
|
||||
return Result.Success(new MessageResponse("Cleanup completed successfully"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediatR;
|
||||
using Knot.Shared.Kernel;
|
||||
using Host.Models;
|
||||
using Knot.Modules.Identity.Domain;
|
||||
using Knot.Modules.Identity.Infrastructure.Persistence;
|
||||
using Knot.Modules.Identity.Application.Abstractions;
|
||||
|
||||
namespace Host.Application.Admin.Commands;
|
||||
|
||||
public record ResetUserPasswordCommand(Guid UserId, string NewPassword) : ICommand<SuccessResponse>;
|
||||
|
||||
internal sealed class ResetUserPasswordCommandHandler : ICommandHandler<ResetUserPasswordCommand, SuccessResponse>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IIdentityUnitOfWork _identityUnitOfWork;
|
||||
|
||||
public ResetUserPasswordCommandHandler(IUserRepository userRepository, IIdentityUnitOfWork identityUnitOfWork)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_identityUnitOfWork = identityUnitOfWork;
|
||||
}
|
||||
|
||||
public async Task<Result<SuccessResponse>> Handle(ResetUserPasswordCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return Result.Failure<SuccessResponse>(IdentityErrors.UserNotFound);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.NewPassword))
|
||||
return Result.Failure<SuccessResponse>(DomainErrors.InvalidPassword);
|
||||
|
||||
var hash = BCrypt.Net.BCrypt.HashPassword(request.NewPassword);
|
||||
user.ChangePassword(hash);
|
||||
|
||||
await _identityUnitOfWork.SaveChangesAsync(cancellationToken);
|
||||
return Result.Success(new SuccessResponse(true));
|
||||
}
|
||||
}
|
||||
@@ -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,101 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediatR;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Shared.Kernel.Storage;
|
||||
using Knot.Modules.Identity.Infrastructure.Persistence;
|
||||
using Knot.Modules.Chats.Infrastructure.Persistence;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using MongoDB.Driver;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Host.Models;
|
||||
|
||||
namespace Host.Application.Admin.Queries;
|
||||
|
||||
public record CleanDryRunQuery(IFileStorageService FileStorage, IdentityDbContext IdentityDb) : IQuery<CleanupDryRunResultDto>;
|
||||
|
||||
internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery, CleanupDryRunResultDto>
|
||||
{
|
||||
private readonly ChatsDbContext _chatsDbContext;
|
||||
private readonly IMongoCollection<Message> _messages;
|
||||
|
||||
public CleanDryRunQueryHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb)
|
||||
{
|
||||
_chatsDbContext = chatsDbContext;
|
||||
_messages = mongoDb.GetCollection<Message>("Messages");
|
||||
}
|
||||
|
||||
public async Task<Result<CleanupDryRunResultDto>> Handle(CleanDryRunQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken);
|
||||
var activeChatIds = allChats.Select(c => c.Id).ToHashSet();
|
||||
|
||||
var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken);
|
||||
|
||||
var orphanedMessages = allMessages
|
||||
.Where(m => !activeChatIds.Contains(m.ChatId))
|
||||
.ToList();
|
||||
|
||||
var keptMessages = allMessages
|
||||
.Where(m => activeChatIds.Contains(m.ChatId))
|
||||
.ToList();
|
||||
|
||||
var orphanedMessagesCount = orphanedMessages.Count;
|
||||
|
||||
var allMinioFiles = (await request.FileStorage.ListFilesAsync()).ToList();
|
||||
|
||||
var allUsers = await request.IdentityDb.Users.AsNoTracking().ToListAsync(cancellationToken);
|
||||
|
||||
var validUrls = new HashSet<string>();
|
||||
|
||||
var activeMessageUrls = keptMessages.OfType<MediaMessage>()
|
||||
.Where(m => m.Media != null)
|
||||
.SelectMany(m => m.Media)
|
||||
.Select(me => me.Url)
|
||||
.Where(u => !string.IsNullOrEmpty(u));
|
||||
|
||||
var activeChatUrls = allChats
|
||||
.Where(c => !string.IsNullOrEmpty(c.Avatar))
|
||||
.Select(c => c.Avatar!);
|
||||
|
||||
var activeUserUrls = allUsers
|
||||
.Where(u => !string.IsNullOrEmpty(u.Avatar))
|
||||
.Select(u => u.Avatar!);
|
||||
|
||||
foreach (var u in activeMessageUrls)
|
||||
{
|
||||
validUrls.Add(u!);
|
||||
}
|
||||
foreach (var u in activeChatUrls)
|
||||
{
|
||||
validUrls.Add(u);
|
||||
}
|
||||
foreach (var u in activeUserUrls)
|
||||
{
|
||||
validUrls.Add(u);
|
||||
}
|
||||
|
||||
var validFileIds = validUrls
|
||||
.Where(u => u.Contains("/api/files/"))
|
||||
.Select(u => u.Split('/').Last())
|
||||
.ToHashSet();
|
||||
|
||||
long safeBytes = 0;
|
||||
foreach (var file in allMinioFiles)
|
||||
{
|
||||
if (!validFileIds.Contains(file.FileId))
|
||||
{
|
||||
safeBytes += file.Size;
|
||||
}
|
||||
}
|
||||
|
||||
return Result.Success(new CleanupDryRunResultDto
|
||||
{
|
||||
OrphanedMessagesCount = orphanedMessagesCount,
|
||||
OrphanedMediaBytes = safeBytes
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.Domain;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace Host.Application.Admin.Queries;
|
||||
|
||||
public record GetUserDetailsQuery(Guid UserId) : IQuery<AdminUserDetailsDto>;
|
||||
|
||||
internal sealed class GetUserDetailsQueryHandler : IQueryHandler<GetUserDetailsQuery, AdminUserDetailsDto>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IMongoCollection<Message> _messages;
|
||||
|
||||
public GetUserDetailsQueryHandler(IUserRepository userRepository, IMongoDatabase mongoDatabase)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_messages = mongoDatabase.GetCollection<Message>("Messages");
|
||||
}
|
||||
|
||||
public async Task<Result<AdminUserDetailsDto>> Handle(GetUserDetailsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var targetUser = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
|
||||
if (targetUser == null)
|
||||
{
|
||||
return Result.Failure<AdminUserDetailsDto>(IdentityErrors.UserNotFound);
|
||||
}
|
||||
|
||||
var filter = Builders<Message>.Filter.Eq(m => m.SenderId, request.UserId);
|
||||
var userMessages = await _messages.Find(filter).ToListAsync(cancellationToken);
|
||||
|
||||
var messagesCount = userMessages.Count;
|
||||
|
||||
var allUserMedia = userMessages.OfType<MediaMessage>().SelectMany(m => m.Media).ToList();
|
||||
|
||||
var mediaCount = allUserMedia.Count(m => m.Type == "image" || m.Type == "video");
|
||||
var filesCount = allUserMedia.Count(m => m.Type == "file" || m.Type == "audio");
|
||||
var storageUsed = allUserMedia.Sum(m => m.Size ?? 0);
|
||||
|
||||
var userContents = userMessages.OfType<TextMessage>().Select(m => m.Content)
|
||||
.Concat(userMessages.OfType<MediaMessage>().Where(m => m.Caption != null).Select(m => m.Caption))
|
||||
.ToList();
|
||||
|
||||
var linksCount = userContents.Count(c => !string.IsNullOrEmpty(c) && c.Contains("http"));
|
||||
|
||||
var result = new AdminUserDetailsDto(
|
||||
targetUser.Id,
|
||||
targetUser.Username,
|
||||
targetUser.DisplayName,
|
||||
targetUser.Bio,
|
||||
targetUser.Avatar,
|
||||
targetUser.CreatedAt,
|
||||
Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()),
|
||||
Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()) ? DateTime.UtcNow : targetUser.CreatedAt,
|
||||
new AdminUserStatsDto(
|
||||
messagesCount,
|
||||
mediaCount,
|
||||
filesCount,
|
||||
linksCount,
|
||||
storageUsed
|
||||
)
|
||||
);
|
||||
|
||||
return Result.Success(result);
|
||||
}
|
||||
}
|
||||
@@ -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,52 @@
|
||||
using Host.Application.Stories;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Shared.Kernel.Configuration;
|
||||
using Knot.Shared.Kernel.Constants;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediatR;
|
||||
|
||||
namespace Host.Application.Federation.Commands;
|
||||
|
||||
public record HandshakeRequest(string Domain, string PublicKey);
|
||||
public record HandshakeResponse(string Domain, string PublicKey, string Status);
|
||||
|
||||
public record HandshakeFederationCommand(HandshakeRequest Request) : ICommand<HandshakeResponse>;
|
||||
|
||||
internal sealed class HandshakeFederationCommandHandler : ICommandHandler<HandshakeFederationCommand, HandshakeResponse>
|
||||
{
|
||||
private readonly ISettingsService _settings;
|
||||
|
||||
public HandshakeFederationCommandHandler(ISettingsService settings)
|
||||
{
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
public Task<Result<HandshakeResponse>> Handle(HandshakeFederationCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var conf = _settings.Current;
|
||||
if (!conf.EnableConfederation)
|
||||
return Task.FromResult(Result.Failure<HandshakeResponse>(new Error(Errors.DisabledByAdmin, "Federation is disabled")));
|
||||
|
||||
if (string.IsNullOrEmpty(request.Request.Domain) || string.IsNullOrEmpty(request.Request.PublicKey))
|
||||
return Task.FromResult(Result.Failure<HandshakeResponse>(DomainErrors.RequestInvalid));
|
||||
|
||||
var allowedList = conf.AllowedDomains?.Select(d => d.Trim().ToLower()).ToList() ?? new System.Collections.Generic.List<string>();
|
||||
if (!allowedList.Contains(request.Request.Domain.ToLowerInvariant()))
|
||||
return Task.FromResult(Result.Failure<HandshakeResponse>(StoryErrors.Unauthorized));
|
||||
|
||||
using var rsa = RSA.Create(2048);
|
||||
var selfPublicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey());
|
||||
|
||||
var response = new HandshakeResponse(
|
||||
Environment.GetEnvironmentVariable("DOMAIN") ?? "knot.local",
|
||||
selfPublicKey,
|
||||
"Accepted"
|
||||
);
|
||||
|
||||
return Task.FromResult(Result.Success(response));
|
||||
}
|
||||
}
|
||||
@@ -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,141 @@
|
||||
using Host.Application.Stories;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediatR;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Identity.Domain;
|
||||
using Knot.Modules.Identity.Infrastructure.Persistence;
|
||||
using Knot.Modules.Identity.Application.Abstractions;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Modules.Chats.Application.Messages.Send;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Knot.Modules.Chats.Infrastructure.SignalR;
|
||||
using Host.Models;
|
||||
|
||||
namespace Host.Application.Stories.Commands.AddStoryReaction;
|
||||
|
||||
public record AddStoryReactionCommand(Guid UserId, Guid StoryId, string Emoji) : ICommand<MessageResponse>;
|
||||
|
||||
internal sealed class AddStoryReactionCommandHandler : ICommandHandler<AddStoryReactionCommand, MessageResponse>
|
||||
{
|
||||
private readonly IdentityDbContext _context;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly ISender _sender;
|
||||
private readonly IHubContext<ChatHub> _hubContext;
|
||||
|
||||
public AddStoryReactionCommandHandler(
|
||||
IdentityDbContext context,
|
||||
IUserRepository userRepository,
|
||||
IChatRepository chatRepository,
|
||||
IMessageRepository messageRepository,
|
||||
ISender sender,
|
||||
IHubContext<ChatHub> hubContext)
|
||||
{
|
||||
_context = context;
|
||||
_userRepository = userRepository;
|
||||
_chatRepository = chatRepository;
|
||||
_messageRepository = messageRepository;
|
||||
_sender = sender;
|
||||
_hubContext = hubContext;
|
||||
}
|
||||
|
||||
public async Task<Result<MessageResponse>> Handle(AddStoryReactionCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var story = await _context.Stories.FindAsync(new object[] { request.StoryId }, cancellationToken);
|
||||
if (story == null)
|
||||
{
|
||||
return Result.Failure<MessageResponse>(StoryErrors.StoryNotFound);
|
||||
}
|
||||
|
||||
var existing = await _context.StoryReactions
|
||||
.FirstOrDefaultAsync(r => r.StoryId == request.StoryId && r.UserId == request.UserId && r.Emoji == request.Emoji, cancellationToken);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
return Result.Success(new MessageResponse("Reaction already exists"));
|
||||
}
|
||||
|
||||
var reaction = new StoryReaction(request.StoryId, request.UserId, request.Emoji);
|
||||
_context.StoryReactions.Add(reaction);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var chatId = await GetOrCreatePersonalChatIdAsync(request.UserId, story.UserId, cancellationToken);
|
||||
var lastStoryMessage = await _messageRepository.GetLastStoryMessageAsync(chatId, story.Id, cancellationToken);
|
||||
|
||||
if (lastStoryMessage != null)
|
||||
{
|
||||
var addReactionCommand = new Knot.Modules.Chats.Application.Messages.React.AddReactionCommand(
|
||||
lastStoryMessage.Id, request.UserId, request.Emoji, chatId);
|
||||
await _sender.Send(addReactionCommand, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
var storyQuote = GetStoryQuote(story);
|
||||
var messageCommand = new SendMessageCommand(
|
||||
ChatId: chatId,
|
||||
SenderId: request.UserId,
|
||||
Content: request.Emoji,
|
||||
Type: "text",
|
||||
Quote: storyQuote,
|
||||
StoryId: story.Id,
|
||||
StoryMediaUrl: story.MediaUrl,
|
||||
StoryMediaType: story.Type,
|
||||
Attachments: null,
|
||||
ReplyToId: null,
|
||||
ForwardedFromId: null);
|
||||
await _sender.Send(messageCommand, cancellationToken);
|
||||
}
|
||||
|
||||
var reactor = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
|
||||
await _hubContext.Clients.All.SendAsync("story_reaction", new
|
||||
{
|
||||
storyId = story.Id,
|
||||
userId = request.UserId,
|
||||
username = reactor?.Username,
|
||||
displayName = reactor?.DisplayName,
|
||||
avatar = reactor?.Avatar,
|
||||
emoji = request.Emoji,
|
||||
createdAt = DateTime.UtcNow,
|
||||
ownerId = story.UserId
|
||||
}, cancellationToken);
|
||||
|
||||
return Result.Success(new MessageResponse("Reaction added"));
|
||||
}
|
||||
|
||||
private string GetStoryQuote(Story story)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(story.Content))
|
||||
{
|
||||
return story.Content;
|
||||
}
|
||||
return story.Type.ToLower() switch
|
||||
{
|
||||
"image" => "🖼 Фото",
|
||||
"video" => "🎬 Видео",
|
||||
_ => "История"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<Guid> GetOrCreatePersonalChatIdAsync(Guid userId1, Guid userId2, CancellationToken cancellationToken)
|
||||
{
|
||||
var userChats = await _chatRepository.GetUserChatsAsync(userId1, cancellationToken);
|
||||
var personalChat = userChats.FirstOrDefault(c =>
|
||||
c.Type == ChatType.Personal &&
|
||||
c.Members.Any(m => m.UserId == userId2));
|
||||
|
||||
if (personalChat != null)
|
||||
{
|
||||
return personalChat.Id;
|
||||
}
|
||||
|
||||
var command = new Knot.Modules.Chats.Application.Chats.Create.CreateChatCommand(string.Empty, ChatType.Personal, new List<Guid> { userId1, userId2 });
|
||||
var result = await _sender.Send(command, cancellationToken);
|
||||
return result.Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using Host.Application.Stories;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediatR;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Identity.Domain;
|
||||
using Knot.Modules.Identity.Infrastructure.Persistence;
|
||||
using Knot.Modules.Identity.Application.Abstractions;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Modules.Chats.Application.Messages.Send;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Knot.Modules.Chats.Infrastructure.SignalR;
|
||||
using Host.Models;
|
||||
|
||||
namespace Host.Application.Stories.Commands.AddStoryReply;
|
||||
|
||||
public record AddStoryReplyCommand(Guid UserId, Guid StoryId, string Content) : ICommand<MessageResponse>;
|
||||
|
||||
internal sealed class AddStoryReplyCommandHandler : ICommandHandler<AddStoryReplyCommand, MessageResponse>
|
||||
{
|
||||
private readonly IdentityDbContext _context;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly ISender _sender;
|
||||
private readonly IHubContext<ChatHub> _hubContext;
|
||||
|
||||
public AddStoryReplyCommandHandler(
|
||||
IdentityDbContext context,
|
||||
IUserRepository userRepository,
|
||||
IChatRepository chatRepository,
|
||||
IMessageRepository messageRepository,
|
||||
ISender sender,
|
||||
IHubContext<ChatHub> hubContext)
|
||||
{
|
||||
_context = context;
|
||||
_userRepository = userRepository;
|
||||
_chatRepository = chatRepository;
|
||||
_messageRepository = messageRepository;
|
||||
_sender = sender;
|
||||
_hubContext = hubContext;
|
||||
}
|
||||
|
||||
public async Task<Result<MessageResponse>> Handle(AddStoryReplyCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var story = await _context.Stories.FindAsync(new object[] { request.StoryId }, cancellationToken);
|
||||
if (story == null)
|
||||
{
|
||||
return Result.Failure<MessageResponse>(StoryErrors.StoryNotFound);
|
||||
}
|
||||
|
||||
var reply = new StoryReply(request.StoryId, request.UserId, request.Content);
|
||||
_context.StoryReplies.Add(reply);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var chatId = await GetOrCreatePersonalChatIdAsync(request.UserId, story.UserId, cancellationToken);
|
||||
var lastStoryMessage = await _messageRepository.GetLastStoryMessageAsync(chatId, story.Id, cancellationToken);
|
||||
|
||||
var storyQuote = GetStoryQuote(story);
|
||||
var messageCommand = new SendMessageCommand(
|
||||
ChatId: chatId,
|
||||
SenderId: request.UserId,
|
||||
Content: request.Content,
|
||||
Type: "text",
|
||||
Quote: storyQuote,
|
||||
ReplyToId: lastStoryMessage?.Id,
|
||||
StoryId: story.Id,
|
||||
StoryMediaUrl: story.MediaUrl,
|
||||
StoryMediaType: story.Type,
|
||||
Attachments: null,
|
||||
ForwardedFromId: null);
|
||||
await _sender.Send(messageCommand, cancellationToken);
|
||||
|
||||
var replier = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
|
||||
await _hubContext.Clients.All.SendAsync("story_reply", new
|
||||
{
|
||||
storyId = story.Id,
|
||||
userId = request.UserId,
|
||||
username = replier?.Username,
|
||||
displayName = replier?.DisplayName,
|
||||
avatar = replier?.Avatar,
|
||||
content = request.Content,
|
||||
createdAt = DateTime.UtcNow,
|
||||
ownerId = story.UserId
|
||||
}, cancellationToken);
|
||||
|
||||
return Result.Success(new MessageResponse("Reply added"));
|
||||
}
|
||||
|
||||
private string GetStoryQuote(Story story)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(story.Content))
|
||||
{
|
||||
return story.Content;
|
||||
}
|
||||
return story.Type.ToLower() switch
|
||||
{
|
||||
"image" => "<22><> Фото",
|
||||
"video" => "🎬 Видео",
|
||||
_ => "История"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<Guid> GetOrCreatePersonalChatIdAsync(Guid userId1, Guid userId2, CancellationToken cancellationToken)
|
||||
{
|
||||
var userChats = await _chatRepository.GetUserChatsAsync(userId1, cancellationToken);
|
||||
var personalChat = userChats.FirstOrDefault(c =>
|
||||
c.Type == ChatType.Personal &&
|
||||
c.Members.Any(m => m.UserId == userId2));
|
||||
|
||||
if (personalChat != null)
|
||||
{
|
||||
return personalChat.Id;
|
||||
}
|
||||
|
||||
var command = new Knot.Modules.Chats.Application.Chats.Create.CreateChatCommand(string.Empty, ChatType.Personal, new List<Guid> { userId1, userId2 });
|
||||
var result = await _sender.Send(command, cancellationToken);
|
||||
return result.Value;
|
||||
}
|
||||
}
|
||||
@@ -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,40 @@
|
||||
using Host.Application.Stories;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediatR;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Identity.Infrastructure.Persistence;
|
||||
using Host.Models;
|
||||
|
||||
namespace Host.Application.Stories.Commands.DeleteStory;
|
||||
|
||||
public record DeleteStoryCommand(Guid UserId, Guid StoryId) : ICommand<MessageResponse>;
|
||||
|
||||
internal sealed class DeleteStoryCommandHandler : ICommandHandler<DeleteStoryCommand, MessageResponse>
|
||||
{
|
||||
private readonly IdentityDbContext _context;
|
||||
|
||||
public DeleteStoryCommandHandler(IdentityDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<Result<MessageResponse>> Handle(DeleteStoryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var story = await _context.Stories.FindAsync(new object[] { request.StoryId }, cancellationToken);
|
||||
if (story == null)
|
||||
{
|
||||
return Result.Failure<MessageResponse>(StoryErrors.StoryNotFound);
|
||||
}
|
||||
if (story.UserId != request.UserId)
|
||||
{
|
||||
return Result.Failure<MessageResponse>(StoryErrors.Unauthorized);
|
||||
}
|
||||
|
||||
_context.Stories.Remove(story);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(new MessageResponse("Story deleted"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediatR;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Identity.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Host.Models;
|
||||
|
||||
namespace Host.Application.Stories.Commands.RemoveStoryReaction;
|
||||
|
||||
public record RemoveStoryReactionCommand(Guid UserId, Guid StoryId, string Emoji) : ICommand<MessageResponse>;
|
||||
|
||||
internal sealed class RemoveStoryReactionCommandHandler : ICommandHandler<RemoveStoryReactionCommand, MessageResponse>
|
||||
{
|
||||
private readonly IdentityDbContext _context;
|
||||
|
||||
public RemoveStoryReactionCommandHandler(IdentityDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<Result<MessageResponse>> Handle(RemoveStoryReactionCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var reaction = await _context.StoryReactions
|
||||
.FirstOrDefaultAsync(r => r.StoryId == request.StoryId && r.UserId == request.UserId && r.Emoji == request.Emoji, cancellationToken);
|
||||
|
||||
if (reaction == null)
|
||||
{
|
||||
return Result.Success(new MessageResponse("Reaction not found"));
|
||||
}
|
||||
|
||||
_context.StoryReactions.Remove(reaction);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(new MessageResponse("Reaction removed"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Host.Application.Stories;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediatR;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Identity.Domain;
|
||||
using Knot.Modules.Identity.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Knot.Modules.Chats.Infrastructure.SignalR;
|
||||
using Host.Models;
|
||||
|
||||
namespace Host.Application.Stories.Commands.ViewStory;
|
||||
|
||||
public record ViewStoryCommand(Guid UserId, Guid StoryId) : ICommand<MessageResponse>;
|
||||
|
||||
internal sealed class ViewStoryCommandHandler : ICommandHandler<ViewStoryCommand, MessageResponse>
|
||||
{
|
||||
private readonly IdentityDbContext _context;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IHubContext<ChatHub> _hubContext;
|
||||
|
||||
public ViewStoryCommandHandler(IdentityDbContext context, IUserRepository userRepository, IHubContext<ChatHub> hubContext)
|
||||
{
|
||||
_context = context;
|
||||
_userRepository = userRepository;
|
||||
_hubContext = hubContext;
|
||||
}
|
||||
|
||||
public async Task<Result<MessageResponse>> Handle(ViewStoryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var story = await _context.Stories
|
||||
.Include(s => s.Viewers)
|
||||
.FirstOrDefaultAsync(s => s.Id == request.StoryId, cancellationToken);
|
||||
|
||||
if (story == null)
|
||||
{
|
||||
return Result.Failure<MessageResponse>(StoryErrors.StoryNotFound);
|
||||
}
|
||||
|
||||
if (story.UserId == request.UserId)
|
||||
{
|
||||
return Result.Success(new MessageResponse("Owner view"));
|
||||
}
|
||||
|
||||
if (!story.Viewers.Any(v => v.UserId == request.UserId))
|
||||
{
|
||||
story.AddViewer(request.UserId);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var viewer = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
|
||||
var updatedStory = await _context.Stories.Include(s => s.Viewers).FirstAsync(s => s.Id == story.Id, cancellationToken);
|
||||
|
||||
await _hubContext.Clients.All.SendAsync("story_viewed", new
|
||||
{
|
||||
storyId = story.Id,
|
||||
userId = request.UserId,
|
||||
username = viewer?.Username,
|
||||
displayName = viewer?.DisplayName,
|
||||
avatar = viewer?.Avatar,
|
||||
viewedAt = DateTime.UtcNow,
|
||||
viewCount = updatedStory.Viewers.Count,
|
||||
ownerId = story.UserId
|
||||
}, cancellationToken);
|
||||
}
|
||||
return Result.Success(new MessageResponse("Story viewed"));
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
return Result.Success(new MessageResponse("Story already viewed"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediatR;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Identity.Domain;
|
||||
using Knot.Modules.Identity.Infrastructure.Persistence;
|
||||
using Knot.Modules.Identity.Application.Abstractions;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Modules.Chats.Application.Messages.Send;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Knot.Modules.Chats.Infrastructure.SignalR;
|
||||
using Host.Models;
|
||||
|
||||
namespace Host.Application.Stories.Queries.GetStories;
|
||||
|
||||
public record GetStoriesQuery(Guid UserId) : IQuery<List<StoryGroupDto>>;
|
||||
|
||||
internal sealed class GetStoriesQueryHandler : IQueryHandler<GetStoriesQuery, List<StoryGroupDto>>
|
||||
{
|
||||
private readonly IdentityDbContext _context;
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public GetStoriesQueryHandler(IdentityDbContext context, IUserRepository userRepository)
|
||||
{
|
||||
_context = context;
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
public async Task<Result<List<StoryGroupDto>>> Handle(GetStoriesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var friendships = await _context.Set<Friendship>()
|
||||
.Where(f => f.Status == FriendshipStatus.Accepted && (f.UserId == request.UserId || f.FriendId == request.UserId))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var friendIds = friendships.Select(f => f.UserId == request.UserId ? f.FriendId : f.UserId).ToList();
|
||||
friendIds.Add(request.UserId);
|
||||
|
||||
var stories = await _context.Stories
|
||||
.Include(s => s.Viewers)
|
||||
.Include(s => s.Reactions)
|
||||
.Include(s => s.Replies)
|
||||
.Where(s => s.ExpiresAt > DateTime.UtcNow && friendIds.Contains(s.UserId))
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var groups = stories.GroupBy(s => s.UserId).ToList();
|
||||
var result = new List<StoryGroupDto>();
|
||||
|
||||
var userIds = groups.Select(g => g.Key).ToList();
|
||||
var userMap = new Dictionary<Guid, dynamic>();
|
||||
foreach (var uid in userIds)
|
||||
{
|
||||
var u = await _userRepository.GetByIdAsync(uid, cancellationToken);
|
||||
if (u != null)
|
||||
{
|
||||
userMap[uid] = u;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
if (!userMap.TryGetValue(group.Key, out var user))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(new StoryGroupDto(
|
||||
new StoryUserDto(user.Id, user.Username, user.DisplayName, user.Avatar),
|
||||
group.Select(s => new StoryDto(
|
||||
s.Id,
|
||||
s.Type,
|
||||
s.MediaUrl,
|
||||
s.Content,
|
||||
s.BgColor,
|
||||
s.CreatedAt,
|
||||
s.ExpiresAt,
|
||||
s.Viewers.Count,
|
||||
s.Viewers.Any(v => v.UserId == request.UserId),
|
||||
s.Reactions.Select(r => new StoryReactionDto(r.Id, r.UserId, r.Emoji, r.CreatedAt)).ToList(),
|
||||
s.Replies.Count
|
||||
)).OrderBy(s => s.CreatedAt).ToList(),
|
||||
group.Any(s => !s.Viewers.Any(v => v.UserId == request.UserId))
|
||||
));
|
||||
}
|
||||
|
||||
return Result.Success(result.OrderBy(r =>
|
||||
{
|
||||
if (r.User.Id == request.UserId)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}).ToList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Host.Application.Stories;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediatR;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Identity.Domain;
|
||||
using Knot.Modules.Identity.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Host.Models;
|
||||
|
||||
namespace Host.Application.Stories.Queries.GetStoryReplies;
|
||||
|
||||
public record GetStoryRepliesQuery(Guid UserId, Guid StoryId) : IQuery<List<StoryReplyDto>>;
|
||||
|
||||
internal sealed class GetStoryRepliesQueryHandler : IQueryHandler<GetStoryRepliesQuery, List<StoryReplyDto>>
|
||||
{
|
||||
private readonly IdentityDbContext _context;
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public GetStoryRepliesQueryHandler(IdentityDbContext context, IUserRepository userRepository)
|
||||
{
|
||||
_context = context;
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
public async Task<Result<List<StoryReplyDto>>> Handle(GetStoryRepliesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var story = await _context.Stories
|
||||
.Include(s => s.Replies)
|
||||
.FirstOrDefaultAsync(s => s.Id == request.StoryId, cancellationToken);
|
||||
|
||||
if (story == null)
|
||||
{
|
||||
return Result.Failure<List<StoryReplyDto>>(StoryErrors.StoryNotFound);
|
||||
}
|
||||
if (story.UserId != request.UserId)
|
||||
{
|
||||
return Result.Failure<List<StoryReplyDto>>(StoryErrors.Unauthorized);
|
||||
}
|
||||
|
||||
var replies = new List<StoryReplyDto>();
|
||||
foreach (var reply in story.Replies.OrderBy(r => r.CreatedAt))
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(reply.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
replies.Add(new StoryReplyDto(
|
||||
reply.Id,
|
||||
user.Id,
|
||||
user.Username,
|
||||
user.DisplayName,
|
||||
user.Avatar,
|
||||
reply.Content,
|
||||
reply.CreatedAt
|
||||
));
|
||||
}
|
||||
|
||||
return Result.Success(replies);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Host.Application.Stories;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediatR;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Identity.Domain;
|
||||
using Knot.Modules.Identity.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Host.Models;
|
||||
|
||||
namespace Host.Application.Stories.Queries.GetStoryViewers;
|
||||
|
||||
public record GetStoryViewersQuery(Guid UserId, Guid StoryId) : IQuery<List<StoryViewerDto>>;
|
||||
|
||||
internal sealed class GetStoryViewersQueryHandler : IQueryHandler<GetStoryViewersQuery, List<StoryViewerDto>>
|
||||
{
|
||||
private readonly IdentityDbContext _context;
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public GetStoryViewersQueryHandler(IdentityDbContext context, IUserRepository userRepository)
|
||||
{
|
||||
_context = context;
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
public async Task<Result<List<StoryViewerDto>>> Handle(GetStoryViewersQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var story = await _context.Stories
|
||||
.Include(s => s.Viewers)
|
||||
.FirstOrDefaultAsync(s => s.Id == request.StoryId, cancellationToken);
|
||||
|
||||
if (story == null)
|
||||
{
|
||||
return Result.Failure<List<StoryViewerDto>>(StoryErrors.StoryNotFound);
|
||||
}
|
||||
if (story.UserId != request.UserId)
|
||||
{
|
||||
return Result.Failure<List<StoryViewerDto>>(StoryErrors.Unauthorized);
|
||||
}
|
||||
|
||||
var viewerIds = story.Viewers.Select(v => v.UserId).ToList();
|
||||
var viewers = new List<StoryViewerDto>();
|
||||
|
||||
foreach (var viewerId in viewerIds)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(viewerId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var viewerRecord = story.Viewers.First(v => v.UserId == viewerId);
|
||||
|
||||
viewers.Add(new StoryViewerDto(
|
||||
user.Id,
|
||||
user.Username,
|
||||
user.DisplayName,
|
||||
user.Avatar,
|
||||
viewerRecord.ViewedAt
|
||||
));
|
||||
}
|
||||
|
||||
return Result.Success(viewers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediatR;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Identity.Domain;
|
||||
using Knot.Modules.Identity.Infrastructure.Persistence;
|
||||
using Knot.Modules.Identity.Application.Abstractions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Host.Models;
|
||||
|
||||
namespace Host.Application.Stories.Queries.GetUserStories;
|
||||
|
||||
public record GetUserStoriesQuery(Guid CurrentUserId, Guid TargetUserId) : IQuery<StoryGroupDto>;
|
||||
|
||||
internal sealed class GetUserStoriesQueryHandler : IQueryHandler<GetUserStoriesQuery, StoryGroupDto>
|
||||
{
|
||||
private readonly IdentityDbContext _context;
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public GetUserStoriesQueryHandler(IdentityDbContext context, IUserRepository userRepository)
|
||||
{
|
||||
_context = context;
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
public async Task<Result<StoryGroupDto>> Handle(GetUserStoriesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var stories = await _context.Stories
|
||||
.Include(s => s.Viewers)
|
||||
.Include(s => s.Reactions)
|
||||
.Include(s => s.Replies)
|
||||
.Where(s => s.UserId == request.TargetUserId)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var user = await _userRepository.GetByIdAsync(request.TargetUserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return Result.Failure<StoryGroupDto>(IdentityErrors.UserNotFound);
|
||||
}
|
||||
|
||||
var result = new StoryGroupDto(
|
||||
new StoryUserDto(user.Id, user.Username, user.DisplayName, user.Avatar),
|
||||
stories.Select(s => new StoryDto(
|
||||
s.Id,
|
||||
s.Type,
|
||||
s.MediaUrl,
|
||||
s.Content,
|
||||
s.BgColor,
|
||||
s.CreatedAt,
|
||||
s.ExpiresAt,
|
||||
s.Viewers.Count,
|
||||
s.Viewers.Any(v => v.UserId == request.CurrentUserId),
|
||||
s.Reactions.Select(r => new StoryReactionDto(r.Id, r.UserId, r.Emoji, r.CreatedAt)).ToList(),
|
||||
s.Replies.Count
|
||||
)).ToList(),
|
||||
stories.Any(s => !s.Viewers.Any(v => v.UserId == request.CurrentUserId))
|
||||
);
|
||||
|
||||
return Result.Success(result);
|
||||
}
|
||||
}
|
||||
9
backend/src/Host/Application/Stories/StoryErrors.cs
Normal file
9
backend/src/Host/Application/Stories/StoryErrors.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Host.Application.Stories;
|
||||
|
||||
public static class StoryErrors
|
||||
{
|
||||
public static readonly Error Unauthorized = new Error("Unauthorized", "Access denied");
|
||||
public static readonly Error StoryNotFound = new Error("Story.NotFound", "Story not found");
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
110
backend/src/Host/Controllers/AdminController.cs
Normal file
110
backend/src/Host/Controllers/AdminController.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Host.Models;
|
||||
using Knot.Shared.Kernel.Configuration;
|
||||
using Knot.Modules.Identity.Application.Users.Register;
|
||||
using Knot.Shared.Kernel.Storage;
|
||||
using Knot.Modules.Identity.Infrastructure.Persistence;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System;
|
||||
using MediatR;
|
||||
using Host.Application.Admin.Queries;
|
||||
using Host.Application.Admin.Commands;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Host.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AdminController : ControllerBase
|
||||
{
|
||||
private readonly ISender _sender;
|
||||
|
||||
public AdminController(ISender sender)
|
||||
{
|
||||
_sender = sender;
|
||||
}
|
||||
|
||||
[HttpGet("settings")]
|
||||
public async Task<IActionResult> GetSettings(CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new GetSettingsQuery(), ct);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("settings")]
|
||||
public async Task<IActionResult> UpdateSettings([FromBody] SystemSettingsDto settings, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new UpdateSettingsCommand(settings), ct);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpGet("dashboard")]
|
||||
public async Task<IActionResult> GetDashboardStats(CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new GetDashboardStatsQuery(), ct);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("users")]
|
||||
public async Task<IActionResult> CreateUser([FromBody] RegisterUserCommand command, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(command, ct);
|
||||
if (result.IsFailure) return BadRequest(new { error = result.Error.Description });
|
||||
|
||||
var userDetails = await _sender.Send(new GetUserDetailsQuery(result.Value.User.Id), ct);
|
||||
return Ok(userDetails.Value);
|
||||
}
|
||||
|
||||
[HttpPost("users/{userId:guid}/reset-password")]
|
||||
public async Task<IActionResult> ResetUserPassword(Guid userId, [FromBody] ResetPasswordDto dto, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new ResetUserPasswordCommand(userId, dto.NewPassword), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
if (result.Error.Code == "User.NotFound") return NotFound(new { error = "User not found" });
|
||||
return BadRequest(new { error = result.Error.Description });
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpGet("users")]
|
||||
public async Task<IActionResult> SearchUsers([FromQuery] string query = "", CancellationToken ct = default)
|
||||
{
|
||||
var result = await _sender.Send(new SearchUsersQuery(query), ct);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpGet("users/{id:guid}")]
|
||||
public async Task<IActionResult> GetUserDetails(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new GetUserDetailsQuery(id), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return NotFound("User not found");
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpGet("clean/dry-run")]
|
||||
public async Task<IActionResult> CleanDryRun(
|
||||
[FromServices] IFileStorageService fileStorage,
|
||||
[FromServices] IdentityDbContext identityDb,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new CleanDryRunQuery(fileStorage, identityDb), ct);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("clean/run")]
|
||||
public async Task<IActionResult> CleanRun(
|
||||
[FromServices] IFileStorageService fileStorage,
|
||||
[FromServices] IdentityDbContext identityDb,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new CleanRunCommand(fileStorage, identityDb), ct);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
50
backend/src/Host/Controllers/AuthController.cs
Normal file
50
backend/src/Host/Controllers/AuthController.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Identity.Application.Users.Auth;
|
||||
using Knot.Modules.Identity.Application.Users.Register;
|
||||
using Knot.Modules.Identity.Application.Users.Login;
|
||||
using Knot.Modules.Identity.Application.Users.GetMe;
|
||||
using MediatR;
|
||||
|
||||
namespace Host.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public sealed class AuthController : ControllerBase
|
||||
{
|
||||
private readonly ISender _sender;
|
||||
|
||||
public AuthController(ISender sender)
|
||||
{
|
||||
_sender = sender;
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
public async Task<IActionResult> Register([FromBody] RegisterUserCommand command, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(command, ct);
|
||||
if (result.IsFailure) return BadRequest(new { error = result.Error.Code ?? result.Error.Description });
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetMe([FromServices] IUserContext userContext, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new GetMeQuery(userContext.UserId), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
return Ok(new { User = result.Value.User });
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
public async Task<IActionResult> Login([FromBody] LoginUserCommand command, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(command, ct);
|
||||
if (result.IsFailure) return Unauthorized(new { error = result.Error.Code ?? result.Error.Description });
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
241
backend/src/Host/Controllers/ChatsController.cs
Normal file
241
backend/src/Host/Controllers/ChatsController.cs
Normal file
@@ -0,0 +1,241 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Chats.Application.DTOs;
|
||||
using Knot.Shared.Kernel.Storage;
|
||||
using Knot.Modules.Chats.Application.Chats.GetChats;
|
||||
using Knot.Modules.Chats.Application.Chats.GetChatById;
|
||||
using Knot.Modules.Chats.Application.Chats.Create;
|
||||
using Knot.Modules.Chats.Application.Chats.GetOrCreateFavorites;
|
||||
using Knot.Modules.Chats.Application.Chats.Update;
|
||||
using Knot.Modules.Chats.Application.Chats.LeaveOrDelete;
|
||||
using Knot.Modules.Chats.Application.Chats.Clear;
|
||||
using Knot.Modules.Chats.Application.Chats.TogglePin;
|
||||
using Knot.Modules.Chats.Application.Chats.Members;
|
||||
using Knot.Modules.Chats.Application.Chats.Avatar;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using MediatR;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Host.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/chats")]
|
||||
public sealed class ChatsController : ControllerBase
|
||||
{
|
||||
private readonly ISender _sender;
|
||||
private readonly IUserContext _userContext;
|
||||
|
||||
public ChatsController(ISender sender, IUserContext userContext)
|
||||
{
|
||||
_sender = sender;
|
||||
_userContext = userContext;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetChats(CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new GetChatsQuery(_userContext.UserId), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return BadRequest(result.Error.Description);
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateChatRequest request, CancellationToken ct)
|
||||
{
|
||||
var command = new CreateChatCommand(request.Name, request.Type, request.MemberIds);
|
||||
var result = await _sender.Send(command, ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return BadRequest(result.Error.Description);
|
||||
}
|
||||
|
||||
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
|
||||
return Ok(chatResult.Value);
|
||||
}
|
||||
|
||||
[HttpPost("personal")]
|
||||
public async Task<IActionResult> CreatePersonal([FromBody] CreatePersonalChatRequest request, CancellationToken ct)
|
||||
{
|
||||
var command = new CreateChatCommand(string.Empty, ChatType.Personal, new List<Guid> { _userContext.UserId, request.UserId });
|
||||
var result = await _sender.Send(command, ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return BadRequest(result.Error.Description);
|
||||
}
|
||||
|
||||
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
|
||||
return Ok(chatResult.Value);
|
||||
}
|
||||
|
||||
[HttpPost("group")]
|
||||
public async Task<IActionResult> CreateGroup([FromBody] CreateGroupChatRequest request, CancellationToken ct)
|
||||
{
|
||||
var memberIds = request.MemberIds.ToList();
|
||||
if (memberIds.Contains(_userContext.UserId))
|
||||
{
|
||||
memberIds.Remove(_userContext.UserId);
|
||||
}
|
||||
memberIds.Insert(0, _userContext.UserId);
|
||||
|
||||
var command = new CreateChatCommand(request.Name, ChatType.Group, memberIds);
|
||||
var result = await _sender.Send(command, ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return BadRequest(result.Error.Description);
|
||||
}
|
||||
|
||||
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
|
||||
return Ok(chatResult.Value);
|
||||
}
|
||||
|
||||
[HttpPost("favorites")]
|
||||
public async Task<IActionResult> GetOrCreateFavorites(CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new GetOrCreateFavoritesCommand(_userContext.UserId), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return BadRequest(result.Error.Description);
|
||||
}
|
||||
|
||||
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
|
||||
return Ok(chatResult.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<IActionResult> UpdateChat(Guid id, [FromBody] UpdateChatRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new UpdateChatCommand(id, _userContext.UserId, request.Name, request.Description), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
|
||||
return Ok(chatResult.Value);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> LeaveOrDeleteChat(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new LeaveOrDeleteChatCommand(id, _userContext.UserId), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
if (result.Error.Code == "Unauthorized")
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
return NotFound();
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/clear")]
|
||||
public async Task<IActionResult> ClearChat(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new ClearChatCommand(id, _userContext.UserId), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/pin")]
|
||||
public async Task<IActionResult> TogglePin(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new TogglePinCommand(id, _userContext.UserId), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/members")]
|
||||
public async Task<IActionResult> AddMembers(Guid id, [FromBody] AddMembersRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new AddMembersCommand(id, _userContext.UserId, request.UserIds.ToList()), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
|
||||
return Ok(chatResult.Value);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}/members/{userId:guid}")]
|
||||
public async Task<IActionResult> RemoveMember(Guid id, Guid userId, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new RemoveMemberCommand(id, _userContext.UserId, userId), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
|
||||
return Ok(chatResult.Value);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/avatar")]
|
||||
public async Task<IActionResult> UploadGroupAvatar(Guid id, Microsoft.AspNetCore.Http.IFormFile avatar, CancellationToken ct)
|
||||
{
|
||||
if (avatar == null || avatar.Length == 0)
|
||||
{
|
||||
return BadRequest("No file");
|
||||
}
|
||||
|
||||
using var stream = avatar.OpenReadStream();
|
||||
var result = await _sender.Send(new UploadGroupAvatarCommand(id, _userContext.UserId, avatar.FileName, avatar.ContentType, stream), ct);
|
||||
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
|
||||
return Ok(chatResult.Value);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/avatar/crop")]
|
||||
public async Task<IActionResult> CropGroupAvatar(Guid id, [FromForm] Microsoft.AspNetCore.Http.IFormFile avatar, [FromForm] int x, [FromForm] int y, [FromForm] int width, [FromForm] int height, CancellationToken ct)
|
||||
{
|
||||
if (avatar == null || avatar.Length == 0)
|
||||
{
|
||||
return BadRequest("No file");
|
||||
}
|
||||
|
||||
using var stream = avatar.OpenReadStream();
|
||||
var result = await _sender.Send(new CropGroupAvatarCommand(id, _userContext.UserId, avatar.FileName, avatar.ContentType, stream, x, y, width, height), ct);
|
||||
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
|
||||
return Ok(chatResult.Value);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}/avatar")]
|
||||
public async Task<IActionResult> RemoveGroupAvatar(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new RemoveGroupAvatarCommand(id, _userContext.UserId), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var chatResult = await _sender.Send(new GetChatByIdQuery(_userContext.UserId, result.Value), ct);
|
||||
return Ok(chatResult.Value);
|
||||
}
|
||||
}
|
||||
34
backend/src/Host/Controllers/FilesController.cs
Normal file
34
backend/src/Host/Controllers/FilesController.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Knot.Shared.Kernel.Storage;
|
||||
|
||||
namespace Host.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/files")]
|
||||
public sealed class FilesController : ControllerBase
|
||||
{
|
||||
private readonly IFileStorageService _fileStorage;
|
||||
|
||||
public FilesController(IFileStorageService fileStorage)
|
||||
{
|
||||
_fileStorage = fileStorage;
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> DownloadFile(string id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _fileStorage.DownloadFileAsync(id);
|
||||
// Обратите внимание, что мы возвращаем поток с автоматическим освобождением памяти.
|
||||
return File(result.Stream, result.ContentType, result.FileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return NotFound(new { error = "Файл не найден или ошибка доступа.", message = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
113
backend/src/Host/Controllers/FriendsController.cs
Normal file
113
backend/src/Host/Controllers/FriendsController.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Modules.Identity.Application.Friends;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Host.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/friends")]
|
||||
public sealed class FriendsController : ControllerBase
|
||||
{
|
||||
private readonly ISender _sender;
|
||||
private readonly IUserContext _userContext;
|
||||
|
||||
public FriendsController(ISender sender, IUserContext userContext)
|
||||
{
|
||||
_sender = sender;
|
||||
_userContext = userContext;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetFriends(CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new GetFriendsQuery(_userContext.UserId), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return BadRequest(result.Error);
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpGet("requests")]
|
||||
public async Task<IActionResult> GetRequests(CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new GetIncomingRequestsQuery(_userContext.UserId), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return BadRequest(result.Error);
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpGet("outgoing")]
|
||||
public async Task<IActionResult> GetOutgoingRequests(CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new GetOutgoingRequestsQuery(_userContext.UserId), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return BadRequest(result.Error);
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("request")]
|
||||
public async Task<IActionResult> SendRequest([FromBody] Host.Models.SendFriendRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new SendFriendRequestCommand(_userContext.UserId, request.FriendId), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return BadRequest(result.Error.Description);
|
||||
}
|
||||
return Ok(new { status = "pending" });
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/accept")]
|
||||
public async Task<IActionResult> AcceptRequest(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new AcceptFriendRequestCommand(_userContext.UserId, id), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return NotFound(result.Error.Description);
|
||||
}
|
||||
return Ok(new { id = result.Value });
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/decline")]
|
||||
public async Task<IActionResult> DeclineRequest(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new DeclineFriendRequestCommand(_userContext.UserId, id), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return NotFound(result.Error.Description);
|
||||
}
|
||||
return Ok(new Knot.Shared.Kernel.SuccessResponse(true));
|
||||
}
|
||||
|
||||
[HttpGet("status/{userId:guid}")]
|
||||
public async Task<IActionResult> GetStatus(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new GetFriendshipStatusQuery(_userContext.UserId, userId), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return BadRequest(result.Error);
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> RemoveFriend(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new RemoveFriendCommand(_userContext.UserId, id), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return NotFound(result.Error.Description);
|
||||
}
|
||||
return Ok(new Knot.Shared.Kernel.SuccessResponse(true));
|
||||
}
|
||||
}
|
||||
114
backend/src/Host/Controllers/MessagesController.cs
Normal file
114
backend/src/Host/Controllers/MessagesController.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Knot.Modules.Chats.Application.DTOs;
|
||||
using Knot.Shared.Kernel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using MediatR;
|
||||
using Knot.Modules.Chats.Application.Messages.GetMessages;
|
||||
using Knot.Modules.Chats.Application.Messages.SearchMessages;
|
||||
using Knot.Modules.Chats.Application.Messages.UploadFile;
|
||||
using Knot.Modules.Chats.Application.Messages.GetSharedMedia;
|
||||
using Knot.Modules.Chats.Application.Messages.Send;
|
||||
using System.Linq;
|
||||
|
||||
namespace Host.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/messages")]
|
||||
public sealed class MessagesController : ControllerBase
|
||||
{
|
||||
private readonly ISender _sender;
|
||||
private readonly IUserContext _userContext;
|
||||
|
||||
public MessagesController(ISender sender, IUserContext userContext)
|
||||
{
|
||||
_sender = sender;
|
||||
_userContext = userContext;
|
||||
}
|
||||
|
||||
[HttpGet("chat/{chatId:guid}")]
|
||||
public async Task<IActionResult> GetMessages(Guid chatId, [FromQuery] string? cursor, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _sender.Send(new GetMessagesQuery(_userContext.UserId, chatId, cursor), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return BadRequest(result.Error.Description);
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpGet("search")]
|
||||
public async Task<IActionResult> GetSearch([FromQuery] string q, [FromQuery] Guid? chatId, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new SearchMessagesQuery(_userContext.UserId, q, chatId), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return BadRequest(result.Error.Description);
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("upload")]
|
||||
public async Task<IActionResult> UploadFile(IFormFile file, CancellationToken ct)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
return BadRequest("No file uploaded");
|
||||
}
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _sender.Send(new UploadFileCommand(file.FileName, file.ContentType, file.Length, stream), ct);
|
||||
|
||||
if (result.IsFailure)
|
||||
{
|
||||
if (result.Error.Code == "File.TooLarge")
|
||||
{
|
||||
return StatusCode(413, result.Error.Description);
|
||||
}
|
||||
return BadRequest(result.Error.Description);
|
||||
}
|
||||
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpGet("chat/{chatId:guid}/shared")]
|
||||
public async Task<IActionResult> GetSharedMedia(Guid chatId, [FromQuery] string? type, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new GetSharedMediaQuery(_userContext.UserId, chatId, type), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return BadRequest(result.Error.Description);
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("chat/{chatId:guid}")]
|
||||
public async Task<IActionResult> SendMessage(Guid chatId, [FromBody] SendMessageRequest request, CancellationToken ct)
|
||||
{
|
||||
var attachments = request.Attachments?.Select(a =>
|
||||
new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList();
|
||||
|
||||
var command = new SendMessageCommand(
|
||||
chatId,
|
||||
_userContext.UserId,
|
||||
request.Content,
|
||||
request.Type,
|
||||
attachments,
|
||||
request.ReplyToId,
|
||||
request.Quote,
|
||||
request.ForwardedFromId);
|
||||
|
||||
var result = await _sender.Send(command, ct);
|
||||
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return BadRequest(result.Error.Description);
|
||||
}
|
||||
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
149
backend/src/Host/Controllers/StoriesController.cs
Normal file
149
backend/src/Host/Controllers/StoriesController.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Host.Models;
|
||||
using Knot.Shared.Kernel;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediatR;
|
||||
using Host.Application.Stories.Queries.GetStories;
|
||||
using Host.Application.Stories.Commands.CreateStory;
|
||||
using Host.Application.Stories.Queries.GetUserStories;
|
||||
using Host.Application.Stories.Commands.ViewStory;
|
||||
using Host.Application.Stories.Queries.GetStoryViewers;
|
||||
using Host.Application.Stories.Commands.AddStoryReaction;
|
||||
using Host.Application.Stories.Commands.RemoveStoryReaction;
|
||||
using Host.Application.Stories.Commands.AddStoryReply;
|
||||
using Host.Application.Stories.Queries.GetStoryReplies;
|
||||
using Host.Application.Stories.Commands.DeleteStory;
|
||||
|
||||
namespace Host.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/stories")]
|
||||
public sealed class StoriesController : ControllerBase
|
||||
{
|
||||
private readonly ISender _sender;
|
||||
private readonly IUserContext _userContext;
|
||||
|
||||
public StoriesController(ISender sender, IUserContext userContext)
|
||||
{
|
||||
_sender = sender;
|
||||
_userContext = userContext;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetStories(CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new GetStoriesQuery(_userContext.UserId), ct);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateStory([FromBody] CreateStoryRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new CreateStoryCommand(_userContext.UserId, request.Type, request.MediaUrl, request.Content, request.BgColor), ct);
|
||||
return Ok(new { id = result.Value });
|
||||
}
|
||||
|
||||
[HttpGet("user/{userId}")]
|
||||
public async Task<IActionResult> GetUserStories(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new GetUserStoriesQuery(_userContext.UserId, userId), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/view")]
|
||||
public async Task<IActionResult> ViewStory(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new ViewStoryCommand(_userContext.UserId, id), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpGet("{id}/viewers")]
|
||||
public async Task<IActionResult> GetStoryViewers(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new GetStoryViewersQuery(_userContext.UserId, id), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
if (result.Error.Code == "Unauthorized")
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
return NotFound();
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/reaction")]
|
||||
public async Task<IActionResult> AddReaction(Guid id, [FromBody] AddStoryReactionRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new AddStoryReactionCommand(_userContext.UserId, id, request.Emoji), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpDelete("{id}/reaction")]
|
||||
public async Task<IActionResult> RemoveReaction(Guid id, [FromBody] RemoveStoryReactionRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new RemoveStoryReactionCommand(_userContext.UserId, id, request.Emoji), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/reply")]
|
||||
public async Task<IActionResult> AddReply(Guid id, [FromBody] AddStoryReplyRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new AddStoryReplyCommand(_userContext.UserId, id, request.Content), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpGet("{id}/replies")]
|
||||
public async Task<IActionResult> GetReplies(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new GetStoryRepliesQuery(_userContext.UserId, id), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
if (result.Error.Code == "Unauthorized")
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
return NotFound();
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> DeleteStory(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new DeleteStoryCommand(_userContext.UserId, id), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
if (result.Error.Code == "Unauthorized")
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
return NotFound();
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
64
backend/src/Host/Controllers/TelegramImportController.cs
Normal file
64
backend/src/Host/Controllers/TelegramImportController.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using MediatR;
|
||||
using Host.Models;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Chats.Application.TelegramImport;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Host.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/import/telegram")]
|
||||
public sealed class TelegramImportController : ControllerBase
|
||||
{
|
||||
private readonly ISender _sender;
|
||||
private readonly IUserContext _userContext;
|
||||
|
||||
public TelegramImportController(ISender sender, IUserContext userContext)
|
||||
{
|
||||
_sender = sender;
|
||||
_userContext = userContext;
|
||||
}
|
||||
|
||||
[HttpPost("analyze")]
|
||||
[DisableRequestSizeLimit]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 10L * 1024 * 1024 * 1024)] // 10GB for big exports
|
||||
public async Task<IActionResult> Analyze(IFormFile file, CancellationToken ct)
|
||||
{
|
||||
if (file == null)
|
||||
{
|
||||
return BadRequest("No file uploaded.");
|
||||
}
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var command = new AnalyzeImportCommand(stream, file.FileName);
|
||||
|
||||
var result = await _sender.Send(command, ct);
|
||||
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return BadRequest(result.Error.Description ?? result.Error.Code);
|
||||
}
|
||||
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("execute")]
|
||||
public async Task<IActionResult> Execute([FromBody] ExecuteImportRequest req, CancellationToken ct)
|
||||
{
|
||||
var command = new ExecuteImportCommand(_userContext.UserId, req.Token, req.Mapping, req.GroupName);
|
||||
|
||||
var result = await _sender.Send(command, ct);
|
||||
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return BadRequest(result.Error.Description ?? result.Error.Code);
|
||||
}
|
||||
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
134
backend/src/Host/Controllers/UsersController.cs
Normal file
134
backend/src/Host/Controllers/UsersController.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Knot.Shared.Kernel;
|
||||
using Host.Models;
|
||||
using Knot.Shared.Kernel.Storage;
|
||||
using MediatR;
|
||||
using Knot.Modules.Identity.Application.Users;
|
||||
using Knot.Modules.Identity.Application.Users.Avatar;
|
||||
using Knot.Modules.Identity.Application.Users.GetUser;
|
||||
using Knot.Modules.Identity.Application.Users.Search;
|
||||
using Knot.Modules.Identity.Application.Users.UpdateProfile;
|
||||
using Knot.Modules.Identity.Application.Users.UpdateSettings;
|
||||
|
||||
namespace Host.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/users")]
|
||||
public sealed class UsersController : ControllerBase
|
||||
{
|
||||
private readonly ISender _sender;
|
||||
private readonly IUserContext _userContext;
|
||||
|
||||
public UsersController(ISender sender, IUserContext userContext)
|
||||
{
|
||||
_sender = sender;
|
||||
_userContext = userContext;
|
||||
}
|
||||
|
||||
[HttpGet("search")]
|
||||
public async Task<IActionResult> Search([FromQuery] string q, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new SearchUsersQuery(q), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return BadRequest(result.Error);
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("settings")]
|
||||
public async Task<IActionResult> UpdateSettings([FromBody] UpdateSettingsRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new UpdateSettingsCommand(_userContext.UserId, request.HideStoryViews), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return NotFound(result.Error);
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("avatar")]
|
||||
public async Task<IActionResult> UploadAvatar(IFormFile avatar, CancellationToken ct)
|
||||
{
|
||||
var fileToUpload = avatar ?? Request.Form.Files.FirstOrDefault();
|
||||
if (fileToUpload == null || fileToUpload.Length == 0)
|
||||
{
|
||||
return BadRequest("No file uploaded");
|
||||
}
|
||||
|
||||
using var stream = fileToUpload.OpenReadStream();
|
||||
var command = new UploadAvatarCommand(
|
||||
_userContext.UserId,
|
||||
stream,
|
||||
fileToUpload.FileName,
|
||||
fileToUpload.ContentType
|
||||
);
|
||||
|
||||
var result = await _sender.Send(command, ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return NotFound(result.Error);
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("avatar/crop")]
|
||||
public async Task<IActionResult> CropAvatar([FromForm] IFormFile avatar, [FromForm] int x, [FromForm] int y, [FromForm] int width, [FromForm] int height, CancellationToken ct)
|
||||
{
|
||||
if (avatar == null || avatar.Length == 0)
|
||||
{
|
||||
return BadRequest("No file uploaded");
|
||||
}
|
||||
|
||||
using var stream = avatar.OpenReadStream();
|
||||
var command = new CropAvatarCommand(
|
||||
_userContext.UserId,
|
||||
stream,
|
||||
avatar.FileName ?? "avatar.jpg",
|
||||
avatar.ContentType,
|
||||
x, y, width, height
|
||||
);
|
||||
|
||||
var result = await _sender.Send(command, ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return NotFound(result.Error);
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpDelete("avatar")]
|
||||
public async Task<IActionResult> DeleteAvatar(CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new DeleteAvatarCommand(_userContext.UserId), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return NotFound(result.Error);
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("profile")]
|
||||
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new UpdateProfileCommand(_userContext.UserId, request.DisplayName, request.Bio, request.Birthday), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return NotFound(result.Error);
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IActionResult> GetUser(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await _sender.Send(new GetUserQuery(id), ct);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
return NotFound(result.Error);
|
||||
}
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
25
backend/src/Host/Endpoints/ConfigEndpoints.cs
Normal file
25
backend/src/Host/Endpoints/ConfigEndpoints.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using Carter;
|
||||
using MediatR;
|
||||
using Knot.Shared.Kernel.Constants;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
||||
namespace Host.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Регистрация эндпоинтов для конфигурации приложения.
|
||||
/// </summary>
|
||||
public sealed class ConfigEndpoints : ICarterModule
|
||||
{
|
||||
public void AddRoutes(IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(Routes.ApiConfig);
|
||||
|
||||
group.MapGet("/", async (ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new Host.Application.Config.Queries.GetPublicConfigQuery(), ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
|
||||
});
|
||||
}
|
||||
}
|
||||
41
backend/src/Host/Endpoints/FederationEndpoints.cs
Normal file
41
backend/src/Host/Endpoints/FederationEndpoints.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using Carter;
|
||||
using MediatR;
|
||||
using Knot.Shared.Kernel.Constants;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using System;
|
||||
|
||||
namespace Host.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Регистрация эндпоинтов федерации.
|
||||
/// </summary>
|
||||
public sealed class FederationEndpoints : ICarterModule
|
||||
{
|
||||
public void AddRoutes(IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(Routes.ApiFederation);
|
||||
|
||||
group.MapPost("/handshake", async ([FromBody] Host.Application.Federation.Commands.HandshakeRequest request, ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new Host.Application.Federation.Commands.HandshakeFederationCommand(request), ct);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
return Results.Ok(result.Value);
|
||||
}
|
||||
|
||||
if (result.Error.Code == "Unauthorized")
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
if (result.Error.Code == Knot.Shared.Kernel.Constants.Errors.DisabledByAdmin)
|
||||
{
|
||||
return Results.StatusCode(503);
|
||||
}
|
||||
|
||||
return Results.BadRequest(new { error = result.Error.Description });
|
||||
});
|
||||
}
|
||||
}
|
||||
34
backend/src/Host/Endpoints/KlipyEndpoints.cs
Normal file
34
backend/src/Host/Endpoints/KlipyEndpoints.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using Carter;
|
||||
using MediatR;
|
||||
using Knot.Shared.Kernel.Constants;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Host.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Регистрация эндпоинтов для сервиса Klipy.
|
||||
/// </summary>
|
||||
public sealed class KlipyEndpoints : ICarterModule
|
||||
{
|
||||
public void AddRoutes(IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(Routes.ApiKlipy).RequireAuthorization();
|
||||
|
||||
group.MapGet("/trending", async (ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new Host.Application.Klipy.Queries.GetTrendingGifsQuery(), ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Description });
|
||||
});
|
||||
|
||||
group.MapGet("/search", async ([FromQuery] string q, ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new Host.Application.Klipy.Queries.SearchGifsQuery(q), ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Description });
|
||||
});
|
||||
}
|
||||
}
|
||||
28
backend/src/Host/Endpoints/WebRtcEndpoints.cs
Normal file
28
backend/src/Host/Endpoints/WebRtcEndpoints.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using Carter;
|
||||
using MediatR;
|
||||
using Knot.Shared.Kernel;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
||||
namespace Host.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Регистрация эндпоинтов WebRTC
|
||||
/// </summary>
|
||||
public sealed class WebRtcEndpoints : ICarterModule
|
||||
{
|
||||
public void AddRoutes(IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup(Knot.Shared.Kernel.Constants.Routes.ApiWebRtc)
|
||||
.RequireAuthorization();
|
||||
|
||||
group.MapGet("/ice-servers", async (ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new Host.Application.WebRtc.Queries.GetIceServersQuery(), ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
|
||||
})
|
||||
.WithName("GetIceServers")
|
||||
.WithOpenApi();
|
||||
}
|
||||
}
|
||||
36
backend/src/Host/Host.csproj
Normal file
36
backend/src/Host/Host.csproj
Normal file
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" Version="1.4.0" />
|
||||
<PackageReference Include="Carter" Version="10.0.0" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
|
||||
<PackageReference Include="Mapster" Version="7.4.0" />
|
||||
<PackageReference Include="MediatR" Version="12.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0-preview.1.25120.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0-preview.6.25358.103" />
|
||||
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.5" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Modules\Identity\Knot.Modules.Identity.csproj" />
|
||||
<ProjectReference Include="..\Modules\Chats\Knot.Modules.Chats.csproj" />
|
||||
<ProjectReference Include="..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
6
backend/src/Host/Host.http
Normal file
6
backend/src/Host/Host.http
Normal file
@@ -0,0 +1,6 @@
|
||||
@Host_HostAddress = http://localhost:5059
|
||||
|
||||
GET {{Host_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
15
backend/src/Host/Models/Admin/AdminUserDetailsDto.cs
Normal file
15
backend/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
backend/src/Host/Models/Admin/AdminUserDto.cs
Normal file
14
backend/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
|
||||
);
|
||||
9
backend/src/Host/Models/Admin/AdminUserStatsDto.cs
Normal file
9
backend/src/Host/Models/Admin/AdminUserStatsDto.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Host.Models;
|
||||
|
||||
public record AdminUserStatsDto(
|
||||
int MessagesCount,
|
||||
int MediaCount,
|
||||
int FilesCount,
|
||||
int LinksCount,
|
||||
long StorageUsedBytes
|
||||
);
|
||||
7
backend/src/Host/Models/Admin/CleanupDryRunResultDto.cs
Normal file
7
backend/src/Host/Models/Admin/CleanupDryRunResultDto.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Host.Models;
|
||||
|
||||
public class CleanupDryRunResultDto
|
||||
{
|
||||
public int OrphanedMessagesCount { get; set; }
|
||||
public long OrphanedMediaBytes { get; set; }
|
||||
}
|
||||
20
backend/src/Host/Models/Auth/AuthResponseDto.cs
Normal file
20
backend/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
backend/src/Host/Models/Auth/ResetPasswordDto.cs
Normal file
6
backend/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
backend/src/Host/Models/Config/PublicConfigDto.cs
Normal file
26
backend/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
backend/src/Host/Models/Friends/FriendDto.cs
Normal file
13
backend/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
backend/src/Host/Models/Friends/FriendRequestDto.cs
Normal file
16
backend/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
|
||||
);
|
||||
5
backend/src/Host/Models/Friends/SendFriendRequest.cs
Normal file
5
backend/src/Host/Models/Friends/SendFriendRequest.cs
Normal file
@@ -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
|
||||
);
|
||||
10
backend/src/Host/Models/Import/ExecuteImportRequest.cs
Normal file
10
backend/src/Host/Models/Import/ExecuteImportRequest.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Host.Models;
|
||||
|
||||
public record ExecuteImportRequest(
|
||||
Guid Token,
|
||||
Dictionary<string, Guid> Mapping,
|
||||
string? GroupName
|
||||
);
|
||||
@@ -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);
|
||||
5
backend/src/Host/Models/Stories/AddStoryReplyRequest.cs
Normal file
5
backend/src/Host/Models/Stories/AddStoryReplyRequest.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
using System;
|
||||
|
||||
namespace Host.Models;
|
||||
|
||||
public record AddStoryReplyRequest(string Content);
|
||||
5
backend/src/Host/Models/Stories/CreateStoryRequest.cs
Normal file
5
backend/src/Host/Models/Stories/CreateStoryRequest.cs
Normal file
@@ -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
backend/src/Host/Models/Stories/StoriesDtos.cs
Normal file
10
backend/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
backend/src/Host/Models/Stories/StoryDto.cs
Normal file
18
backend/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
backend/src/Host/Models/Stories/StoryReactionDto.cs
Normal file
10
backend/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
backend/src/Host/Models/Stories/StoryReplyDto.cs
Normal file
13
backend/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
backend/src/Host/Models/Stories/StoryUserDto.cs
Normal file
10
backend/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
backend/src/Host/Models/Stories/StoryViewerDto.cs
Normal file
11
backend/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
|
||||
);
|
||||
5
backend/src/Host/Models/Users/UpdateProfileRequest.cs
Normal file
5
backend/src/Host/Models/Users/UpdateProfileRequest.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
using System;
|
||||
|
||||
namespace Host.Models;
|
||||
|
||||
public sealed record UpdateProfileRequest(string? DisplayName, string? Bio, DateTime? Birthday);
|
||||
3
backend/src/Host/Models/Users/UpdateSettingsRequest.cs
Normal file
3
backend/src/Host/Models/Users/UpdateSettingsRequest.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace Host.Models;
|
||||
|
||||
public sealed record UpdateSettingsRequest(bool? HideStoryViews);
|
||||
12
backend/src/Host/Models/Users/UserDto.cs
Normal file
12
backend/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
backend/src/Host/Models/Users/UserProfileDto.cs
Normal file
16
backend/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
|
||||
);
|
||||
210
backend/src/Host/Program.cs
Normal file
210
backend/src/Host/Program.cs
Normal file
@@ -0,0 +1,210 @@
|
||||
using Carter;
|
||||
using Knot.Shared.Infrastructure;
|
||||
using Knot.Modules.Identity;
|
||||
using Knot.Modules.Chats;
|
||||
using Knot.Modules.Chats.Infrastructure.SignalR;
|
||||
using Knot.Modules.Identity.Infrastructure.Persistence;
|
||||
using Knot.Modules.Chats.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.Text;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using System.Security.Claims;
|
||||
|
||||
|
||||
|
||||
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Регистрация модулей
|
||||
// Маппинг стандартных переменных окружения в иерархию .NET
|
||||
var envMappings = new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:DefaultConnection"] = builder.Configuration["DATABASE_URL"],
|
||||
["ConnectionStrings:MongoConnection"] = builder.Configuration["MONGO_CONNECTION"],
|
||||
["Jwt:Secret"] = builder.Configuration["JWT_SECRET"],
|
||||
["Jwt:Issuer"] = builder.Configuration["JWT_ISSUER"],
|
||||
["Jwt:Audience"] = builder.Configuration["JWT_AUDIENCE"],
|
||||
["WebRtc:TurnUrl"] = builder.Configuration["TURN_URL"],
|
||||
["WebRtc:TurnUsername"] = builder.Configuration["TURN_USERNAME"],
|
||||
["WebRtc:TurnPassword"] = builder.Configuration["TURN_PASSWORD"],
|
||||
["KNOT_MASTER_ENCRYPTION_KEY"] = builder.Configuration["KNOT_MASTER_ENCRYPTION_KEY"],
|
||||
["S3_ENDPOINT"] = builder.Configuration["S3_ENDPOINT"],
|
||||
["S3_ACCESS_KEY"] = builder.Configuration["S3_ACCESS_KEY"],
|
||||
["S3_SECRET_KEY"] = builder.Configuration["S3_SECRET_KEY"],
|
||||
["S3_BUCKET"] = builder.Configuration["S3_BUCKET"],
|
||||
["KNOT_ADMIN_USER"] = builder.Configuration["KNOT_ADMIN_USER"],
|
||||
["KNOT_ADMIN_PASSWORD"] = builder.Configuration["KNOT_ADMIN_PASSWORD"]
|
||||
};
|
||||
|
||||
// Добавляем только те, что реально заданы в ENV
|
||||
builder.Configuration.AddInMemoryCollection(
|
||||
envMappings.Where(kv => !string.IsNullOrEmpty(kv.Value))
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value));
|
||||
|
||||
builder.Services.AddIdentityModule(builder.Configuration);
|
||||
builder.Services.AddChatsModule(builder.Configuration);
|
||||
builder.Services.AddSharedInfrastructure(builder.Configuration);
|
||||
|
||||
// CQRS / MediatR для команд в Host (например, AdminController)
|
||||
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
|
||||
|
||||
// Carter для вызова Minimal APIs (Endpoints)
|
||||
builder.Services.AddCarter();
|
||||
|
||||
// Настройка CORS
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
var originsFromConfig = builder.Configuration["Cors:Origins"];
|
||||
var domain = builder.Configuration["DOMAIN"];
|
||||
|
||||
var origins = !string.IsNullOrEmpty(originsFromConfig)
|
||||
? originsFromConfig.Split(',')
|
||||
: (!string.IsNullOrEmpty(domain) ? new[] { $"https://{domain}" } : new[] { "*" });
|
||||
|
||||
var corsBuilder = policy.WithOrigins(origins)
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod();
|
||||
|
||||
if (origins.Contains("*"))
|
||||
{
|
||||
// The CORS protocol does not allow specifying a wildcard (any) origin and credentials at the same time.
|
||||
}
|
||||
else
|
||||
{
|
||||
corsBuilder.AllowCredentials();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Настройка Swagger/OpenAPI
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
// Настройка маршрутизации
|
||||
builder.Services.AddRouting(options =>
|
||||
{
|
||||
options.LowercaseUrls = true;
|
||||
options.LowercaseQueryStrings = true;
|
||||
});
|
||||
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
builder.Services.AddControllers()
|
||||
.AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
});
|
||||
|
||||
builder.Services.AddSignalR()
|
||||
.AddJsonProtocol(options =>
|
||||
{
|
||||
options.PayloadSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||
options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<IUserIdProvider, CustomUserIdProvider>();
|
||||
|
||||
// Настройка JWT Аутентификации
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = builder.Configuration["Jwt:Issuer"],
|
||||
ValidAudience = builder.Configuration["Jwt:Audience"],
|
||||
IssuerSigningKey = new SymmetricSecurityKey(
|
||||
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!))
|
||||
};
|
||||
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = context =>
|
||||
{
|
||||
var accessToken = context.Request.Query["access_token"];
|
||||
var path = context.HttpContext.Request.Path;
|
||||
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
|
||||
{
|
||||
context.Token = accessToken;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Применяем миграции при старте
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var identityDb = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
|
||||
await identityDb.Database.MigrateAsync();
|
||||
|
||||
var chatsDb = scope.ServiceProvider.GetRequiredService<ChatsDbContext>();
|
||||
await chatsDb.Database.MigrateAsync();
|
||||
|
||||
var systemDb = scope.ServiceProvider.GetRequiredService<Knot.Shared.Infrastructure.Persistence.SystemDbContext>();
|
||||
await systemDb.Database.MigrateAsync();
|
||||
|
||||
// Set Encryption Service for MongoDB serializers
|
||||
var encryptionService = scope.ServiceProvider.GetRequiredService<Knot.Shared.Kernel.Security.IEncryptionService>();
|
||||
Knot.Modules.Chats.Infrastructure.Persistence.Mongo.EncryptedStringSerializer.EncryptionService = encryptionService;
|
||||
|
||||
// Initialize Global Settings Cache
|
||||
var settingsService = scope.ServiceProvider.GetRequiredService<Knot.Shared.Kernel.Configuration.ISettingsService>();
|
||||
if (settingsService is Knot.Shared.Infrastructure.Configuration.SettingsService concreteSettings)
|
||||
{
|
||||
concreteSettings.Initialize();
|
||||
}
|
||||
}
|
||||
|
||||
// Настройка конвейера запросов
|
||||
app.UseMiddleware<Knot.Shared.Infrastructure.Middleware.ExceptionHandlingMiddleware>();
|
||||
app.UseMiddleware<Knot.Shared.Infrastructure.Middleware.AdminAuthMiddleware>();
|
||||
|
||||
app.UseCors();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
// app.UseHttpsRedirection();
|
||||
|
||||
// Не раздаем статические файлы, так как теперь используем MinIO
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// Добавляем контроллеры и Carter (Minimal APIs)
|
||||
app.MapControllers();
|
||||
app.MapCarter();
|
||||
|
||||
// Добавляем SignalR хабы
|
||||
app.MapHub<ChatHub>("/hubs/chat");
|
||||
|
||||
app.Run();
|
||||
|
||||
public class CustomUserIdProvider : IUserIdProvider
|
||||
{
|
||||
public string? GetUserId(HubConnectionContext connection)
|
||||
{
|
||||
return connection.User?.FindFirstValue("sub") ?? connection.User?.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
}
|
||||
}
|
||||
23
backend/src/Host/Properties/launchSettings.json
Normal file
23
backend/src/Host/Properties/launchSettings.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5059",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7124;http://localhost:5059",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
backend/src/Host/appsettings.Development.json
Normal file
8
backend/src/Host/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
backend/src/Host/appsettings.json
Normal file
19
backend/src/Host/appsettings.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=knot_db;Username=knot;Password=knot_pass"
|
||||
},
|
||||
"Jwt": {
|
||||
"Secret": "knot_super_secret_key_1234567890_knot",
|
||||
"Issuer": "Knot",
|
||||
"Audience": "KnotUsers",
|
||||
"ExpiryInMinutes": 1440
|
||||
},
|
||||
"KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Unit of Work специфичный для модуля Chats.
|
||||
/// </summary>
|
||||
public interface IChatsUnitOfWork : IUnitOfWork
|
||||
{
|
||||
}
|
||||
129
backend/src/Modules/Chats/Application/Chats/Avatar/Avatar.cs
Normal file
129
backend/src/Modules/Chats/Application/Chats/Avatar/Avatar.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Shared.Kernel.Storage;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Modules.Chats.Application.Abstractions;
|
||||
using MediatR;
|
||||
using System.Linq;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Chats.Avatar;
|
||||
|
||||
public record UploadGroupAvatarCommand(Guid ChatId, Guid UserId, string FileName, string ContentType, Stream FileStream) : ICommand<Guid>;
|
||||
|
||||
internal sealed class UploadGroupAvatarCommandHandler : ICommandHandler<UploadGroupAvatarCommand, Guid>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _uow;
|
||||
private readonly IFileStorageService _fileStorage;
|
||||
|
||||
public UploadGroupAvatarCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow, IFileStorageService fileStorage)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_uow = uow;
|
||||
_fileStorage = fileStorage;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(UploadGroupAvatarCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
|
||||
{
|
||||
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
|
||||
}
|
||||
|
||||
var fileId = await _fileStorage.UploadFileAsync(request.FileStream, request.FileName, request.ContentType);
|
||||
var url = $"/api/files/{fileId}";
|
||||
|
||||
chat.UpdateAvatar(url);
|
||||
_chatRepository.Update(chat);
|
||||
await _uow.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(request.ChatId);
|
||||
}
|
||||
}
|
||||
|
||||
public record CropGroupAvatarCommand(Guid ChatId, Guid UserId, string FileName, string ContentType, Stream FileStream, int X, int Y, int Width, int Height) : ICommand<Guid>;
|
||||
|
||||
internal sealed class CropGroupAvatarCommandHandler : ICommandHandler<CropGroupAvatarCommand, Guid>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _uow;
|
||||
private readonly IFileStorageService _fileStorage;
|
||||
|
||||
public CropGroupAvatarCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow, IFileStorageService fileStorage)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_uow = uow;
|
||||
_fileStorage = fileStorage;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(CropGroupAvatarCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
|
||||
{
|
||||
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
|
||||
}
|
||||
|
||||
string url;
|
||||
|
||||
using (var image = await SixLabors.ImageSharp.Image.LoadAsync(request.FileStream))
|
||||
{
|
||||
int startX = Math.Max(0, Math.Min(request.X, image.Width - 1));
|
||||
int startY = Math.Max(0, Math.Min(request.Y, image.Height - 1));
|
||||
int rectWidth = Math.Max(1, Math.Min(request.Width, image.Width - startX));
|
||||
int rectHeight = Math.Max(1, Math.Min(request.Height, image.Height - startY));
|
||||
|
||||
image.Mutate(ctx => ctx.Crop(new SixLabors.ImageSharp.Rectangle(startX, startY, rectWidth, rectHeight)));
|
||||
image.Mutate(ctx => ctx.Resize(400, 400));
|
||||
|
||||
using var outStream = new MemoryStream();
|
||||
await image.SaveAsJpegAsync(outStream, cancellationToken);
|
||||
outStream.Position = 0;
|
||||
|
||||
var fileName = request.FileName ?? "avatar.jpg";
|
||||
var fileId = await _fileStorage.UploadFileAsync(outStream, fileName, "image/jpeg");
|
||||
url = $"/api/files/{fileId}";
|
||||
}
|
||||
|
||||
chat.UpdateAvatar(url);
|
||||
_chatRepository.Update(chat);
|
||||
await _uow.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(request.ChatId);
|
||||
}
|
||||
}
|
||||
|
||||
public record RemoveGroupAvatarCommand(Guid ChatId, Guid UserId) : ICommand<Guid>;
|
||||
|
||||
internal sealed class RemoveGroupAvatarCommandHandler : ICommandHandler<RemoveGroupAvatarCommand, Guid>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _uow;
|
||||
|
||||
public RemoveGroupAvatarCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(RemoveGroupAvatarCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
|
||||
{
|
||||
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
|
||||
}
|
||||
|
||||
chat.UpdateAvatar(null);
|
||||
_chatRepository.Update(chat);
|
||||
await _uow.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(request.ChatId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Modules.Chats.Application.Abstractions;
|
||||
using MediatR;
|
||||
using System.Linq;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Chats.Clear;
|
||||
|
||||
public record ClearChatCommand(Guid ChatId, Guid UserId) : ICommand<MessageResponse>;
|
||||
|
||||
internal sealed class ClearChatCommandHandler : ICommandHandler<ClearChatCommand, MessageResponse>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
|
||||
public ClearChatCommandHandler(IChatRepository chatRepository)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
}
|
||||
|
||||
public async Task<Result<MessageResponse>> Handle(ClearChatCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
|
||||
{
|
||||
return Result.Failure<MessageResponse>(ChatErrors.ChatNotFound);
|
||||
}
|
||||
|
||||
// Currently a placeholder
|
||||
return Result.Success(new MessageResponse("Cleared"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
using Knot.Modules.Chats.Application.Abstractions;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Chats.Create;
|
||||
|
||||
/// <summary>
|
||||
/// Команда для создания чата.
|
||||
/// </summary>
|
||||
public sealed record CreateChatCommand(
|
||||
string Name,
|
||||
ChatType Type,
|
||||
List<Guid> MemberIds) : ICommand<Guid>;
|
||||
|
||||
public sealed class CreateChatCommandHandler : ICommandHandler<CreateChatCommand, Guid>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _unitOfWork;
|
||||
|
||||
public CreateChatCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(CreateChatCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = Chat.Create(request.Name, request.Type);
|
||||
|
||||
for (int i = 0; i < request.MemberIds.Count; i++)
|
||||
{
|
||||
var userId = request.MemberIds[i];
|
||||
var role = (i == 0) ? ChatRole.Owner : ChatRole.Member;
|
||||
chat.AddMember(userId, role);
|
||||
}
|
||||
|
||||
_chatRepository.Add(chat);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(chat.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediatR;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Chats.Application.DTOs;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Modules.Chats.Application.Abstractions;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Chats.GetChatById;
|
||||
|
||||
public record GetChatByIdQuery(Guid UserId, Guid ChatId) : IQuery<ChatDto?>;
|
||||
|
||||
internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery, ChatDto?>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IUserDisplayNameProvider _userProvider;
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
|
||||
public GetChatByIdQueryHandler(IChatRepository chatRepository, IUserDisplayNameProvider userProvider, IMessageRepository messageRepository)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_userProvider = userProvider;
|
||||
_messageRepository = messageRepository;
|
||||
}
|
||||
|
||||
public async Task<Result<ChatDto?>> Handle(GetChatByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat == null)
|
||||
{
|
||||
return Result.Success<ChatDto?>(null);
|
||||
}
|
||||
|
||||
if (!chat.Members.Any(m => m.UserId == request.UserId))
|
||||
{
|
||||
return Result.Failure<ChatDto?>(ChatErrors.ChatsForbidden);
|
||||
}
|
||||
|
||||
var userIdsToFetch = new HashSet<Guid>();
|
||||
foreach (var member in chat.Members)
|
||||
{
|
||||
userIdsToFetch.Add(member.UserId);
|
||||
}
|
||||
|
||||
var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken);
|
||||
if (latestMessage != null)
|
||||
{
|
||||
userIdsToFetch.Add(latestMessage.SenderId);
|
||||
foreach (var reaction in latestMessage.Reactions)
|
||||
{
|
||||
userIdsToFetch.Add(reaction.UserId);
|
||||
}
|
||||
}
|
||||
|
||||
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
|
||||
|
||||
var members = new List<ChatMemberDto>();
|
||||
foreach (var member in chat.Members)
|
||||
{
|
||||
usersInfo.TryGetValue(member.UserId, out var user);
|
||||
members.Add(new ChatMemberDto(
|
||||
member.Id,
|
||||
member.UserId,
|
||||
member.Role,
|
||||
member.IsPinned,
|
||||
user != null ? new ChatUserDto(
|
||||
user.Id,
|
||||
user.Username,
|
||||
user.DisplayName,
|
||||
user.Avatar,
|
||||
false,
|
||||
DateTime.UtcNow
|
||||
) : null
|
||||
));
|
||||
}
|
||||
|
||||
var messagesList = new List<ChatMessageDto>();
|
||||
if (latestMessage != null)
|
||||
{
|
||||
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
|
||||
|
||||
var reactionsWithUser = new List<ReactionDto>();
|
||||
foreach (var reaction in latestMessage.Reactions)
|
||||
{
|
||||
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
|
||||
reactionsWithUser.Add(new ReactionDto(
|
||||
reaction.Id,
|
||||
reaction.Emoji,
|
||||
reaction.UserId,
|
||||
reactionUser != null
|
||||
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
|
||||
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null)
|
||||
));
|
||||
}
|
||||
|
||||
messagesList.Add(new ChatMessageDto(
|
||||
latestMessage.Id,
|
||||
latestMessage.ChatId,
|
||||
latestMessage.SenderId,
|
||||
latestMessage.Content,
|
||||
latestMessage.Type,
|
||||
latestMessage.ReplyToId,
|
||||
latestMessage.Quote,
|
||||
latestMessage.StoryId,
|
||||
latestMessage.StoryMediaUrl,
|
||||
latestMessage.StoryMediaType,
|
||||
latestMessage.IsEdited,
|
||||
latestMessage.IsDeleted,
|
||||
latestMessage.CreatedAt,
|
||||
latestMessage.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
|
||||
senderObj != null ? new MessageSenderDto(
|
||||
senderObj.Id,
|
||||
senderObj.Username,
|
||||
senderObj.DisplayName,
|
||||
senderObj.Avatar
|
||||
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
|
||||
reactionsWithUser,
|
||||
latestMessage.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList()
|
||||
));
|
||||
}
|
||||
|
||||
var dto = new ChatDto(
|
||||
chat.Id,
|
||||
chat.Type.ToString().ToLowerInvariant(),
|
||||
chat.Type == ChatType.Favorites ? "Избранное" : (chat.Type == ChatType.Personal ? null : chat.Name),
|
||||
chat.Description,
|
||||
chat.Avatar,
|
||||
chat.CreatedAt,
|
||||
members,
|
||||
messagesList,
|
||||
0
|
||||
);
|
||||
|
||||
return Result.Success<ChatDto?>(dto);
|
||||
}
|
||||
}
|
||||
|
||||
148
backend/src/Modules/Chats/Application/Chats/GetChats/GetChats.cs
Normal file
148
backend/src/Modules/Chats/Application/Chats/GetChats/GetChats.cs
Normal file
@@ -0,0 +1,148 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediatR;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Chats.Application.DTOs;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Modules.Chats.Application.Abstractions;
|
||||
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Chats.GetChats;
|
||||
|
||||
public record GetChatsQuery(Guid UserId) : IQuery<List<ChatDto>>;
|
||||
|
||||
internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<ChatDto>>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IUserDisplayNameProvider _userProvider;
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
|
||||
public GetChatsQueryHandler(IChatRepository chatRepository, IUserDisplayNameProvider userProvider, IMessageRepository messageRepository)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_userProvider = userProvider;
|
||||
_messageRepository = messageRepository;
|
||||
}
|
||||
|
||||
public async Task<Result<List<ChatDto>>> Handle(GetChatsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var userChats = await _chatRepository.GetUserChatsAsync(request.UserId, cancellationToken);
|
||||
var dtos = new List<ChatDto>();
|
||||
bool hasFavorites = false;
|
||||
|
||||
foreach (var chat in userChats)
|
||||
{
|
||||
var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken);
|
||||
|
||||
if (latestMessage == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var userIdsToFetch = new HashSet<Guid>();
|
||||
foreach (var member in chat.Members)
|
||||
{
|
||||
userIdsToFetch.Add(member.UserId);
|
||||
}
|
||||
|
||||
userIdsToFetch.Add(latestMessage.SenderId);
|
||||
foreach (var r in latestMessage.Reactions)
|
||||
{
|
||||
userIdsToFetch.Add(r.UserId);
|
||||
}
|
||||
|
||||
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
|
||||
|
||||
var members = new List<ChatMemberDto>();
|
||||
foreach (var member in chat.Members)
|
||||
{
|
||||
usersInfo.TryGetValue(member.UserId, out var user);
|
||||
members.Add(new ChatMemberDto(
|
||||
member.Id,
|
||||
member.UserId,
|
||||
member.Role,
|
||||
member.IsPinned,
|
||||
user != null ? new ChatUserDto(
|
||||
user.Id,
|
||||
user.Username,
|
||||
user.DisplayName,
|
||||
user.Avatar,
|
||||
false,
|
||||
DateTime.UtcNow
|
||||
) : null
|
||||
));
|
||||
}
|
||||
|
||||
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
|
||||
|
||||
var reactionsWithUser = new List<ReactionDto>();
|
||||
foreach (var reaction in latestMessage.Reactions)
|
||||
{
|
||||
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
|
||||
reactionsWithUser.Add(new ReactionDto(
|
||||
reaction.Id,
|
||||
reaction.Emoji,
|
||||
reaction.UserId,
|
||||
reactionUser != null
|
||||
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
|
||||
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null)
|
||||
));
|
||||
}
|
||||
|
||||
var messagesList = new List<ChatMessageDto>
|
||||
{
|
||||
new ChatMessageDto(
|
||||
latestMessage.Id,
|
||||
latestMessage.ChatId,
|
||||
latestMessage.SenderId,
|
||||
latestMessage.Content,
|
||||
latestMessage.Type,
|
||||
latestMessage.ReplyToId,
|
||||
latestMessage.Quote,
|
||||
latestMessage.StoryId,
|
||||
latestMessage.StoryMediaUrl,
|
||||
latestMessage.StoryMediaType,
|
||||
latestMessage.IsEdited,
|
||||
latestMessage.IsDeleted,
|
||||
latestMessage.CreatedAt,
|
||||
latestMessage.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
|
||||
senderObj != null ? new MessageSenderDto(
|
||||
senderObj.Id,
|
||||
senderObj.Username,
|
||||
senderObj.DisplayName,
|
||||
senderObj.Avatar
|
||||
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
|
||||
reactionsWithUser,
|
||||
latestMessage.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList()
|
||||
)
|
||||
};
|
||||
|
||||
var unreadCount = await _messageRepository.GetUnreadCountAsync(chat.Id, request.UserId, cancellationToken);
|
||||
|
||||
dtos.Add(new ChatDto(
|
||||
chat.Id,
|
||||
chat.Type.ToString().ToLowerInvariant(),
|
||||
chat.Type == ChatType.Favorites ? "Избранное" : (chat.Type == ChatType.Personal ? null : chat.Name),
|
||||
chat.Description,
|
||||
chat.Avatar,
|
||||
chat.CreatedAt,
|
||||
members,
|
||||
messagesList,
|
||||
unreadCount
|
||||
));
|
||||
}
|
||||
|
||||
if (!hasFavorites)
|
||||
{
|
||||
var favs = new ChatDto(Guid.Empty, "favorites", "Избранное", null, null, DateTime.UtcNow, new List<ChatMemberDto>(), new List<ChatMessageDto>(), 0);
|
||||
dtos.Add(favs);
|
||||
}
|
||||
|
||||
var sorted = dtos.OrderByDescending(d => d.Messages.FirstOrDefault()?.CreatedAt ?? d.CreatedAt).ToList();
|
||||
return Result.Success(sorted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Chats.Application.Abstractions;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Chats.GetOrCreateFavorites;
|
||||
|
||||
public sealed record GetOrCreateFavoritesCommand(Guid UserId) : ICommand<Guid>;
|
||||
|
||||
public sealed class GetOrCreateFavoritesCommandHandler : ICommandHandler<GetOrCreateFavoritesCommand, Guid>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _unitOfWork;
|
||||
|
||||
public GetOrCreateFavoritesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(GetOrCreateFavoritesCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var favorites = await _chatRepository.GetFavoritesAsync(request.UserId, cancellationToken);
|
||||
|
||||
if (favorites != null)
|
||||
{
|
||||
return Result.Success(favorites.Id);
|
||||
}
|
||||
|
||||
// Create new favorites chat
|
||||
var chat = Chat.Create("Избранное", ChatType.Favorites);
|
||||
chat.AddMember(request.UserId, ChatRole.Owner);
|
||||
|
||||
_chatRepository.Add(chat);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(chat.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Modules.Chats.Application.Abstractions;
|
||||
using MediatR;
|
||||
using System.Linq;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Chats.LeaveOrDelete;
|
||||
|
||||
public record LeaveOrDeleteChatCommand(Guid ChatId, Guid UserId) : ICommand<SuccessResponse>;
|
||||
|
||||
internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrDeleteChatCommand, SuccessResponse>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _uow;
|
||||
|
||||
public LeaveOrDeleteChatCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<Result<SuccessResponse>> Handle(LeaveOrDeleteChatCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat == null)
|
||||
{
|
||||
return Result.Success(new SuccessResponse(true));
|
||||
}
|
||||
|
||||
if (!chat.Members.Any(m => m.UserId == request.UserId))
|
||||
{
|
||||
return Result.Failure<SuccessResponse>(ChatErrors.Unauthorized);
|
||||
}
|
||||
|
||||
if (chat.Type == ChatType.Group)
|
||||
{
|
||||
chat.RemoveMember(request.UserId);
|
||||
_chatRepository.Update(chat);
|
||||
}
|
||||
else
|
||||
{
|
||||
_chatRepository.Remove(chat);
|
||||
}
|
||||
|
||||
await _uow.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(new SuccessResponse(true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Modules.Chats.Application.Abstractions;
|
||||
using MediatR;
|
||||
using System.Linq;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Chats.Members;
|
||||
|
||||
public record AddMembersCommand(Guid ChatId, Guid UserId, List<Guid> UserIdsToAdd) : ICommand<Guid>;
|
||||
|
||||
internal sealed class AddMembersCommandHandler : ICommandHandler<AddMembersCommand, Guid>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _uow;
|
||||
|
||||
public AddMembersCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(AddMembersCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
|
||||
{
|
||||
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
|
||||
}
|
||||
|
||||
foreach (var userId in request.UserIdsToAdd)
|
||||
{
|
||||
chat.AddMember(userId);
|
||||
}
|
||||
_chatRepository.Update(chat);
|
||||
await _uow.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(request.ChatId);
|
||||
}
|
||||
}
|
||||
|
||||
public record RemoveMemberCommand(Guid ChatId, Guid UserId, Guid UserIdToRemove) : ICommand<Guid>;
|
||||
|
||||
internal sealed class RemoveMemberCommandHandler : ICommandHandler<RemoveMemberCommand, Guid>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _uow;
|
||||
|
||||
public RemoveMemberCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(RemoveMemberCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
|
||||
{
|
||||
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
|
||||
}
|
||||
|
||||
chat.RemoveMember(request.UserIdToRemove);
|
||||
_chatRepository.Update(chat);
|
||||
await _uow.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(request.ChatId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Modules.Chats.Application.Abstractions;
|
||||
using Knot.Modules.Chats.Application.DTOs;
|
||||
using MediatR;
|
||||
using System.Linq;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Chats.TogglePin;
|
||||
|
||||
public record TogglePinCommand(Guid ChatId, Guid UserId) : ICommand<TogglePinResponse>;
|
||||
|
||||
internal sealed class TogglePinCommandHandler : ICommandHandler<TogglePinCommand, TogglePinResponse>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _uow;
|
||||
|
||||
public TogglePinCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<Result<TogglePinResponse>> Handle(TogglePinCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat == null)
|
||||
{
|
||||
return Result.Failure<TogglePinResponse>(ChatErrors.ChatNotFound);
|
||||
}
|
||||
|
||||
var member = chat.Members.FirstOrDefault(m => m.UserId == request.UserId);
|
||||
if (member == null)
|
||||
{
|
||||
return Result.Failure<TogglePinResponse>(ChatErrors.ChatNotFound);
|
||||
}
|
||||
|
||||
member.TogglePin();
|
||||
_chatRepository.Update(chat);
|
||||
await _uow.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(new TogglePinResponse(member.IsPinned));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using Knot.Modules.Chats.Application.Abstractions;
|
||||
using MediatR;
|
||||
using System.Linq;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.Chats.Update;
|
||||
|
||||
public record UpdateChatCommand(Guid ChatId, Guid UserId, string? Name, string? Description) : ICommand<Guid>;
|
||||
|
||||
internal sealed class UpdateChatCommandHandler : ICommandHandler<UpdateChatCommand, Guid>
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _uow;
|
||||
|
||||
public UpdateChatCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(UpdateChatCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId))
|
||||
{
|
||||
return Result.Failure<Guid>(ChatErrors.ChatNotFound);
|
||||
}
|
||||
|
||||
if (request.Name != null)
|
||||
{
|
||||
chat.UpdateName(request.Name);
|
||||
}
|
||||
|
||||
if (request.Description != null)
|
||||
{
|
||||
chat.UpdateDescription(request.Description);
|
||||
}
|
||||
|
||||
_chatRepository.Update(chat);
|
||||
await _uow.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(chat.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.DTOs;
|
||||
|
||||
public sealed record AddMembersRequest(List<Guid> UserIds);
|
||||
16
backend/src/Modules/Chats/Application/DTOs/ChatDto.cs
Normal file
16
backend/src/Modules/Chats/Application/DTOs/ChatDto.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.DTOs;
|
||||
|
||||
public record ChatDto(
|
||||
Guid Id,
|
||||
string Type,
|
||||
string? Name,
|
||||
string? Description,
|
||||
string? Avatar,
|
||||
DateTime CreatedAt,
|
||||
List<ChatMemberDto> Members,
|
||||
List<ChatMessageDto> Messages,
|
||||
int UnreadCount
|
||||
);
|
||||
12
backend/src/Modules/Chats/Application/DTOs/ChatMemberDto.cs
Normal file
12
backend/src/Modules/Chats/Application/DTOs/ChatMemberDto.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Knot.Modules.Chats.Domain;
|
||||
using System;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.DTOs;
|
||||
|
||||
public record ChatMemberDto(
|
||||
Guid Id,
|
||||
Guid UserId,
|
||||
string Role,
|
||||
bool IsPinned,
|
||||
ChatUserDto? User
|
||||
);
|
||||
24
backend/src/Modules/Chats/Application/DTOs/ChatMessageDto.cs
Normal file
24
backend/src/Modules/Chats/Application/DTOs/ChatMessageDto.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.DTOs;
|
||||
|
||||
public record ChatMessageDto(
|
||||
Guid Id,
|
||||
Guid ChatId,
|
||||
Guid SenderId,
|
||||
string? Content,
|
||||
string Type,
|
||||
Guid? ReplyToId,
|
||||
string? Quote,
|
||||
Guid? StoryId,
|
||||
string? StoryMediaUrl,
|
||||
string? StoryMediaType,
|
||||
bool IsEdited,
|
||||
bool IsDeleted,
|
||||
DateTime CreatedAt,
|
||||
List<MediaDto> Media,
|
||||
MessageSenderDto Sender,
|
||||
List<ReactionDto> Reactions,
|
||||
List<ReadByDto> ReadBy
|
||||
);
|
||||
12
backend/src/Modules/Chats/Application/DTOs/ChatUserDto.cs
Normal file
12
backend/src/Modules/Chats/Application/DTOs/ChatUserDto.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.DTOs;
|
||||
|
||||
public record ChatUserDto(
|
||||
Guid Id,
|
||||
string Username,
|
||||
string DisplayName,
|
||||
string? Avatar,
|
||||
bool IsOnline,
|
||||
DateTime LastSeen
|
||||
);
|
||||
@@ -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);
|
||||
11
backend/src/Modules/Chats/Application/DTOs/MediaDto.cs
Normal file
11
backend/src/Modules/Chats/Application/DTOs/MediaDto.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.DTOs;
|
||||
|
||||
public record MediaDto(
|
||||
Guid Id,
|
||||
string Type,
|
||||
string Url,
|
||||
string? Filename,
|
||||
long? Size
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
10
backend/src/Modules/Chats/Application/DTOs/ReactionDto.cs
Normal file
10
backend/src/Modules/Chats/Application/DTOs/ReactionDto.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.DTOs;
|
||||
|
||||
public record ReactionDto(
|
||||
Guid Id,
|
||||
string Emoji,
|
||||
Guid UserId,
|
||||
MessageSenderDto User
|
||||
);
|
||||
7
backend/src/Modules/Chats/Application/DTOs/ReadByDto.cs
Normal file
7
backend/src/Modules/Chats/Application/DTOs/ReadByDto.cs
Normal file
@@ -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);
|
||||
20
backend/src/Modules/Chats/Application/DTOs/SharedMediaDto.cs
Normal file
20
backend/src/Modules/Chats/Application/DTOs/SharedMediaDto.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.DTOs;
|
||||
|
||||
public record SharedMediaDto(
|
||||
Guid Id,
|
||||
string? Content,
|
||||
DateTime CreatedAt,
|
||||
List<string>? Links,
|
||||
MessageSenderDto? Sender,
|
||||
Guid? ReplyToId,
|
||||
string? Quote,
|
||||
Guid? StoryId,
|
||||
string? StoryMediaUrl,
|
||||
string? StoryMediaType,
|
||||
bool? IsEdited,
|
||||
string? Type,
|
||||
List<MediaDto>? Media
|
||||
);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Knot.Modules.Chats.Application.DTOs;
|
||||
|
||||
public record UploadFileResponseDto(
|
||||
string Url,
|
||||
string Filename,
|
||||
long Size
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user