Литералы статусов/видов/фаз и wire-ключей заменены каталогами: значения сущностей и репозиториев — TenantStatuses/TenantLimitPeriods/InviteStatuses/ContainerKinds/ContainerSpaces/CardIds/Discovery*/PipelineQueueStatuses; контакты и виды вложений — CardContactTypes/CardFileKinds; источники и этапы отсева — PipelineRejectStages/PipelineRejectSources; ключи промптов/провайдеров — SettingsFieldKeys; wire-ключи канбана — KanbanWireKeys; режимы правил — ColumnRuleModes; операции/фазы/виды диалогов/контакты Telegram и общий BoolText — в Deal.Contracts/Deal.SharedKernel; имена метрик — DealMetrics. Значения не менялись.
157 lines
7.5 KiB
C#
157 lines
7.5 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Прикладной сервис операторского реестра тенантов
|
|
/// </summary>
|
|
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 = "Пользователь";
|
|
|
|
/// <summary>
|
|
/// Создаёт тенанта оператором
|
|
/// </summary>
|
|
/// <param name="name">Имя тенанта (обязательно; обрезается).</param>
|
|
/// <param name="email">Email владельца (опционально): создаёт пользователя-владельца сразу, иначе владелец заводится инвайтом.</param>
|
|
/// <returns>Ok=true — тенант создан (Tenant); при email дополнительно OwnerUserId/OwnerLogin/InitialPassword. Ok=false — код ошибки (имя пусто, email невалиден/уже зарегистрирован); текст — HTTP-слой.</returns>
|
|
public async Task<TenantCreateResultDto> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Список тенантов со счётчиками пользователей.
|
|
/// </summary>
|
|
/// <returns>Тенанты в порядке создания (реестр) с числом пользователей каждого.</returns>
|
|
public async Task<IReadOnlyList<TenantListItemDto>> ListAsync(CancellationToken ct)
|
|
{
|
|
var tenants = await tenantRepository.ListAsync(ct);
|
|
var items = new List<TenantListItemDto>(tenants.Count);
|
|
foreach (TenantRecordDto tenant in tenants)
|
|
{
|
|
IReadOnlyList<UserIdentityDto> users = await authStore.ListUsersByTenantIdAsync(tenant.Id, ct);
|
|
items.Add(new TenantListItemDto(tenant.Id, tenant.Name, tenant.Status, tenant.CreatedAt, users.Count));
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Детали тенанта с пользователями
|
|
/// </summary>
|
|
/// <param name="id">Идентификатор тенанта.</param>
|
|
/// <returns>Детали и пользователи тенанта (по CreatedAt).</returns>
|
|
/// <exception cref="NotFoundException">Тенант не найден.</exception>
|
|
public async Task<TenantDetailDto> GetAsync(Guid id, CancellationToken ct)
|
|
{
|
|
var tenant = await tenantRepository.FindByIdAsync(id, ct)
|
|
?? throw new NotFoundException(TenantEntityName, id.ToString("N"));
|
|
|
|
IReadOnlyList<UserIdentityDto> users = await authStore.ListUsersByTenantIdAsync(id, ct);
|
|
return new TenantDetailDto(tenant.Id, tenant.Name, tenant.Status, tenant.CreatedAt, users);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Меняет статус тенанта
|
|
/// </summary>
|
|
/// <param name="id">Идентификатор тенанта.</param>
|
|
/// <param name="status">Новый статус — константа <c>TenantStatuses</c>.</param>
|
|
/// <returns>Результат: тенант не найден (404) или применение с признаком реального изменения (Changed).</returns>
|
|
public async Task<TenantStatusChangeResultDto> 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);
|
|
}
|