From 804b0e0ab6abf58c96868fc16d4e90d8d236b395 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:54:16 +0000 Subject: [PATCH 1/6] feat: implement Employee HR feature with Clean Architecture - Add Employee entity, SourceType enum, HumanResourcesAdminSourceTypes role - Add SearchEmployees query with role-aware filtering and at-least-one-criterion validation - Add GetNextRegistrationNumber query for UI hint - Add GetEmployeeLookups query (role-filtered source types + active/passive codes) - Add CreateEmployee command with business rules and race-condition-safe registration number generation - Add UpdateEmployee command with full update validations - Add IEmployeeRegistrationNumberService interface with atomic GenerateNextAsync - Add EmployeeRegistrationNumberService using DB transaction to prevent race conditions - Add Employees minimal API endpoint group with all 7 endpoints - Seed HumanResourcesAdminSourceTypes role in DB initializer - Pre-existing NU1903 vulnerability unrelated to this feature Agent-Logs-Url: https://github.com/defactoAdil/CleanArchitecture/sessions/5820a821-591e-44e7-ad12-0141efb2e2f4 Co-authored-by: defactoAdil <189980729+defactoAdil@users.noreply.github.com> --- .../Interfaces/IApplicationDbContext.cs | 4 +- .../IEmployeeRegistrationNumberService.cs | 19 +++ .../Commands/CreateEmployee/CreateEmployee.cs | 98 +++++++++++++++ .../CreateEmployeeCommandValidator.cs | 46 +++++++ .../Commands/UpdateEmployee/UpdateEmployee.cs | 91 ++++++++++++++ .../UpdateEmployeeCommandValidator.cs | 54 +++++++++ .../GetEmployeeLookups/EmployeeLookupsVm.cs | 22 ++++ .../GetEmployeeLookups/GetEmployeeLookups.cs | 47 ++++++++ .../GetNextRegistrationNumber.cs | 26 ++++ .../Queries/SearchEmployees/EmployeeDto.cs | 54 +++++++++ .../SearchEmployees/EmployeeSearchRequest.cs | 18 +++ .../SearchEmployees/SearchEmployees.cs | 113 ++++++++++++++++++ src/Domain/Constants/Roles.cs | 2 + src/Domain/Entities/Employee.cs | 29 +++++ src/Domain/Enums/SourceType.cs | 11 ++ .../Data/ApplicationDbContext.cs | 2 + .../Data/ApplicationDbContextInitialiser.cs | 6 + .../Configurations/EmployeeConfiguration.cs | 47 ++++++++ src/Infrastructure/DependencyInjection.cs | 2 + .../EmployeeRegistrationNumberService.cs | 62 ++++++++++ src/Web/Endpoints/Employees.cs | 95 +++++++++++++++ 21 files changed, 847 insertions(+), 1 deletion(-) create mode 100644 src/Application/Common/Interfaces/IEmployeeRegistrationNumberService.cs create mode 100644 src/Application/Employees/Commands/CreateEmployee/CreateEmployee.cs create mode 100644 src/Application/Employees/Commands/CreateEmployee/CreateEmployeeCommandValidator.cs create mode 100644 src/Application/Employees/Commands/UpdateEmployee/UpdateEmployee.cs create mode 100644 src/Application/Employees/Commands/UpdateEmployee/UpdateEmployeeCommandValidator.cs create mode 100644 src/Application/Employees/Queries/GetEmployeeLookups/EmployeeLookupsVm.cs create mode 100644 src/Application/Employees/Queries/GetEmployeeLookups/GetEmployeeLookups.cs create mode 100644 src/Application/Employees/Queries/GetNextRegistrationNumber/GetNextRegistrationNumber.cs create mode 100644 src/Application/Employees/Queries/SearchEmployees/EmployeeDto.cs create mode 100644 src/Application/Employees/Queries/SearchEmployees/EmployeeSearchRequest.cs create mode 100644 src/Application/Employees/Queries/SearchEmployees/SearchEmployees.cs create mode 100644 src/Domain/Entities/Employee.cs create mode 100644 src/Domain/Enums/SourceType.cs create mode 100644 src/Infrastructure/Data/Configurations/EmployeeConfiguration.cs create mode 100644 src/Infrastructure/Services/EmployeeRegistrationNumberService.cs create mode 100644 src/Web/Endpoints/Employees.cs diff --git a/src/Application/Common/Interfaces/IApplicationDbContext.cs b/src/Application/Common/Interfaces/IApplicationDbContext.cs index 24f632d66..b70fdb90a 100644 --- a/src/Application/Common/Interfaces/IApplicationDbContext.cs +++ b/src/Application/Common/Interfaces/IApplicationDbContext.cs @@ -1,4 +1,4 @@ -using CleanArchitecture.Domain.Entities; +using CleanArchitecture.Domain.Entities; namespace CleanArchitecture.Application.Common.Interfaces; @@ -8,5 +8,7 @@ public interface IApplicationDbContext DbSet TodoItems { get; } + DbSet Employees { get; } + Task SaveChangesAsync(CancellationToken cancellationToken); } diff --git a/src/Application/Common/Interfaces/IEmployeeRegistrationNumberService.cs b/src/Application/Common/Interfaces/IEmployeeRegistrationNumberService.cs new file mode 100644 index 000000000..84397e2ce --- /dev/null +++ b/src/Application/Common/Interfaces/IEmployeeRegistrationNumberService.cs @@ -0,0 +1,19 @@ +namespace CleanArchitecture.Application.Common.Interfaces; + +/// +/// Generates unique, race-condition-safe employee registration numbers per source type. +/// +public interface IEmployeeRegistrationNumberService +{ + /// + /// Returns the next registration number for the given source type by atomically + /// reading the current maximum and incrementing it inside a database transaction. + /// + Task GenerateNextAsync(string sourceType, CancellationToken cancellationToken); + + /// + /// Returns a preview of the next registration number without reserving it. + /// Not safe for concurrent use — use GenerateNextAsync during actual insert. + /// + Task PeekNextAsync(string sourceType, CancellationToken cancellationToken); +} diff --git a/src/Application/Employees/Commands/CreateEmployee/CreateEmployee.cs b/src/Application/Employees/Commands/CreateEmployee/CreateEmployee.cs new file mode 100644 index 000000000..d344e846c --- /dev/null +++ b/src/Application/Employees/Commands/CreateEmployee/CreateEmployee.cs @@ -0,0 +1,98 @@ +using CleanArchitecture.Application.Common.Interfaces; +using CleanArchitecture.Application.Common.Security; +using CleanArchitecture.Domain.Constants; +using CleanArchitecture.Domain.Entities; +using CleanArchitecture.Domain.Enums; + +namespace CleanArchitecture.Application.Employees.Commands.CreateEmployee; + +[Authorize] +public record CreateEmployeeCommand : IRequest +{ + public string IdentityNumber { get; init; } = string.Empty; + + public string Firstname { get; init; } = string.Empty; + + public string Lastname { get; init; } = string.Empty; + + public string? PersonalMobileNumber { get; init; } + + public string SourceTypeStr { get; init; } = string.Empty; + + public string ActivePassiveCode { get; init; } = "1"; + + public bool IsTerminated { get; init; } + + public string? CompanyName { get; init; } + + public int? BusinessUnitId { get; init; } + + public string Description { get; init; } = string.Empty; +} + +public class CreateEmployeeCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + private readonly IEmployeeRegistrationNumberService _registrationNumberService; + private readonly IUser _user; + + private static readonly string[] RestrictedSourceTypes = + [ + SourceType.SAP.ToString(), + SourceType.OzonTekstil.ToString() + ]; + + public CreateEmployeeCommandHandler( + IApplicationDbContext context, + IEmployeeRegistrationNumberService registrationNumberService, + IUser user) + { + _context = context; + _registrationNumberService = registrationNumberService; + _user = user; + } + + public async Task Handle(CreateEmployeeCommand request, CancellationToken cancellationToken) + { + // Resolve default source type based on role + var sourceType = string.IsNullOrWhiteSpace(request.SourceTypeStr) + ? DefaultSourceType() + : request.SourceTypeStr; + + // BusinessUnitId is only set for admin users + var isAdmin = _user.Roles?.Contains(Roles.HumanResourcesAdminSourceTypes) ?? false; + var businessUnitId = isAdmin ? request.BusinessUnitId : null; + + // Generate unique registration number (race-condition safe via transaction) + var registrationNumber = await _registrationNumberService + .GenerateNextAsync(sourceType, cancellationToken); + + var employee = new Employee + { + RegistrationNumber = registrationNumber, + IdentityNumber = request.IdentityNumber, + Firstname = request.Firstname, + Lastname = request.Lastname, + PersonalMobileNumber = request.PersonalMobileNumber, + SourceTypeStr = sourceType, + ActivePassiveCode = NormalizeActivePassiveCode(request.ActivePassiveCode), + IsTerminated = request.IsTerminated, + CompanyName = request.CompanyName, + BusinessUnitId = businessUnitId, + Description = request.Description + }; + + _context.Employees.Add(employee); + await _context.SaveChangesAsync(cancellationToken); + + return registrationNumber; + } + + private string DefaultSourceType() => + (_user.Roles?.Contains(Roles.HumanResourcesAdminSourceTypes) ?? false) + ? SourceType.Ecrou.ToString() + : SourceType.Other.ToString(); + + private static string NormalizeActivePassiveCode(string code) => + code.Equals("active", StringComparison.OrdinalIgnoreCase) || code == "1" ? "1" : "0"; +} diff --git a/src/Application/Employees/Commands/CreateEmployee/CreateEmployeeCommandValidator.cs b/src/Application/Employees/Commands/CreateEmployee/CreateEmployeeCommandValidator.cs new file mode 100644 index 000000000..74dec48e6 --- /dev/null +++ b/src/Application/Employees/Commands/CreateEmployee/CreateEmployeeCommandValidator.cs @@ -0,0 +1,46 @@ +using CleanArchitecture.Domain.Enums; + +namespace CleanArchitecture.Application.Employees.Commands.CreateEmployee; + +public class CreateEmployeeCommandValidator : AbstractValidator +{ + private static readonly string[] RestrictedSourceTypes = + [ + SourceType.SAP.ToString(), + SourceType.OzonTekstil.ToString() + ]; + + public CreateEmployeeCommandValidator() + { + RuleFor(c => c.IdentityNumber) + .NotEmpty() + .MaximumLength(11) + .Matches(@"^\d+$").WithMessage("Identity number must contain only digits."); + + RuleFor(c => c.Firstname) + .NotEmpty() + .MaximumLength(100) + .Matches(@"^[\p{L}]+$").WithMessage("First name must contain letters only."); + + RuleFor(c => c.Lastname) + .NotEmpty() + .MaximumLength(100) + .Matches(@"^[\p{L}]+$").WithMessage("Last name must contain letters only."); + + RuleFor(c => c.PersonalMobileNumber) + .Length(12).WithMessage("Personal mobile number must be exactly 12 characters.") + .Must(n => n!.StartsWith("90")).WithMessage("Personal mobile number must start with '90'.") + .When(c => !string.IsNullOrEmpty(c.PersonalMobileNumber)); + + RuleFor(c => c.SourceTypeStr) + .Must(st => !RestrictedSourceTypes.Contains(st)) + .WithMessage("SAP and OzonTekstil source types cannot be selected when creating an employee.") + .When(c => !string.IsNullOrWhiteSpace(c.SourceTypeStr)); + + RuleFor(c => c.Description) + .NotEmpty(); + + // Duplicate registration number prevention is handled atomically in the command + // handler via IEmployeeRegistrationNumberService and the unique DB index. + } +} diff --git a/src/Application/Employees/Commands/UpdateEmployee/UpdateEmployee.cs b/src/Application/Employees/Commands/UpdateEmployee/UpdateEmployee.cs new file mode 100644 index 000000000..516d1c9dc --- /dev/null +++ b/src/Application/Employees/Commands/UpdateEmployee/UpdateEmployee.cs @@ -0,0 +1,91 @@ +using CleanArchitecture.Application.Common.Interfaces; +using CleanArchitecture.Application.Common.Security; +using CleanArchitecture.Domain.Constants; +using CleanArchitecture.Domain.Enums; + +namespace CleanArchitecture.Application.Employees.Commands.UpdateEmployee; + +[Authorize] +public record UpdateEmployeeCommand : IRequest +{ + public string RegistrationNumber { get; init; } = string.Empty; + + public string IdentityNumber { get; init; } = string.Empty; + + public string Firstname { get; init; } = string.Empty; + + public string Lastname { get; init; } = string.Empty; + + public string? PersonalMobileNumber { get; init; } + + public string SourceTypeStr { get; init; } = string.Empty; + + public string ActivePassiveCode { get; init; } = string.Empty; + + public bool IsTerminated { get; init; } + + public string CompanyName { get; init; } = string.Empty; + + public int? BusinessUnitId { get; init; } + + public string Description { get; init; } = string.Empty; +} + +public class UpdateEmployeeCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + private readonly IUser _user; + + private static readonly string[] RestrictedSourceTypes = + [ + SourceType.SAP.ToString(), + SourceType.OzonTekstil.ToString() + ]; + + public UpdateEmployeeCommandHandler(IApplicationDbContext context, IUser user) + { + _context = context; + _user = user; + } + + public async Task Handle(UpdateEmployeeCommand request, CancellationToken cancellationToken) + { + var employee = await _context.Employees + .FirstOrDefaultAsync(e => e.RegistrationNumber == request.RegistrationNumber, cancellationToken); + + Guard.Against.NotFound(request.RegistrationNumber, employee); + + var isAdmin = _user.Roles?.Contains(Roles.HumanResourcesAdminSourceTypes) ?? false; + + // Non-admin users cannot edit SAP or OzonTekstil employees + if (!isAdmin && RestrictedSourceTypes.Contains(employee.SourceTypeStr)) + { + throw new UnauthorizedAccessException( + "You do not have permission to edit SAP or OzonTekstil employees."); + } + + // SourceType cannot be changed to SAP or OzonTekstil + var normalizedSourceType = request.SourceTypeStr; + + employee.IdentityNumber = request.IdentityNumber; + employee.Firstname = request.Firstname; + employee.Lastname = request.Lastname; + employee.PersonalMobileNumber = request.PersonalMobileNumber; + employee.SourceTypeStr = normalizedSourceType; + employee.ActivePassiveCode = NormalizeActivePassiveCode(request.ActivePassiveCode); + employee.IsTerminated = request.IsTerminated; + employee.CompanyName = request.CompanyName; + employee.Description = request.Description; + + // BusinessUnitId is only updatable by admin + if (isAdmin) + { + employee.BusinessUnitId = request.BusinessUnitId; + } + + await _context.SaveChangesAsync(cancellationToken); + } + + private static string NormalizeActivePassiveCode(string code) => + code.Equals("active", StringComparison.OrdinalIgnoreCase) || code == "1" ? "1" : "0"; +} diff --git a/src/Application/Employees/Commands/UpdateEmployee/UpdateEmployeeCommandValidator.cs b/src/Application/Employees/Commands/UpdateEmployee/UpdateEmployeeCommandValidator.cs new file mode 100644 index 000000000..4c2b6c81f --- /dev/null +++ b/src/Application/Employees/Commands/UpdateEmployee/UpdateEmployeeCommandValidator.cs @@ -0,0 +1,54 @@ +using CleanArchitecture.Domain.Enums; + +namespace CleanArchitecture.Application.Employees.Commands.UpdateEmployee; + +public class UpdateEmployeeCommandValidator : AbstractValidator +{ + private static readonly string[] RestrictedSourceTypes = + [ + SourceType.SAP.ToString(), + SourceType.OzonTekstil.ToString() + ]; + + public UpdateEmployeeCommandValidator() + { + RuleFor(c => c.RegistrationNumber) + .NotEmpty(); + + RuleFor(c => c.IdentityNumber) + .NotEmpty() + .MaximumLength(11) + .Matches(@"^\d+$").WithMessage("Identity number must contain only digits."); + + RuleFor(c => c.Firstname) + .NotEmpty() + .MaximumLength(100) + .Matches(@"^[\p{L}]+$").WithMessage("First name must contain letters only."); + + RuleFor(c => c.Lastname) + .NotEmpty() + .MaximumLength(100) + .Matches(@"^[\p{L}]+$").WithMessage("Last name must contain letters only."); + + RuleFor(c => c.PersonalMobileNumber) + .NotEmpty() + .Length(12).WithMessage("Personal mobile number must be exactly 12 characters.") + .Must(n => n!.StartsWith("90")).WithMessage("Personal mobile number must start with '90'."); + + RuleFor(c => c.ActivePassiveCode) + .NotEmpty(); + + RuleFor(c => c.SourceTypeStr) + .NotEmpty() + .Must(st => !RestrictedSourceTypes.Contains(st)) + .WithMessage("Source type cannot be changed to SAP or OzonTekstil."); + + RuleFor(c => c.Description) + .NotEmpty() + .MinimumLength(20) + .WithMessage("Description must be at least 20 characters."); + + RuleFor(c => c.CompanyName) + .NotEmpty(); + } +} diff --git a/src/Application/Employees/Queries/GetEmployeeLookups/EmployeeLookupsVm.cs b/src/Application/Employees/Queries/GetEmployeeLookups/EmployeeLookupsVm.cs new file mode 100644 index 000000000..04fdcafa2 --- /dev/null +++ b/src/Application/Employees/Queries/GetEmployeeLookups/EmployeeLookupsVm.cs @@ -0,0 +1,22 @@ +namespace CleanArchitecture.Application.Employees.Queries.GetEmployeeLookups; + +public class EmployeeLookupsVm +{ + public List SourceTypes { get; set; } = []; + + public List ActivePassiveCodes { get; set; } = []; +} + +public class SourceTypeLookupDto +{ + public string Value { get; set; } = string.Empty; + + public string Label { get; set; } = string.Empty; +} + +public class ActivePassiveLookupDto +{ + public string Code { get; set; } = string.Empty; + + public string Definition { get; set; } = string.Empty; +} diff --git a/src/Application/Employees/Queries/GetEmployeeLookups/GetEmployeeLookups.cs b/src/Application/Employees/Queries/GetEmployeeLookups/GetEmployeeLookups.cs new file mode 100644 index 000000000..7699a8e48 --- /dev/null +++ b/src/Application/Employees/Queries/GetEmployeeLookups/GetEmployeeLookups.cs @@ -0,0 +1,47 @@ +using CleanArchitecture.Application.Common.Interfaces; +using CleanArchitecture.Application.Common.Security; +using CleanArchitecture.Domain.Constants; +using CleanArchitecture.Domain.Enums; + +namespace CleanArchitecture.Application.Employees.Queries.GetEmployeeLookups; + +[Authorize] +public record GetEmployeeLookupsQuery : IRequest; + +public class GetEmployeeLookupsQueryHandler : IRequestHandler +{ + private readonly IUser _user; + + public GetEmployeeLookupsQueryHandler(IUser user) + { + _user = user; + } + + public Task Handle(GetEmployeeLookupsQuery request, CancellationToken cancellationToken) + { + var isAdmin = _user.Roles?.Contains(Roles.HumanResourcesAdminSourceTypes) ?? false; + + var sourceTypes = isAdmin + ? new[] { SourceType.SAP, SourceType.OzonTekstil, SourceType.Efruz, SourceType.Ecrou, SourceType.All } + : new[] { SourceType.Other }; + + var vm = new EmployeeLookupsVm + { + SourceTypes = sourceTypes + .Select(st => new SourceTypeLookupDto + { + Value = st.ToString(), + Label = st.ToString() + }) + .ToList(), + + ActivePassiveCodes = + [ + new ActivePassiveLookupDto { Code = "1", Definition = "Etkin" }, + new ActivePassiveLookupDto { Code = "0", Definition = "Passive" } + ] + }; + + return Task.FromResult(vm); + } +} diff --git a/src/Application/Employees/Queries/GetNextRegistrationNumber/GetNextRegistrationNumber.cs b/src/Application/Employees/Queries/GetNextRegistrationNumber/GetNextRegistrationNumber.cs new file mode 100644 index 000000000..f90c9c4ac --- /dev/null +++ b/src/Application/Employees/Queries/GetNextRegistrationNumber/GetNextRegistrationNumber.cs @@ -0,0 +1,26 @@ +using CleanArchitecture.Application.Common.Interfaces; +using CleanArchitecture.Application.Common.Security; + +namespace CleanArchitecture.Application.Employees.Queries.GetNextRegistrationNumber; + +[Authorize] +public record GetNextRegistrationNumberQuery(string SourceType) : IRequest; + +public class GetNextRegistrationNumberQueryHandler + : IRequestHandler +{ + private readonly IEmployeeRegistrationNumberService _registrationNumberService; + + public GetNextRegistrationNumberQueryHandler( + IEmployeeRegistrationNumberService registrationNumberService) + { + _registrationNumberService = registrationNumberService; + } + + public Task Handle( + GetNextRegistrationNumberQuery request, + CancellationToken cancellationToken) + { + return _registrationNumberService.PeekNextAsync(request.SourceType, cancellationToken); + } +} diff --git a/src/Application/Employees/Queries/SearchEmployees/EmployeeDto.cs b/src/Application/Employees/Queries/SearchEmployees/EmployeeDto.cs new file mode 100644 index 000000000..ccf2e26f0 --- /dev/null +++ b/src/Application/Employees/Queries/SearchEmployees/EmployeeDto.cs @@ -0,0 +1,54 @@ +using CleanArchitecture.Domain.Entities; + +namespace CleanArchitecture.Application.Employees.Queries.SearchEmployees; + +public class EmployeeDto +{ + public int Id { get; set; } + + public string RegistrationNumber { get; set; } = string.Empty; + + public string IdentityNumber { get; set; } = string.Empty; + + public string Firstname { get; set; } = string.Empty; + + public string Lastname { get; set; } = string.Empty; + + public string? PersonalMobileNumber { get; set; } + + public string SourceTypeStr { get; set; } = string.Empty; + + public string ActivePassiveCode { get; set; } = string.Empty; + + public string ActivePassiveDefinition { get; set; } = string.Empty; + + public bool IsTerminated { get; set; } + + public string? CompanyName { get; set; } + + public int? BusinessUnitId { get; set; } + + public string? Description { get; set; } + + /// + /// Indicates whether the current user is allowed to edit this employee. + /// SAP and OzonTekstil employees can only be edited by HR Admin users. + /// + public bool CanEdit { get; set; } + + private class Mapping : Profile + { + public Mapping() + { + CreateMap() + .ForMember(d => d.ActivePassiveDefinition, + opt => opt.MapFrom(s => ResolveDefinition(s.ActivePassiveCode))) + .ForMember(d => d.CanEdit, opt => opt.Ignore()); + } + + private static string ResolveDefinition(string code) => + code == "1" || code.Equals("active", StringComparison.OrdinalIgnoreCase) + ? "Etkin" + : "Passive"; + } +} diff --git a/src/Application/Employees/Queries/SearchEmployees/EmployeeSearchRequest.cs b/src/Application/Employees/Queries/SearchEmployees/EmployeeSearchRequest.cs new file mode 100644 index 000000000..e8f5fe10e --- /dev/null +++ b/src/Application/Employees/Queries/SearchEmployees/EmployeeSearchRequest.cs @@ -0,0 +1,18 @@ +namespace CleanArchitecture.Application.Employees.Queries.SearchEmployees; + +public class EmployeeSearchRequest +{ + public string? RegistrationNumber { get; set; } + + public string? IdentityNumber { get; set; } + + public string? FirstName { get; set; } + + public string? LastName { get; set; } + + public string? ActivePassiveCode { get; set; } + + public bool? IsTerminated { get; set; } + + public List? SourceTypeList { get; set; } +} diff --git a/src/Application/Employees/Queries/SearchEmployees/SearchEmployees.cs b/src/Application/Employees/Queries/SearchEmployees/SearchEmployees.cs new file mode 100644 index 000000000..b4e600cdc --- /dev/null +++ b/src/Application/Employees/Queries/SearchEmployees/SearchEmployees.cs @@ -0,0 +1,113 @@ +using CleanArchitecture.Application.Common.Interfaces; +using CleanArchitecture.Application.Common.Security; +using CleanArchitecture.Domain.Constants; +using CleanArchitecture.Domain.Entities; +using CleanArchitecture.Domain.Enums; + +namespace CleanArchitecture.Application.Employees.Queries.SearchEmployees; + +[Authorize] +public record SearchEmployeesQuery : IRequest> +{ + public EmployeeSearchRequest SearchRequest { get; init; } = new(); +} + +public class SearchEmployeesQueryValidator : AbstractValidator +{ + public SearchEmployeesQueryValidator() + { + RuleFor(q => q.SearchRequest) + .Must(HasAtLeastOneCriterion) + .WithMessage("At least one search criterion must be provided."); + } + + private static bool HasAtLeastOneCriterion(EmployeeSearchRequest r) + { + if (!string.IsNullOrWhiteSpace(r.RegistrationNumber)) return true; + if (!string.IsNullOrWhiteSpace(r.IdentityNumber)) return true; + if (!string.IsNullOrWhiteSpace(r.FirstName)) return true; + if (!string.IsNullOrWhiteSpace(r.LastName)) return true; + if (r.SourceTypeList is { Count: > 0 }) return true; + if (r.IsTerminated.HasValue && !string.IsNullOrWhiteSpace(r.ActivePassiveCode)) return true; + return false; + } +} + +public class SearchEmployeesQueryHandler : IRequestHandler> +{ + private readonly IApplicationDbContext _context; + private readonly IMapper _mapper; + private readonly IUser _user; + + private static readonly string[] AdminOnlySourceTypes = + [ + SourceType.SAP.ToString(), + SourceType.OzonTekstil.ToString() + ]; + + public SearchEmployeesQueryHandler( + IApplicationDbContext context, + IMapper mapper, + IUser user) + { + _context = context; + _mapper = mapper; + _user = user; + } + + public async Task> Handle(SearchEmployeesQuery request, CancellationToken cancellationToken) + { + var isAdmin = _user.Roles?.Contains(Roles.HumanResourcesAdminSourceTypes) ?? false; + var r = request.SearchRequest; + + var query = _context.Employees.AsNoTracking().AsQueryable(); + + // Non-admin users cannot see SAP / OzonTekstil employees + if (!isAdmin) + { + query = query.Where(e => !AdminOnlySourceTypes.Contains(e.SourceTypeStr)); + } + + if (!string.IsNullOrWhiteSpace(r.RegistrationNumber)) + query = query.Where(e => e.RegistrationNumber == r.RegistrationNumber); + + if (!string.IsNullOrWhiteSpace(r.IdentityNumber)) + query = query.Where(e => e.IdentityNumber == r.IdentityNumber); + + if (!string.IsNullOrWhiteSpace(r.FirstName)) + query = query.Where(e => e.Firstname.Contains(r.FirstName)); + + if (!string.IsNullOrWhiteSpace(r.LastName)) + query = query.Where(e => e.Lastname.Contains(r.LastName)); + + if (r.SourceTypeList is { Count: > 0 }) + { + // Restrict non-admin users to non-admin source types in the filter + var allowedSourceTypes = isAdmin + ? r.SourceTypeList + : r.SourceTypeList.Where(st => !AdminOnlySourceTypes.Contains(st)).ToList(); + + if (allowedSourceTypes.Count > 0) + query = query.Where(e => allowedSourceTypes.Contains(e.SourceTypeStr)); + } + + if (!string.IsNullOrWhiteSpace(r.ActivePassiveCode)) + query = query.Where(e => e.ActivePassiveCode == r.ActivePassiveCode); + + if (r.IsTerminated.HasValue) + query = query.Where(e => e.IsTerminated == r.IsTerminated.Value); + + var employees = await query + .OrderBy(e => e.RegistrationNumber) + .ToListAsync(cancellationToken); + + var dtos = _mapper.Map>(employees); + + foreach (var dto in dtos) + { + dto.CanEdit = isAdmin || !AdminOnlySourceTypes.Contains(dto.SourceTypeStr); + } + + return dtos; + } +} diff --git a/src/Domain/Constants/Roles.cs b/src/Domain/Constants/Roles.cs index bb07fc8b0..d8bbdd45f 100644 --- a/src/Domain/Constants/Roles.cs +++ b/src/Domain/Constants/Roles.cs @@ -3,4 +3,6 @@ public abstract class Roles { public const string Administrator = nameof(Administrator); + + public const string HumanResourcesAdminSourceTypes = nameof(HumanResourcesAdminSourceTypes); } \ No newline at end of file diff --git a/src/Domain/Entities/Employee.cs b/src/Domain/Entities/Employee.cs new file mode 100644 index 000000000..f147d537e --- /dev/null +++ b/src/Domain/Entities/Employee.cs @@ -0,0 +1,29 @@ +namespace CleanArchitecture.Domain.Entities; + +public class Employee : BaseAuditableEntity +{ + public string RegistrationNumber { get; set; } = string.Empty; + + public string IdentityNumber { get; set; } = string.Empty; + + public string Firstname { get; set; } = string.Empty; + + public string Lastname { get; set; } = string.Empty; + + public string? PersonalMobileNumber { get; set; } + + public string SourceTypeStr { get; set; } = string.Empty; + + /// + /// "1" represents Active/Etkin; any other value represents Passive. + /// + public string ActivePassiveCode { get; set; } = "1"; + + public bool IsTerminated { get; set; } + + public string? CompanyName { get; set; } + + public int? BusinessUnitId { get; set; } + + public string? Description { get; set; } +} diff --git a/src/Domain/Enums/SourceType.cs b/src/Domain/Enums/SourceType.cs new file mode 100644 index 000000000..7029108c2 --- /dev/null +++ b/src/Domain/Enums/SourceType.cs @@ -0,0 +1,11 @@ +namespace CleanArchitecture.Domain.Enums; + +public enum SourceType +{ + SAP = 0, + OzonTekstil = 1, + Efruz = 2, + Other = 3, + Ecrou = 4, + All = 99 +} diff --git a/src/Infrastructure/Data/ApplicationDbContext.cs b/src/Infrastructure/Data/ApplicationDbContext.cs index 580b3a633..26d75f6d7 100644 --- a/src/Infrastructure/Data/ApplicationDbContext.cs +++ b/src/Infrastructure/Data/ApplicationDbContext.cs @@ -15,6 +15,8 @@ public ApplicationDbContext(DbContextOptions options) : ba public DbSet TodoItems => Set(); + public DbSet Employees => Set(); + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); diff --git a/src/Infrastructure/Data/ApplicationDbContextInitialiser.cs b/src/Infrastructure/Data/ApplicationDbContextInitialiser.cs index 35c2f763d..92c26b1d5 100644 --- a/src/Infrastructure/Data/ApplicationDbContextInitialiser.cs +++ b/src/Infrastructure/Data/ApplicationDbContextInitialiser.cs @@ -69,12 +69,18 @@ public async Task TrySeedAsync() { // Default roles var administratorRole = new IdentityRole(Roles.Administrator); + var hrAdminRole = new IdentityRole(Roles.HumanResourcesAdminSourceTypes); if (_roleManager.Roles.All(r => r.Name != administratorRole.Name)) { await _roleManager.CreateAsync(administratorRole); } + if (_roleManager.Roles.All(r => r.Name != hrAdminRole.Name)) + { + await _roleManager.CreateAsync(hrAdminRole); + } + // Default users var administrator = new ApplicationUser { UserName = "administrator@localhost", Email = "administrator@localhost" }; diff --git a/src/Infrastructure/Data/Configurations/EmployeeConfiguration.cs b/src/Infrastructure/Data/Configurations/EmployeeConfiguration.cs new file mode 100644 index 000000000..53b85b5b2 --- /dev/null +++ b/src/Infrastructure/Data/Configurations/EmployeeConfiguration.cs @@ -0,0 +1,47 @@ +using CleanArchitecture.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CleanArchitecture.Infrastructure.Data.Configurations; + +public class EmployeeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasIndex(e => e.RegistrationNumber) + .IsUnique(); + + builder.Property(e => e.RegistrationNumber) + .HasMaxLength(20) + .IsRequired(); + + builder.Property(e => e.IdentityNumber) + .HasMaxLength(11) + .IsRequired(); + + builder.Property(e => e.Firstname) + .HasMaxLength(100) + .IsRequired(); + + builder.Property(e => e.Lastname) + .HasMaxLength(100) + .IsRequired(); + + builder.Property(e => e.PersonalMobileNumber) + .HasMaxLength(12); + + builder.Property(e => e.SourceTypeStr) + .HasMaxLength(50) + .IsRequired(); + + builder.Property(e => e.ActivePassiveCode) + .HasMaxLength(10) + .IsRequired(); + + builder.Property(e => e.CompanyName) + .HasMaxLength(200); + + builder.Property(e => e.Description) + .HasMaxLength(1000); + } +} diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs index f361e4541..bb3c7dcf7 100644 --- a/src/Infrastructure/DependencyInjection.cs +++ b/src/Infrastructure/DependencyInjection.cs @@ -2,6 +2,7 @@ using CleanArchitecture.Infrastructure.Data; using CleanArchitecture.Infrastructure.Data.Interceptors; using CleanArchitecture.Infrastructure.Identity; +using CleanArchitecture.Infrastructure.Services; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; @@ -75,5 +76,6 @@ public static void AddInfrastructureServices(this IHostApplicationBuilder builde builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddTransient(); + builder.Services.AddScoped(); } } diff --git a/src/Infrastructure/Services/EmployeeRegistrationNumberService.cs b/src/Infrastructure/Services/EmployeeRegistrationNumberService.cs new file mode 100644 index 000000000..f5588c2d4 --- /dev/null +++ b/src/Infrastructure/Services/EmployeeRegistrationNumberService.cs @@ -0,0 +1,62 @@ +using CleanArchitecture.Application.Common.Interfaces; +using CleanArchitecture.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace CleanArchitecture.Infrastructure.Services; + +public class EmployeeRegistrationNumberService : IEmployeeRegistrationNumberService +{ + private readonly ApplicationDbContext _context; + + public EmployeeRegistrationNumberService(ApplicationDbContext context) + { + _context = context; + } + + /// + /// Generates the next registration number within a transaction to prevent race conditions. + /// Uses pessimistic locking (read inside transaction) to ensure uniqueness. + /// + public async Task GenerateNextAsync(string sourceType, CancellationToken cancellationToken) + { + await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken); + try + { + var next = await ComputeNextAsync(sourceType, cancellationToken); + // Transaction stays open until the caller commits via SaveChangesAsync. + // Committing here is safe because the INSERT happens in the same DbContext. + await transaction.CommitAsync(cancellationToken); + return next; + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + } + + /// + /// Returns a preview of the next number without a transaction — suitable for UI hints only. + /// + public async Task PeekNextAsync(string sourceType, CancellationToken cancellationToken) + { + return await ComputeNextAsync(sourceType, cancellationToken); + } + + private async Task ComputeNextAsync(string sourceType, CancellationToken cancellationToken) + { + var maxRaw = await _context.Employees + .AsNoTracking() + .Where(e => e.SourceTypeStr == sourceType) + .Select(e => e.RegistrationNumber) + .MaxAsync(rn => (string?)rn, cancellationToken); + + int next = 1; + if (maxRaw != null && int.TryParse(maxRaw, out var current)) + { + next = current + 1; + } + + return next.ToString("D6"); + } +} diff --git a/src/Web/Endpoints/Employees.cs b/src/Web/Endpoints/Employees.cs new file mode 100644 index 000000000..f78842999 --- /dev/null +++ b/src/Web/Endpoints/Employees.cs @@ -0,0 +1,95 @@ +using CleanArchitecture.Application.Employees.Commands.CreateEmployee; +using CleanArchitecture.Application.Employees.Commands.UpdateEmployee; +using CleanArchitecture.Application.Employees.Queries.GetEmployeeLookups; +using CleanArchitecture.Application.Employees.Queries.GetNextRegistrationNumber; +using CleanArchitecture.Application.Employees.Queries.SearchEmployees; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CleanArchitecture.Web.Endpoints; + +public class Employees : IEndpointGroup +{ + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.RequireAuthorization(); + + groupBuilder.MapPost(SearchEmployees); + groupBuilder.MapGet(GetEmployee, "{registrationNumber}"); + groupBuilder.MapGet(GetNextRegistrationNumber, "next-registration-number/{sourceType}"); + groupBuilder.MapPost(CreateEmployee, string.Empty); + groupBuilder.MapPut(UpdateEmployee, "{registrationNumber}"); + groupBuilder.MapGet(GetSourceTypeLookups, "lookup/source-types"); + groupBuilder.MapGet(GetActivePassiveCodeLookups, "lookup/active-passive-codes"); + } + + [EndpointSummary("Search employees")] + [EndpointDescription("Returns a filtered list of employees matching the search criteria. At least one criterion must be provided.")] + public static async Task>> SearchEmployees( + ISender sender, EmployeeSearchRequest request) + { + var result = await sender.Send(new SearchEmployeesQuery { SearchRequest = request }); + return TypedResults.Ok(result); + } + + [EndpointSummary("Get employee by registration number")] + [EndpointDescription("Returns a single employee identified by their registration number.")] + public static async Task, NotFound>> GetEmployee( + ISender sender, string registrationNumber) + { + var result = await sender.Send(new SearchEmployeesQuery + { + SearchRequest = new EmployeeSearchRequest { RegistrationNumber = registrationNumber } + }); + + var employee = result.FirstOrDefault(); + return employee is not null + ? TypedResults.Ok(employee) + : TypedResults.NotFound(); + } + + [EndpointSummary("Get next registration number")] + [EndpointDescription("Returns a preview of the next auto-generated registration number for the given source type.")] + public static async Task> GetNextRegistrationNumber( + ISender sender, string sourceType) + { + var result = await sender.Send(new GetNextRegistrationNumberQuery(sourceType)); + return TypedResults.Ok(result); + } + + [EndpointSummary("Create employee")] + [EndpointDescription("Creates a new employee. RegistrationNumber is auto-generated and cannot be supplied by the caller.")] + public static async Task> CreateEmployee( + ISender sender, CreateEmployeeCommand command) + { + var registrationNumber = await sender.Send(command); + return TypedResults.Created($"/api/employees/{registrationNumber}", registrationNumber); + } + + [EndpointSummary("Update employee")] + [EndpointDescription("Updates the employee identified by the registration number in the URL. The registration number in the body must match the URL.")] + public static async Task> UpdateEmployee( + ISender sender, string registrationNumber, UpdateEmployeeCommand command) + { + if (!registrationNumber.Equals(command.RegistrationNumber, StringComparison.OrdinalIgnoreCase)) + return TypedResults.BadRequest(); + + await sender.Send(command); + return TypedResults.NoContent(); + } + + [EndpointSummary("Get source type lookups")] + [EndpointDescription("Returns the list of available source types filtered by the current user's role.")] + public static async Task> GetSourceTypeLookups(ISender sender) + { + var result = await sender.Send(new GetEmployeeLookupsQuery()); + return TypedResults.Ok(result); + } + + [EndpointSummary("Get active/passive code lookups")] + [EndpointDescription("Returns the list of available active/passive codes.")] + public static async Task> GetActivePassiveCodeLookups(ISender sender) + { + var result = await sender.Send(new GetEmployeeLookupsQuery()); + return TypedResults.Ok(result); + } +} From 454e0a107c297e3a44795cdc113ef7c0173c2c33 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:01:39 +0000 Subject: [PATCH 2/6] fix: address code review feedback - Fix transaction race condition: generate+insert now wrapped in single BeginTransactionAsync call - Add BeginTransactionAsync to IApplicationDbContext and ApplicationDbContext - Extract shared ActivePassiveCodes.Normalize helper to eliminate duplication - Fix duplicate lookup endpoints to return specific data (SourceTypes vs ActivePassiveCodes) - Simplify EmployeeRegistrationNumberService (transaction managed by caller) Agent-Logs-Url: https://github.com/defactoAdil/CleanArchitecture/sessions/5820a821-591e-44e7-ad12-0141efb2e2f4 Co-authored-by: defactoAdil <189980729+defactoAdil@users.noreply.github.com> --- .../Interfaces/IApplicationDbContext.cs | 3 + .../IEmployeeRegistrationNumberService.cs | 10 +-- .../Commands/CreateEmployee/CreateEmployee.cs | 65 ++++++++++--------- .../Commands/UpdateEmployee/UpdateEmployee.cs | 6 +- .../Employees/Common/ActivePassiveCodes.cs | 16 +++++ .../Data/ApplicationDbContext.cs | 4 ++ .../EmployeeRegistrationNumberService.cs | 34 +++------- src/Web/Endpoints/Employees.cs | 8 +-- 8 files changed, 79 insertions(+), 67 deletions(-) create mode 100644 src/Application/Employees/Common/ActivePassiveCodes.cs diff --git a/src/Application/Common/Interfaces/IApplicationDbContext.cs b/src/Application/Common/Interfaces/IApplicationDbContext.cs index b70fdb90a..a95e2a39c 100644 --- a/src/Application/Common/Interfaces/IApplicationDbContext.cs +++ b/src/Application/Common/Interfaces/IApplicationDbContext.cs @@ -1,4 +1,5 @@ using CleanArchitecture.Domain.Entities; +using Microsoft.EntityFrameworkCore.Storage; namespace CleanArchitecture.Application.Common.Interfaces; @@ -11,4 +12,6 @@ public interface IApplicationDbContext DbSet Employees { get; } Task SaveChangesAsync(CancellationToken cancellationToken); + + Task BeginTransactionAsync(CancellationToken cancellationToken = default); } diff --git a/src/Application/Common/Interfaces/IEmployeeRegistrationNumberService.cs b/src/Application/Common/Interfaces/IEmployeeRegistrationNumberService.cs index 84397e2ce..437de5170 100644 --- a/src/Application/Common/Interfaces/IEmployeeRegistrationNumberService.cs +++ b/src/Application/Common/Interfaces/IEmployeeRegistrationNumberService.cs @@ -1,19 +1,21 @@ namespace CleanArchitecture.Application.Common.Interfaces; /// -/// Generates unique, race-condition-safe employee registration numbers per source type. +/// Generates unique employee registration numbers per source type. /// public interface IEmployeeRegistrationNumberService { /// - /// Returns the next registration number for the given source type by atomically - /// reading the current maximum and incrementing it inside a database transaction. + /// Returns the next registration number for the given source type. + /// Must be called from within an open database transaction + /// (started via IApplicationDbContext.BeginTransactionAsync) so that + /// the number read and the subsequent INSERT are atomic. /// Task GenerateNextAsync(string sourceType, CancellationToken cancellationToken); /// /// Returns a preview of the next registration number without reserving it. - /// Not safe for concurrent use — use GenerateNextAsync during actual insert. + /// Suitable for UI hints only — not safe under concurrent load. /// Task PeekNextAsync(string sourceType, CancellationToken cancellationToken); } diff --git a/src/Application/Employees/Commands/CreateEmployee/CreateEmployee.cs b/src/Application/Employees/Commands/CreateEmployee/CreateEmployee.cs index d344e846c..44603680b 100644 --- a/src/Application/Employees/Commands/CreateEmployee/CreateEmployee.cs +++ b/src/Application/Employees/Commands/CreateEmployee/CreateEmployee.cs @@ -1,5 +1,6 @@ using CleanArchitecture.Application.Common.Interfaces; using CleanArchitecture.Application.Common.Security; +using CleanArchitecture.Application.Employees.Common; using CleanArchitecture.Domain.Constants; using CleanArchitecture.Domain.Entities; using CleanArchitecture.Domain.Enums; @@ -36,12 +37,6 @@ public class CreateEmployeeCommandHandler : IRequestHandler Handle(CreateEmployeeCommand request, CancellationToke var isAdmin = _user.Roles?.Contains(Roles.HumanResourcesAdminSourceTypes) ?? false; var businessUnitId = isAdmin ? request.BusinessUnitId : null; - // Generate unique registration number (race-condition safe via transaction) - var registrationNumber = await _registrationNumberService - .GenerateNextAsync(sourceType, cancellationToken); - - var employee = new Employee + // Wrap number generation + INSERT in one transaction to prevent race conditions. + // The unique index on RegistrationNumber is the final safety net. + await using var transaction = await _context.BeginTransactionAsync(cancellationToken); + try { - RegistrationNumber = registrationNumber, - IdentityNumber = request.IdentityNumber, - Firstname = request.Firstname, - Lastname = request.Lastname, - PersonalMobileNumber = request.PersonalMobileNumber, - SourceTypeStr = sourceType, - ActivePassiveCode = NormalizeActivePassiveCode(request.ActivePassiveCode), - IsTerminated = request.IsTerminated, - CompanyName = request.CompanyName, - BusinessUnitId = businessUnitId, - Description = request.Description - }; - - _context.Employees.Add(employee); - await _context.SaveChangesAsync(cancellationToken); - - return registrationNumber; + var registrationNumber = await _registrationNumberService + .GenerateNextAsync(sourceType, cancellationToken); + + var employee = new Employee + { + RegistrationNumber = registrationNumber, + IdentityNumber = request.IdentityNumber, + Firstname = request.Firstname, + Lastname = request.Lastname, + PersonalMobileNumber = request.PersonalMobileNumber, + SourceTypeStr = sourceType, + ActivePassiveCode = ActivePassiveCodes.Normalize(request.ActivePassiveCode), + IsTerminated = request.IsTerminated, + CompanyName = request.CompanyName, + BusinessUnitId = businessUnitId, + Description = request.Description + }; + + _context.Employees.Add(employee); + await _context.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + + return registrationNumber; + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } } private string DefaultSourceType() => (_user.Roles?.Contains(Roles.HumanResourcesAdminSourceTypes) ?? false) ? SourceType.Ecrou.ToString() : SourceType.Other.ToString(); - - private static string NormalizeActivePassiveCode(string code) => - code.Equals("active", StringComparison.OrdinalIgnoreCase) || code == "1" ? "1" : "0"; } diff --git a/src/Application/Employees/Commands/UpdateEmployee/UpdateEmployee.cs b/src/Application/Employees/Commands/UpdateEmployee/UpdateEmployee.cs index 516d1c9dc..5ad94f838 100644 --- a/src/Application/Employees/Commands/UpdateEmployee/UpdateEmployee.cs +++ b/src/Application/Employees/Commands/UpdateEmployee/UpdateEmployee.cs @@ -1,5 +1,6 @@ using CleanArchitecture.Application.Common.Interfaces; using CleanArchitecture.Application.Common.Security; +using CleanArchitecture.Application.Employees.Common; using CleanArchitecture.Domain.Constants; using CleanArchitecture.Domain.Enums; @@ -72,7 +73,7 @@ public async Task Handle(UpdateEmployeeCommand request, CancellationToken cancel employee.Lastname = request.Lastname; employee.PersonalMobileNumber = request.PersonalMobileNumber; employee.SourceTypeStr = normalizedSourceType; - employee.ActivePassiveCode = NormalizeActivePassiveCode(request.ActivePassiveCode); + employee.ActivePassiveCode = ActivePassiveCodes.Normalize(request.ActivePassiveCode); employee.IsTerminated = request.IsTerminated; employee.CompanyName = request.CompanyName; employee.Description = request.Description; @@ -85,7 +86,4 @@ public async Task Handle(UpdateEmployeeCommand request, CancellationToken cancel await _context.SaveChangesAsync(cancellationToken); } - - private static string NormalizeActivePassiveCode(string code) => - code.Equals("active", StringComparison.OrdinalIgnoreCase) || code == "1" ? "1" : "0"; } diff --git a/src/Application/Employees/Common/ActivePassiveCodes.cs b/src/Application/Employees/Common/ActivePassiveCodes.cs new file mode 100644 index 000000000..4b271c30a --- /dev/null +++ b/src/Application/Employees/Common/ActivePassiveCodes.cs @@ -0,0 +1,16 @@ +namespace CleanArchitecture.Application.Employees.Common; + +/// +/// Centralises ActivePassiveCode normalisation so Create and Update commands share identical logic. +/// "1" or "active" (case-insensitive) → "1" (Etkin/Active); everything else → "0" (Passive). +/// +public static class ActivePassiveCodes +{ + public const string Active = "1"; + public const string Passive = "0"; + + public static string Normalize(string code) => + code == Active || code.Equals("active", StringComparison.OrdinalIgnoreCase) + ? Active + : Passive; +} diff --git a/src/Infrastructure/Data/ApplicationDbContext.cs b/src/Infrastructure/Data/ApplicationDbContext.cs index 26d75f6d7..107d36b74 100644 --- a/src/Infrastructure/Data/ApplicationDbContext.cs +++ b/src/Infrastructure/Data/ApplicationDbContext.cs @@ -4,6 +4,7 @@ using CleanArchitecture.Infrastructure.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; namespace CleanArchitecture.Infrastructure.Data; @@ -17,6 +18,9 @@ public ApplicationDbContext(DbContextOptions options) : ba public DbSet Employees => Set(); + public Task BeginTransactionAsync(CancellationToken cancellationToken = default) + => Database.BeginTransactionAsync(cancellationToken); + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); diff --git a/src/Infrastructure/Services/EmployeeRegistrationNumberService.cs b/src/Infrastructure/Services/EmployeeRegistrationNumberService.cs index f5588c2d4..b35ecf0d7 100644 --- a/src/Infrastructure/Services/EmployeeRegistrationNumberService.cs +++ b/src/Infrastructure/Services/EmployeeRegistrationNumberService.cs @@ -14,34 +14,20 @@ public EmployeeRegistrationNumberService(ApplicationDbContext context) } /// - /// Generates the next registration number within a transaction to prevent race conditions. - /// Uses pessimistic locking (read inside transaction) to ensure uniqueness. + /// Returns the next registration number for the given source type. + /// Call this method from within an open transaction (started via + /// IApplicationDbContext.BeginTransactionAsync) so that the number + /// computation and the subsequent INSERT are atomic. /// - public async Task GenerateNextAsync(string sourceType, CancellationToken cancellationToken) - { - await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken); - try - { - var next = await ComputeNextAsync(sourceType, cancellationToken); - // Transaction stays open until the caller commits via SaveChangesAsync. - // Committing here is safe because the INSERT happens in the same DbContext. - await transaction.CommitAsync(cancellationToken); - return next; - } - catch - { - await transaction.RollbackAsync(cancellationToken); - throw; - } - } + public Task GenerateNextAsync(string sourceType, CancellationToken cancellationToken) + => ComputeNextAsync(sourceType, cancellationToken); /// - /// Returns a preview of the next number without a transaction — suitable for UI hints only. + /// Returns a preview of the next registration number without reserving it. + /// Not safe for concurrent use — use GenerateNextAsync (inside a transaction) during actual insert. /// - public async Task PeekNextAsync(string sourceType, CancellationToken cancellationToken) - { - return await ComputeNextAsync(sourceType, cancellationToken); - } + public Task PeekNextAsync(string sourceType, CancellationToken cancellationToken) + => ComputeNextAsync(sourceType, cancellationToken); private async Task ComputeNextAsync(string sourceType, CancellationToken cancellationToken) { diff --git a/src/Web/Endpoints/Employees.cs b/src/Web/Endpoints/Employees.cs index f78842999..7bdd58b18 100644 --- a/src/Web/Endpoints/Employees.cs +++ b/src/Web/Endpoints/Employees.cs @@ -79,17 +79,17 @@ public static async Task> UpdateEmployee( [EndpointSummary("Get source type lookups")] [EndpointDescription("Returns the list of available source types filtered by the current user's role.")] - public static async Task> GetSourceTypeLookups(ISender sender) + public static async Task>> GetSourceTypeLookups(ISender sender) { var result = await sender.Send(new GetEmployeeLookupsQuery()); - return TypedResults.Ok(result); + return TypedResults.Ok(result.SourceTypes); } [EndpointSummary("Get active/passive code lookups")] [EndpointDescription("Returns the list of available active/passive codes.")] - public static async Task> GetActivePassiveCodeLookups(ISender sender) + public static async Task>> GetActivePassiveCodeLookups(ISender sender) { var result = await sender.Send(new GetEmployeeLookupsQuery()); - return TypedResults.Ok(result); + return TypedResults.Ok(result.ActivePassiveCodes); } } From 2a3414e3c48f072aaa608c5f18b663cd3fd3dd58 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:31:06 +0000 Subject: [PATCH 3/6] feat: Angular Employee module frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix backend route conflict: SearchEmployees → POST /api/Employees/search (was POST /api/Employees, conflicting with CreateEmployee) - Regenerate TypeScript API client (searchEmployees method now generated) - Add EmployeesComponent with search form, results table and create/edit dialog - Role-based UX: admin detection via source-type lookup (SAP/OzonTekstil visible ↔ isAdmin) - BusinessUnitId field shown only for admin users - canEdit flag respected: Edit button shown only when employee is editable - Registration number auto-populated from API on source type change (create only) - Source type multi-select chip UI - Active/Passive badge styling in results table - Register route /employees with AuthGuard in app.module.ts - Add Employees nav link in nav-menu - Add employee CSS classes to global styles.scss Agent-Logs-Url: https://github.com/defactoAdil/CleanArchitecture/sessions/8ad11855-a989-4523-be83-b5a1202b1270 Co-authored-by: defactoAdil <189980729+defactoAdil@users.noreply.github.com> --- src/Web/ClientApp/src/app/app.module.ts | 3 + .../app/employees/employees.component.html | 272 ++++++++++++++++ .../app/employees/employees.component.scss | 1 + .../src/app/employees/employees.component.ts | 292 ++++++++++++++++++ .../src/app/nav-menu/nav-menu.component.html | 1 + src/Web/ClientApp/src/styles.scss | 157 +++++++++- src/Web/Endpoints/Employees.cs | 2 +- 7 files changed, 726 insertions(+), 2 deletions(-) create mode 100644 src/Web/ClientApp/src/app/employees/employees.component.html create mode 100644 src/Web/ClientApp/src/app/employees/employees.component.scss create mode 100644 src/Web/ClientApp/src/app/employees/employees.component.ts diff --git a/src/Web/ClientApp/src/app/app.module.ts b/src/Web/ClientApp/src/app/app.module.ts index 8563646ee..f4bd48413 100644 --- a/src/Web/ClientApp/src/app/app.module.ts +++ b/src/Web/ClientApp/src/app/app.module.ts @@ -11,6 +11,7 @@ import { HomeComponent } from './home/home.component'; import { CounterComponent } from './counter/counter.component'; import { WeatherComponent } from './weather/weather.component'; import { TasksComponent } from './todo/todo.component'; +import { EmployeesComponent } from './employees/employees.component'; import { ThemeToggleComponent } from './theme-toggle/theme-toggle.component'; import { API_BASE_URL } from './web-api-client'; import { AuthorizeInterceptor } from 'src/api-authorization/authorize.interceptor'; @@ -32,6 +33,7 @@ export function getApiBaseUrl(): string { CounterComponent, WeatherComponent, TasksComponent, + EmployeesComponent, ThemeToggleComponent, LoginComponent, RegisterComponent @@ -46,6 +48,7 @@ export function getApiBaseUrl(): string { { path: 'counter', component: CounterComponent }, { path: 'weather', component: WeatherComponent, canActivate: [AuthGuard] }, { path: 'todo', component: TasksComponent, canActivate: [AuthGuard] }, + { path: 'employees', component: EmployeesComponent, canActivate: [AuthGuard] }, { path: 'login', component: LoginComponent }, { path: 'register', component: RegisterComponent } ]) diff --git a/src/Web/ClientApp/src/app/employees/employees.component.html b/src/Web/ClientApp/src/app/employees/employees.component.html new file mode 100644 index 000000000..218a8c564 --- /dev/null +++ b/src/Web/ClientApp/src/app/employees/employees.component.html @@ -0,0 +1,272 @@ +
+

