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; /// /// Публичный эндпоинт активации инвайта: POST /api/join (Ruling 2/11 этапа 7). /// /// /// Ручка не требует сессии (публичная; фронт её не вызывает — API-only, curl/будущий UI). Тело /// {code, email, name?, password} → JoinService: валидация кода/email/пароля, CAS-резервирование инвайта, /// создание тенанта (при пустом TenantId — с провижинингом схемы) и пользователя. Кука НЕ ставится: после /// активации клиент входит обычным /api/auth/login (план Task 6). Успех — {ok:true, login}; ошибки — 400 /// {detail} с фиксированным текстом причины (все отказы активации — 400, включая истёкший инвайт: слой /// эндпоинта, см. Task 6; Ruling 2 называет это «410-семантикой» — ресурс больше недоступен). Результат /// пишется в аудит — invite_joined (актор — новый пользователь тенанта, детали email+codeHash). /// public static class JoinEndpoint { // Путь ручки (вне группы /api/operator — публичная). private const string JoinPath = "/api/join"; // OpenAPI-тег. private const string JoinOpenApiTag = "join"; // Текст 400: приглашение с таким кодом не найдено. private const string InviteNotFoundDetail = "Приглашение не найдено"; // Текст 400: срок действия приглашения истёк (план Task 6, Ruling 2). private const string InviteExpiredDetail = "Срок действия приглашения истёк"; // Текст 400: приглашение уже активировано (повторная активация тем же кодом). private const string InviteUsedDetail = "Приглашение уже использовано"; // Текст 400: приглашение отозвано оператором. private const string InviteRevokedDetail = "Приглашение отозвано"; // Текст 400: email запроса не совпадает с email приглашения (Ruling 2). private const string EmailMismatchDetail = "Email не совпадает с приглашением"; // Текст 400: пользователь с таким email уже зарегистрирован (users.login unique, Ruling 2). private const string EmailTakenDetail = "Этот email уже зарегистрирован"; // Текст 400: пароль короче минимума (текст как в AuthEndpoints, план Task 6). 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; } // POST /api/join: активация инвайта; успех пишется в аудит (invite_activated, Task 6/Ruling 4). 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(); }