add account creation when adding an employee

This commit is contained in:
cuqmbr 2025-05-28 17:55:32 +03:00
parent 7229a10ad5
commit bb309d7c20
Signed by: cuqmbr
GPG Key ID: 0AA446880C766199
18 changed files with 1590 additions and 29 deletions

View File

@ -9,7 +9,7 @@ public static class CustomValidators
{ {
return return
ruleBuilder ruleBuilder
.Matches(@"^[a-z0-9-_.]*$"); .Matches(@"^[a-z0-9-_\.]*$");
} }
// According to RFC 5321. // According to RFC 5321.
@ -18,7 +18,7 @@ public static class CustomValidators
{ {
return return
ruleBuilder ruleBuilder
.Matches(@"^[\w\.-]{1,64}@[\w\.-]{1,251}\.\w{2,4}$"); .Matches(@"^[a-z0-9-_\.]{1,64}@[a-z0-9-_\.]{1,251}\.[a-z0-9-_]{2,4}$");
} }
// According to ITU-T E.164, no spaces. // According to ITU-T E.164, no spaces.

View File

@ -20,4 +20,11 @@ public record AddEmployeeCommand : IRequest<EmployeeDto>
public Guid CompanyGuid { get; set; } public Guid CompanyGuid { get; set; }
public ICollection<EmployeeDocumentModel> Documents { get; set; } public ICollection<EmployeeDocumentModel> Documents { get; set; }
public string Username { get; set; }
public string Email { get; set; }
public string Password { get; set; }
} }

View File

@ -4,6 +4,10 @@ using cuqmbr.TravelGuide.Domain.Entities;
using AutoMapper; using AutoMapper;
using cuqmbr.TravelGuide.Application.Common.Exceptions; using cuqmbr.TravelGuide.Application.Common.Exceptions;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using cuqmbr.TravelGuide.Domain.Enums;
using System.Security.Cryptography;
using cuqmbr.TravelGuide.Application.Common.Services;
using System.Text;
namespace cuqmbr.TravelGuide.Application.Employees.Commands.AddEmployee; namespace cuqmbr.TravelGuide.Application.Employees.Commands.AddEmployee;
@ -13,15 +17,15 @@ public class AddEmployeeCommandHandler :
private readonly UnitOfWork _unitOfWork; private readonly UnitOfWork _unitOfWork;
private readonly IMapper _mapper; private readonly IMapper _mapper;
private readonly IStringLocalizer _localizer; private readonly IStringLocalizer _localizer;
private readonly PasswordHasherService _passwordHasher;
public AddEmployeeCommandHandler( public AddEmployeeCommandHandler(UnitOfWork unitOfWork, IMapper mapper,
UnitOfWork unitOfWork, IStringLocalizer localizer, PasswordHasherService passwordHasher)
IMapper mapper,
IStringLocalizer localizer)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_mapper = mapper; _mapper = mapper;
_localizer = localizer; _localizer = localizer;
_passwordHasher = passwordHasher;
} }
public async Task<EmployeeDto> Handle( public async Task<EmployeeDto> Handle(
@ -52,6 +56,45 @@ public class AddEmployeeCommandHandler :
throw new DuplicateEntityException(); throw new DuplicateEntityException();
} }
// Create new account for employee
var account = await _unitOfWork.AccountRepository.GetOneAsync(
e => e.Email == request.Email,
cancellationToken);
if (account != null)
{
throw new DuplicateEntityException();
}
var role = (await _unitOfWork.RoleRepository.GetPageAsync(
1, IdentityRole.Enumerations.Count(), cancellationToken))
.Items
.First(r => r.Value.Equals(IdentityRole.CompanyEmployee));
var salt = RandomNumberGenerator.GetBytes(128 / 8);
var hash = await _passwordHasher.HashAsync(
Encoding.UTF8.GetBytes(request.Password),
salt, cancellationToken);
var saltBase64 = Convert.ToBase64String(salt);
var hashBase64 = Convert.ToBase64String(hash);
account = new Account()
{
Username = request.Username,
Email = request.Email,
PasswordHash = hashBase64,
PasswordSalt = saltBase64,
AccountRoles = new AccountRole[] { new() { RoleId = role.Id } }
};
account = await _unitOfWork.AccountRepository.AddOneAsync(
account, cancellationToken);
entity = new Employee() entity = new Employee()
{ {
FirstName = request.FirstName, FirstName = request.FirstName,
@ -66,12 +109,14 @@ public class AddEmployeeCommandHandler :
Information = d.Information Information = d.Information
}) })
.ToArray(), .ToArray(),
Company = parentEntity Company = parentEntity,
Account = account
}; };
entity = await _unitOfWork.EmployeeRepository.AddOneAsync( entity = await _unitOfWork.EmployeeRepository.AddOneAsync(
entity, cancellationToken); entity, cancellationToken);
await _unitOfWork.SaveAsync(cancellationToken); await _unitOfWork.SaveAsync(cancellationToken);
_unitOfWork.Dispose(); _unitOfWork.Dispose();

