Files
Deal/src/core/Deal.Api/Endpoints/OperatorInvitesEndpoints.cs
T
Rustam Khalimov e3a2692507 Добить структуру Api, Contracts, SharedKernel и сервисов
Deal.Api/Http -> Services/Models/Extensions; Contracts/Integrations
и SharedKernel/Tenants -> Abstractions/Models; extension-классы
telegram/ml -> Extensions. namespace/using/FQN мигрированы, using
дедуплицированы.
2026-09-11 13:25:18 +03:00

153 lines
7.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Deal.Api.Extensions;
using Deal.Api.Models;
using Deal.Api.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.Api.Dtos;
namespace Deal.Api.Endpoints;
/// <summary>
/// Операторские эндпоинты приглашений: GET /api/operator/invites, POST (создание), POST {code}/revoke (Ruling 2/11 этапа 7).
/// </summary>
/// <remarks>
/// Создание/отзыв/чтение — только оператор: без операторской сессии 401 «Требуется вход оператора»
/// (как /api/operator/auth/me). Создание возвращает {code, email, tenantId, expiresAt, status} (план Task 5),
/// список — {items:[...]} (полные строки; форма как у GET /api/operator/audit), отзыв — {ok:true}. Результаты
/// пишутся в аудит — invite_created/invite_revoked с email и codeHash в DetailJson (Ruling 4; операторские события,
/// TenantId null; хэш кода — Security review). Тексты ошибок — фиксированные строки HTTP-слоя (паттерн AuthEndpoints).
/// </remarks>
public static class OperatorInvitesEndpoints
{
// Текст 400: email пустой/некорректного формата.
private const string InvalidEmailDetail = "Некорректный email";
// Текст 400: на email уже есть активное приглашение (план Task 5, Ruling 2).
private const string DuplicateActiveDetail = "Для этого email уже есть активное приглашение";
// Текст 404: приглашение с таким кодом не найдено.
private const string InviteNotFoundDetail = "Приглашение не найдено";
// Текст 400: отзыв приглашения не в статусе pending (уже отозвано/использовано/истекло).
private const string InviteNotPendingDetail = "Отозвать можно только ожидающее активации приглашение";
// Префикс группы операторских ручек приглашений (Ruling 11).
private const string InvitesGroupPrefix = "/api/operator/invites";
// Относительный путь отзыва приглашения.
private const string RevokePath = "/{code}/revoke";
// OpenAPI-тег группы.
private const string InvitesOpenApiTag = "operator-invites";
/// <summary>
/// Регистрирует группу /api/operator/invites: GET (список), POST (создание), POST {code}/revoke (отзыв).
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapOperatorInvitesEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup(InvitesGroupPrefix).WithTags(InvitesOpenApiTag);
group.MapGet("", ListAsync);
group.MapPost("", CreateAsync);
group.MapPost(RevokePath, RevokeAsync);
return app;
}
// GET /api/operator/invites: список приглашений (новые сверху, со статусами; expired проставляется лениво).
private static async Task<IResult> ListAsync(
HttpContext context,
InvitesService invitesService,
CancellationToken ct)
{
if (context.GetCurrentOperator() is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
IReadOnlyList<InviteDto> items = await invitesService.ListAsync(ct);
return Results.Ok(new { items });
}
// POST /api/operator/invites: создание приглашения; результат пишется в аудит (invite_created).
private static async Task<IResult> CreateAsync(
OperatorInviteCreateRequest body,
HttpContext context,
InvitesService invitesService,
AuditService auditService,
CancellationToken ct)
{
var operatorIdentity = context.GetCurrentOperator();
if (operatorIdentity is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
InviteCreateResultDto result = await invitesService.CreateInviteAsync(
operatorIdentity.OperatorId, body.Email, body.TenantId, ct);
if (!result.Ok || result.Invite is null)
{
string detail = result.Error == InviteCreateResultDto.ErrorDuplicateActive
? DuplicateActiveDetail
: InvalidEmailDetail;
return EndpointResults.BadRequest(detail);
}
await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.InviteCreated,
AuditActorTypes.Operator,
ActorId: operatorIdentity.OperatorId,
TenantId: null,
Ip: ClientIp(context),
// Код инвайта — capability-токен (по нему активируется приглашение): в аудит пишется
// только его SHA-256-хэш, чтобы утечка ленты не давала рабочие коды (Security review).
DetailJson: AuditService.ToDetailJson(new { email = result.Invite.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct);
InviteDto invite = result.Invite;
return Results.Ok(new { invite.Code, invite.Email, invite.TenantId, invite.ExpiresAt, invite.Status });
}
// POST /api/operator/invites/{code}/revoke: отзыв ожидающего приглашения; результат пишется в аудит (invite_revoked).
private static async Task<IResult> RevokeAsync(
string code,
HttpContext context,
InvitesService invitesService,
AuditService auditService,
CancellationToken ct)
{
var operatorIdentity = context.GetCurrentOperator();
if (operatorIdentity is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
InviteRevokeResultDto result = await invitesService.RevokeAsync(code, ct);
if (!result.Ok)
{
return result.Error == InviteRevokeResultDto.ErrorNotFound
? EndpointResults.NotFound(InviteNotFoundDetail)
: EndpointResults.BadRequest(InviteNotPendingDetail);
}
await auditService.AppendAsync(new AuditRecordDto(
AuditEvents.InviteRevoked,
AuditActorTypes.Operator,
ActorId: operatorIdentity.OperatorId,
TenantId: null,
Ip: ClientIp(context),
DetailJson: AuditService.ToDetailJson(new { email = result.Invite!.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct);
return Results.Ok(new { ok = true });
}
// IP-адрес клиента для аудита (без порта; null, если недоступен).
// context: Контекст запроса.
// Возвращает: Строковое представление IP или null.
private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
}