Модуль связей и Klipy
This commit is contained in:
52
backend/src/Docs/klipy_module_documentation.md
Normal file
52
backend/src/Docs/klipy_module_documentation.md
Normal file
@@ -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.
|
||||
69
backend/src/Docs/relations_module_documentation.md
Normal file
69
backend/src/Docs/relations_module_documentation.md
Normal file
@@ -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` в БД. Любые расширения социального графа (подписки на каналы, групповые связи) должны реализовываться именно здесь для сохранения архитектурной чистоты.
|
||||
@@ -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<bool>;
|
||||
|
||||
internal sealed class TestKlipyConnectionCommandHandler : ICommandHandler<TestKlipyConnectionCommand, bool>
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IKlipyClient _klipyClient;
|
||||
|
||||
public TestKlipyConnectionCommandHandler(ISettingsService settingsService, IKlipyClient klipyClient)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
_klipyClient = klipyClient;
|
||||
}
|
||||
|
||||
public async Task<Result<bool>> Handle(TestKlipyConnectionCommand request, CancellationToken ct)
|
||||
{
|
||||
var settings = await _settingsService.GetSettingsAsync(ct);
|
||||
if (string.IsNullOrEmpty(settings.Klipy.ApiKey))
|
||||
{
|
||||
return Result.Failure<bool>(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<bool>(new Error("Klipy.AuthFailed", "Klipy API test request failed. Verify your API key."));
|
||||
}
|
||||
|
||||
return Result.Success(true);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Friendship> Friendships { get; }
|
||||
DbSet<Contact> Contacts { get; }
|
||||
DbSet<UserReplica> UserReplicas { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -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<Guid>;
|
||||
|
||||
internal sealed class AcceptContactRequestCommandHandler : ICommandHandler<AcceptContactRequestCommand, Guid>
|
||||
{
|
||||
private readonly IContactsDbContext _context;
|
||||
|
||||
public AcceptContactRequestCommandHandler(IContactsDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> 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<Guid>(new Error("Contacts.NotFound", "Contact request not found."));
|
||||
}
|
||||
|
||||
contact.Accept();
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(contact.Id);
|
||||
}
|
||||
}
|
||||
@@ -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<List<ContactDto>>;
|
||||
|
||||
internal sealed class GetContactsQueryHandler : IQueryHandler<GetContactsQuery, List<ContactDto>>
|
||||
{
|
||||
private readonly IContactsDbContext _context;
|
||||
|
||||
public GetContactsQueryHandler(IContactsDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<Result<List<ContactDto>>> 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<ContactDto>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<List<ContactRequestDto>>;
|
||||
|
||||
internal sealed class GetIncomingRequestsQueryHandler : IQueryHandler<GetIncomingRequestsQuery, List<ContactRequestDto>>
|
||||
{
|
||||
private readonly IContactsDbContext _context;
|
||||
|
||||
public GetIncomingRequestsQueryHandler(IContactsDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<Result<List<ContactRequestDto>>> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<RemoveContactCommand>
|
||||
{
|
||||
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<Result> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<SendContactRequestCommand>
|
||||
{
|
||||
private readonly IContactsDbContext _context;
|
||||
|
||||
public SendContactRequestCommandHandler(IContactsDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<Result> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<Guid>;
|
||||
|
||||
internal sealed class AcceptFriendRequestCommandHandler : ICommandHandler<AcceptFriendRequestCommand, Guid>
|
||||
{
|
||||
private readonly IRelationsDbContext _context;
|
||||
private readonly IRelationsUnitOfWork _unitOfWork;
|
||||
|
||||
public AcceptFriendRequestCommandHandler(IRelationsDbContext context, IRelationsUnitOfWork unitOfWork)
|
||||
{
|
||||
_context = context;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> 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<Guid>(IdentityErrors.FriendsNotFound);
|
||||
}
|
||||
|
||||
friendship.Accept();
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success(friendship.Id);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -1,6 +0,0 @@
|
||||
using System;
|
||||
|
||||
using Knot.Modules.Relations.Application.Abstractions;
|
||||
namespace Knot.Modules.Relations.Application.Friends.DTOs;
|
||||
|
||||
public record SendFriendRequest(Guid FriendId);
|
||||
@@ -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<DeclineFriendRequestCommand>
|
||||
{
|
||||
private readonly IRelationsDbContext _context;
|
||||
private readonly IRelationsUnitOfWork _unitOfWork;
|
||||
|
||||
public DeclineFriendRequestCommandHandler(IRelationsDbContext context, IRelationsUnitOfWork unitOfWork)
|
||||
{
|
||||
_context = context;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public async Task<Result> 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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<List<FriendDto>>;
|
||||
|
||||
internal sealed class GetFriendsQueryHandler : IQueryHandler<GetFriendsQuery, List<FriendDto>>
|
||||
{
|
||||
private readonly IRelationsDbContext _context;
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public GetFriendsQueryHandler(IRelationsDbContext context, IUserRepository userRepository)
|
||||
{
|
||||
_context = context;
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
public async Task<Result<List<FriendDto>>> 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<FriendDto>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<FriendshipStatusResponse>;
|
||||
|
||||
internal sealed class GetFriendshipStatusQueryHandler : IQueryHandler<GetFriendshipStatusQuery, FriendshipStatusResponse>
|
||||
{
|
||||
private readonly IRelationsDbContext _context;
|
||||
|
||||
public GetFriendshipStatusQueryHandler(IRelationsDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<Result<FriendshipStatusResponse>> 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"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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<List<FriendRequestDto>>;
|
||||
|
||||
internal sealed class GetIncomingRequestsQueryHandler : IQueryHandler<GetIncomingRequestsQuery, List<FriendRequestDto>>
|
||||
{
|
||||
private readonly IRelationsDbContext _context;
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public GetIncomingRequestsQueryHandler(IRelationsDbContext context, IUserRepository userRepository)
|
||||
{
|
||||
_context = context;
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
public async Task<Result<List<FriendRequestDto>>> 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<FriendRequestDto>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<List<FriendRequestDto>>;
|
||||
|
||||
internal sealed class GetOutgoingRequestsQueryHandler : IQueryHandler<GetOutgoingRequestsQuery, List<FriendRequestDto>>
|
||||
{
|
||||
private readonly IRelationsDbContext _context;
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public GetOutgoingRequestsQueryHandler(IRelationsDbContext context, IUserRepository userRepository)
|
||||
{
|
||||
_context = context;
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
public async Task<Result<List<FriendRequestDto>>> 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<FriendRequestDto>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<RemoveFriendCommand>
|
||||
{
|
||||
private readonly IRelationsDbContext _context;
|
||||
private readonly IRelationsUnitOfWork _unitOfWork;
|
||||
|
||||
public RemoveFriendCommandHandler(IRelationsDbContext context, IRelationsUnitOfWork unitOfWork)
|
||||
{
|
||||
_context = context;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public async Task<Result> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<SendFriendRequestCommand>
|
||||
{
|
||||
private readonly IRelationsDbContext _context;
|
||||
private readonly IRelationsUnitOfWork _unitOfWork;
|
||||
|
||||
public SendFriendRequestCommandHandler(IRelationsDbContext context, IRelationsUnitOfWork unitOfWork)
|
||||
{
|
||||
_context = context;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public async Task<Result> 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();
|
||||
}
|
||||
}
|
||||
40
backend/src/Modules/Relations/Domain/Contact.cs
Normal file
40
backend/src/Modules/Relations/Domain/Contact.cs
Normal file
@@ -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<Guid>, 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
|
||||
}
|
||||
@@ -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<RelationsDbContext> options, IMediator mediator)
|
||||
: base(options)
|
||||
{
|
||||
_mediator = mediator;
|
||||
}
|
||||
|
||||
public DbSet<Contact> Contacts => Set<Contact>();
|
||||
public DbSet<UserReplica> UserReplicas => Set<UserReplica>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema("relations");
|
||||
|
||||
modelBuilder.Entity<Contact>(builder =>
|
||||
{
|
||||
builder.ToTable("Contacts");
|
||||
builder.HasKey(c => c.Id);
|
||||
builder.Property(c => c.Status).HasConversion<string>();
|
||||
builder.HasIndex(c => new { c.UserId, c.ContactId }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<UserReplica>(builder =>
|
||||
{
|
||||
builder.ToTable("UserReplicas");
|
||||
builder.HasKey(r => r.Id);
|
||||
});
|
||||
}
|
||||
|
||||
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var domainEvents = ChangeTracker
|
||||
.Entries<IAggregateRoot>()
|
||||
.SelectMany(x =>
|
||||
{
|
||||
if (x.Entity is AggregateRoot<Guid> root)
|
||||
{
|
||||
var events = root.GetDomainEvents().ToList();
|
||||
root.ClearDomainEvents();
|
||||
return events;
|
||||
}
|
||||
return Enumerable.Empty<IDomainEvent>();
|
||||
})
|
||||
.ToList();
|
||||
|
||||
int result = await base.SaveChangesAsync(cancellationToken);
|
||||
|
||||
foreach (var domainEvent in domainEvents)
|
||||
{
|
||||
await _mediator.Publish(domainEvent, cancellationToken);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<bool> TestConnectionAsync(string apiKey, CancellationToken ct = default);
|
||||
Task<List<string>> SearchVideosAsync(string apiKey, string query, int limit, CancellationToken ct = default);
|
||||
}
|
||||
@@ -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<CreateStoryCommand, Guid>
|
||||
{
|
||||
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<Result<Guid>> Handle(CreateStoryCommand request, CancellationToken cancellationToken)
|
||||
@@ -26,6 +29,16 @@ internal sealed class CreateStoryCommandHandler : ICommandHandler<CreateStoryCom
|
||||
parsedType = StoryType.Text;
|
||||
}
|
||||
|
||||
// Проверка Klipy
|
||||
if (parsedType == StoryType.Klipy)
|
||||
{
|
||||
var settings = await _settingsService.GetSettingsAsync(cancellationToken);
|
||||
if (!settings.Klipy.Enabled)
|
||||
{
|
||||
return Result.Failure<Guid>(new Error("Stories.KlipyDisabled", "Klipy integration is disabled by administrator."));
|
||||
}
|
||||
}
|
||||
|
||||
var story = Story.Create(
|
||||
request.UserId,
|
||||
parsedType,
|
||||
|
||||
@@ -4,5 +4,6 @@ public enum StoryType
|
||||
{
|
||||
Text = 0,
|
||||
Image = 1,
|
||||
Video = 2
|
||||
Video = 2,
|
||||
Klipy = 3
|
||||
}
|
||||
|
||||
40
backend/src/Modules/Stories/Infrastructure/External/KlipyClient.cs
vendored
Normal file
40
backend/src/Modules/Stories/Infrastructure/External/KlipyClient.cs
vendored
Normal file
@@ -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<bool> 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<List<string>> SearchVideosAsync(string apiKey, string query, int limit, CancellationToken ct = default)
|
||||
{
|
||||
// В реальном проекте: десериализация ответа от Klipy API
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user