View File

@ -1,3 +1,4 @@
using cuqmbr.TravelGuide.Application.Common.FluentValidation;
using cuqmbr.TravelGuide.Application.Common.Services; using cuqmbr.TravelGuide.Application.Common.Services;
using cuqmbr.TravelGuide.Domain.Enums; using cuqmbr.TravelGuide.Domain.Enums;
using FluentValidation; using FluentValidation;
@ -79,5 +80,46 @@ public class AddEmployeeCommandValidator : AbstractValidator<AddEmployeeCommand>
localizer["FluentValidation.MaximumLength"], localizer["FluentValidation.MaximumLength"],
256)); 256));
}); });
RuleFor(v => v.Username)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"])
.MinimumLength(1)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.MinimumLength"],
1))
.MaximumLength(32)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.MaximumLength"],
32))
.IsUsername()
.WithMessage(localizer["FluentValidation.IsUsername"]);
RuleFor(v => v.Email)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"])
.IsEmail()
.WithMessage(localizer["FluentValidation.IsEmail"]);
RuleFor(v => v.Password)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"])
.MinimumLength(8)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.MinimumLength"],
8))
.MaximumLength(64)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.MaximumLength"],
64));
} }
} }

View File

@ -18,7 +18,7 @@ public class DeleteEmployeeCommandHandler : IRequestHandler<DeleteEmployeeComman
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var entity = await _unitOfWork.EmployeeRepository.GetOneAsync( var entity = await _unitOfWork.EmployeeRepository.GetOneAsync(
e => e.Guid == request.Guid, cancellationToken); e => e.Guid == request.Guid, e => e.Account, cancellationToken);
if (entity == null) if (entity == null)
{ {
@ -31,6 +31,9 @@ public class DeleteEmployeeCommandHandler : IRequestHandler<DeleteEmployeeComman
await _unitOfWork.EmployeeRepository.DeleteOneAsync( await _unitOfWork.EmployeeRepository.DeleteOneAsync(
entity, cancellationToken); entity, cancellationToken);
await _unitOfWork.AccountRepository.DeleteOneAsync(
entity.Account, cancellationToken);
await _unitOfWork.SaveAsync(cancellationToken); await _unitOfWork.SaveAsync(cancellationToken);
_unitOfWork.Dispose(); _unitOfWork.Dispose();
} }

View File

