using Deal.Api.Services; using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Services; namespace Deal.Api.Endpoints; /// /// Публичный эндпоинт активации инвайта /// public static class JoinEndpoint { // Путь ручки (вне группы /api/operator — публичная). private const string JoinPath = "/api/join"; // OpenAPI-тег. private const string JoinOpenApiTag = "join"; // Текст 400: приглашение с таким кодом не найдено. private const string InviteNotFoundDetail = "Приглашение не найдено"; private const string InviteExpiredDetail = "Срок действия приглашения истёк"; // Текст 400: приглашение уже активировано (повторная активация тем же кодом). private const string InviteUsedDetail = "Приглашение уже использовано"; // Текст 400: приглашение отозвано оператором. private const string InviteRevokedDetail = "Приглашение отозвано"; private const string EmailMismatchDetail = "Email не совпадает с приглашением"; private const string EmailTakenDetail = "Этот email уже зарегистрирован"; private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)"; // Текст 400: целевой тенант инвайта не существует (Security review). private const string TenantNotFoundDetail = "Тенант приглашения не найден"; // Текст 400: целевой тенант инвайта приостановлен (Security review). private const string TenantSuspendedDetail = "Тенант приглашения приостановлен"; /// /// Регистрирует POST /api/join. /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. public static IEndpointRouteBuilder MapJoinEndpoint(this IEndpointRouteBuilder app) { app.MapPost(JoinPath, JoinAsync).WithTags(JoinOpenApiTag); return app; } private static async Task JoinAsync( JoinRequest body, JoinService joinService, AuditService auditService, HttpContext context, CancellationToken ct) { var result = await joinService.ActivateAsync(body.Code, body.Email, body.Name, body.Password, ct); if (!result.Ok || result.Login is null || result.UserId is null || result.TenantId is null) { return EndpointResults.BadRequest(DetailFor(result.Error)); } await auditService.AppendAsync(new AuditRecordDto( AuditEvents.InviteJoined, AuditActorTypes.Tenant, ActorId: result.UserId, TenantId: result.TenantId, Ip: ClientIp(context), // Код инвайта — capability-токен: в аудит пишется только SHA-256-хэш (Security review). DetailJson: AuditService.ToDetailJson(new { email = result.Login, codeHash = SessionTokens.HashToken(body.Code?.Trim() ?? string.Empty) })), ct); return Results.Ok(new { ok = true, login = result.Login }); } // Фиксированный текст 400 по коду ошибки JoinService (все отказы активации — 400). // error: Код ошибки JoinResultDto. // Возвращает: Текст детали ошибки. private static string DetailFor(string? error) => error switch { JoinResultDto.ErrorExpired => InviteExpiredDetail, JoinResultDto.ErrorUsed => InviteUsedDetail, JoinResultDto.ErrorRevoked => InviteRevokedDetail, JoinResultDto.ErrorEmailMismatch => EmailMismatchDetail, JoinResultDto.ErrorEmailTaken => EmailTakenDetail, JoinResultDto.ErrorPasswordTooShort => PasswordTooShortDetail, JoinResultDto.ErrorTenantNotFound => TenantNotFoundDetail, JoinResultDto.ErrorTenantSuspended => TenantSuspendedDetail, _ => InviteNotFoundDetail, }; // IP-адрес клиента для аудита (без порта; null, если недоступен). // context: Контекст запроса. // Возвращает: Строковое представление IP или null. private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); }