Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/Application/Common/Interfaces/IApplicationDbContext.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Entities;
using Microsoft.EntityFrameworkCore.Storage;

namespace CleanArchitecture.Application.Common.Interfaces;

Expand All @@ -8,5 +9,9 @@ public interface IApplicationDbContext

DbSet<TodoItem> TodoItems { get; }

DbSet<Employee> Employees { get; }

Task<int> SaveChangesAsync(CancellationToken cancellationToken);

Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace CleanArchitecture.Application.Common.Interfaces;

/// <summary>
/// Generates unique employee registration numbers per source type.
/// </summary>
public interface IEmployeeRegistrationNumberService
{
/// <summary>
/// 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.
/// </summary>
Task<string> GenerateNextAsync(string sourceType, CancellationToken cancellationToken);

/// <summary>
/// Returns a preview of the next registration number without reserving it.
/// Suitable for UI hints only — not safe under concurrent load.
/// </summary>
Task<string> PeekNextAsync(string sourceType, CancellationToken cancellationToken);
}
101 changes: 101 additions & 0 deletions src/Application/Employees/Commands/CreateEmployee/CreateEmployee.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
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;

namespace CleanArchitecture.Application.Employees.Commands.CreateEmployee;

[Authorize]
public record CreateEmployeeCommand : IRequest<string>
{
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<CreateEmployeeCommand, string>
{
private readonly IApplicationDbContext _context;
private readonly IEmployeeRegistrationNumberService _registrationNumberService;
private readonly IUser _user;

public CreateEmployeeCommandHandler(
IApplicationDbContext context,
IEmployeeRegistrationNumberService registrationNumberService,
IUser user)
{
_context = context;
_registrationNumberService = registrationNumberService;
_user = user;
}

public async Task<string> 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;

// 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
{
var registrationNumber = await _registrationNumberService
.GenerateNextAsync(sourceType, cancellationToken);

Comment on lines +50 to +68
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;
}
Comment on lines +61 to +94
}

private string DefaultSourceType() =>
(_user.Roles?.Contains(Roles.HumanResourcesAdminSourceTypes) ?? false)
? SourceType.Ecrou.ToString()
: SourceType.Other.ToString();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using CleanArchitecture.Domain.Enums;

namespace CleanArchitecture.Application.Employees.Commands.CreateEmployee;

public class CreateEmployeeCommandValidator : AbstractValidator<CreateEmployeeCommand>
{
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));
Comment on lines +35 to +38

RuleFor(c => c.Description)
.NotEmpty();

// Duplicate registration number prevention is handled atomically in the command
// handler via IEmployeeRegistrationNumberService and the unique DB index.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using CleanArchitecture.Application.Common.Interfaces;
using CleanArchitecture.Application.Common.Security;
using CleanArchitecture.Application.Employees.Common;
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<UpdateEmployeeCommand>
{
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 = ActivePassiveCodes.Normalize(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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
namespace CleanArchitecture.Application.Employees.Commands.UpdateEmployee;

public class UpdateEmployeeCommandValidator : AbstractValidator<UpdateEmployeeCommand>
{
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)
.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.ActivePassiveCode)
.NotEmpty();

RuleFor(c => c.SourceTypeStr)
.NotEmpty();

Comment thread
defactoAdil marked this conversation as resolved.
RuleFor(c => c.Description)
.NotEmpty()
.MinimumLength(20)
.WithMessage("Description must be at least 20 characters.");

RuleFor(c => c.CompanyName)
.NotEmpty();
}
}
16 changes: 16 additions & 0 deletions src/Application/Employees/Common/ActivePassiveCodes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace CleanArchitecture.Application.Employees.Common;

/// <summary>
/// Centralises ActivePassiveCode normalisation so Create and Update commands share identical logic.
/// "1" or "active" (case-insensitive) → "1" (Etkin/Active); everything else → "0" (Passive).
/// </summary>
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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace CleanArchitecture.Application.Employees.Queries.GetEmployeeLookups;

public class EmployeeLookupsVm
{
public List<SourceTypeLookupDto> SourceTypes { get; set; } = [];

public List<ActivePassiveLookupDto> 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;
}
Loading