@ -38,7 +38,7 @@ public class UpdateEmployeeCommandHandler :
} }
var entity = await _unitOfWork.EmployeeRepository.GetOneAsync( var employee = await _unitOfWork.EmployeeRepository.GetOneAsync(
e => e =>
e.FirstName == request.FirstName && e.FirstName == request.FirstName &&
e.LastName == request.LastName && e.LastName == request.LastName &&
@ -49,30 +49,34 @@ public class UpdateEmployeeCommandHandler :
e.Guid != request.Guid, e.Guid != request.Guid,
cancellationToken); cancellationToken);
if (entity != null) if (employee != null)
{ {
throw new DuplicateEntityException(); throw new DuplicateEntityException();
} }
entity = await _unitOfWork.EmployeeRepository.GetOneAsync( employee = await _unitOfWork.EmployeeRepository.GetOneAsync(
e => e.Guid == request.Guid, e => e.Documents, cancellationToken); e => e.Guid == request.Guid, e => e.Documents, cancellationToken);
if (entity == null) if (employee == null)
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
var account = await _unitOfWork.AccountRepository.GetOneAsync(
a => a.Id == employee.AccountId, cancellationToken);
entity.Guid = request.Guid;
entity.FirstName = request.FirstName;
entity.LastName = request.LastName;
entity.Patronymic = request.Patronymic;
entity.Sex = request.Sex;
entity.BirthDate = request.BirthDate;
entity.CompanyId = parentEntity.Id;
entity.Company = parentEntity; employee.Guid = request.Guid;
employee.FirstName = request.FirstName;
employee.LastName = request.LastName;
employee.Patronymic = request.Patronymic;
employee.Sex = request.Sex;
employee.BirthDate = request.BirthDate;
employee.CompanyId = parentEntity.Id;
employee.Company = parentEntity;
employee.Account = account;
var requestEmployeeDocuments = request.Documents.Select( var requestEmployeeDocuments = request.Documents.Select(
@ -82,27 +86,27 @@ public class UpdateEmployeeCommandHandler :
Information = d.Information Information = d.Information
}); });
var commonEmployeeDocuments = entity.Documents.IntersectBy( var commonEmployeeDocuments = employee.Documents.IntersectBy(
requestEmployeeDocuments.Select( requestEmployeeDocuments.Select(
ed => (ed.DocumentType, ed.Information)), ed => (ed.DocumentType, ed.Information)),
ed => (ed.DocumentType, ed.Information)); ed => (ed.DocumentType, ed.Information));
var newEmployeeDocuments = requestEmployeeDocuments.ExceptBy( var newEmployeeDocuments = requestEmployeeDocuments.ExceptBy(
entity.Documents.Select(ed => (ed.DocumentType, ed.Information)), employee.Documents.Select(ed => (ed.DocumentType, ed.Information)),
ed => (ed.DocumentType, ed.Information)); ed => (ed.DocumentType, ed.Information));
var combinedEmployeeDocuments = commonEmployeeDocuments.UnionBy( var combinedEmployeeDocuments = commonEmployeeDocuments.UnionBy(
newEmployeeDocuments, ed => (ed.DocumentType, ed.Information)); newEmployeeDocuments, ed => (ed.DocumentType, ed.Information));
entity.Documents = combinedEmployeeDocuments.ToList(); employee.Documents = combinedEmployeeDocuments.ToList();
entity = await _unitOfWork.EmployeeRepository.UpdateOneAsync( employee = await _unitOfWork.EmployeeRepository.UpdateOneAsync(
entity, cancellationToken); employee, cancellationToken);
await _unitOfWork.SaveAsync(cancellationToken); await _unitOfWork.SaveAsync(cancellationToken);
_unitOfWork.Dispose(); _unitOfWork.Dispose();
return _mapper.Map<EmployeeDto>(entity); return _mapper.Map<EmployeeDto>(employee);
} }
} }

View File

@ -0,0 +1,11 @@
using cuqmbr.TravelGuide.Application.Common.Mappings;
using cuqmbr.TravelGuide.Domain.Entities;
namespace cuqmbr.TravelGuide.Application.Employees;
public sealed class EmployeeAccountDto : IMapFrom<Account>
{
public string Username { get; set; }
public string Email { get; set; }
}

View File

