feat(api): super-admin endpoints (orgs CRUD + setup-status + audit-log + dashboard)

SuperAdminOrganizationsController (/api/super-admin/organizations):
все методы используют IgnoreQueryFilters() для обхода tenant-фильтра.
- GET / — таблица с пагинацией, фильтр archived, поиск по Name/Bin,
  возвращает счётчики (employees, products) + last login по users.
- GET /{id} — детали + статистика (employees, products, counterparties,
  supplies за 30 дней) + AccountOwner данные.
- POST / — создание орга вместе с админом: Org + Store «Основной» +
  EmployeeRole «Администратор» (IsSystem) + AppUser (random temp pwd
  возвращается один раз) + Employee. Owner = созданный AppUser.
- PUT /{id} — правка базовых данных, лог EditOrg с before/after.
- POST /{id}/archive — требует ConfirmationName == Org.Name (ввод).
- POST /{id}/restore — снять архив.
- DELETE /{id} — только если в архиве >30 дней + повторное подтверждение.
- POST /{id}/change-owner — Reason обязателен, валидируем что user
  принадлежит этой орге, лог ChangeOwner с from/to.

Все мутации пишут запись в SuperAdminAuditLog с ActionType,
Description, Reason, ChangesJson, IpAddress, SuperAdminUserId.

SuperAdminController (/api/super-admin):
- GET /setup-status — нужен ли wizard? (OrgCount == 0).
- GET /dashboard — total/active/archived orgs, users, products, supplies/month.
- GET /audit-log — фильтры organizationId/actionType/from/to + paged + join
  на orgs для имени.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 12:54:07 +05:00
parent e93634fad4
commit 18eb362702
2 changed files with 363 additions and 0 deletions

View file

@ -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;
/// <summary>SuperAdmin: setup-status, dashboard статистика, audit log.</summary>
[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<ActionResult<SetupStatusDto>> 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<ActionResult<DashboardStats>> 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<ActionResult<PagedResult<AuditRow>>> 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<AuditRow> { Items = rows, Total = total, Page = req.Page, PageSize = req.Take };
}
}

View file

@ -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;
/// <summary>SuperAdmin console: управление организациями. Все запросы
/// IgnoreQueryFilters() — обходим tenant-фильтр, видим всё. Все мутации
/// логируются в super_admin_audit_log.</summary>
[ApiController]
[Authorize(Roles = "SuperAdmin")]
[Route("api/super-admin/organizations")]
public class SuperAdminOrganizationsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly UserManager<User> _userMgr;
public SuperAdminOrganizationsController(AppDbContext db, UserManager<User> 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<ActionResult<PagedResult<OrgRow>>> 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<OrgRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<OrgDetail>> Get(Guid id, CancellationToken ct)
{
var dto = await ProjectAsync(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
public async Task<ActionResult<CreateOrgResult>> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<OrgDetail?> 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<char>
{
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());
}
}