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 _userMgr; public EmployeesController(AppDbContext db, ITenantContext tenant, UserManager 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 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? RetailPointIds, // CreateAccount=true → создаём User c email + temp password. // Возвращается в response один раз (showOnce). bool CreateAccount = false); public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPassword); [HttpGet] public async Task>> 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 { 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] 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 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 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(); } /// Синхронизирует активность связанного AppUser с активностью /// сотрудника. При деактивации дополнительно отзывает valid refresh/access /// токены — без этого у уволенного остаётся рабочий refresh до 30 дней /// (логин и refresh в AuthorizationController гейтятся на User.IsActive). 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 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 { 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()); } }