@ -22,6 +22,8 @@ public sealed class EmployeeDto : IMapFrom<Employee>
public ICollection<EmployeeDocumentDto> Documents { get; set; } public ICollection<EmployeeDocumentDto> Documents { get; set; }
public EmployeeAccountDto Account { get; set; }
public void Mapping(MappingProfile profile) public void Mapping(MappingProfile profile)
{ {
profile.CreateMap<Employee, EmployeeDto>() profile.CreateMap<Employee, EmployeeDto>()

View File

@ -33,13 +33,18 @@ public class GetEmployeeQueryHandler :
} }
// Hydrate employees with companies // Hydrate employee
var company = await _unitOfWork.CompanyRepository.GetOneAsync( var company = await _unitOfWork.CompanyRepository.GetOneAsync(
e => e.Id == entity.CompanyId, cancellationToken); e => e.Id == entity.CompanyId, cancellationToken);
entity.Company = company; entity.Company = company;
var account = await _unitOfWork.AccountRepository.GetOneAsync(
e => e.Id == entity.AccountId, cancellationToken);
entity.Account = account;
_unitOfWork.Dispose(); _unitOfWork.Dispose();

View File

@ -49,7 +49,7 @@ public class GetEmployeesPageQueryHandler :
cancellationToken); cancellationToken);
// Hydrate employees with companies // Hydrate employees
var companies = await _unitOfWork.CompanyRepository.GetPageAsync( var companies = await _unitOfWork.CompanyRepository.GetPageAsync(
e => paginatedList.Items.Select(e => e.CompanyId).Contains(e.Id), e => paginatedList.Items.Select(e => e.CompanyId).Contains(e.Id),
@ -61,6 +61,16 @@ public class GetEmployeesPageQueryHandler :
companies.Items.First(c => c.Id == employee.CompanyId); companies.Items.First(c => c.Id == employee.CompanyId);
} }
var accounts = await _unitOfWork.AccountRepository.GetPageAsync(
e => paginatedList.Items.Select(e => e.AccountId).Contains(e.Id),
1, paginatedList.Items.Count, cancellationToken);
foreach (var employee in paginatedList.Items)
{
employee.Account =
accounts.Items.First(a => a.Id == employee.AccountId);
}
var mappedItems = _mapper var mappedItems = _mapper
.ProjectTo<EmployeeDto>(paginatedList.Items.AsQueryable()); .ProjectTo<EmployeeDto>(paginatedList.Items.AsQueryable());

View File

@ -16,4 +16,11 @@ public sealed class AddEmployeeViewModel
public Guid CompanyUuid { get; set; } public Guid CompanyUuid { get; set; }
public ICollection<EmployeeDocumentViewModel> Documents { get; set; } public ICollection<EmployeeDocumentViewModel> Documents { get; set; }
public string Username { get; set; }
public string Email { get; set; }
public string Password { get; set; }
} }

View File

@ -13,4 +13,7 @@ public sealed class Account : EntityBase
public ICollection<AccountRole> AccountRoles { get; set; } public ICollection<AccountRole> AccountRoles { get; set; }
public ICollection<RefreshToken> RefreshTokens { get; set; } public ICollection<RefreshToken> RefreshTokens { get; set; }
public Employee? Employee { get; set; }
} }

View File

@ -22,4 +22,9 @@ public sealed class Employee : EntityBase
public ICollection<EmployeeDocument> Documents { get; set; } public ICollection<EmployeeDocument> Documents { get; set; }
public ICollection<VehicleEnrollmentEmployee> VehicleEnrollmentEmployees { get; set; } public ICollection<VehicleEnrollmentEmployee> VehicleEnrollmentEmployees { get; set; }
public long AccountId { get; set; }
public Account Account { get; set; }
} }

View File

@ -59,7 +59,10 @@ public class EmployeesController : ControllerBase
Information = e.Information Information = e.Information
}).ToArray(), }).ToArray(),
CompanyGuid = viewModel.CompanyUuid CompanyGuid = viewModel.CompanyUuid,
Username = viewModel.Username,
Email = viewModel.Email,
Password = viewModel.Password
}, },
cancellationToken)); cancellationToken));
} }

View File

