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