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;
///
/// Операторские эндпоинты приглашений: GET /api/operator/invites, POST (создание), POST {code}/revoke (Ruling 2/11 этапа 7).
///
///
/// Создание/отзыв/чтение — только оператор: без операторской сессии 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).
///
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";
///
/// Регистрирует группу /api/operator/invites: GET (список), POST (создание), POST {code}/revoke (отзыв).
///
/// Построитель маршрутов приложения.
/// Построитель маршрутов для цепочки вызовов.
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 ListAsync(
HttpContext context,
InvitesService invitesService,
CancellationToken ct)
{
if (context.GetCurrentOperator() is null)
{
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
}
IReadOnlyList items = await invitesService.ListAsync(ct);
return Results.Ok(new { items });
}
// POST /api/operator/invites: создание приглашения; результат пишется в аудит (invite_created).
private static async Task 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 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();
}