food-market/src/food-market.api/Controllers/Organizations/EmployeesController.cs
nns 46877cc134 fix(phone): серверная KZ-ФЛК на всех endpoint'ах принимающих phone
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 остаётся валидным для опциональных полей —
контроллер сам решает требовать или нет.
2026-05-08 01:05:48 +05:00

351 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Security.Claims;
using foodmarket.Application.Common;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Organizations;
using foodmarket.Infrastructure.Identity;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Organizations;
[ApiController]
[Authorize(Roles = "SuperAdmin,Admin")]
[Route("api/organization/employees")]
public class EmployeesController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
private readonly UserManager<User> _userMgr;
public EmployeesController(AppDbContext db, ITenantContext tenant, UserManager<User> userMgr)
{
_db = db; _tenant = tenant; _userMgr = userMgr;
}
public record EmployeeDto(
Guid Id, Guid? UserId, string LastName, string FirstName, string? MiddleName,
string? Position, string? Email, string? Phone,
decimal? Salary, string? TaxNumber, string? Description, string? ImageUrl,
Guid RoleId, string RoleName,
bool IsActive, DateTime? FiredAt,
bool IsDeleted, DateTime? DeletedAt,
// active | fired | deleted — производное от IsActive/IsDeleted, удобно для UI-бейджа
string Status,
IReadOnlyList<Guid> RetailPointIds,
bool IsOwner, bool IsSelf);
public record EmployeeInput(
string LastName, string FirstName, string? MiddleName,
string? Position, string? Email, string? Phone,
decimal? Salary, string? TaxNumber, string? Description, string? ImageUrl,
Guid RoleId, bool IsActive,
IReadOnlyList<Guid>? RetailPointIds,
// CreateAccount=true → создаём User c email + temp password.
// Возвращается в response один раз (showOnce).
bool CreateAccount = false);
public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPassword);
[HttpGet]
public async Task<ActionResult<PagedResult<EmployeeDto>>> List(
[FromQuery] PagedRequest req,
[FromQuery] string? status, // active | fired | deleted | all (default: active+fired)
CancellationToken ct = default)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var ownerUserId = await _db.Organizations.IgnoreQueryFilters()
.Where(o => o.Id == orgId)
.Select(o => o.AccountOwnerUserId)
.FirstOrDefaultAsync(ct);
var currentUserId = ParseUserId(User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst("sub")?.Value);
var q = _db.Employees.AsNoTracking().Include(e => e.Role).Include(e => e.RetailPointAssignments).AsQueryable();
// Фильтр по статусу. По умолчанию (status=null) — показываем активных
// и уволенных, удалённых скрываем; «all» включает удалённых.
switch (status)
{
case "active": q = q.Where(e => e.IsActive && !e.IsDeleted); break;
case "fired": q = q.Where(e => !e.IsActive && !e.IsDeleted); break;
case "deleted": q = q.Where(e => e.IsDeleted); break;
case "all": /* без фильтра */ break;
default: q = q.Where(e => !e.IsDeleted); break;
}
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(e =>
e.LastName.ToLower().Contains(s) ||
e.FirstName.ToLower().Contains(s) ||
(e.Email != null && e.Email.ToLower().Contains(s)) ||
(e.Phone != null && e.Phone.Contains(s)));
}
var total = await q.CountAsync(ct);
var items = await q
.OrderBy(e => e.LastName).ThenBy(e => e.FirstName)
.Skip(req.Skip).Take(req.Take)
.Select(e => new EmployeeDto(
e.Id, e.UserId, e.LastName, e.FirstName, e.MiddleName,
e.Position, e.Email, e.Phone,
e.Salary, e.TaxNumber, e.Description, e.ImageUrl,
e.RoleId, e.Role.Name,
e.IsActive, e.FiredAt,
e.IsDeleted, e.DeletedAt,
e.IsDeleted ? "deleted" : (e.IsActive ? "active" : "fired"),
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(),
e.UserId != null && ownerUserId == e.UserId,
e.UserId != null && currentUserId != null && e.UserId == currentUserId))
.ToListAsync(ct);
return new PagedResult<EmployeeDto>
{ Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<EmployeeDto>> Get(Guid id, CancellationToken ct)
{
var dto = await ProjectAsync(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
public async Task<ActionResult<EmployeeCreateResult>> Create([FromBody] EmployeeInput input, CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var role = await _db.EmployeeRoles.FirstOrDefaultAsync(r => r.Id == input.RoleId, ct);
if (role is null) return BadRequest(new { error = "Роль не найдена." });
var employee = new Employee
{
OrganizationId = orgId,
LastName = input.LastName, FirstName = input.FirstName, MiddleName = input.MiddleName,
Position = input.Position, Email = input.Email, Phone = input.Phone,
Salary = input.Salary, TaxNumber = input.TaxNumber,
Description = input.Description, ImageUrl = input.ImageUrl,
RoleId = input.RoleId, IsActive = input.IsActive,
};
string? tempPassword = null;
if (input.CreateAccount)
{
if (string.IsNullOrWhiteSpace(input.Email))
return BadRequest(new { error = "Для создания учётной записи нужен email." });
var existing = await _userMgr.FindByEmailAsync(input.Email);
if (existing is not null)
return BadRequest(new { error = $"Пользователь с email «{input.Email}» уже существует." });
tempPassword = GenerateTempPassword();
var user = new User
{
UserName = input.Email,
Email = input.Email,
EmailConfirmed = true,
FullName = $"{input.LastName} {input.FirstName}".Trim(),
OrganizationId = orgId,
IsActive = input.IsActive,
};
var result = await _userMgr.CreateAsync(user, tempPassword);
if (!result.Succeeded)
return BadRequest(new { error = string.Join("; ", result.Errors.Select(e => e.Description)) });
employee.UserId = user.Id;
// Identity-роль маппится из orgRole.Name. Кастомные orgRole не
// получают Identity-роли — они только дают UI-permissions, без
// доступа к role-locked endpoint'ам.
var identityRole = foodmarket.Api.Infrastructure.IdentityRoleMapper.FromOrgRoleName(role.Name);
if (identityRole is not null)
{
await _userMgr.AddToRoleAsync(user, identityRole);
}
}
foreach (var rpId in input.RetailPointIds ?? [])
{
employee.RetailPointAssignments.Add(new EmployeeRetailPointAssignment
{ OrganizationId = orgId, RetailPointId = rpId });
}
_db.Employees.Add(employee);
await _db.SaveChangesAsync(ct);
var dto = await ProjectAsync(employee.Id, ct);
return new EmployeeCreateResult(dto!, tempPassword);
}
[HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin")]
public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeInput input, CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var e = await _db.Employees.Include(x => x.RetailPointAssignments)
.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
// Гард для главного администратора организации (Organization.AccountOwnerUserId).
// По требованию: его роль и активность может менять только Супер-администратор
// платформы. В обычной tenant-админке — отказ. Удаление главного администратора
// обрабатывается отдельной веткой в DELETE.
var ownerUserId = await _db.Organizations.IgnoreQueryFilters()
.Where(o => o.Id == orgId)
.Select(o => o.AccountOwnerUserId)
.FirstOrDefaultAsync(ct);
var isOwner = e.UserId is not null && ownerUserId == e.UserId;
if (isOwner)
{
if (input.RoleId != e.RoleId)
{
var newRole = await _db.EmployeeRoles.AsNoTracking()
.FirstOrDefaultAsync(r => r.Id == input.RoleId, ct);
if (newRole is null || newRole.Name != "Администратор")
return StatusCode(StatusCodes.Status403Forbidden, new
{
error = "Нельзя сменить роль главного администратора организации. " +
"Это действие выполняет только Супер-администратор платформы.",
});
}
if (!input.IsActive)
return StatusCode(StatusCodes.Status403Forbidden, new
{
error = "Нельзя деактивировать главного администратора организации. " +
"Это действие выполняет только Супер-администратор платформы.",
});
}
e.LastName = input.LastName;
e.FirstName = input.FirstName;
e.MiddleName = input.MiddleName;
e.Position = input.Position;
e.Email = input.Email;
if (!string.IsNullOrWhiteSpace(input.Phone)
&& !foodmarket.Application.Common.PhoneNormalization.IsValidOrEmpty(input.Phone))
return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage });
e.Phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone);
e.Salary = input.Salary;
e.TaxNumber = input.TaxNumber;
e.Description = input.Description;
e.ImageUrl = input.ImageUrl;
e.RoleId = input.RoleId;
var nowActive = input.IsActive;
if (e.IsActive && !nowActive) e.FiredAt = DateTime.UtcNow;
if (!e.IsActive && nowActive) e.FiredAt = null;
e.IsActive = nowActive;
// Replace assignments wholesale
_db.EmployeeRetailPointAssignments.RemoveRange(e.RetailPointAssignments);
e.RetailPointAssignments.Clear();
foreach (var rpId in input.RetailPointIds ?? [])
e.RetailPointAssignments.Add(new EmployeeRetailPointAssignment
{ OrganizationId = orgId, RetailPointId = rpId });
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpDelete("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var e = await _db.Employees.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
// Двухступенчатое удаление:
// IsActive=true → этот endpoint выполняет «увольнение» (Fired).
// IsActive=false && IsDeleted=false → этот endpoint выполняет soft-delete.
// IsDeleted=true → 409, уже удалён.
// Гарды (главный админ + self) применяются на ОБОИХ шагах.
var currentUserId = ParseUserId(User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst("sub")?.Value);
if (currentUserId is not null && e.UserId == currentUserId)
{
return StatusCode(StatusCodes.Status403Forbidden, new
{
error = "Нельзя уволить или удалить себя. Покинуть организацию можно через Настройки → Аккаунт → Покинуть организацию.",
});
}
if (e.UserId is not null)
{
var ownerUserId = await _db.Organizations.IgnoreQueryFilters()
.Where(o => o.Id == e.OrganizationId)
.Select(o => o.AccountOwnerUserId)
.FirstOrDefaultAsync(ct);
if (ownerUserId == e.UserId)
{
return StatusCode(StatusCodes.Status403Forbidden, new
{
error = "Нельзя удалить главного администратора организации. " +
"Это действие выполняет только Супер-администратор платформы.",
});
}
}
if (e.IsDeleted)
return Conflict(new { error = "Сотрудник уже удалён." });
if (e.IsActive)
{
// Шаг 1: увольнение.
e.IsActive = false;
e.FiredAt ??= DateTime.UtcNow;
}
else
{
// Шаг 2: soft-delete (физически не удаляем — есть FK из retail_sales/supplies).
e.IsDeleted = true;
e.DeletedAt = DateTime.UtcNow;
}
await _db.SaveChangesAsync(ct);
return NoContent();
}
private static Guid? ParseUserId(string? raw) => Guid.TryParse(raw, out var g) ? g : null;
private async Task<EmployeeDto?> ProjectAsync(Guid id, CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var ownerUserId = await _db.Organizations.IgnoreQueryFilters()
.Where(o => o.Id == orgId)
.Select(o => o.AccountOwnerUserId)
.FirstOrDefaultAsync(ct);
var currentUserId = ParseUserId(User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst("sub")?.Value);
return await _db.Employees.AsNoTracking()
.Include(e => e.Role)
.Include(e => e.RetailPointAssignments)
.Where(e => e.Id == id)
.Select(e => new EmployeeDto(
e.Id, e.UserId, e.LastName, e.FirstName, e.MiddleName,
e.Position, e.Email, e.Phone,
e.Salary, e.TaxNumber, e.Description, e.ImageUrl,
e.RoleId, e.Role.Name,
e.IsActive, e.FiredAt,
e.IsDeleted, e.DeletedAt,
e.IsDeleted ? "deleted" : (e.IsActive ? "active" : "fired"),
e.RetailPointAssignments.Select(a => a.RetailPointId).ToList(),
e.UserId != null && ownerUserId == e.UserId,
e.UserId != null && currentUserId != null && e.UserId == currentUserId))
.FirstOrDefaultAsync(ct);
}
private static string GenerateTempPassword()
{
// 12 символов: цифры + строчные/заглавные + спецсимвол — соответствует
// дефолтным правилам ASP.NET Identity (>=8, разные классы символов).
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());
}
}