using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Models; using Deal.SharedKernel; using Deal.SharedKernel.Errors; using Deal.SharedKernel.Utilities; namespace Deal.Modules.Tenants.Application.Services; /// /// Прикладной сервис операторского реестра тенантов /// public sealed class TenantAdminService( ITenantRepository tenantRepository, IAuthStore authStore, TenantService tenantService, IPasswordHasher passwordHasher) { // Случайные байты одноразового пароля владельца: 12 → ровно 16 символов Base64Url (как InviteCodeGenerator). private const int InitialPasswordRandomByteCount = 12; // Имя сущности для текста ошибки «не найдено» (пользователь = тенант с его окружением). private const string TenantEntityName = "Пользователь"; /// /// Создаёт тенанта оператором /// /// Имя тенанта (обязательно; обрезается). /// Email владельца (опционально): создаёт пользователя-владельца сразу, иначе владелец заводится инвайтом. /// Ok=true — тенант создан (Tenant); при email дополнительно OwnerUserId/OwnerLogin/InitialPassword. Ok=false — код ошибки (имя пусто, email невалиден/уже зарегистрирован); текст — HTTP-слой. public async Task CreateAsync( string? name, string? email, CancellationToken ct) { string tenantName = name?.Trim() ?? string.Empty; if (string.IsNullOrEmpty(tenantName)) { return TenantCreateResultDto.Failed(TenantCreateResultDto.ErrorNameRequired); } string? normalizedEmail = null; if (!string.IsNullOrWhiteSpace(email)) { normalizedEmail = InvitesService.NormalizeEmail(email); if (!InvitesService.IsValidEmail(email)) { return TenantCreateResultDto.Failed(TenantCreateResultDto.ErrorInvalidEmail); } var existingUser = await authStore.FindUserByLoginAsync(normalizedEmail, ct); if (existingUser is not null) { return TenantCreateResultDto.Failed(TenantCreateResultDto.ErrorEmailTaken); } } // Создание тенанта — единая операция: строка public.tenants (Status active) + провижининг схемы. var tenantId = Guid.NewGuid(); await tenantService.CreateTenantAsync(tenantName, tenantId, ct); var tenant = new TenantRecordDto(tenantId, tenantName, TenantStatuses.Active, DateTimeOffset.UtcNow); if (normalizedEmail is null) { return new TenantCreateResultDto(Ok: true, Error: null, Tenant: tenant); } // Пользователь-владелец с одноразовым паролем: raw-пароль возвращается один раз (наружу), в хранилище — // только хэш; в аудит/логи не пишется. string initialPassword = GenerateInitialPassword(); var ownerUserId = Guid.NewGuid(); await authStore.CreateUserAsync( new StoredUserDto( Id: ownerUserId, Login: normalizedEmail, TenantId: tenantId, Status: TenantStatuses.Active, PasswordHash: passwordHasher.Hash(initialPassword)), ct); return new TenantCreateResultDto( Ok: true, Error: null, Tenant: tenant, OwnerUserId: ownerUserId, OwnerLogin: normalizedEmail, InitialPassword: initialPassword); } /// /// Список тенантов со счётчиками пользователей. /// /// Тенанты в порядке создания (реестр) с числом пользователей каждого. public async Task> ListAsync(CancellationToken ct) { var tenants = await tenantRepository.ListAsync(ct); var items = new List(tenants.Count); foreach (TenantRecordDto tenant in tenants) { IReadOnlyList users = await authStore.ListUsersByTenantIdAsync(tenant.Id, ct); items.Add(new TenantListItemDto(tenant.Id, tenant.Name, tenant.Status, tenant.CreatedAt, users.Count)); } return items; } /// /// Детали тенанта с пользователями /// /// Идентификатор тенанта. /// Детали и пользователи тенанта (по CreatedAt). /// Тенант не найден. public async Task GetAsync(Guid id, CancellationToken ct) { var tenant = await tenantRepository.FindByIdAsync(id, ct) ?? throw new NotFoundException(TenantEntityName, id.ToString("N")); IReadOnlyList users = await authStore.ListUsersByTenantIdAsync(id, ct); return new TenantDetailDto(tenant.Id, tenant.Name, tenant.Status, tenant.CreatedAt, users); } /// /// Меняет статус тенанта /// /// Идентификатор тенанта. /// Новый статус — константа TenantStatuses. /// Результат: тенант не найден (404) или применение с признаком реального изменения (Changed). public async Task ChangeStatusAsync( Guid id, string status, CancellationToken ct) { var tenant = await tenantRepository.FindByIdAsync(id, ct); if (tenant is null) { return new TenantStatusChangeResultDto(Ok: false, Error: TenantStatusChangeResultDto.ErrorNotFound, Changed: false, Tenant: null); } // Идемпотентность: повторный suspend уже приостановленного — Ok без изменения (аудит не дублируется). if (tenant.Status == status) { return new TenantStatusChangeResultDto(Ok: true, Error: null, Changed: false, Tenant: tenant, PreviousStatus: tenant.Status); } bool updated = await tenantRepository.UpdateStatusAsync(id, status, ct); return new TenantStatusChangeResultDto( Ok: true, Error: null, Changed: updated, Tenant: tenant with { Status = status }, PreviousStatus: tenant.Status); } // Новый одноразовый пароль владельца: 16 url-safe символов (общий UrlSafeToken, Security review C36). // Возвращает: Строка пароля. private static string GenerateInitialPassword() => UrlSafeToken.New(InitialPasswordRandomByteCount); }