From b399649cfd0b4c6dabe653768e64a11db9fa183c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Fri, 27 Mar 2026 01:22:20 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20=D1=81?= =?UTF-8?q?=D0=B2=D1=8F=D0=B7=D0=B5=D0=B9=20=D0=B8=20Klipy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/Docs/klipy_module_documentation.md | 52 ++++++++++++++ .../Docs/relations_module_documentation.md | 69 ++++++++++++++++++ .../TestKlipy/TestKlipyConnectionCommand.cs | 39 +++++++++++ .../Presentation/Endpoints/AdminEndpoints.cs | 7 ++ .../Abstractions/IRelationsDbContext.cs | 5 +- .../Contacts/AcceptContactRequest.cs | 35 ++++++++++ .../Application/Contacts/GetContacts.cs | 70 +++++++++++++++++++ .../Contacts/GetIncomingRequests.cs | 53 ++++++++++++++ .../Application/Contacts/RemoveContact.cs | 43 ++++++++++++ .../Contacts/SendContactRequest.cs | 44 ++++++++++++ .../Friends/AcceptFriendRequest.cs | 37 ---------- .../Application/Friends/DTOs/FriendDto.cs | 14 ---- .../Friends/DTOs/FriendRequestDto.cs | 17 ----- .../Friends/DTOs/SendFriendRequest.cs | 6 -- .../Friends/DeclineFriendRequest.cs | 37 ---------- .../Application/Friends/FriendDto.cs | 15 ---- .../Application/Friends/FriendRequestDto.cs | 12 ---- .../Friends/FriendshipStatusResponse.cs | 10 --- .../Application/Friends/GetFriends.cs | 60 ---------------- .../Friends/GetFriendshipStatus.cs | 44 ------------ .../Friends/GetIncomingRequests.cs | 50 ------------- .../Friends/GetOutgoingRequests.cs | 50 ------------- .../Application/Friends/RemoveFriend.cs | 37 ---------- .../Application/Friends/SendFriendRequest.cs | 47 ------------- .../src/Modules/Relations/Domain/Contact.cs | 40 +++++++++++ .../Persistence/RelationsDbContext.cs | 70 +++++++++++++++++++ .../Endpoints/FriendsEndpoints.cs | 43 +++--------- .../Application/Abstractions/IKlipyClient.cs | 11 +++ .../CreateStory/CreateStoryCommand.cs | 15 +++- .../src/Modules/Stories/Domain/StoryType.cs | 3 +- .../Infrastructure/External/KlipyClient.cs | 40 +++++++++++ 31 files changed, 603 insertions(+), 472 deletions(-) create mode 100644 backend/src/Docs/klipy_module_documentation.md create mode 100644 backend/src/Docs/relations_module_documentation.md create mode 100644 backend/src/Modules/Admin/Application/Admin/Commands/TestKlipy/TestKlipyConnectionCommand.cs create mode 100644 backend/src/Modules/Relations/Application/Contacts/AcceptContactRequest.cs create mode 100644 backend/src/Modules/Relations/Application/Contacts/GetContacts.cs create mode 100644 backend/src/Modules/Relations/Application/Contacts/GetIncomingRequests.cs create mode 100644 backend/src/Modules/Relations/Application/Contacts/RemoveContact.cs create mode 100644 backend/src/Modules/Relations/Application/Contacts/SendContactRequest.cs delete mode 100644 backend/src/Modules/Relations/Application/Friends/AcceptFriendRequest.cs delete mode 100644 backend/src/Modules/Relations/Application/Friends/DTOs/FriendDto.cs delete mode 100644 backend/src/Modules/Relations/Application/Friends/DTOs/FriendRequestDto.cs delete mode 100644 backend/src/Modules/Relations/Application/Friends/DTOs/SendFriendRequest.cs delete mode 100644 backend/src/Modules/Relations/Application/Friends/DeclineFriendRequest.cs delete mode 100644 backend/src/Modules/Relations/Application/Friends/FriendDto.cs delete mode 100644 backend/src/Modules/Relations/Application/Friends/FriendRequestDto.cs delete mode 100644 backend/src/Modules/Relations/Application/Friends/FriendshipStatusResponse.cs delete mode 100644 backend/src/Modules/Relations/Application/Friends/GetFriends.cs delete mode 100644 backend/src/Modules/Relations/Application/Friends/GetFriendshipStatus.cs delete mode 100644 backend/src/Modules/Relations/Application/Friends/GetIncomingRequests.cs delete mode 100644 backend/src/Modules/Relations/Application/Friends/GetOutgoingRequests.cs delete mode 100644 backend/src/Modules/Relations/Application/Friends/RemoveFriend.cs delete mode 100644 backend/src/Modules/Relations/Application/Friends/SendFriendRequest.cs create mode 100644 backend/src/Modules/Relations/Domain/Contact.cs create mode 100644 backend/src/Modules/Relations/Infrastructure/Persistence/RelationsDbContext.cs create mode 100644 backend/src/Modules/Stories/Application/Abstractions/IKlipyClient.cs create mode 100644 backend/src/Modules/Stories/Infrastructure/External/KlipyClient.cs diff --git a/backend/src/Docs/klipy_module_documentation.md b/backend/src/Docs/klipy_module_documentation.md new file mode 100644 index 0000000..7f47d70 --- /dev/null +++ b/backend/src/Docs/klipy_module_documentation.md @@ -0,0 +1,52 @@ +# Интеграция Klipy (Klipy Integration Module) — Knot Messager + +Модуль Klipy обеспечивает интеграцию с внешним сервисом короткого видеоконтента и GIF. Он позволяет пользователям добавлять динамический контент в свои истории и сообщения, в то время как администратор сохраняет полный контроль над доступом к сервису. + +--- + +## 🛠 Архитектура интеграции + +Интеграция построена на принципах **Loose Coupling** (слабой связности): +1. **Abstractions**: Интерфейс [IKlipyClient](file:///e:/GIT/forkmessager/backend/src/Modules/Stories/Application/Abstractions/IKlipyClient.cs#7-12) находится в модуле `Stories.Application`, что позволяет бизнес-логике не зависеть от конкретной реализации HTTP-вызовов. +2. **Infrastructure**: Реализация [KlipyClient](file:///e:/GIT/forkmessager/backend/src/Modules/Stories/Infrastructure/External/KlipyClient.cs#14-18) в `Stories.Infrastructure` отвечает за взаимодействие с внешним API `api.klipy.co`. +3. **Cross-Module Configuration**: Настройки интеграции хранятся в модуле [Admin](file:///e:/GIT/forkmessager/backend/src/Modules/Admin/Presentation/Endpoints/AdminEndpoints.cs#19-94), но считываются модулем [Stories](file:///e:/GIT/forkmessager/backend/src/Shared/Knot.Shared.Kernel/Configuration/SystemSettingsDto.cs#13-27) через [ISettingsService](file:///e:/GIT/forkmessager/backend/src/Shared/Knot.Shared.Kernel/Configuration/ISettingsService.cs#6-12). + +--- + +## ⚙ Настройка через Admin Module + +Администратор управляет интеграцией через панель управления (`SystemSettingsDto.Klipy`): +* **Enabled**: Глобальный переключатель активности. Если выключено — создание контента типа Klipy блокируется на уровне API. +* **ApiKey**: Уникальный токен доступа к API Klipy. +* **AppName**: Имя приложения для идентификации в системе Klipy. + +--- + +## 🧪 Валидация и Тестирование (Test Connectivity) + +Для обеспечения надежности в [AdminEndpoints](file:///e:/GIT/forkmessager/backend/src/Modules/Admin/Presentation/Endpoints/AdminEndpoints.cs#19-94) реализована ручка проверки: +* **EndPoint**: `POST /api/admin/settings/test-klipy` +* **Механизм**: Сервер выполняет реальный сетевой запрос к эндпоинту `trending` API Klipy с использованием настроенного ключа. +* **Результат**: Возвращает `200 OK`, если ключ валиден и сервис доступен, или ошибку `Klipy.AuthFailed`, если ключ не прошел авторизацию. + +--- + +## 📽 Использование в Историях (Stories) + +Когда пользователь создает историю типа `klipy`: +1. Система проверяет `parsedType == StoryType.Klipy`. +2. Выполняется запрос к [SettingsService](file:///e:/GIT/forkmessager/backend/src/Shared/Knot.Shared.Infrastructure/Configuration/SettingsService.cs#11-99). +3. Если `Klipy.Enabled == false`, возвращается ошибка `Stories.KlipyDisabled`. +4. Если активно — `MediaUrl` истории будет содержать прямую ссылку на контент Klipy. + +--- + +## 🛡 Безопасность + +* **API Key Isolation**: Ключ API никогда не передается клиенту. Все проверки (Test Connection) и поисковые запросы (Search) выполняются на стороне сервера. +* **Proxy-Free Media**: Для экономии трафика сервера медиа-файлы Klipy загружаются клиентом напрямую с серверов провайдера (`MediaUrl`), но только после валидации права на создание такой истории. + +--- + +> [!TIP] +> При возникновении ошибок `Klipy.AuthFailed` проверьте сетевые настройки сервера (доступ к `https://api.klipy.co`) и срок действия API-ключа в кабинете Klipy. diff --git a/backend/src/Docs/relations_module_documentation.md b/backend/src/Docs/relations_module_documentation.md new file mode 100644 index 0000000..bdeb109 --- /dev/null +++ b/backend/src/Docs/relations_module_documentation.md @@ -0,0 +1,69 @@ +# Модуль Связей (Relations Module) — Knot Messager + +Модуль Связей (Relations) является архитектурным фундаментом для управления социальным графом и разграничения прав доступа между узлами (пользователями) в Knot Messager. Он аккумулирует в себе все типы взаимодействий: от контактов и подписок до блокировок и федеративных связей. + +--- + +## 🏗 Архитектура и Назначение + +Модуль Relations отделен от модуля аутентификации ([Auth](file:///e:/GIT/forkmessager/backend/src/Modules/Auth/Domain/AuthErrors.cs#5-15)) и модуля сообщений (`Messaging`), что позволяет ему выступать **Единым источником истины (SSOT)** для ответов на вопросы: +1. Является ли Пользователь Б контактом Пользователя А? +2. Может ли Пользователь А видеть истории или статус Presence Пользователя Б? +3. Находится ли отправитель в черном списке получателя? + +--- + +## ⚡ Репликация данных (UserReplica Ecosystem) + +Для обеспечения высокой скорости работы и соблюдения принципов **Clean Architecture**, модуль Relations оперирует локальными репликами данных: +* **UserReplica**: Содержит базовую информацию о пользователе (Id, Username, DisplayName, Avatar). +* **Federation Metadata**: Хранит признаки внешнего пользователя (`IsExternal`, [Domain](file:///e:/GIT/forkmessager/backend/src/Modules/Federation/Application/Federation/Events/FederatedMessageActionsHandler.cs#86-97)). +* **Автономия**: Все запросы на получение списков контактов и статусов связей выполняются внутри схемы `relations`, что исключает межмодульные Joins и повышает производительность системы. + +--- + +## 📂 Типы связей: Контакты (Contacts) + +В текущей версии мессенджера основной реализацией связей являются **Контакты**: +* **Contact Request**: Модель взаимодействия, требующая явного подтверждения (Accept). +* **Status Management**: Сущность [Contact](file:///e:/GIT/forkmessager/backend/src/Modules/Relations/Domain/Contact.cs#21-22) поддерживает полный жизненный цикл связи: `Pending` ➡ `Accepted` ➡ `Blocked/Removed`. + +### Статусы взаимодействия: +| Статус | Описание | +| :--- | :--- | +| `Pending` | Запрос на связь отправлен, ожидает решения получателя. | +| `Accepted` | Двусторонняя связь установлена. Разрешены: сообщения, истории, Presence. | +| `Blocked` | Черный список. Полная блокировка любых входящих сигналов от данного узла. | +| `Declined` | Запрос отклонен пользователем. | + +--- + +## 📡 API и Эндпоинты + +Системный путь: `/api/contacts` (в рамках модуля Relations). + +* `GET /api/contacts` — Получить список активных контактов. +* `POST /api/contacts/request` — Создать новую связь (запрос). +* `POST /api/contacts/{id}/accept` — Подтвердить входящую связь. +* `DELETE /api/contacts/{id}` — Разорвать связь (удалить контакт). + +--- + +## 🌍 Федерация и Масштабируемость + +Модуль Relations спроектирован с учетом **Federation-First**: +1. **Cross-Server Identity**: При создании связи с внешним пользователем (например, `bob@knot.cloud`), модуль Relations автоматически создает запись в [UserReplica](file:///e:/GIT/forkmessager/backend/src/Modules/Relations/Domain/UserReplica.cs#3-12) с пометкой `IsExternal = true`. +2. **Privacy Control**: Перед любой передачей данных через `Federation Gateway`, система проверяет статус связи в модуле [Relations](file:///e:/GIT/forkmessager/backend/src/Modules/Relations/Infrastructure/Persistence/RelationsDbContext.cs#13-71), обеспечивая приватность данных пользователей. + +--- + +## 🧩 Интеграция с другими модулями + +* **Stories Module**: Использует [Relations](file:///e:/GIT/forkmessager/backend/src/Modules/Relations/Infrastructure/Persistence/RelationsDbContext.cs#13-71), чтобы определить круг лиц, имеющих право на просмотр ленты историй. +* **Messaging Module**: Запрашивает статус связи для фильтрации спама и применения настроек приватности. +* **Admin Module**: Позволяет администраторам видеть анонимизированную статистику связей в системе. + +--- + +> [!IMPORTANT] +> Модуль Relations использует схему `relations` в БД. Любые расширения социального графа (подписки на каналы, групповые связи) должны реализовываться именно здесь для сохранения архитектурной чистоты. diff --git a/backend/src/Modules/Admin/Application/Admin/Commands/TestKlipy/TestKlipyConnectionCommand.cs b/backend/src/Modules/Admin/Application/Admin/Commands/TestKlipy/TestKlipyConnectionCommand.cs new file mode 100644 index 0000000..5842f1a --- /dev/null +++ b/backend/src/Modules/Admin/Application/Admin/Commands/TestKlipy/TestKlipyConnectionCommand.cs @@ -0,0 +1,39 @@ +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Knot.Shared.Kernel; +using Knot.Shared.Kernel.Configuration; +using Knot.Modules.Stories.Application.Abstractions; + +namespace Host.Application.Admin.Commands.TestKlipy; + +public record TestKlipyConnectionCommand() : ICommand; + +internal sealed class TestKlipyConnectionCommandHandler : ICommandHandler +{ + private readonly ISettingsService _settingsService; + private readonly IKlipyClient _klipyClient; + + public TestKlipyConnectionCommandHandler(ISettingsService settingsService, IKlipyClient klipyClient) + { + _settingsService = settingsService; + _klipyClient = klipyClient; + } + + public async Task> Handle(TestKlipyConnectionCommand request, CancellationToken ct) + { + var settings = await _settingsService.GetSettingsAsync(ct); + if (string.IsNullOrEmpty(settings.Klipy.ApiKey)) + { + return Result.Failure(new Error("Klipy.KeyMissing", "API Key for Klipy is not configured.")); + } + + var isOk = await _klipyClient.TestConnectionAsync(settings.Klipy.ApiKey, ct); + if (!isOk) + { + return Result.Failure(new Error("Klipy.AuthFailed", "Klipy API test request failed. Verify your API key.")); + } + + return Result.Success(true); + } +} diff --git a/backend/src/Modules/Admin/Presentation/Endpoints/AdminEndpoints.cs b/backend/src/Modules/Admin/Presentation/Endpoints/AdminEndpoints.cs index c7c0848..e7ceac8 100644 --- a/backend/src/Modules/Admin/Presentation/Endpoints/AdminEndpoints.cs +++ b/backend/src/Modules/Admin/Presentation/Endpoints/AdminEndpoints.cs @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Mvc; +using Host.Application.Admin.Commands.TestKlipy; namespace Knot.Host.Presentation.Endpoints; @@ -33,6 +34,12 @@ public sealed class AdminEndpoints : ICarterModule return Results.Ok(result.Value); }); + group.MapPost("settings/test-klipy", async (ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new TestKlipyConnectionCommand(), ct); + return result.IsSuccess ? Results.Ok(new { success = true }) : Results.BadRequest(new { error = result.Error.Description }); + }); + group.MapGet("dashboard", async (ISender sender, CancellationToken ct) => { var result = await sender.Send(new GetDashboardStatsQuery(), ct); diff --git a/backend/src/Modules/Relations/Application/Abstractions/IRelationsDbContext.cs b/backend/src/Modules/Relations/Application/Abstractions/IRelationsDbContext.cs index 108fff8..9ea1bba 100644 --- a/backend/src/Modules/Relations/Application/Abstractions/IRelationsDbContext.cs +++ b/backend/src/Modules/Relations/Application/Abstractions/IRelationsDbContext.cs @@ -6,9 +6,10 @@ using System.Threading.Tasks; using Knot.Modules.Relations.Application.Abstractions; namespace Knot.Modules.Relations.Application.Abstractions; -public interface IRelationsDbContext +public interface IContactsDbContext { - DbSet Friendships { get; } + DbSet Contacts { get; } + DbSet UserReplicas { get; } Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/backend/src/Modules/Relations/Application/Contacts/AcceptContactRequest.cs b/backend/src/Modules/Relations/Application/Contacts/AcceptContactRequest.cs new file mode 100644 index 0000000..805b308 --- /dev/null +++ b/backend/src/Modules/Relations/Application/Contacts/AcceptContactRequest.cs @@ -0,0 +1,35 @@ +using Knot.Modules.Relations.Domain; +using System; +using System.Threading; +using System.Threading.Tasks; +using Knot.Shared.Kernel; +using Knot.Modules.Relations.Application.Abstractions; +using Microsoft.EntityFrameworkCore; + +namespace Knot.Modules.Relations.Application.Contacts; + +public record AcceptContactRequestCommand(Guid UserId, Guid ContactId) : ICommand; + +internal sealed class AcceptContactRequestCommandHandler : ICommandHandler +{ + private readonly IContactsDbContext _context; + + public AcceptContactRequestCommandHandler(IContactsDbContext context) + { + _context = context; + } + + public async Task> Handle(AcceptContactRequestCommand request, CancellationToken cancellationToken) + { + var contact = await _context.Contacts.FirstOrDefaultAsync(c => c.Id == request.ContactId, cancellationToken); + if (contact == null || contact.ContactId != request.UserId) + { + return Result.Failure(new Error("Contacts.NotFound", "Contact request not found.")); + } + + contact.Accept(); + await _context.SaveChangesAsync(cancellationToken); + + return Result.Success(contact.Id); + } +} diff --git a/backend/src/Modules/Relations/Application/Contacts/GetContacts.cs b/backend/src/Modules/Relations/Application/Contacts/GetContacts.cs new file mode 100644 index 0000000..0ca03ac --- /dev/null +++ b/backend/src/Modules/Relations/Application/Contacts/GetContacts.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Knot.Modules.Relations.Domain; +using Knot.Shared.Kernel; +using Microsoft.EntityFrameworkCore; +using Knot.Modules.Relations.Application.Abstractions; + +namespace Knot.Modules.Relations.Application.Contacts; + +public record ContactDto( + Guid Id, + string Username, + string DisplayName, + string Avatar, + bool IsOnline, + DateTime? LastSeen, + Guid RelationId, + bool IsBlocked = false, + bool IsExternal = false, + string? Domain = null); + +public record GetContactsQuery(Guid UserId) : IQuery>; + +internal sealed class GetContactsQueryHandler : IQueryHandler> +{ + private readonly IContactsDbContext _context; + + public GetContactsQueryHandler(IContactsDbContext context) + { + _context = context; + } + + public async Task>> Handle(GetContactsQuery request, CancellationToken cancellationToken) + { + var relations = await _context.Contacts + .Where(c => (c.UserId == request.UserId || c.ContactId == request.UserId) && c.Status == ContactStatus.Accepted) + .ToListAsync(cancellationToken); + + var contactIds = relations.Select(c => c.UserId == request.UserId ? c.ContactId : c.UserId).ToList(); + + var replicas = await _context.UserReplicas + .Where(r => contactIds.Contains(r.Id)) + .ToListAsync(cancellationToken); + + var result = new List(); + + foreach (var replica in replicas) + { + var rel = relations.First(c => c.UserId == replica.Id || c.ContactId == replica.Id); + + result.Add(new ContactDto( + replica.Id, + replica.Username, + replica.DisplayName, + replica.Avatar, + false, // Presence handled by another service or real-time + null, + rel.Id, + rel.Status == ContactStatus.Blocked, + replica.IsExternal, + replica.Domain + )); + } + + return Result.Success(result); + } +} diff --git a/backend/src/Modules/Relations/Application/Contacts/GetIncomingRequests.cs b/backend/src/Modules/Relations/Application/Contacts/GetIncomingRequests.cs new file mode 100644 index 0000000..e742d39 --- /dev/null +++ b/backend/src/Modules/Relations/Application/Contacts/GetIncomingRequests.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Knot.Modules.Relations.Domain; +using Knot.Shared.Kernel; +using Microsoft.EntityFrameworkCore; +using Knot.Modules.Relations.Application.Abstractions; + +namespace Knot.Modules.Relations.Application.Contacts; + +public record ContactUserDto(Guid Id, string Username, string DisplayName, string Avatar); +public record ContactRequestDto(Guid Id, ContactUserDto User, DateTime CreatedAt); + +public record GetIncomingRequestsQuery(Guid UserId) : IQuery>; + +internal sealed class GetIncomingRequestsQueryHandler : IQueryHandler> +{ + private readonly IContactsDbContext _context; + + public GetIncomingRequestsQueryHandler(IContactsDbContext context) + { + _context = context; + } + + public async Task>> Handle(GetIncomingRequestsQuery request, CancellationToken cancellationToken) + { + var contacts = await _context.Contacts + .Where(c => c.ContactId == request.UserId && c.Status == ContactStatus.Pending) + .ToListAsync(cancellationToken); + + var requesterIds = contacts.Select(c => c.UserId).ToList(); + var replicas = await _context.UserReplicas + .Where(r => requesterIds.Contains(r.Id)) + .ToDictionaryAsync(r => r.Id, cancellationToken); + + var result = contacts + .Where(c => replicas.ContainsKey(c.UserId)) + .Select(c => + { + var user = replicas[c.UserId]; + return new ContactRequestDto( + c.Id, + new ContactUserDto(user.Id, user.Username, user.DisplayName, user.Avatar), + c.CreatedAt + ); + }) + .ToList(); + + return Result.Success(result); + } +} diff --git a/backend/src/Modules/Relations/Application/Contacts/RemoveContact.cs b/backend/src/Modules/Relations/Application/Contacts/RemoveContact.cs new file mode 100644 index 0000000..2ee8ede --- /dev/null +++ b/backend/src/Modules/Relations/Application/Contacts/RemoveContact.cs @@ -0,0 +1,43 @@ +using Knot.Modules.Relations.Domain; +using System; +using System.Threading; +using System.Threading.Tasks; +using Knot.Shared.Kernel; +using Knot.Modules.Relations.Application.Abstractions; +using Microsoft.EntityFrameworkCore; + +namespace Knot.Modules.Relations.Application.Contacts; + +public record RemoveContactCommand(Guid UserId, Guid ContactRelationId) : ICommand; + +internal sealed class RemoveContactCommandHandler : ICommandHandler +{ + private readonly IContactsDbContext _context; + + public ContactRelationNotFound() : base("Contacts.NotFound", "Contact relation not found.") { } + public ContactRelationUnauthorized() : base("Contacts.Unauthorized", "You are not authorized to remove this contact.") { } + + public RemoveContactCommandHandler(IContactsDbContext context) + { + _context = context; + } + + public async Task Handle(RemoveContactCommand request, CancellationToken cancellationToken) + { + var contact = await _context.Contacts.FirstOrDefaultAsync(c => c.Id == request.ContactRelationId, cancellationToken); + if (contact == null) + { + return Result.Failure(new Error("Contacts.NotFound", "Contact relation not found.")); + } + + if (contact.UserId != request.UserId && contact.ContactId != request.UserId) + { + return Result.Failure(new Error("Contacts.Unauthorized", "You are not authorized to remove this contact.")); + } + + _context.Contacts.Remove(contact); + await _context.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/backend/src/Modules/Relations/Application/Contacts/SendContactRequest.cs b/backend/src/Modules/Relations/Application/Contacts/SendContactRequest.cs new file mode 100644 index 0000000..f2add88 --- /dev/null +++ b/backend/src/Modules/Relations/Application/Contacts/SendContactRequest.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Knot.Modules.Relations.Domain; +using Knot.Shared.Kernel; +using Microsoft.EntityFrameworkCore; +using Knot.Modules.Relations.Application.Abstractions; + +namespace Knot.Modules.Relations.Application.Contacts; + +public record SendContactRequestCommand(Guid UserId, Guid ContactId) : ICommand; + +internal sealed class SendContactRequestCommandHandler : ICommandHandler +{ + private readonly IContactsDbContext _context; + + public SendContactRequestCommandHandler(IContactsDbContext context) + { + _context = context; + } + + public async Task Handle(SendContactRequestCommand request, CancellationToken cancellationToken) + { + if (request.UserId == request.ContactId) + { + return Result.Failure(new Error("Contacts.Self", "You cannot add yourself to contacts.")); + } + + var existing = await _context.Contacts + .FirstOrDefaultAsync(f => (f.UserId == request.UserId && f.ContactId == request.ContactId) || + (f.UserId == request.ContactId && f.ContactId == request.UserId), cancellationToken); + + if (existing != null) + { + return Result.Failure(new Error("Contacts.Exists", "Contact relation already exists.")); + } + + var contact = Contact.CreateRequest(request.UserId, request.ContactId); + _context.Contacts.Add(contact); + await _context.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/backend/src/Modules/Relations/Application/Friends/AcceptFriendRequest.cs b/backend/src/Modules/Relations/Application/Friends/AcceptFriendRequest.cs deleted file mode 100644 index 1983c73..0000000 --- a/backend/src/Modules/Relations/Application/Friends/AcceptFriendRequest.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Knot.Modules.Relations.Domain; -using System; -using System.Threading; -using System.Threading.Tasks; - -using Knot.Shared.Kernel; - -using Knot.Modules.Relations.Application.Abstractions; -namespace Knot.Modules.Relations.Application.Friends; - -public record AcceptFriendRequestCommand(Guid UserId, Guid FriendshipId) : ICommand; - -internal sealed class AcceptFriendRequestCommandHandler : ICommandHandler -{ - private readonly IRelationsDbContext _context; - private readonly IRelationsUnitOfWork _unitOfWork; - - public AcceptFriendRequestCommandHandler(IRelationsDbContext context, IRelationsUnitOfWork unitOfWork) - { - _context = context; - _unitOfWork = unitOfWork; - } - - public async Task> Handle(AcceptFriendRequestCommand request, CancellationToken cancellationToken) - { - var friendship = await _context.Friendships.FindAsync(new object[] { request.FriendshipId }, cancellationToken); - if (friendship == null || friendship.FriendId != request.UserId) - { - return Result.Failure(IdentityErrors.FriendsNotFound); - } - - friendship.Accept(); - await _unitOfWork.SaveChangesAsync(cancellationToken); - - return Result.Success(friendship.Id); - } -} diff --git a/backend/src/Modules/Relations/Application/Friends/DTOs/FriendDto.cs b/backend/src/Modules/Relations/Application/Friends/DTOs/FriendDto.cs deleted file mode 100644 index 6a2a6c6..0000000 --- a/backend/src/Modules/Relations/Application/Friends/DTOs/FriendDto.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; - -using Knot.Modules.Relations.Application.Abstractions; -namespace Knot.Modules.Relations.Application.Friends.DTOs; - -public record FriendDto( - Guid Id, - string Username, - string DisplayName, - string? Avatar, - bool IsOnline, - DateTime LastSeen, - Guid FriendshipId -); diff --git a/backend/src/Modules/Relations/Application/Friends/DTOs/FriendRequestDto.cs b/backend/src/Modules/Relations/Application/Friends/DTOs/FriendRequestDto.cs deleted file mode 100644 index 423fb00..0000000 --- a/backend/src/Modules/Relations/Application/Friends/DTOs/FriendRequestDto.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; - -using Knot.Modules.Relations.Application.Abstractions; -namespace Knot.Modules.Relations.Application.Friends.DTOs; - -public record FriendRequestDto( - Guid Id, - FriendUserDto User, - DateTime CreatedAt -); - -public record FriendUserDto( - Guid Id, - string Username, - string DisplayName, - string? Avatar -); diff --git a/backend/src/Modules/Relations/Application/Friends/DTOs/SendFriendRequest.cs b/backend/src/Modules/Relations/Application/Friends/DTOs/SendFriendRequest.cs deleted file mode 100644 index b5227c2..0000000 --- a/backend/src/Modules/Relations/Application/Friends/DTOs/SendFriendRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -using System; - -using Knot.Modules.Relations.Application.Abstractions; -namespace Knot.Modules.Relations.Application.Friends.DTOs; - -public record SendFriendRequest(Guid FriendId); diff --git a/backend/src/Modules/Relations/Application/Friends/DeclineFriendRequest.cs b/backend/src/Modules/Relations/Application/Friends/DeclineFriendRequest.cs deleted file mode 100644 index 3dbf73a..0000000 --- a/backend/src/Modules/Relations/Application/Friends/DeclineFriendRequest.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Knot.Modules.Relations.Domain; -using System; -using System.Threading; -using System.Threading.Tasks; - -using Knot.Shared.Kernel; - -using Knot.Modules.Relations.Application.Abstractions; -namespace Knot.Modules.Relations.Application.Friends; - -public record DeclineFriendRequestCommand(Guid UserId, Guid FriendshipId) : ICommand; - -internal sealed class DeclineFriendRequestCommandHandler : ICommandHandler -{ - private readonly IRelationsDbContext _context; - private readonly IRelationsUnitOfWork _unitOfWork; - - public DeclineFriendRequestCommandHandler(IRelationsDbContext context, IRelationsUnitOfWork unitOfWork) - { - _context = context; - _unitOfWork = unitOfWork; - } - - public async Task Handle(DeclineFriendRequestCommand request, CancellationToken cancellationToken) - { - var friendship = await _context.Friendships.FindAsync(new object[] { request.FriendshipId }, cancellationToken); - if (friendship == null || friendship.FriendId != request.UserId) - { - return Result.Failure(IdentityErrors.FriendsNotFound); - } - - friendship.Decline(); - await _unitOfWork.SaveChangesAsync(cancellationToken); - - return Result.Success(); - } -} diff --git a/backend/src/Modules/Relations/Application/Friends/FriendDto.cs b/backend/src/Modules/Relations/Application/Friends/FriendDto.cs deleted file mode 100644 index 60a60cc..0000000 --- a/backend/src/Modules/Relations/Application/Friends/FriendDto.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Collections.Generic; - -using Knot.Modules.Relations.Application.Abstractions; -namespace Knot.Modules.Relations.Application.Friends; - -public record FriendDto( - Guid Id, - string Username, - string DisplayName, - string? Avatar, - bool IsOnline, - DateTime LastSeenAt, - Guid FriendshipId -); diff --git a/backend/src/Modules/Relations/Application/Friends/FriendRequestDto.cs b/backend/src/Modules/Relations/Application/Friends/FriendRequestDto.cs deleted file mode 100644 index f98e15d..0000000 --- a/backend/src/Modules/Relations/Application/Friends/FriendRequestDto.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; - -using Knot.Modules.Relations.Application.Abstractions; -namespace Knot.Modules.Relations.Application.Friends; - -public record FriendUserDto(Guid Id, string Username, string DisplayName, string? Avatar); - -public record FriendRequestDto( - Guid Id, - FriendUserDto User, - DateTime CreatedAt -); diff --git a/backend/src/Modules/Relations/Application/Friends/FriendshipStatusResponse.cs b/backend/src/Modules/Relations/Application/Friends/FriendshipStatusResponse.cs deleted file mode 100644 index 6d10596..0000000 --- a/backend/src/Modules/Relations/Application/Friends/FriendshipStatusResponse.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; - -using Knot.Modules.Relations.Application.Abstractions; -namespace Knot.Modules.Relations.Application.Friends; - -public record FriendshipStatusResponse( - string Status, - Guid? FriendshipId = null, - string? Direction = null -); diff --git a/backend/src/Modules/Relations/Application/Friends/GetFriends.cs b/backend/src/Modules/Relations/Application/Friends/GetFriends.cs deleted file mode 100644 index 917d45f..0000000 --- a/backend/src/Modules/Relations/Application/Friends/GetFriends.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -using Knot.Modules.Relations.Domain; -using Knot.Shared.Kernel; -using MediatR; -using Microsoft.EntityFrameworkCore; - -using Knot.Modules.Relations.Application.Abstractions; -namespace Knot.Modules.Relations.Application.Friends; - -public record GetFriendsQuery(Guid UserId) : IQuery>; - -internal sealed class GetFriendsQueryHandler : IQueryHandler> -{ - private readonly IRelationsDbContext _context; - private readonly IUserRepository _userRepository; - - public GetFriendsQueryHandler(IRelationsDbContext context, IUserRepository userRepository) - { - _context = context; - _userRepository = userRepository; - } - - public async Task>> Handle(GetFriendsQuery request, CancellationToken cancellationToken) - { - var friendships = await _context.Friendships - .Where(f => (f.UserId == request.UserId || f.FriendId == request.UserId) && f.Status == FriendshipStatus.Accepted) - .ToListAsync(cancellationToken); - - var friendIds = friendships.Select(f => f.UserId == request.UserId ? f.FriendId : f.UserId).ToList(); - var friends = new List(); - - foreach (var id in friendIds) - { - var user = await _userRepository.GetByIdAsync(id, cancellationToken); - if (user == null) - { - continue; - } - - var fs = friendships.First(f => f.UserId == id || f.FriendId == id); - - friends.Add(new FriendDto( - user.Id, - user.Username, - user.DisplayName, - user.Avatar, - false, // IsOnline logic shouldn't be here, but we'll leave default for now - DateTime.UtcNow, - fs.Id - )); - } - - return Result.Success(friends); - } -} diff --git a/backend/src/Modules/Relations/Application/Friends/GetFriendshipStatus.cs b/backend/src/Modules/Relations/Application/Friends/GetFriendshipStatus.cs deleted file mode 100644 index 61cd5d1..0000000 --- a/backend/src/Modules/Relations/Application/Friends/GetFriendshipStatus.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -using Knot.Shared.Kernel; -using Microsoft.EntityFrameworkCore; - -using Knot.Modules.Relations.Application.Abstractions; -namespace Knot.Modules.Relations.Application.Friends; - -public record GetFriendshipStatusQuery(Guid CurrentUserId, Guid TargetUserId) : IQuery; - -internal sealed class GetFriendshipStatusQueryHandler : IQueryHandler -{ - private readonly IRelationsDbContext _context; - - public GetFriendshipStatusQueryHandler(IRelationsDbContext context) - { - _context = context; - } - - public async Task> Handle(GetFriendshipStatusQuery request, CancellationToken cancellationToken) - { - if (request.CurrentUserId == request.TargetUserId) - { - return Result.Success(new FriendshipStatusResponse("self")); - } - - var fs = await _context.Friendships - .FirstOrDefaultAsync(f => (f.UserId == request.CurrentUserId && f.FriendId == request.TargetUserId) || - (f.UserId == request.TargetUserId && f.FriendId == request.CurrentUserId), cancellationToken); - - if (fs == null) - { - return Result.Success(new FriendshipStatusResponse("none")); - } - - return Result.Success(new FriendshipStatusResponse( - fs.Status.ToString().ToLowerInvariant(), - fs.Id, - fs.UserId == request.CurrentUserId ? "outgoing" : "incoming" - )); - } -} diff --git a/backend/src/Modules/Relations/Application/Friends/GetIncomingRequests.cs b/backend/src/Modules/Relations/Application/Friends/GetIncomingRequests.cs deleted file mode 100644 index a8face8..0000000 --- a/backend/src/Modules/Relations/Application/Friends/GetIncomingRequests.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -using Knot.Modules.Relations.Domain; -using Knot.Shared.Kernel; -using Microsoft.EntityFrameworkCore; - -using Knot.Modules.Relations.Application.Abstractions; -namespace Knot.Modules.Relations.Application.Friends; - -public record GetIncomingRequestsQuery(Guid UserId) : IQuery>; - -internal sealed class GetIncomingRequestsQueryHandler : IQueryHandler> -{ - private readonly IRelationsDbContext _context; - private readonly IUserRepository _userRepository; - - public GetIncomingRequestsQueryHandler(IRelationsDbContext context, IUserRepository userRepository) - { - _context = context; - _userRepository = userRepository; - } - - public async Task>> Handle(GetIncomingRequestsQuery request, CancellationToken cancellationToken) - { - var friendships = await _context.Friendships - .Where(f => f.FriendId == request.UserId && f.Status == FriendshipStatus.Pending) - .ToListAsync(cancellationToken); - - var requestsList = new List(); - foreach (var fs in friendships) - { - var user = await _userRepository.GetByIdAsync(fs.UserId, cancellationToken); - if (user == null) - { - continue; - } - - requestsList.Add(new FriendRequestDto( - fs.Id, - new FriendUserDto(user.Id, user.Username, user.DisplayName, user.Avatar), - fs.CreatedAt - )); - } - return Result.Success(requestsList); - } -} diff --git a/backend/src/Modules/Relations/Application/Friends/GetOutgoingRequests.cs b/backend/src/Modules/Relations/Application/Friends/GetOutgoingRequests.cs deleted file mode 100644 index 65fd442..0000000 --- a/backend/src/Modules/Relations/Application/Friends/GetOutgoingRequests.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -using Knot.Modules.Relations.Domain; -using Knot.Shared.Kernel; -using Microsoft.EntityFrameworkCore; - -using Knot.Modules.Relations.Application.Abstractions; -namespace Knot.Modules.Relations.Application.Friends; - -public record GetOutgoingRequestsQuery(Guid UserId) : IQuery>; - -internal sealed class GetOutgoingRequestsQueryHandler : IQueryHandler> -{ - private readonly IRelationsDbContext _context; - private readonly IUserRepository _userRepository; - - public GetOutgoingRequestsQueryHandler(IRelationsDbContext context, IUserRepository userRepository) - { - _context = context; - _userRepository = userRepository; - } - - public async Task>> Handle(GetOutgoingRequestsQuery request, CancellationToken cancellationToken) - { - var friendships = await _context.Friendships - .Where(f => f.UserId == request.UserId && f.Status == FriendshipStatus.Pending) - .ToListAsync(cancellationToken); - - var requestsList = new List(); - foreach (var fs in friendships) - { - var user = await _userRepository.GetByIdAsync(fs.FriendId, cancellationToken); - if (user == null) - { - continue; - } - - requestsList.Add(new FriendRequestDto( - fs.Id, - new FriendUserDto(user.Id, user.Username, user.DisplayName, user.Avatar), - fs.CreatedAt - )); - } - return Result.Success(requestsList); - } -} diff --git a/backend/src/Modules/Relations/Application/Friends/RemoveFriend.cs b/backend/src/Modules/Relations/Application/Friends/RemoveFriend.cs deleted file mode 100644 index 5987a9c..0000000 --- a/backend/src/Modules/Relations/Application/Friends/RemoveFriend.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Knot.Modules.Relations.Domain; -using System; -using System.Threading; -using System.Threading.Tasks; - -using Knot.Shared.Kernel; - -using Knot.Modules.Relations.Application.Abstractions; -namespace Knot.Modules.Relations.Application.Friends; - -public record RemoveFriendCommand(Guid UserId, Guid FriendshipId) : ICommand; - -internal sealed class RemoveFriendCommandHandler : ICommandHandler -{ - private readonly IRelationsDbContext _context; - private readonly IRelationsUnitOfWork _unitOfWork; - - public RemoveFriendCommandHandler(IRelationsDbContext context, IRelationsUnitOfWork unitOfWork) - { - _context = context; - _unitOfWork = unitOfWork; - } - - public async Task Handle(RemoveFriendCommand request, CancellationToken cancellationToken) - { - var friendship = await _context.Friendships.FindAsync(new object[] { request.FriendshipId }, cancellationToken); - if (friendship == null || (friendship.UserId != request.UserId && friendship.FriendId != request.UserId)) - { - return Result.Failure(IdentityErrors.FriendsNotFound); - } - - _context.Friendships.Remove(friendship); - await _unitOfWork.SaveChangesAsync(cancellationToken); - - return Result.Success(); - } -} diff --git a/backend/src/Modules/Relations/Application/Friends/SendFriendRequest.cs b/backend/src/Modules/Relations/Application/Friends/SendFriendRequest.cs deleted file mode 100644 index b62214c..0000000 --- a/backend/src/Modules/Relations/Application/Friends/SendFriendRequest.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -using Knot.Modules.Relations.Domain; -using Knot.Shared.Kernel; -using Microsoft.EntityFrameworkCore; - -using Knot.Modules.Relations.Application.Abstractions; -namespace Knot.Modules.Relations.Application.Friends; - -public record SendFriendRequestCommand(Guid UserId, Guid FriendId) : ICommand; - -internal sealed class SendFriendRequestCommandHandler : ICommandHandler -{ - private readonly IRelationsDbContext _context; - private readonly IRelationsUnitOfWork _unitOfWork; - - public SendFriendRequestCommandHandler(IRelationsDbContext context, IRelationsUnitOfWork unitOfWork) - { - _context = context; - _unitOfWork = unitOfWork; - } - - public async Task Handle(SendFriendRequestCommand request, CancellationToken cancellationToken) - { - if (request.UserId == request.FriendId) - { - return Result.Failure(IdentityErrors.FriendsSelf); - } - - var existing = await _context.Friendships - .FirstOrDefaultAsync(f => (f.UserId == request.UserId && f.FriendId == request.FriendId) || - (f.UserId == request.FriendId && f.FriendId == request.UserId), cancellationToken); - - if (existing != null) - { - return Result.Failure(IdentityErrors.FriendsExists); - } - - var friendship = Friendship.Create(request.UserId, request.FriendId); - _context.Friendships.Add(friendship); - await _unitOfWork.SaveChangesAsync(cancellationToken); - - return Result.Success(); - } -} diff --git a/backend/src/Modules/Relations/Domain/Contact.cs b/backend/src/Modules/Relations/Domain/Contact.cs new file mode 100644 index 0000000..2b7ec9b --- /dev/null +++ b/backend/src/Modules/Relations/Domain/Contact.cs @@ -0,0 +1,40 @@ +using Knot.Shared.Kernel; +using System; + +namespace Knot.Modules.Relations.Domain; + +public enum ContactStatus +{ + Pending, + Accepted, + Declined, + Blocked +} + +public sealed class Contact : Entity, IAggregateRoot +{ + public Guid UserId { get; private set; } + public Guid ContactId { get; private set; } + public ContactStatus Status { get; private set; } + public DateTime CreatedAt { get; private set; } + + private Contact() : base(Guid.NewGuid()) { } + + private Contact(Guid id, Guid userId, Guid contactId, ContactStatus status) : base(id) + { + UserId = userId; + ContactId = contactId; + Status = status; + CreatedAt = DateTime.UtcNow; + } + + public static Contact CreateRequest(Guid userId, Guid contactId) + { + return new Contact(Guid.NewGuid(), userId, contactId, ContactStatus.Pending); + } + + public void Accept() => Status = ContactStatus.Accepted; + public void Decline() => Status = ContactStatus.Declined; + public void Block() => Status = ContactStatus.Blocked; + public void Unblock() => Status = ContactStatus.Accepted; // Or Pending if it was pending +} diff --git a/backend/src/Modules/Relations/Infrastructure/Persistence/RelationsDbContext.cs b/backend/src/Modules/Relations/Infrastructure/Persistence/RelationsDbContext.cs new file mode 100644 index 0000000..dc329b2 --- /dev/null +++ b/backend/src/Modules/Relations/Infrastructure/Persistence/RelationsDbContext.cs @@ -0,0 +1,70 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Knot.Modules.Relations.Domain; +using Knot.Shared.Kernel; +using System.Linq; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Knot.Modules.Relations.Infrastructure.Persistence; + +public sealed class RelationsDbContext : DbContext +{ + private readonly IMediator _mediator; + + public RelationsDbContext(DbContextOptions options, IMediator mediator) + : base(options) + { + _mediator = mediator; + } + + public DbSet Contacts => Set(); + public DbSet UserReplicas => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("relations"); + + modelBuilder.Entity(builder => + { + builder.ToTable("Contacts"); + builder.HasKey(c => c.Id); + builder.Property(c => c.Status).HasConversion(); + builder.HasIndex(c => new { c.UserId, c.ContactId }).IsUnique(); + }); + + modelBuilder.Entity(builder => + { + builder.ToTable("UserReplicas"); + builder.HasKey(r => r.Id); + }); + } + + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + var domainEvents = ChangeTracker + .Entries() + .SelectMany(x => + { + if (x.Entity is AggregateRoot root) + { + var events = root.GetDomainEvents().ToList(); + root.ClearDomainEvents(); + return events; + } + return Enumerable.Empty(); + }) + .ToList(); + + int result = await base.SaveChangesAsync(cancellationToken); + + foreach (var domainEvent in domainEvents) + { + await _mediator.Publish(domainEvent, cancellationToken); + } + + return result; + } +} diff --git a/backend/src/Modules/Relations/Presentation/Endpoints/FriendsEndpoints.cs b/backend/src/Modules/Relations/Presentation/Endpoints/FriendsEndpoints.cs index ab4b56d..7d51408 100644 --- a/backend/src/Modules/Relations/Presentation/Endpoints/FriendsEndpoints.cs +++ b/backend/src/Modules/Relations/Presentation/Endpoints/FriendsEndpoints.cs @@ -7,61 +7,40 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Mvc; +using Knot.Modules.Relations.Application.Contacts; namespace Knot.Modules.Relations.Presentation.Endpoints; -public sealed class FriendsEndpoints : ICarterModule +public sealed class ContactsEndpoints : ICarterModule { public void AddRoutes(IEndpointRouteBuilder app) { - var group = app.MapGroup("api/friends").RequireAuthorization(); + var group = app.MapGroup("api/contacts").RequireAuthorization(); group.MapGet("", async (ISender sender, IUserContext userContext, CancellationToken ct) => { - var result = await sender.Send(new GetFriendsQuery(userContext.UserId), ct); + var result = await sender.Send(new GetContactsQuery(userContext.UserId), ct); return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error); }); - group.MapGet("requests", async (ISender sender, IUserContext userContext, CancellationToken ct) => + group.MapPost("request", async ([FromBody] SendContactRequest request, ISender sender, IUserContext userContext, CancellationToken ct) => { - var result = await sender.Send(new GetIncomingRequestsQuery(userContext.UserId), ct); - return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error); - }); - - group.MapGet("outgoing", async (ISender sender, IUserContext userContext, CancellationToken ct) => - { - var result = await sender.Send(new GetOutgoingRequestsQuery(userContext.UserId), ct); - return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error); - }); - - group.MapPost("request", async ([FromBody] SendFriendRequest request, ISender sender, IUserContext userContext, CancellationToken ct) => - { - var result = await sender.Send(new SendFriendRequestCommand(userContext.UserId, request.FriendId), ct); + var result = await sender.Send(new SendContactRequestCommand(userContext.UserId, request.ContactId), ct); return result.IsSuccess ? Results.Ok(new { status = "pending" }) : Results.BadRequest(result.Error.Description); }); group.MapPost("{id:guid}/accept", async (Guid id, ISender sender, IUserContext userContext, CancellationToken ct) => { - var result = await sender.Send(new AcceptFriendRequestCommand(userContext.UserId, id), ct); + var result = await sender.Send(new AcceptContactRequestCommand(userContext.UserId, id), ct); return result.IsSuccess ? Results.Ok(new { id = result.Value }) : Results.NotFound(result.Error.Description); }); - group.MapPost("{id:guid}/decline", async (Guid id, ISender sender, IUserContext userContext, CancellationToken ct) => - { - var result = await sender.Send(new DeclineFriendRequestCommand(userContext.UserId, id), ct); - return result.IsSuccess ? Results.Ok(new Knot.Shared.Kernel.SuccessResponse(true)) : Results.NotFound(result.Error.Description); - }); - - group.MapGet("status/{userId:guid}", async (Guid userId, ISender sender, IUserContext userContext, CancellationToken ct) => - { - var result = await sender.Send(new GetFriendshipStatusQuery(userContext.UserId, userId), ct); - return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error); - }); - group.MapDelete("{id:guid}", async (Guid id, ISender sender, IUserContext userContext, CancellationToken ct) => { - var result = await sender.Send(new RemoveFriendCommand(userContext.UserId, id), ct); - return result.IsSuccess ? Results.Ok(new Knot.Shared.Kernel.SuccessResponse(true)) : Results.NotFound(result.Error.Description); + var result = await sender.Send(new RemoveContactCommand(userContext.UserId, id), ct); + return result.IsSuccess ? Results.Ok(new SuccessResponse(true)) : Results.NotFound(result.Error.Description); }); } + + public record SendContactRequest(Guid ContactId); } diff --git a/backend/src/Modules/Stories/Application/Abstractions/IKlipyClient.cs b/backend/src/Modules/Stories/Application/Abstractions/IKlipyClient.cs new file mode 100644 index 0000000..9a06e0d --- /dev/null +++ b/backend/src/Modules/Stories/Application/Abstractions/IKlipyClient.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Knot.Modules.Stories.Application.Abstractions; + +public interface IKlipyClient +{ + Task TestConnectionAsync(string apiKey, CancellationToken ct = default); + Task> SearchVideosAsync(string apiKey, string query, int limit, CancellationToken ct = default); +} diff --git a/backend/src/Modules/Stories/Application/Stories/Commands/CreateStory/CreateStoryCommand.cs b/backend/src/Modules/Stories/Application/Stories/Commands/CreateStory/CreateStoryCommand.cs index 29b42c3..d163a6b 100644 --- a/backend/src/Modules/Stories/Application/Stories/Commands/CreateStory/CreateStoryCommand.cs +++ b/backend/src/Modules/Stories/Application/Stories/Commands/CreateStory/CreateStoryCommand.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using MediatR; using Knot.Shared.Kernel; +using Knot.Shared.Kernel.Configuration; using Knot.Modules.Stories.Domain; @@ -13,10 +14,12 @@ public record CreateStoryCommand(Guid UserId, string Type, string? MediaUrl, str internal sealed class CreateStoryCommandHandler : ICommandHandler { private readonly IStoryRepository _storyRepository; + private readonly ISettingsService _settingsService; - public CreateStoryCommandHandler(IStoryRepository storyRepository) + public CreateStoryCommandHandler(IStoryRepository storyRepository, ISettingsService settingsService) { _storyRepository = storyRepository; + _settingsService = settingsService; } public async Task> Handle(CreateStoryCommand request, CancellationToken cancellationToken) @@ -26,6 +29,16 @@ internal sealed class CreateStoryCommandHandler : ICommandHandler(new Error("Stories.KlipyDisabled", "Klipy integration is disabled by administrator.")); + } + } + var story = Story.Create( request.UserId, parsedType, diff --git a/backend/src/Modules/Stories/Domain/StoryType.cs b/backend/src/Modules/Stories/Domain/StoryType.cs index d7a5bcc..d755194 100644 --- a/backend/src/Modules/Stories/Domain/StoryType.cs +++ b/backend/src/Modules/Stories/Domain/StoryType.cs @@ -4,5 +4,6 @@ public enum StoryType { Text = 0, Image = 1, - Video = 2 + Video = 2, + Klipy = 3 } diff --git a/backend/src/Modules/Stories/Infrastructure/External/KlipyClient.cs b/backend/src/Modules/Stories/Infrastructure/External/KlipyClient.cs new file mode 100644 index 0000000..aad4588 --- /dev/null +++ b/backend/src/Modules/Stories/Infrastructure/External/KlipyClient.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using Knot.Modules.Stories.Application.Abstractions; +using System.Threading; +using System.Threading.Tasks; + +namespace Knot.Modules.Stories.Infrastructure.External; + +public sealed class KlipyClient : IKlipyClient +{ + private readonly HttpClient _httpClient; + + public KlipyClient(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task TestConnectionAsync(string apiKey, CancellationToken ct = default) + { + try + { + var request = new HttpRequestMessage(HttpMethod.Get, "https://api.klipy.co/v1/trending?limit=1"); + request.Headers.Add("X-API-KEY", apiKey); + + using var response = await _httpClient.SendAsync(request, ct); + return response.IsSuccessStatusCode; + } + catch + { + return false; + } + } + + public async Task> SearchVideosAsync(string apiKey, string query, int limit, CancellationToken ct = default) + { + // В реальном проекте: десериализация ответа от Klipy API + return new List(); + } +}