Files
Deal/src/core/Deal.Api/Endpoints/JoinEndpoint.cs
T
Rustam Khalimov 9e07568ddd Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
2026-09-11 02:50:17 +03:00

113 lines
6.6 KiB
C#

using Deal.Api.Http;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// Публичный эндпоинт активации инвайта: POST /api/join (Ruling 2/11 этапа 7).
/// </summary>
/// <remarks>
/// Ручка не требует сессии (публичная; фронт её не вызывает — 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).
/// </remarks>
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 = "Тенант приглашения приостановлен";
/// <summary>
/// Регистрирует POST /api/join.
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
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<IResult> 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();
}