using foodmarket.Api.Seed; using foodmarket.Application.Common; using foodmarket.Domain.Organizations; using foodmarket.Infrastructure.Identity; using foodmarket.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.Security.Claims; namespace foodmarket.Api.Controllers.SuperAdmin; /// SuperAdmin console: управление организациями. Все запросы /// IgnoreQueryFilters() — обходим tenant-фильтр, видим всё. Все мутации /// логируются в super_admin_audit_log. [ApiController] [Authorize(Roles = "SuperAdmin")] [Route("api/super-admin/organizations")] public class SuperAdminOrganizationsController : ControllerBase { private readonly AppDbContext _db; private readonly UserManager _userMgr; public SuperAdminOrganizationsController(AppDbContext db, UserManager userMgr) { _db = db; _userMgr = userMgr; } public record OrgRow( Guid Id, string Name, string CountryCode, bool IsActive, bool IsArchived, DateTime? ArchivedAt, DateTime CreatedAt, int EmployeeCount, int ProductCount, DateTime? LastLoginAt); public record OrgDetail( Guid Id, string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email, Guid? DefaultCurrencyId, string? DefaultCurrencyCode, bool IsActive, bool IsArchived, DateTime? ArchivedAt, Guid? AccountOwnerUserId, string? AccountOwnerName, string? AccountOwnerEmail, DateTime CreatedAt, DateTime? UpdatedAt, int EmployeeCount, int ProductCount, int CounterpartyCount, int SupplyCountThisMonth); public record OrgInput( string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email, Guid? DefaultCurrencyId, Guid? AccountOwnerUserId); public record CreateOrgRequest(OrgInput Org, string AdminLastName, string AdminFirstName, string AdminEmail, string? AdminPosition); public record CreateOrgResult(OrgDetail Organization, string AdminEmail, string AdminTempPassword); public record ArchiveRequest(string ConfirmationName); public record DeleteRequest(string ConfirmationName); public record ChangeOwnerRequest(Guid NewOwnerUserId, string Reason); [HttpGet] public async Task>> List( [FromQuery] PagedRequest req, [FromQuery] bool? archived, CancellationToken ct) { var q = _db.Organizations.IgnoreQueryFilters().AsNoTracking().AsQueryable(); if (archived is not null) q = q.Where(o => o.IsArchived == archived); if (!string.IsNullOrWhiteSpace(req.Search)) { var s = req.Search.Trim().ToLower(); q = q.Where(o => o.Name.ToLower().Contains(s) || (o.Bin != null && o.Bin.Contains(s))); } var total = await q.CountAsync(ct); var items = await q .OrderBy(o => o.IsArchived).ThenBy(o => o.Name) .Skip(req.Skip).Take(req.Take) .Select(o => new OrgRow( o.Id, o.Name, o.CountryCode, o.IsActive, o.IsArchived, o.ArchivedAt, o.CreatedAt, _db.Employees.IgnoreQueryFilters().Count(e => e.OrganizationId == o.Id), _db.Products.IgnoreQueryFilters().Count(p => p.OrganizationId == o.Id), _db.Users.Where(u => u.OrganizationId == o.Id && u.LastLoginAt != null) .Max(u => (DateTime?)u.LastLoginAt))) .ToListAsync(ct); return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; } [HttpGet("{id:guid}")] public async Task> Get(Guid id, CancellationToken ct) { var dto = await ProjectAsync(id, ct); return dto is null ? NotFound() : Ok(dto); } [HttpPost] public async Task> Create([FromBody] CreateOrgRequest input, CancellationToken ct) { var phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Org.Phone); if (!string.IsNullOrEmpty(input.Org.Phone) && phone is null) return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage }); var org = new Organization { Name = input.Org.Name, CountryCode = input.Org.CountryCode, Bin = input.Org.Bin, Address = input.Org.Address, Phone = phone, Email = input.Org.Email, DefaultCurrencyId = input.Org.DefaultCurrencyId, }; _db.Organizations.Add(org); await _db.SaveChangesAsync(ct); // Полный bootstrap tenant-сущностей: единицы измерения, типы цен, // «Основной склад», «Касса 1», 6 ролей (2 системные + 4 кастомные шаблона). // Один helper и в DevDataSeeder, и здесь — гарантирует одинаковое // состояние новой орги независимо от пути создания. await DevDataSeeder.SeedTenantReferencesAsync(_db, org.Id, ct); var adminRole = await _db.EmployeeRoles.IgnoreQueryFilters() .FirstAsync(r => r.OrganizationId == org.Id && r.IsSystem && r.Name == "Администратор", ct); // AppUser админа var existing = await _userMgr.FindByEmailAsync(input.AdminEmail); if (existing is not null) return BadRequest(new { error = $"Пользователь {input.AdminEmail} уже существует." }); var tempPwd = GenerateTempPassword(); var user = new User { UserName = input.AdminEmail, Email = input.AdminEmail, EmailConfirmed = true, FullName = $"{input.AdminLastName} {input.AdminFirstName}".Trim(), OrganizationId = org.Id, IsActive = true, }; var ur = await _userMgr.CreateAsync(user, tempPwd); if (!ur.Succeeded) return BadRequest(new { error = string.Join("; ", ur.Errors.Select(e => e.Description)) }); await _userMgr.AddToRoleAsync(user, "Admin"); org.AccountOwnerUserId = user.Id; _db.Employees.Add(new Employee { OrganizationId = org.Id, UserId = user.Id, LastName = input.AdminLastName, FirstName = input.AdminFirstName, Position = input.AdminPosition ?? "Директор", Email = input.AdminEmail, Role = adminRole, IsActive = true, }); await _db.SaveChangesAsync(ct); await LogAsync("CreateOrg", org.Id, $"Создана организация «{org.Name}»", null, $"{{\"adminEmail\":\"{input.AdminEmail}\"}}", ct); var detail = await ProjectAsync(org.Id, ct); return new CreateOrgResult(detail!, input.AdminEmail, tempPwd); } [HttpPut("{id:guid}")] public async Task Update(Guid id, [FromBody] OrgInput input, CancellationToken ct) { var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct); if (o is null) return NotFound(); var phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone); if (!string.IsNullOrEmpty(input.Phone) && phone is null) return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage }); var before = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}"; o.Name = input.Name; o.CountryCode = input.CountryCode; o.Bin = input.Bin; o.Address = input.Address; o.Phone = phone; o.Email = input.Email; o.DefaultCurrencyId = input.DefaultCurrencyId; await _db.SaveChangesAsync(ct); var after = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}"; await LogAsync("EditOrg", o.Id, $"Изменены данные «{o.Name}»", null, $"{{\"before\":{before},\"after\":{after}}}", ct); return NoContent(); } [HttpPost("{id:guid}/archive")] public async Task Archive(Guid id, [FromBody] ArchiveRequest req, CancellationToken ct) { var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct); if (o is null) return NotFound(); if (o.IsArchived) return Conflict(new { error = "Уже в архиве." }); if (req.ConfirmationName != o.Name) return BadRequest(new { error = "Введи название организации точно для подтверждения." }); o.IsArchived = true; o.ArchivedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); await LogAsync("ArchiveOrg", o.Id, $"Архивирована «{o.Name}»", null, "{}", ct); return NoContent(); } [HttpPost("{id:guid}/restore")] public async Task Restore(Guid id, CancellationToken ct) { var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct); if (o is null) return NotFound(); if (!o.IsArchived) return Conflict(new { error = "Не в архиве." }); o.IsArchived = false; o.ArchivedAt = null; await _db.SaveChangesAsync(ct); await LogAsync("RestoreOrg", o.Id, $"Восстановлена из архива «{o.Name}»", null, "{}", ct); return NoContent(); } [HttpDelete("{id:guid}")] public async Task Delete(Guid id, [FromBody] DeleteRequest req, CancellationToken ct) { var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct); if (o is null) return NotFound(); if (!o.IsArchived || o.ArchivedAt is null) return Conflict(new { error = "Удалить можно только архивированную организацию." }); var retentionDays = await _db.SystemSettings.Select(s => (int?)s.ArchiveRetentionDays).FirstOrDefaultAsync(ct) ?? 30; if (o.ArchivedAt > DateTime.UtcNow.AddDays(-retentionDays)) return Conflict(new { error = $"Доступно через {retentionDays} дней архива." }); if (req.ConfirmationName != o.Name) return BadRequest(new { error = "Введи название организации точно." }); await LogAsync("DeleteOrg", o.Id, $"Удалена навсегда «{o.Name}»", null, $"{{\"name\":\"{o.Name}\"}}", ct); // Деактивируем всех AppUser привязанных к этой org (orphan reference после Remove). // Чистим OpenIddict tokens — иначе у юзера остаются valid refresh-tokens 30 дней. var orphanUsers = await _db.Users.IgnoreQueryFilters() .Where(u => u.OrganizationId == o.Id).ToListAsync(ct); foreach (var u in orphanUsers) { u.IsActive = false; u.OrganizationId = null; } var userIds = orphanUsers.Select(u => u.Id.ToString()).ToList(); if (userIds.Count > 0) { await _db.Database.ExecuteSqlRawAsync( "UPDATE \"OpenIddictTokens\" SET \"Status\" = 'revoked' WHERE \"Subject\" = ANY({0})", new object[] { userIds.ToArray() }); } // Cascade delete domain entities is up to FK config; здесь просто Remove, // EF выкинет ошибку если есть restrict-связи — оператор увидит и решит. _db.Organizations.Remove(o); await _db.SaveChangesAsync(ct); return NoContent(); } [HttpPost("{id:guid}/change-owner")] public async Task ChangeOwner(Guid id, [FromBody] ChangeOwnerRequest req, CancellationToken ct) { var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct); if (o is null) return NotFound(); if (string.IsNullOrWhiteSpace(req.Reason)) return BadRequest(new { error = "Reason required." }); var user = await _userMgr.FindByIdAsync(req.NewOwnerUserId.ToString()); if (user is null || user.OrganizationId != o.Id) return BadRequest(new { error = "Пользователь не найден или не принадлежит этой организации." }); var prev = o.AccountOwnerUserId; o.AccountOwnerUserId = req.NewOwnerUserId; await _db.SaveChangesAsync(ct); await LogAsync("ChangeOwner", o.Id, $"Сменён владелец «{o.Name}»", req.Reason, $"{{\"from\":\"{prev}\",\"to\":\"{req.NewOwnerUserId}\"}}", ct); return NoContent(); } private async Task ProjectAsync(Guid id, CancellationToken ct) { var o = await _db.Organizations.IgnoreQueryFilters().AsNoTracking() .Include(x => x.DefaultCurrency) .FirstOrDefaultAsync(x => x.Id == id, ct); if (o is null) return null; var emp = await _db.Employees.IgnoreQueryFilters().CountAsync(e => e.OrganizationId == id, ct); var prod = await _db.Products.IgnoreQueryFilters().CountAsync(p => p.OrganizationId == id, ct); var cp = await _db.Counterparties.IgnoreQueryFilters().CountAsync(c => c.OrganizationId == id, ct); var monthAgo = DateTime.UtcNow.AddDays(-30); var supplies = await _db.Supplies.IgnoreQueryFilters() .CountAsync(s => s.OrganizationId == id && s.Date >= monthAgo, ct); User? owner = null; if (o.AccountOwnerUserId is not null) owner = await _userMgr.FindByIdAsync(o.AccountOwnerUserId.ToString()!); return new OrgDetail( o.Id, o.Name, o.CountryCode, o.Bin, o.Address, o.Phone, o.Email, o.DefaultCurrencyId, o.DefaultCurrency?.Code, o.IsActive, o.IsArchived, o.ArchivedAt, o.AccountOwnerUserId, owner?.FullName, owner?.Email, o.CreatedAt, o.UpdatedAt, emp, prod, cp, supplies); } private async Task LogAsync(string actionType, Guid orgId, string description, string? reason, string changesJson, CancellationToken ct) { var userIdRaw = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); Guid.TryParse(userIdRaw, out var uid); _db.SuperAdminAuditLogs.Add(new SuperAdminAuditLog { SuperAdminUserId = uid, ActionType = actionType, OrganizationId = orgId, Description = description, Reason = reason, ChangesJson = changesJson, IpAddress = HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "", }); await _db.SaveChangesAsync(ct); } private static string GenerateTempPassword() { const string lower = "abcdefghkmnpqrstuvwxyz"; const string upper = "ABCDEFGHKMNPQRSTUVWXYZ"; const string digits = "23456789"; const string special = "!@#$%&*"; var rnd = new Random(); var chars = new List { upper[rnd.Next(upper.Length)], lower[rnd.Next(lower.Length)], digits[rnd.Next(digits.Length)], special[rnd.Next(special.Length)], }; var pool = lower + upper + digits; for (var i = 0; i < 8; i++) chars.Add(pool[rnd.Next(pool.Length)]); return new string(chars.OrderBy(_ => rnd.Next()).ToArray()); } }