diff --git a/src/food-market.api/Controllers/SuperAdmin/SuperAdminController.cs b/src/food-market.api/Controllers/SuperAdmin/SuperAdminController.cs new file mode 100644 index 0000000..cda385c --- /dev/null +++ b/src/food-market.api/Controllers/SuperAdmin/SuperAdminController.cs @@ -0,0 +1,81 @@ +using foodmarket.Application.Common; +using foodmarket.Domain.Organizations; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.SuperAdmin; + +/// SuperAdmin: setup-status, dashboard статистика, audit log. +[ApiController] +[Authorize(Roles = "SuperAdmin")] +[Route("api/super-admin")] +public class SuperAdminController : ControllerBase +{ + private readonly AppDbContext _db; + + public SuperAdminController(AppDbContext db) => _db = db; + + public record SetupStatusDto(bool NeedsSetup, int OrgCount); + + [HttpGet("setup-status")] + public async Task> GetSetupStatus(CancellationToken ct) + { + var count = await _db.Organizations.IgnoreQueryFilters().CountAsync(ct); + return new SetupStatusDto(NeedsSetup: count == 0, OrgCount: count); + } + + public record DashboardStats( + int TotalOrgs, int ActiveOrgs, int ArchivedOrgs, + int TotalUsers, int ActiveUsers, + int TotalProducts, int TotalSuppliesThisMonth); + + [HttpGet("dashboard")] + public async Task> Dashboard(CancellationToken ct) + { + var monthAgo = DateTime.UtcNow.AddDays(-30); + return new DashboardStats( + TotalOrgs: await _db.Organizations.IgnoreQueryFilters().CountAsync(ct), + ActiveOrgs: await _db.Organizations.IgnoreQueryFilters().CountAsync(o => !o.IsArchived, ct), + ArchivedOrgs: await _db.Organizations.IgnoreQueryFilters().CountAsync(o => o.IsArchived, ct), + TotalUsers: await _db.Users.CountAsync(ct), + ActiveUsers: await _db.Users.CountAsync(u => u.IsActive, ct), + TotalProducts: await _db.Products.IgnoreQueryFilters().CountAsync(ct), + TotalSuppliesThisMonth: await _db.Supplies.IgnoreQueryFilters().CountAsync(s => s.Date >= monthAgo, ct)); + } + + public record AuditRow( + Guid Id, DateTime CreatedAt, Guid SuperAdminUserId, + string ActionType, Guid? OrganizationId, string? OrganizationName, + string? EntityType, Guid? EntityId, string? Description, string? Reason, string IpAddress); + + [HttpGet("audit-log")] + public async Task>> AuditLog( + [FromQuery] PagedRequest req, + [FromQuery] Guid? organizationId, + [FromQuery] string? actionType, + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + CancellationToken ct) + { + var q = _db.SuperAdminAuditLogs.AsNoTracking().AsQueryable(); + if (organizationId is not null) q = q.Where(x => x.OrganizationId == organizationId); + if (!string.IsNullOrWhiteSpace(actionType)) q = q.Where(x => x.ActionType == actionType); + if (from is not null) q = q.Where(x => x.CreatedAt >= from); + if (to is not null) q = q.Where(x => x.CreatedAt <= to); + var total = await q.CountAsync(ct); + var orgNames = await _db.Organizations.IgnoreQueryFilters() + .ToDictionaryAsync(o => o.Id, o => o.Name, ct); + var items = await q + .OrderByDescending(x => x.CreatedAt) + .Skip(req.Skip).Take(req.Take) + .ToListAsync(ct); + var rows = items.Select(x => new AuditRow( + x.Id, x.CreatedAt, x.SuperAdminUserId, + x.ActionType, x.OrganizationId, + x.OrganizationId is not null && orgNames.TryGetValue(x.OrganizationId.Value, out var n) ? n : null, + x.EntityType, x.EntityId, x.Description, x.Reason, x.IpAddress)).ToList(); + return new PagedResult { Items = rows, Total = total, Page = req.Page, PageSize = req.Take }; + } +} diff --git a/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs b/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs new file mode 100644 index 0000000..72d776d --- /dev/null +++ b/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs @@ -0,0 +1,282 @@ +using foodmarket.Application.Common; +using foodmarket.Domain.Catalog; +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 org = new Organization + { + Name = input.Org.Name, CountryCode = input.Org.CountryCode, + Bin = input.Org.Bin, Address = input.Org.Address, + Phone = input.Org.Phone, Email = input.Org.Email, + DefaultCurrencyId = input.Org.DefaultCurrencyId, + }; + _db.Organizations.Add(org); + // Системные референсы (Stores, Cashiers, Roles) подсеет DevDataSeeder при + // следующем рестарте; для немедленной готовности создаём минимум руками. + var mainStore = new Store + { + OrganizationId = org.Id, Name = "Основной склад", Code = "MAIN", IsMain = true, + }; + _db.Stores.Add(mainStore); + + var adminRole = new EmployeeRole + { + OrganizationId = org.Id, + Name = "Администратор", Description = "Полный доступ ко всем разделам организации", + IsSystem = true, SortOrder = 0, Permissions = RolePermissions.All(), + }; + _db.EmployeeRoles.Add(adminRole); + + // 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 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 = input.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 || o.ArchivedAt > DateTime.UtcNow.AddDays(-30)) + return Conflict(new { error = "Удалить можно только организации в архиве >30 дней." }); + if (req.ConfirmationName != o.Name) return BadRequest(new { error = "Введи название организации точно." }); + await LogAsync("DeleteOrg", o.Id, $"Удалена навсегда «{o.Name}»", null, + $"{{\"name\":\"{o.Name}\"}}", ct); + // 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()); + } +}