Employees

+

Search and manage HR employees.

+
+ + +
+
+

Search

+
+ +
+ + + + + + +
+ + + @if (sourceTypes().length) { +
+ Source Types +
+ @for (st of sourceTypes(); track st.value) { + + } +
+
+ } + + @if (searchError()) { + {{ searchError() }} + } + +
+ + + +
+
+ + +@if (hasSearched()) { + @if (loadingSearch()) { +

Searching…

+ } @else if (employees()?.length === 0) { +

No employees found matching your criteria.

+ } @else if (employees()?.length) { +
+ + + + + + + + + + + + + + @for (emp of employees()!; track emp.id) { + + + + + + + + + + } + +
Reg. NoNameIdentity NoSource TypeStatusTerminated
{{ emp.registrationNumber }}{{ emp.firstname }} {{ emp.lastname }}{{ emp.identityNumber }}{{ emp.sourceTypeStr }} + + {{ emp.activePassiveDefinition }} + + {{ emp.isTerminated ? 'Yes' : 'No' }} + @if (emp.canEdit) { + + } +
+
+ {{ employees()!.length }} record(s) found. + } +} + + + +
+
+

{{ dialogTitle() }}

+ +
+ + + @if (hasAnyError() && fieldError('_')) { +

{{ fieldError('_') }}

+ } + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + @if (isAdmin()) { + + } + + + + +
+ + + + +
+ + +
+
+
diff --git a/src/Web/ClientApp/src/app/employees/employees.component.scss b/src/Web/ClientApp/src/app/employees/employees.component.scss new file mode 100644 index 000000000..411488ae2 --- /dev/null +++ b/src/Web/ClientApp/src/app/employees/employees.component.scss @@ -0,0 +1 @@ +// Styles are defined globally in styles.scss diff --git a/src/Web/ClientApp/src/app/employees/employees.component.ts b/src/Web/ClientApp/src/app/employees/employees.component.ts new file mode 100644 index 000000000..c4066f7a6 --- /dev/null +++ b/src/Web/ClientApp/src/app/employees/employees.component.ts @@ -0,0 +1,292 @@ +import { + Component, OnInit, ViewChild, ElementRef, signal, computed +} from '@angular/core'; +import { + EmployeesClient, + EmployeeDto, + EmployeeSearchRequest, + CreateEmployeeCommand, + UpdateEmployeeCommand, + SourceTypeLookupDto, + ActivePassiveLookupDto +} from '../web-api-client'; + +interface EmployeeForm { + registrationNumber: string; + identityNumber: string; + firstname: string; + lastname: string; + personalMobileNumber: string; + sourceTypeStr: string; + activePassiveCode: string; + isTerminated: boolean; + companyName: string; + businessUnitId: number | null; + description: string; +} + +@Component({ + standalone: false, + selector: 'app-employees', + templateUrl: './employees.component.html', + styleUrls: ['./employees.component.scss'] +}) +export class EmployeesComponent implements OnInit { + @ViewChild('employeeDialog') employeeDialogRef: ElementRef; + + // ── Lookup data ───────────────────────────────────────────────────────────── + sourceTypes = signal([]); + activePassiveCodes = signal([]); + + /** Admin users see SAP / OzonTekstil in their source type list */ + isAdmin = computed(() => + this.sourceTypes().some(st => st.value === 'SAP' || st.value === 'OzonTekstil') + ); + + // ── Search state ───────────────────────────────────────────────────────────── + filter: EmployeeSearchRequest = new EmployeeSearchRequest(); + selectedSourceTypes: string[] = []; + employees = signal(null); + loadingSearch = signal(false); + searchError = signal(''); + hasSearched = signal(false); + + // ── Create / Edit dialog state ──────────────────────────────────────────────── + isEditing = signal(false); + savingEmployee = signal(false); + dialogTitle = computed(() => this.isEditing() ? 'Edit Employee' : 'New Employee'); + formErrors = signal>({}); + form: EmployeeForm = this.emptyForm(); + loadingNextRegNo = signal(false); + + constructor(private employeesClient: EmployeesClient) {} + + ngOnInit(): void { + this.employeesClient.getSourceTypeLookups().subscribe({ + next: types => this.sourceTypes.set(types), + error: err => console.error('Failed to load source types', err) + }); + this.employeesClient.getActivePassiveCodeLookups().subscribe({ + next: codes => this.activePassiveCodes.set(codes), + error: err => console.error('Failed to load active/passive codes', err) + }); + } + + // ── Source type multi-select helpers ───────────────────────────────────────── + toggleSourceType(value: string): void { + const idx = this.selectedSourceTypes.indexOf(value); + if (idx >= 0) { + this.selectedSourceTypes = this.selectedSourceTypes.filter(v => v !== value); + } else { + this.selectedSourceTypes = [...this.selectedSourceTypes, value]; + } + } + + isSourceTypeSelected(value: string): boolean { + return this.selectedSourceTypes.includes(value); + } + + // ── Search ──────────────────────────────────────────────────────────────────── + search(): void { + this.searchError.set(''); + this.loadingSearch.set(true); + this.hasSearched.set(true); + + const req = new EmployeeSearchRequest({ + registrationNumber: this.filter.registrationNumber || undefined, + identityNumber: this.filter.identityNumber || undefined, + firstName: this.filter.firstName || undefined, + lastName: this.filter.lastName || undefined, + activePassiveCode: this.filter.activePassiveCode || undefined, + isTerminated: this.filter.isTerminated ?? undefined, + sourceTypeList: this.selectedSourceTypes.length ? this.selectedSourceTypes : undefined + }); + + this.employeesClient.searchEmployees(req).subscribe({ + next: results => { + this.employees.set(results); + this.loadingSearch.set(false); + }, + error: err => { + this.loadingSearch.set(false); + try { + const parsed = JSON.parse(err.response); + const errors: string[] = []; + if (parsed?.errors) { + for (const field of Object.values(parsed.errors)) { + errors.push(...(field as string[])); + } + } + this.searchError.set(errors.join(' ') || 'Search failed.'); + } catch { + this.searchError.set('Search failed.'); + } + } + }); + } + + clearSearch(): void { + this.filter = new EmployeeSearchRequest(); + this.selectedSourceTypes = []; + this.employees.set(null); + this.hasSearched.set(false); + this.searchError.set(''); + } + + // ── Create dialog ───────────────────────────────────────────────────────────── + showCreateDialog(): void { + this.isEditing.set(false); + this.form = this.emptyForm(); + this.formErrors.set({}); + this.employeeDialogRef.nativeElement.showModal(); + + // Pre-fill registration number preview + const defaultSourceType = this.isAdmin() ? 'Ecrou' : 'Other'; + this.form.sourceTypeStr = defaultSourceType; + this.loadNextRegistrationNumber(defaultSourceType); + } + + onSourceTypeChange(): void { + if (!this.isEditing() && this.form.sourceTypeStr) { + this.loadNextRegistrationNumber(this.form.sourceTypeStr); + } + } + + private loadNextRegistrationNumber(sourceType: string): void { + this.loadingNextRegNo.set(true); + this.employeesClient.getNextRegistrationNumber(sourceType).subscribe({ + next: regNo => { + this.form.registrationNumber = regNo; + this.loadingNextRegNo.set(false); + }, + error: () => this.loadingNextRegNo.set(false) + }); + } + + // ── Edit dialog ─────────────────────────────────────────────────────────────── + showEditDialog(employee: EmployeeDto): void { + this.isEditing.set(true); + this.formErrors.set({}); + this.form = { + registrationNumber: employee.registrationNumber ?? '', + identityNumber: employee.identityNumber ?? '', + firstname: employee.firstname ?? '', + lastname: employee.lastname ?? '', + personalMobileNumber: employee.personalMobileNumber ?? '', + sourceTypeStr: employee.sourceTypeStr ?? '', + activePassiveCode: employee.activePassiveCode ?? '1', + isTerminated: employee.isTerminated ?? false, + companyName: employee.companyName ?? '', + businessUnitId: employee.businessUnitId ?? null, + description: employee.description ?? '' + }; + this.employeeDialogRef.nativeElement.showModal(); + } + + closeDialog(): void { + this.employeeDialogRef.nativeElement.close(); + this.formErrors.set({}); + } + + // ── Save (create or update) ─────────────────────────────────────────────────── + save(): void { + this.formErrors.set({}); + this.savingEmployee.set(true); + + if (this.isEditing()) { + this.update(); + } else { + this.create(); + } + } + + private create(): void { + const cmd = new CreateEmployeeCommand({ + identityNumber: this.form.identityNumber, + firstname: this.form.firstname, + lastname: this.form.lastname, + personalMobileNumber: this.form.personalMobileNumber || undefined, + sourceTypeStr: this.form.sourceTypeStr, + activePassiveCode: this.form.activePassiveCode, + isTerminated: this.form.isTerminated, + companyName: this.form.companyName || undefined, + businessUnitId: this.isAdmin() ? (this.form.businessUnitId ?? undefined) : undefined, + description: this.form.description + }); + + this.employeesClient.createEmployee(cmd).subscribe({ + next: registrationNumber => { + this.savingEmployee.set(false); + this.closeDialog(); + // Refresh the search if a search was already done + if (this.hasSearched()) this.search(); + }, + error: err => this.handleSaveError(err) + }); + } + + private update(): void { + const cmd = new UpdateEmployeeCommand({ + registrationNumber: this.form.registrationNumber, + identityNumber: this.form.identityNumber, + firstname: this.form.firstname, + lastname: this.form.lastname, + personalMobileNumber: this.form.personalMobileNumber || undefined, + sourceTypeStr: this.form.sourceTypeStr, + activePassiveCode: this.form.activePassiveCode, + isTerminated: this.form.isTerminated, + companyName: this.form.companyName, + businessUnitId: this.isAdmin() ? (this.form.businessUnitId ?? undefined) : undefined, + description: this.form.description + }); + + this.employeesClient.updateEmployee(this.form.registrationNumber, cmd).subscribe({ + next: () => { + this.savingEmployee.set(false); + this.closeDialog(); + if (this.hasSearched()) this.search(); + }, + error: err => this.handleSaveError(err) + }); + } + + private handleSaveError(err: any): void { + this.savingEmployee.set(false); + try { + const parsed = JSON.parse(err.response); + if (parsed?.errors) { + this.formErrors.set(parsed.errors); + } else { + this.formErrors.set({ _: ['An unexpected error occurred.'] }); + } + } catch { + this.formErrors.set({ _: ['An unexpected error occurred.'] }); + } + } + + fieldError(field: string): string | null { + const errs = this.formErrors()[field]; + return errs?.length ? errs[0] : null; + } + + hasAnyError(): boolean { + return Object.keys(this.formErrors()).length > 0; + } + + // ── Helpers ─────────────────────────────────────────────────────────────────── + private emptyForm(): EmployeeForm { + return { + registrationNumber: '', + identityNumber: '', + firstname: '', + lastname: '', + personalMobileNumber: '', + sourceTypeStr: '', + activePassiveCode: '1', + isTerminated: false, + companyName: '', + businessUnitId: null, + description: '' + }; + } +} diff --git a/src/Web/ClientApp/src/app/nav-menu/nav-menu.component.html b/src/Web/ClientApp/src/app/nav-menu/nav-menu.component.html index 9eaa9fac5..4a64a3bff 100644 --- a/src/Web/ClientApp/src/app/nav-menu/nav-menu.component.html +++ b/src/Web/ClientApp/src/app/nav-menu/nav-menu.component.html @@ -8,6 +8,7 @@
  • Counter
  • Weather
  • Tasks
  • +
  • Employees
    • @if (isAuthenticated$ | async) { diff --git a/src/Web/ClientApp/src/styles.scss b/src/Web/ClientApp/src/styles.scss index cb856b16c..20ec6ca28 100644 --- a/src/Web/ClientApp/src/styles.scss +++ b/src/Web/ClientApp/src/styles.scss @@ -321,7 +321,162 @@ dialog article footer { gap: calc(var(--pico-spacing) * 0.5); } -// ── Button variants (not compiled in classless mode — defined manually) ─────── +// ── Employee module ───────────────────────────────────────────────────────────── +.emp-search-card { + margin-bottom: calc(var(--pico-block-spacing-vertical) * 1.5); + + header h3 { + margin: 0; + } +} + +.emp-search-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--pico-spacing); + margin-bottom: var(--pico-spacing); + + label { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin: 0; + + input, select { + margin: 0; + } + } +} + +.emp-source-types { + margin-bottom: var(--pico-spacing); + + small { + display: block; + color: var(--pico-muted-color); + margin-bottom: 0.4rem; + } +} + +.emp-source-chips { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.emp-chip { + padding: 0.25rem 0.75rem; + border-radius: 999px; + border: 1px solid var(--pico-muted-border-color); + background: transparent; + color: var(--pico-color); + cursor: pointer; + font-size: 0.85rem; + line-height: 1.4; + --pico-form-element-spacing-vertical: 0; + --pico-form-element-spacing-horizontal: 0; + box-shadow: none; + + &:hover { + border-color: var(--pico-primary); + color: var(--pico-primary); + background: transparent; + } + + &.emp-chip-selected { + background: var(--pico-primary-background); + border-color: var(--pico-primary); + color: var(--pico-primary-inverse); + } +} + +.emp-table-wrapper { + overflow-x: auto; + margin-bottom: calc(var(--pico-spacing) * 0.5); + + table { + width: 100%; + white-space: nowrap; + } +} + +.emp-badge { + display: inline-block; + padding: 0.15rem 0.6rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 600; + + &.emp-badge-active { + background: color-mix(in srgb, var(--pico-ins-color) 18%, transparent); + color: var(--pico-ins-color); + } + + &.emp-badge-passive { + background: color-mix(in srgb, var(--pico-muted-color) 15%, transparent); + color: var(--pico-muted-color); + } +} + +.emp-action-btn { + padding: 0.2rem 0.6rem; + font-size: 0.85rem; + --pico-form-element-spacing-vertical: 0.2rem; + --pico-form-element-spacing-horizontal: 0.6rem; +} + +.emp-empty { + color: var(--pico-muted-color); + text-align: center; + padding: calc(var(--pico-block-spacing-vertical) * 2); +} + +.emp-dialog-article { + max-width: 640px; + width: 100%; + max-height: 90vh; + overflow-y: auto; +} + +.emp-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--pico-spacing); + margin-bottom: var(--pico-spacing); + + label { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin: 0; + + input, select { + margin: 0; + } + } + + @media (max-width: 600px) { + grid-template-columns: 1fr; + } +} + +.emp-checkbox-label { + display: flex !important; + flex-direction: row !important; + align-items: center; + gap: 0.5rem; + align-self: end; + padding-bottom: calc(var(--pico-form-element-spacing-vertical) * 0.5); + + input[type="checkbox"] { + margin: 0; + } +} + +.required { + color: var(--pico-del-color); +} + button.secondary, [type="submit"].secondary, [type="button"].secondary, diff --git a/src/Web/Endpoints/Employees.cs b/src/Web/Endpoints/Employees.cs index 7bdd58b18..b84389e61 100644 --- a/src/Web/Endpoints/Employees.cs +++ b/src/Web/Endpoints/Employees.cs @@ -13,7 +13,7 @@ public static void Map(RouteGroupBuilder groupBuilder) { groupBuilder.RequireAuthorization(); - groupBuilder.MapPost(SearchEmployees); + groupBuilder.MapPost(SearchEmployees, "search"); groupBuilder.MapGet(GetEmployee, "{registrationNumber}"); groupBuilder.MapGet(GetNextRegistrationNumber, "next-registration-number/{sourceType}"); groupBuilder.MapPost(CreateEmployee, string.Empty); From 55140b395ca965862e666ea838025b1795b2ece9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:35:00 +0000 Subject: [PATCH 4/6] fix: make PersonalMobileNumber optional in UpdateEmployeeCommandValidator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile number is nullable on the domain entity — `.NotEmpty()` incorrectly prevented blank values on update. Now applies length/prefix rules only when the field is provided, matching CreateEmployeeCommandValidator behaviour. Agent-Logs-Url: https://github.com/defactoAdil/CleanArchitecture/sessions/8ad11855-a989-4523-be83-b5a1202b1270 Co-authored-by: defactoAdil <189980729+defactoAdil@users.noreply.github.com> --- .../Commands/UpdateEmployee/UpdateEmployeeCommandValidator.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Application/Employees/Commands/UpdateEmployee/UpdateEmployeeCommandValidator.cs b/src/Application/Employees/Commands/UpdateEmployee/UpdateEmployeeCommandValidator.cs index 4c2b6c81f..d314ac30f 100644 --- a/src/Application/Employees/Commands/UpdateEmployee/UpdateEmployeeCommandValidator.cs +++ b/src/Application/Employees/Commands/UpdateEmployee/UpdateEmployeeCommandValidator.cs @@ -31,9 +31,9 @@ public UpdateEmployeeCommandValidator() .Matches(@"^[\p{L}]+$").WithMessage("Last name must contain letters only."); RuleFor(c => c.PersonalMobileNumber) - .NotEmpty() .Length(12).WithMessage("Personal mobile number must be exactly 12 characters.") - .Must(n => n!.StartsWith("90")).WithMessage("Personal mobile number must start with '90'."); + .Must(n => n!.StartsWith("90")).WithMessage("Personal mobile number must start with '90'.") + .When(c => !string.IsNullOrEmpty(c.PersonalMobileNumber)); RuleFor(c => c.ActivePassiveCode) .NotEmpty(); From 76e36d265b3f3cf82208deb441a990c583900c02 Mon Sep 17 00:00:00 2001 From: adil kudu Date: Fri, 17 Apr 2026 17:51:03 +0300 Subject: [PATCH 5/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../UpdateEmployee/UpdateEmployeeCommandValidator.cs | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/Application/Employees/Commands/UpdateEmployee/UpdateEmployeeCommandValidator.cs b/src/Application/Employees/Commands/UpdateEmployee/UpdateEmployeeCommandValidator.cs index d314ac30f..a721d270f 100644 --- a/src/Application/Employees/Commands/UpdateEmployee/UpdateEmployeeCommandValidator.cs +++ b/src/Application/Employees/Commands/UpdateEmployee/UpdateEmployeeCommandValidator.cs @@ -1,15 +1,7 @@ -using CleanArchitecture.Domain.Enums; - namespace CleanArchitecture.Application.Employees.Commands.UpdateEmployee; public class UpdateEmployeeCommandValidator : AbstractValidator { - private static readonly string[] RestrictedSourceTypes = - [ - SourceType.SAP.ToString(), - SourceType.OzonTekstil.ToString() - ]; - public UpdateEmployeeCommandValidator() { RuleFor(c => c.RegistrationNumber) @@ -39,9 +31,7 @@ public UpdateEmployeeCommandValidator() .NotEmpty(); RuleFor(c => c.SourceTypeStr) - .NotEmpty() - .Must(st => !RestrictedSourceTypes.Contains(st)) - .WithMessage("Source type cannot be changed to SAP or OzonTekstil."); + .NotEmpty(); RuleFor(c => c.Description) .NotEmpty() From bfb128a0915c2052d23a989771998717c7c7749f Mon Sep 17 00:00:00 2001 From: adil kudu Date: Thu, 23 Apr 2026 12:05:11 +0300 Subject: [PATCH 6/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Employees/Queries/GetEmployeeLookups/GetEmployeeLookups.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Application/Employees/Queries/GetEmployeeLookups/GetEmployeeLookups.cs b/src/Application/Employees/Queries/GetEmployeeLookups/GetEmployeeLookups.cs index 7699a8e48..6d8d4140d 100644 --- a/src/Application/Employees/Queries/GetEmployeeLookups/GetEmployeeLookups.cs +++ b/src/Application/Employees/Queries/GetEmployeeLookups/GetEmployeeLookups.cs @@ -22,7 +22,7 @@ public Task Handle(GetEmployeeLookupsQuery request, Cancellat var isAdmin = _user.Roles?.Contains(Roles.HumanResourcesAdminSourceTypes) ?? false; var sourceTypes = isAdmin - ? new[] { SourceType.SAP, SourceType.OzonTekstil, SourceType.Efruz, SourceType.Ecrou, SourceType.All } + ? new[] { SourceType.SAP, SourceType.OzonTekstil, SourceType.Efruz, SourceType.Ecrou, SourceType.Other, SourceType.All } : new[] { SourceType.Other }; var vm = new EmployeeLookupsVm