Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user