ci / build-test (push) Canceled after 0s
SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/ Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue, контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер), Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог). Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0, тесты 1340/130/52/38/9 зелёные.
139 lines
6.2 KiB
C#
139 lines
6.2 KiB
C#
using Deal.Api.Extensions;
|
|
using Deal.Api.Services;
|
|
using Deal.Modules.Tenants.Application.Models;
|
|
using Deal.Modules.Tenants.Application.Services;
|
|
|
|
namespace Deal.Api.Endpoints;
|
|
|
|
/// <summary>
|
|
/// Операторские эндпоинты приглашений
|
|
/// </summary>
|
|
public static class OperatorInvitesEndpoints
|
|
{
|
|
// Текст 400: email пустой/некорректного формата.
|
|
private const string InvalidEmailDetail = "Некорректный email";
|
|
|
|
private const string DuplicateActiveDetail = "Для этого email уже есть активное приглашение";
|
|
|
|
// Текст 404: приглашение с таким кодом не найдено.
|
|
private const string InviteNotFoundDetail = "Приглашение не найдено";
|
|
|
|
// Текст 400: отзыв приглашения не в статусе pending (уже отозвано/использовано/истекло).
|
|
private const string InviteNotPendingDetail = "Отозвать можно только ожидающее активации приглашение";
|
|
|
|
private const string InvitesGroupPrefix = "/api/operator/invites";
|
|
|
|
// Относительный путь отзыва приглашения.
|
|
private const string RevokePath = "/{code}/revoke";
|
|
|
|
// OpenAPI-тег группы.
|
|
private const string InvitesOpenApiTag = "operator-invites";
|
|
|
|
/// <summary>
|
|
/// Регистрирует группу /api/operator/invites
|
|
/// </summary>
|
|
/// <param name="app">Построитель маршрутов приложения.</param>
|
|
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
|
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<IResult> ListAsync(
|
|
HttpContext context,
|
|
InvitesService invitesService,
|
|
CancellationToken ct)
|
|
{
|
|
if (context.GetCurrentOperator() is null)
|
|
{
|
|
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
|
}
|
|
|
|
IReadOnlyList<InviteDto> items = await invitesService.ListAsync(ct);
|
|
return Results.Ok(new { items });
|
|
}
|
|
|
|
// POST /api/operator/invites: создание приглашения; результат пишется в аудит (invite_created).
|
|
private static async Task<IResult> 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<IResult> 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();
|
|
}
|