food-market/src/food-market.api/Controllers/Organizations/EmployeesController.cs
nns 5091d43f5d fix(employees): увольнение/деактивация гасит логин связанного User
Employee.Delete (увольнение и soft-delete) и Employee.Update (деактивация)
меняли только Employee.IsActive, но не трогали связанный AppUser. Логин и
refresh в AuthorizationController гейтятся на User.IsActive — поэтому
уволенный сотрудник продолжал входить и обновлять токены до 30 дней (ТЗ 0.4:
«возможность залогиниться как удалённого сотрудника» = баг, P0).

Добавлен SetLinkedUserActiveAsync: при деактивации сотрудника гасит
User.IsActive и отзывает его valid OpenIddict-токены (как при удалении орг),
при реактивации через Update — возвращает доступ. Вызывается из DELETE (оба
шага) и из Update при смене активности.

Найдено сценарием employees step07 (было: login/refresh уволенного → 200).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:47:56 +05:00

379 lines
19 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;
// Меняем активность сотрудника — синхронизируем логин связанного User
// (деактивация гасит сессии, реактивация возвращает доступ). См. DELETE.
if (e.IsActive != nowActive) await SetLinkedUserActiveAsync(e.UserId, nowActive, ct);
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;
}
// Увольнение и soft-delete обязаны гасить логин связанного User: иначе
// уволенный сотрудник продолжает входить и обновлять токены (ТЗ 0.4).
// На обоих шагах сотрудник перестаёт быть активным персоналом → гасим.
await DeactivateLinkedUserAsync(e.UserId, ct);
await _db.SaveChangesAsync(ct);
return NoContent();
}
/// <summary>Синхронизирует активность связанного AppUser с активностью
/// сотрудника. При деактивации дополнительно отзывает valid refresh/access
/// токены — без этого у уволенного остаётся рабочий refresh до 30 дней
/// (логин и refresh в AuthorizationController гейтятся на User.IsActive).</summary>
private async Task SetLinkedUserActiveAsync(Guid? userId, bool active, CancellationToken ct)
{
if (userId is null) return;
var user = await _db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId.Value, ct);
if (user is null || user.IsActive == active) return;
user.IsActive = active;
if (!active)
{
await _db.Database.ExecuteSqlRawAsync(
"UPDATE \"OpenIddictTokens\" SET \"Status\" = 'revoked' WHERE \"Subject\" = {0} AND \"Status\" = 'valid'",
user.Id.ToString());
}
}
private Task DeactivateLinkedUserAsync(Guid? userId, CancellationToken ct)
=> SetLinkedUserActiveAsync(userId, active: false, ct);
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());
}
}