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(); }