@ -77,5 +77,29 @@ public class EmployeeConfiguration : BaseConfiguration<Employee>
"ix_" + "ix_" +
$"{builder.Metadata.GetTableName()}_" + $"{builder.Metadata.GetTableName()}_" +
$"{builder.Property(e => e.CompanyId).Metadata.GetColumnName()}"); $"{builder.Property(e => e.CompanyId).Metadata.GetColumnName()}");
builder
.Property(e => e.AccountId)
.HasColumnName("account_id")
.HasColumnType("bigint")
.IsRequired(true);
builder
.HasOne(e => e.Account)
.WithOne(a => a.Employee)
.HasForeignKey<Employee>(e => e.AccountId)
.HasConstraintName(
"fk_" +
$"{builder.Metadata.GetTableName()}_" +
$"{builder.Property(e => e.AccountId).Metadata.GetColumnName()}")
.OnDelete(DeleteBehavior.Cascade);
builder
.HasIndex(e => e.AccountId)
.HasDatabaseName(
"ix_" +
$"{builder.Metadata.GetTableName()}_" +
$"{builder.Property(e => e.AccountId).Metadata.GetColumnName()}");
} }
} }

View File

@ -0,0 +1,58 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Persistence.PostgreSql.Migrations
{
/// <inheritdoc />
public partial class Add_navigation_from_Employee_to_Account : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "account_id",
schema: "application",
table: "employees",
type: "bigint",
nullable: false,
defaultValue: 0L);
migrationBuilder.CreateIndex(
name: "ix_employees_account_id",
schema: "application",
table: "employees",
column: "account_id",
unique: true);
migrationBuilder.AddForeignKey(
name: "fk_employees_account_id",
schema: "application",
table: "employees",
column: "account_id",
principalSchema: "application",
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_employees_account_id",
schema: "application",
table: "employees");
migrationBuilder.DropIndex(
name: "ix_employees_account_id",
schema: "application",
table: "employees");
migrationBuilder.DropColumn(
name: "account_id",
schema: "application",
table: "employees");
}
}
}

View File

@ -306,6 +306,10 @@ namespace Persistence.PostgreSql.Migrations
NpgsqlPropertyBuilderExtensions.UseSequence(b.Property<long>("Id"), "employees_id_sequence"); NpgsqlPropertyBuilderExtensions.UseSequence(b.Property<long>("Id"), "employees_id_sequence");
b.Property<long>("AccountId")
.HasColumnType("bigint")
.HasColumnName("account_id");
b.Property<DateOnly>("BirthDate") b.Property<DateOnly>("BirthDate")
.HasColumnType("date") .HasColumnType("date")
.HasColumnName("birth_date"); .HasColumnName("birth_date");
@ -344,6 +348,10 @@ namespace Persistence.PostgreSql.Migrations
b.HasAlternateKey("Guid") b.HasAlternateKey("Guid")
.HasName("altk_employees_uuid"); .HasName("altk_employees_uuid");
b.HasIndex("AccountId")
.IsUnique()
.HasDatabaseName("ix_employees_account_id");
b.HasIndex("CompanyId") b.HasIndex("CompanyId")
.HasDatabaseName("ix_employees_company_id"); .HasDatabaseName("ix_employees_company_id");
@ -1030,6 +1038,13 @@ namespace Persistence.PostgreSql.Migrations
modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Employee", b => modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Employee", b =>
{ {
b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Account", "Account")
.WithOne("Employee")
.HasForeignKey("cuqmbr.TravelGuide.Domain.Entities.Employee", "AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_employees_account_id");
b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Company", "Company") b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Company", "Company")
.WithMany("Employees") .WithMany("Employees")
.HasForeignKey("CompanyId") .HasForeignKey("CompanyId")
@ -1037,6 +1052,8 @@ namespace Persistence.PostgreSql.Migrations
.IsRequired() .IsRequired()
.HasConstraintName("fk_employees_company_id"); .HasConstraintName("fk_employees_company_id");
b.Navigation("Account");
b.Navigation("Company"); b.Navigation("Company");
}); });
@ -1213,6 +1230,8 @@ namespace Persistence.PostgreSql.Migrations
{ {
b.Navigation("AccountRoles"); b.Navigation("AccountRoles");
b.Navigation("Employee");
b.Navigation("RefreshTokens"); b.Navigation("RefreshTokens");
}); });