SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/ Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue, контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер), Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог). Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0, тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel;
|
||||
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;
|
||||
|
||||
/// <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: "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) или null, если тенанта нет.</returns>
|
||||
public async Task<TenantDetailDto?> GetAsync(Guid id, CancellationToken ct)
|
||||
{
|
||||
var tenant = await tenantRepository.FindByIdAsync(id, ct);
|
||||
if (tenant is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
bool updated = await tenantRepository.UpdateStatusAsync(id, status, ct);
|
||||
return new TenantStatusChangeResultDto(Ok: true, Error: null, Changed: updated, Tenant: tenant with { Status = status });
|
||||
}
|
||||
|
||||
// Новый одноразовый пароль владельца: 16 url-safe символов (общий UrlSafeToken, Security review C36).
|
||||
// Возвращает: Строка пароля.
|
||||
private static string GenerateInitialPassword() => UrlSafeToken.New(InitialPasswordRandomByteCount);
|
||||
}
|
||||
Reference in New Issue
Block a user