Deal.Api/Http -> Services/Models/Extensions; Contracts/Integrations и SharedKernel/Tenants -> Abstractions/Models; extension-классы telegram/ml -> Extensions. namespace/using/FQN мигрированы, using дедуплицированы.
153 lines
7.6 KiB
C#
153 lines
7.6 KiB
C#
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();
|
||
}
|