Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
192 lines
12 KiB
C#
192 lines
12 KiB
C#
using System.Text.RegularExpressions;
|
|
using Deal.Modules.Tenants.Application.Models;
|
|
|
|
namespace Deal.Modules.Tenants.Application;
|
|
|
|
/// <summary>
|
|
/// Прикладной сервис приглашений (Ruling 2 этапа 7): создание оператором, отзыв, список, чтение по коду.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Жизненный цикл статусов: pending → revoked | expired | activated. «expired» хранилище не проставляет само:
|
|
/// он вычисляется лениво при чтении/проверке (<see cref="GetByCodeAsync"/>/<see cref="ListAsync"/>) и сохраняется,
|
|
/// иначе частичный unique-индекс invites.Email по pending (InviteConfiguration, Task 1) заблокировал бы повторное
|
|
/// приглашение на тот же email после истечения. Создание/отзыв — только оператор (вызывается из
|
|
/// /api/operator/invites, Task 5); тексты HTTP-ошибок фиксирует слой эндпоинтов — сервис возвращает коды/null и
|
|
/// не бросает исключений для бизнес-отказов (паттерн AuthService). Активацию (pending → activated) выполняет
|
|
/// /api/join (Task 6): он читает приглашение через <see cref="GetByCodeAsync"/> (валидация статуса/expiry).
|
|
/// </remarks>
|
|
public sealed partial class InvitesService(IInviteStore inviteStore)
|
|
{
|
|
/// <summary>
|
|
/// Срок действия приглашения, часов (Ruling 2: 72).
|
|
/// </summary>
|
|
public const int ExpiryHours = 72;
|
|
|
|
// Максимальная длина email — совпадает с шириной колонки invites.Email (InviteConfiguration, Task 1).
|
|
private const int MaxEmailLength = 200;
|
|
|
|
// Проверяемый формат: один '@', непустые локальная часть и домен с точкой, без пробелов.
|
|
private const string EmailFormatPattern = @"^[^@\s]+@[^@\s]+\.[^@\s]+$";
|
|
|
|
// Компилируется из EmailFormatPattern; вход ограничен 200 символами, паттерн линейный.
|
|
[GeneratedRegex(EmailFormatPattern, RegexOptions.CultureInvariant)]
|
|
private static partial Regex EmailFormatRegex();
|
|
|
|
/// <summary>
|
|
/// Создаёт приглашение оператором: нормализация/валидация email, антидубль активного, срок +72 часа.
|
|
/// </summary>
|
|
/// <param name="operatorId">Идентификатор оператора (CreatedById приглашения).</param>
|
|
/// <param name="email">Email приглашённого (регистр/пробелы не важны — нормализуется).</param>
|
|
/// <param name="tenantId">Целевой тенант; null — при активации будет создан новый тенант (Task 6).</param>
|
|
/// <param name="ct">Токен отмены.</param>
|
|
/// <returns>Ok=true + созданное приглашение, либо код ошибки (текст — HTTP-слой).</returns>
|
|
public async Task<InviteCreateResultDto> CreateInviteAsync(
|
|
Guid operatorId, string? email, Guid? tenantId, CancellationToken ct)
|
|
{
|
|
string normalizedEmail = NormalizeEmail(email);
|
|
if (!IsValidEmail(normalizedEmail))
|
|
{
|
|
return new InviteCreateResultDto(Ok: false, Error: InviteCreateResultDto.ErrorInvalidEmail, Invite: null);
|
|
}
|
|
|
|
// Антидубль «одно активное приглашение на email» (Ruling 2): pending блокирует новое. Если найденное
|
|
// pending уже истекло (статус ещё не переведён), сначала помечаем expired — иначе partial unique-индекс
|
|
// по pending не пустит новую строку; затем создаём новое приглашение. Отозванный/активированный/
|
|
// истёкший email свободен (глобальную уникальность регистрации держит unique-индекс users.Login).
|
|
var active = await inviteStore.FindActiveByEmailAsync(normalizedEmail, ct);
|
|
if (active is not null)
|
|
{
|
|
if (IsExpired(active))
|
|
{
|
|
await inviteStore.UpdateStatusAsync(active.Code, InviteStatuses.Expired, null, ct);
|
|
}
|
|
else
|
|
{
|
|
return new InviteCreateResultDto(Ok: false, Error: InviteCreateResultDto.ErrorDuplicateActive, Invite: null);
|
|
}
|
|
}
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
var invite = new InviteDto(
|
|
Code: InviteCodeGenerator.NewCode(),
|
|
Email: normalizedEmail,
|
|
TenantId: tenantId,
|
|
Status: InviteStatuses.Pending,
|
|
ExpiresAt: now.AddHours(ExpiryHours),
|
|
ActivatedAt: null,
|
|
CreatedById: operatorId,
|
|
CreatedAt: now);
|
|
await inviteStore.CreateAsync(invite, ct);
|
|
return new InviteCreateResultDto(Ok: true, Error: null, Invite: invite);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Отзывает приглашение: только pending переводится в revoked; иные статусы не трогаем (Ruling 2).
|
|
/// </summary>
|
|
/// <param name="code">Код приглашения.</param>
|
|
/// <param name="ct">Токен отмены.</param>
|
|
/// <returns>Ok=true + отозванное приглашение, либо код ошибки (текст — HTTP-слой).</returns>
|
|
public async Task<InviteRevokeResultDto> RevokeAsync(string code, CancellationToken ct)
|
|
{
|
|
var invite = await inviteStore.GetByCodeAsync(code, ct);
|
|
if (invite is null)
|
|
{
|
|
return new InviteRevokeResultDto(Ok: false, Error: InviteRevokeResultDto.ErrorNotFound, Invite: null);
|
|
}
|
|
|
|
if (invite.Status != InviteStatuses.Pending)
|
|
{
|
|
return new InviteRevokeResultDto(Ok: false, Error: InviteRevokeResultDto.ErrorNotPending, Invite: invite);
|
|
}
|
|
|
|
bool updated = await inviteStore.UpdateStatusAsync(code, InviteStatuses.Revoked, null, ct);
|
|
if (!updated)
|
|
{
|
|
// Строка исчезла между чтением и обновлением — считаем «не найдено».
|
|
return new InviteRevokeResultDto(Ok: false, Error: InviteRevokeResultDto.ErrorNotFound, Invite: null);
|
|
}
|
|
|
|
return new InviteRevokeResultDto(Ok: true, Error: null, Invite: invite with { Status = InviteStatuses.Revoked });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Список приглашений для оператора (новые сверху) с ленивой пометкой истёкших (status → expired сохраняется).
|
|
/// </summary>
|
|
/// <param name="ct">Токен отмены.</param>
|
|
/// <returns>Приглашения в порядке CreatedAt DESC; протухшие pending приходят со статусом expired.</returns>
|
|
public async Task<IReadOnlyList<InviteDto>> ListAsync(CancellationToken ct)
|
|
{
|
|
IReadOnlyList<InviteDto> invites = await inviteStore.ListAsync(ct);
|
|
var result = new List<InviteDto>(invites.Count);
|
|
foreach (var invite in invites)
|
|
{
|
|
if (invite.Status == InviteStatuses.Pending && invite.ExpiresAt <= DateTimeOffset.UtcNow)
|
|
{
|
|
await inviteStore.UpdateStatusAsync(invite.Code, InviteStatuses.Expired, null, ct);
|
|
result.Add(invite with { Status = InviteStatuses.Expired });
|
|
}
|
|
else
|
|
{
|
|
result.Add(invite);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Читает приглашение по коду, вычисляя статус expired при чтении (проверку кода использует /api/join, Task 6).
|
|
/// </summary>
|
|
/// <param name="code">Код приглашения.</param>
|
|
/// <param name="ct">Токен отмены.</param>
|
|
/// <returns>Приглашение (протухшее pending — со статусом expired и сохранённым переходом) или null.</returns>
|
|
public async Task<InviteDto?> GetByCodeAsync(string code, CancellationToken ct)
|
|
{
|
|
var invite = await inviteStore.GetByCodeAsync(code, ct);
|
|
if (invite is null || invite.Status != InviteStatuses.Pending || !IsExpired(invite))
|
|
{
|
|
return invite;
|
|
}
|
|
|
|
await inviteStore.UpdateStatusAsync(code, InviteStatuses.Expired, null, ct);
|
|
return invite with { Status = InviteStatuses.Expired };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Активирует приглашение CAS-переходом (Task 6): атомарный pending → activated, ActivatedAt = сейчас.
|
|
/// Прокси над <see cref="IInviteStore.TryActivateAsync"/> — реальное условие на статус исполняет хранилище
|
|
/// (условный UPDATE), сервис лишь фиксирует момент активации. Возвращает false, если к моменту обновления
|
|
/// приглашение уже не pending (параллельно отозвано/активировано) — вызывающий (JoinService) перечитает статус.
|
|
/// </summary>
|
|
/// <param name="code">Код приглашения.</param>
|
|
/// <param name="ct">Токен отмены.</param>
|
|
/// <returns>true, если активация выполнена; false — строка не в статусе pending.</returns>
|
|
public Task<bool> TryActivateAsync(string code, CancellationToken ct) =>
|
|
inviteStore.TryActivateAsync(code, DateTimeOffset.UtcNow, ct);
|
|
|
|
/// <summary>
|
|
/// Нормализация email: обрезка пробелов и нижний регистр (единая форма хранения/сравнения).
|
|
/// </summary>
|
|
/// <param name="email">Входной email (может быть null).</param>
|
|
/// <returns>Нормализованный email (пустая строка, если вход был пустым).</returns>
|
|
public static string NormalizeEmail(string? email) => (email ?? string.Empty).Trim().ToLowerInvariant();
|
|
|
|
/// <summary>
|
|
/// Проверка формата email (санити-уровень): непустой, ≤ <see cref="MaxEmailLength"/>, один '@', домен с точкой, без пробелов.
|
|
/// </summary>
|
|
/// <param name="email">Входной email (регистр/пробелы не важны).</param>
|
|
/// <returns>true, если email выглядит корректно.</returns>
|
|
public static bool IsValidEmail(string? email)
|
|
{
|
|
string normalized = NormalizeEmail(email);
|
|
return normalized.Length > 0
|
|
&& normalized.Length <= MaxEmailLength
|
|
&& EmailFormatRegex().IsMatch(normalized);
|
|
}
|
|
|
|
// Истекло ли приглашение (сравнение по UTC-now; действует только для статуса pending).
|
|
// invite: Приглашение.
|
|
// Возвращает: true, если срок действия уже прошёл.
|
|
private static bool IsExpired(InviteDto invite) => invite.ExpiresAt <= DateTimeOffset.UtcNow;
|
|
}
|