Logic gap из e2e-отчёта: SuperAdmin /organizations принимал любой текст в Phone — серверной валидации ФЛК не было (только в /api/auth/signup). Это позволяло сохранить «abc» в Organization.Phone и невалидные номера для контрагентов и сотрудников. — Application/Common/PhoneNormalization.cs (новый): TryNormalizeKz + IsValidOrEmpty. Принимает любое форматирование, ведущая «8» → «7»; валидно: 11 цифр, начинается с «77» (мобильный код KZ). — SuperAdminOrganizationsController.Create/Update: 400 если phone не парсится; в БД пишется нормализованная форма «+77001234567». — CounterpartiesController.Create/Update: то же. Apply() нормализует. — EmployeesController.Create/Update: то же. — SuperAdminEmployeesController.Create/Update: то же. — AuthSignupController: убран локальный NormalizeKzPhone, используется shared. Сообщение об ошибке унифицировано. Defense-in-depth к фронтовой валидации (PhoneInput / validatePhone). Незаполненный phone остаётся валидным для опциональных полей — контроллер сам решает требовать или нет.
306 lines
15 KiB
C#
306 lines
15 KiB
C#
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;
|
||
|
||
/// <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 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<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 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<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)
|
||
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<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());
|
||
}
|
||
}
|