Модуль связей и Klipy
This commit is contained in:
@@ -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.
|
||||||
@@ -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` в БД. Любые расширения социального графа (подписки на каналы, групповые связи) должны реализовываться именно здесь для сохранения архитектурной чистоты.
|
||||||
+39
@@ -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.Http;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Host.Application.Admin.Commands.TestKlipy;
|
||||||
|
|
||||||
namespace Knot.Host.Presentation.Endpoints;
|
namespace Knot.Host.Presentation.Endpoints;
|
||||||
|
|
||||||
@@ -33,6 +34,12 @@ public sealed class AdminEndpoints : ICarterModule
|
|||||||
return Results.Ok(result.Value);
|
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) =>
|
group.MapGet("dashboard", async (ISender sender, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new GetDashboardStatsQuery(), ct);
|
var result = await sender.Send(new GetDashboardStatsQuery(), ct);
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ using System.Threading.Tasks;
|
|||||||
using Knot.Modules.Relations.Application.Abstractions;
|
using Knot.Modules.Relations.Application.Abstractions;
|
||||||
namespace 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);
|
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.Http;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Knot.Modules.Relations.Application.Contacts;
|
||||||
|
|
||||||
namespace Knot.Modules.Relations.Presentation.Endpoints;
|
namespace Knot.Modules.Relations.Presentation.Endpoints;
|
||||||
|
|
||||||
public sealed class FriendsEndpoints : ICarterModule
|
public sealed class ContactsEndpoints : ICarterModule
|
||||||
{
|
{
|
||||||
public void AddRoutes(IEndpointRouteBuilder app)
|
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) =>
|
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);
|
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);
|
var result = await sender.Send(new SendContactRequestCommand(userContext.UserId, request.ContactId), 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);
|
|
||||||
return result.IsSuccess ? Results.Ok(new { status = "pending" }) : Results.BadRequest(result.Error.Description);
|
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) =>
|
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);
|
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) =>
|
group.MapDelete("{id:guid}", async (Guid id, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new RemoveFriendCommand(userContext.UserId, id), ct);
|
var result = await sender.Send(new RemoveContactCommand(userContext.UserId, id), ct);
|
||||||
return result.IsSuccess ? Results.Ok(new Knot.Shared.Kernel.SuccessResponse(true)) : Results.NotFound(result.Error.Description);
|
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);
|
||||||
|
}
|
||||||
+14
-1
@@ -3,6 +3,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
|
using Knot.Shared.Kernel.Configuration;
|
||||||
|
|
||||||
using Knot.Modules.Stories.Domain;
|
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>
|
internal sealed class CreateStoryCommandHandler : ICommandHandler<CreateStoryCommand, Guid>
|
||||||
{
|
{
|
||||||
private readonly IStoryRepository _storyRepository;
|
private readonly IStoryRepository _storyRepository;
|
||||||
|
private readonly ISettingsService _settingsService;
|
||||||
|
|
||||||
public CreateStoryCommandHandler(IStoryRepository storyRepository)
|
public CreateStoryCommandHandler(IStoryRepository storyRepository, ISettingsService settingsService)
|
||||||
{
|
{
|
||||||
_storyRepository = storyRepository;
|
_storyRepository = storyRepository;
|
||||||
|
_settingsService = settingsService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<Guid>> Handle(CreateStoryCommand request, CancellationToken cancellationToken)
|
public async Task<Result<Guid>> Handle(CreateStoryCommand request, CancellationToken cancellationToken)
|
||||||
@@ -26,6 +29,16 @@ internal sealed class CreateStoryCommandHandler : ICommandHandler<CreateStoryCom
|
|||||||
parsedType = StoryType.Text;
|
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(
|
var story = Story.Create(
|
||||||
request.UserId,
|
request.UserId,
|
||||||
parsedType,
|
parsedType,
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ public enum StoryType
|
|||||||
{
|
{
|
||||||
Text = 0,
|
Text = 0,
|
||||||
Image = 1,
|
Image = 1,
|
||||||
Video = 2
|
Video = 2,
|
||||||
|
Klipy = 3
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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