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; }