Deal — единая кодовая база
ci / build-test (push) Canceled after 0s

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:
Rustam Khalimov
2026-09-11 23:56:47 +03:00
commit 27c7831910
1383 changed files with 158436 additions and 0 deletions
@@ -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);
}