using System.Text.RegularExpressions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Modules.Tenants.Application;
///
/// Прикладной сервис приглашений (Ruling 2 этапа 7): создание оператором, отзыв, список, чтение по коду.
///
///
/// Жизненный цикл статусов: pending → revoked | expired | activated. «expired» хранилище не проставляет само:
/// он вычисляется лениво при чтении/проверке (/) и сохраняется,
/// иначе частичный unique-индекс invites.Email по pending (InviteConfiguration, Task 1) заблокировал бы повторное
/// приглашение на тот же email после истечения. Создание/отзыв — только оператор (вызывается из
/// /api/operator/invites, Task 5); тексты HTTP-ошибок фиксирует слой эндпоинтов — сервис возвращает коды/null и
/// не бросает исключений для бизнес-отказов (паттерн AuthService). Активацию (pending → activated) выполняет
/// /api/join (Task 6): он читает приглашение через (валидация статуса/expiry).
///
public sealed partial class InvitesService(IInviteStore inviteStore)
{
///
/// Срок действия приглашения, часов (Ruling 2: 72).
///
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();
///
/// Создаёт приглашение оператором: нормализация/валидация email, антидубль активного, срок +72 часа.
///
/// Идентификатор оператора (CreatedById приглашения).
/// Email приглашённого (регистр/пробелы не важны — нормализуется).
/// Целевой тенант; null — при активации будет создан новый тенант (Task 6).
/// Токен отмены.
/// Ok=true + созданное приглашение, либо код ошибки (текст — HTTP-слой).
public async Task 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);
}
///
/// Отзывает приглашение: только pending переводится в revoked; иные статусы не трогаем (Ruling 2).
///
/// Код приглашения.
/// Токен отмены.
/// Ok=true + отозванное приглашение, либо код ошибки (текст — HTTP-слой).
public async Task 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 });
}
///
/// Список приглашений для оператора (новые сверху) с ленивой пометкой истёкших (status → expired сохраняется).
///
/// Токен отмены.
/// Приглашения в порядке CreatedAt DESC; протухшие pending приходят со статусом expired.
public async Task> ListAsync(CancellationToken ct)
{
IReadOnlyList invites = await inviteStore.ListAsync(ct);
var result = new List(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;
}
///
/// Читает приглашение по коду, вычисляя статус expired при чтении (проверку кода использует /api/join, Task 6).
///
/// Код приглашения.
/// Токен отмены.
/// Приглашение (протухшее pending — со статусом expired и сохранённым переходом) или null.
public async Task 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 };
}
///
/// Активирует приглашение CAS-переходом (Task 6): атомарный pending → activated, ActivatedAt = сейчас.
/// Прокси над — реальное условие на статус исполняет хранилище
/// (условный UPDATE), сервис лишь фиксирует момент активации. Возвращает false, если к моменту обновления
/// приглашение уже не pending (параллельно отозвано/активировано) — вызывающий (JoinService) перечитает статус.
///
/// Код приглашения.
/// Токен отмены.
/// true, если активация выполнена; false — строка не в статусе pending.
public Task TryActivateAsync(string code, CancellationToken ct) =>
inviteStore.TryActivateAsync(code, DateTimeOffset.UtcNow, ct);
///
/// Нормализация email: обрезка пробелов и нижний регистр (единая форма хранения/сравнения).
///
/// Входной email (может быть null).
/// Нормализованный email (пустая строка, если вход был пустым).
public static string NormalizeEmail(string? email) => (email ?? string.Empty).Trim().ToLowerInvariant();
///
/// Проверка формата email (санити-уровень): непустой, ≤ , один '@', домен с точкой, без пробелов.
///
/// Входной email (регистр/пробелы не важны).
/// true, если email выглядит корректно.
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;
}