From 36e7c07a0d8b0d75ffcd5fb732fed284a8cdc8e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Fri, 27 Feb 2026 23:32:09 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=82=D0=B0=D1=82=D1=8C=20=D0=B8=D1=81?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=BD=D0=B8=D1=82=D0=B5=D0=BB=D0=B5=D0=BC=20?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=B0=D1=8F=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- check_schedule.sql | 2 + get_schedule.sql | 2 + .../Commands/ChangePasswordCommand.cs | 68 +++++++++++++++++++ .../Commands/RegisterUserCommand.cs | 53 ++++++++++++--- .../Commands/UpdateLocationCommand.cs | 55 +++++++++++++++ .../Commands/UpdateScheduleCommand.cs | 64 +++++++++++++++++ .../Queries/GetProfile/GetProfileQuery.cs | 13 +++- .../Identity/Domain/Aggregates/Account.cs | 40 +++++++++++ .../Identity/Domain/Aggregates/UserProfile.cs | 6 ++ src/Modules/Identity/Domain/Enums/Role.cs | 1 + .../Domain/Repositories/IAccountRepository.cs | 1 + .../Configurations/AccountConfiguration.cs | 11 +++ .../Repositories/AccountRepository.cs | 8 +++ .../Endpoints/IdentityEndpoints.cs | 60 ++++++++++++++++ .../BecomePerformerCommandHandlerTests.cs | 7 +- 15 files changed, 379 insertions(+), 12 deletions(-) create mode 100644 check_schedule.sql create mode 100644 get_schedule.sql create mode 100644 src/Modules/Identity/Application/Commands/ChangePasswordCommand.cs create mode 100644 src/Modules/Identity/Application/Commands/UpdateLocationCommand.cs create mode 100644 src/Modules/Identity/Application/Commands/UpdateScheduleCommand.cs diff --git a/check_schedule.sql b/check_schedule.sql new file mode 100644 index 0000000..942ebe6 --- /dev/null +++ b/check_schedule.sql @@ -0,0 +1,2 @@ +SET search_path TO identity; +SELECT "WorkingDays" FROM "WorkSchedules" LIMIT 3; diff --git a/get_schedule.sql b/get_schedule.sql new file mode 100644 index 0000000..8011d23 --- /dev/null +++ b/get_schedule.sql @@ -0,0 +1,2 @@ +SET search_path TO "identity"; +SELECT * FROM "WorkSchedules"; diff --git a/src/Modules/Identity/Application/Commands/ChangePasswordCommand.cs b/src/Modules/Identity/Application/Commands/ChangePasswordCommand.cs new file mode 100644 index 0000000..5fd9fe6 --- /dev/null +++ b/src/Modules/Identity/Application/Commands/ChangePasswordCommand.cs @@ -0,0 +1,68 @@ +using MediatR; +using Nashel.Modules.Identity.Domain.Repositories; +using Nashel.Modules.Identity.Domain.Services; + +namespace Nashel.Modules.Identity.Application.Commands; + +/// +/// Команда смены пароля. +/// +public record ChangePasswordCommand : IRequest +{ + /// + /// ID аккаунта. + /// + public Guid AccountId { get; init; } + + /// + /// Текущий пароль. + /// + public string OldPassword { get; init; } = default!; + + /// + /// Новый пароль. + /// + public string NewPassword { get; init; } = default!; +} + +public class ChangePasswordCommandHandler : IRequestHandler +{ + private readonly IAccountRepository _accountRepository; + private readonly IPasswordHasher _passwordHasher; + + public ChangePasswordCommandHandler(IAccountRepository accountRepository, IPasswordHasher passwordHasher) + { + _accountRepository = accountRepository; + _passwordHasher = passwordHasher; + } + + public async Task Handle(ChangePasswordCommand request, CancellationToken cancellationToken) + { + var account = await _accountRepository.GetByIdAsync(request.AccountId, cancellationToken); + + if (account == null) + { + throw new Exception("Аккаунт не найден"); + } + + // Проверяем старый пароль + if (!_passwordHasher.VerifyPassword(request.OldPassword, account.PasswordHash)) + { + throw new Exception("Неверный текущий пароль"); + } + + // Проверяем, что новый пароль отличается от старого + if (_passwordHasher.VerifyPassword(request.NewPassword, account.PasswordHash)) + { + throw new Exception("Новый пароль должен отличаться от текущего"); + } + + // Хешируем и устанавливаем новый пароль + var newPasswordHash = _passwordHasher.HashPassword(request.NewPassword); + account.ChangePassword(newPasswordHash); + + await _accountRepository.UpdateAsync(account, cancellationToken); + + return true; + } +} diff --git a/src/Modules/Identity/Application/Commands/RegisterUserCommand.cs b/src/Modules/Identity/Application/Commands/RegisterUserCommand.cs index 78bb18a..3b215bc 100644 --- a/src/Modules/Identity/Application/Commands/RegisterUserCommand.cs +++ b/src/Modules/Identity/Application/Commands/RegisterUserCommand.cs @@ -70,23 +70,59 @@ public class RegisterUserCommandHandler : IRequestHandler Handle(RegisterUserCommand request, CancellationToken cancellationToken) { - // Проверка существования пользователя - if (await _accountRepository.GetByPhoneAsync(request.Phone, cancellationToken) != null) + // Проверяем, есть ли аккаунт с таким телефоном (включая удалённые) + var existingAccount = await _accountRepository.GetByPhoneIncludingDeletedAsync(request.Phone, cancellationToken); + + if (existingAccount != null) { - throw new Exception("Пользователь уже существует"); + if (!existingAccount.IsDeleted) + { + // Аккаунт существует и активен - ошибка + throw new Exception("Пользователь с таким номером телефона уже существует"); + } + + // Аккаунт удалён - восстанавливаем его с новыми данными + var passwordHash = _passwordHasher.HashPassword(request.Password); + + // Очищаем старые данные профиля + var profile = UserProfile.Create( + existingAccount.Id, + request.FirstName, + request.LastName, + request.Patronymic, + request.CompanyName, + request.Inn, + request.Description); + + existingAccount.Roles.Clear(); + existingAccount.Roles.Add(Role.User); + + // Добавляем роль только для Company + if (request.Role == Role.Company) + { + existingAccount.Roles.Add(request.Role); + } + + existingAccount.SetProfile(profile); + existingAccount.ChangePassword(passwordHash); + existingAccount.Restore(); + + await _accountRepository.UpdateAsync(existingAccount, cancellationToken); + + return existingAccount.Id; } - var passwordHash = _passwordHasher.HashPassword(request.Password); - var account = Account.Create(request.Phone, passwordHash); + // Создаём новый аккаунт + var passwordHashNew = _passwordHasher.HashPassword(request.Password); + var account = Account.Create(request.Phone, passwordHashNew); // Добавляем роль только для Company. Newbie не добавляем - она добавляется через BecomePerformerCommand if (request.Role == Role.Company) { account.Roles.Add(request.Role); } - // Для Newbie роль не добавляем - пользователь остаётся User до заполнения формы становления исполнителем - var profile = UserProfile.Create( + var newProfile = UserProfile.Create( account.Id, request.FirstName, request.LastName, @@ -95,10 +131,9 @@ public class RegisterUserCommandHandler : IRequestHandler +/// Команда обновления локации пользователя. +/// +public record UpdateLocationCommand : IRequest +{ + /// + /// ID аккаунта. + /// + public Guid AccountId { get; init; } + + /// + /// Локация (город, район). + /// + public string? Location { get; init; } + + /// + /// Текущая геолокация. + /// + public string? CurrentLocation { get; init; } +} + +public class UpdateLocationCommandHandler : IRequestHandler +{ + private readonly IAccountRepository _accountRepository; + + public UpdateLocationCommandHandler(IAccountRepository accountRepository) + { + _accountRepository = accountRepository; + } + + public async Task Handle(UpdateLocationCommand request, CancellationToken cancellationToken) + { + var account = await _accountRepository.GetByIdAsync(request.AccountId, cancellationToken); + + if (account == null) + { + throw new Exception("Аккаунт не найден"); + } + + if (account.Profile == null) + { + throw new Exception("Профиль не найден"); + } + + account.UpdateLocation(request.Location, request.CurrentLocation); + await _accountRepository.UpdateAsync(account, cancellationToken); + + return true; + } +} diff --git a/src/Modules/Identity/Application/Commands/UpdateScheduleCommand.cs b/src/Modules/Identity/Application/Commands/UpdateScheduleCommand.cs new file mode 100644 index 0000000..818aeca --- /dev/null +++ b/src/Modules/Identity/Application/Commands/UpdateScheduleCommand.cs @@ -0,0 +1,64 @@ +using MediatR; +using Nashel.Modules.Identity.Domain.Aggregates; +using Nashel.Modules.Identity.Domain.Entities; +using Nashel.Modules.Identity.Domain.Repositories; + +namespace Nashel.Modules.Identity.Application.Commands; + +/// +/// Команда обновления расписания работы. +/// +public record UpdateScheduleCommand : IRequest +{ + /// + /// ID аккаунта. + /// + public Guid AccountId { get; init; } + + /// + /// Всегда на связи (24/7). + /// + public bool IsAlwaysReady { get; init; } + + /// + /// Рабочие дни в формате JSON. + /// Пример: [{"day":1,"startTime":"09:00","endTime":"18:00"},{"day":2,"startTime":"09:00","endTime":"18:00"}] + /// + public string WorkingDaysJson { get; init; } = default!; +} + +public class UpdateScheduleCommandHandler : IRequestHandler +{ + private readonly IAccountRepository _accountRepository; + + public UpdateScheduleCommandHandler(IAccountRepository accountRepository) + { + _accountRepository = accountRepository; + } + + public async Task Handle(UpdateScheduleCommand request, CancellationToken cancellationToken) + { + var account = await _accountRepository.GetByIdAsync(request.AccountId, cancellationToken); + + + if (account == null) + { + throw new Exception("Аккаунт не найден"); + } + + if (account.Profile == null) + { + throw new Exception("Профиль не найден"); + } + + // Рабочие дни передаем как строку JSON или null + var workingDays = request.IsAlwaysReady ? null : request.WorkingDaysJson; + + var workSchedule = WorkSchedule.Create(request.IsAlwaysReady, workingDays); + account.UpdateSchedule(workSchedule); + + await _accountRepository.UpdateAsync(account, cancellationToken); + + return true; + } +} diff --git a/src/Modules/Identity/Application/Queries/GetProfile/GetProfileQuery.cs b/src/Modules/Identity/Application/Queries/GetProfile/GetProfileQuery.cs index c6a2b74..bc2b9d3 100644 --- a/src/Modules/Identity/Application/Queries/GetProfile/GetProfileQuery.cs +++ b/src/Modules/Identity/Application/Queries/GetProfile/GetProfileQuery.cs @@ -1,3 +1,5 @@ +using System.Text.Json; +using System.Text.Json.Serialization; using MediatR; using Nashel.BuildingBlocks.Application.Abstractions; using Nashel.Modules.Identity.Domain.Repositories; @@ -11,7 +13,10 @@ public record WorkScheduleResponse( Dictionary>? SchedulePerDay ); -public record TimePeriod(string Start, string End); +public record TimePeriod( + [property: JsonPropertyName("start")] string Start, + [property: JsonPropertyName("end")] string End +); public record ProfileResponse( Guid Id, @@ -68,7 +73,11 @@ public class GetProfileQueryHandler : IRequestHandler>? schedulePerDay = null; if (account.Profile.WorkSchedule.WorkingDays != null) { - schedulePerDay = System.Text.Json.JsonSerializer.Deserialize>>(account.Profile.WorkSchedule.WorkingDays); + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + schedulePerDay = JsonSerializer.Deserialize>>(account.Profile.WorkSchedule.WorkingDays, options); } workScheduleResponse = new WorkScheduleResponse( diff --git a/src/Modules/Identity/Domain/Aggregates/Account.cs b/src/Modules/Identity/Domain/Aggregates/Account.cs index 8c47643..6033c6f 100644 --- a/src/Modules/Identity/Domain/Aggregates/Account.cs +++ b/src/Modules/Identity/Domain/Aggregates/Account.cs @@ -12,6 +12,10 @@ public class Account : AggregateRoot public List Roles { get; private set; } = new(); public UserProfile Profile { get; private set; } = default!; + // Soft Delete + public bool IsDeleted { get; private set; } + public DateTime? DeletedAt { get; private set; } + // Конструктор для EF Core private Account() { } @@ -21,6 +25,7 @@ public class Account : AggregateRoot Phone = phone; PasswordHash = passwordHash; Roles.Add(Role.User); + IsDeleted = false; AddDomainEvent(new AccountCreatedEvent(Id)); } @@ -29,6 +34,41 @@ public class Account : AggregateRoot return new Account(Guid.NewGuid(), phone, passwordHash); } + public void SoftDelete() + { + IsDeleted = true; + DeletedAt = DateTime.UtcNow; + } + + public void Restore() + { + IsDeleted = false; + DeletedAt = null; + } + + public void ChangePassword(string newPasswordHash) + { + PasswordHash = newPasswordHash; + } + + public void UpdateLocation(string? location, string? currentLocation) + { + if (Profile == null) + { + throw new InvalidOperationException("Profile not found"); + } + Profile.UpdateLocation(location, currentLocation); + } + + public void UpdateSchedule(WorkSchedule workSchedule) + { + if (Profile == null) + { + throw new InvalidOperationException("Profile not found"); + } + Profile.SetWorkSchedule(workSchedule); + } + public void SetProfile(UserProfile profile) { Profile = profile; diff --git a/src/Modules/Identity/Domain/Aggregates/UserProfile.cs b/src/Modules/Identity/Domain/Aggregates/UserProfile.cs index 57f964d..d7ec513 100644 --- a/src/Modules/Identity/Domain/Aggregates/UserProfile.cs +++ b/src/Modules/Identity/Domain/Aggregates/UserProfile.cs @@ -105,4 +105,10 @@ public class UserProfile : Entity { WorkSchedule = workSchedule; } + + public void UpdateLocation(string? location, string? currentLocation) + { + Location = location; + CurrentLocation = currentLocation; + } } diff --git a/src/Modules/Identity/Domain/Enums/Role.cs b/src/Modules/Identity/Domain/Enums/Role.cs index 54f91d2..06732e5 100644 --- a/src/Modules/Identity/Domain/Enums/Role.cs +++ b/src/Modules/Identity/Domain/Enums/Role.cs @@ -7,6 +7,7 @@ public enum Role { User = 0, Newbie, + Candidate, Master, Company, Admin diff --git a/src/Modules/Identity/Domain/Repositories/IAccountRepository.cs b/src/Modules/Identity/Domain/Repositories/IAccountRepository.cs index e1430e7..a4c1117 100644 --- a/src/Modules/Identity/Domain/Repositories/IAccountRepository.cs +++ b/src/Modules/Identity/Domain/Repositories/IAccountRepository.cs @@ -8,4 +8,5 @@ public interface IAccountRepository Task GetByIdAsync(Guid id, CancellationToken cancellationToken); Task AddAsync(Account account, CancellationToken cancellationToken); Task UpdateAsync(Account account, CancellationToken cancellationToken); + Task GetByPhoneIncludingDeletedAsync(string phone, CancellationToken cancellationToken); } diff --git a/src/Modules/Identity/Infrastructure/Persistence/Configurations/AccountConfiguration.cs b/src/Modules/Identity/Infrastructure/Persistence/Configurations/AccountConfiguration.cs index 5ae3151..7258316 100644 --- a/src/Modules/Identity/Infrastructure/Persistence/Configurations/AccountConfiguration.cs +++ b/src/Modules/Identity/Infrastructure/Persistence/Configurations/AccountConfiguration.cs @@ -37,5 +37,16 @@ public class AccountConfiguration : IEntityTypeConfiguration .WithOne() .HasForeignKey(p => p.Id) .OnDelete(DeleteBehavior.Cascade); + + // Soft Delete + builder.Property(x => x.IsDeleted) + .IsRequired() + .HasDefaultValue(false); + + builder.Property(x => x.DeletedAt) + .IsRequired(false); + + // Фильтр для soft delete + builder.HasQueryFilter(x => !x.IsDeleted); } } diff --git a/src/Modules/Identity/Infrastructure/Repositories/AccountRepository.cs b/src/Modules/Identity/Infrastructure/Repositories/AccountRepository.cs index b2528e4..535d06f 100644 --- a/src/Modules/Identity/Infrastructure/Repositories/AccountRepository.cs +++ b/src/Modules/Identity/Infrastructure/Repositories/AccountRepository.cs @@ -49,6 +49,14 @@ public class AccountRepository : IAccountRepository .FirstOrDefaultAsync(a => a.Phone == phone, cancellationToken); } + public async Task GetByPhoneIncludingDeletedAsync(string phone, CancellationToken cancellationToken) + { + return await _context.Accounts + .IgnoreQueryFilters() + .Include(a => a.Profile) + .FirstOrDefaultAsync(a => a.Phone == phone, cancellationToken); + } + public async Task UpdateAsync(Account account, CancellationToken cancellationToken) { Console.WriteLine($"[AccountRepository.UpdateAsync] Starting update for account: {account.Id}"); diff --git a/src/Modules/Identity/Presentation/Endpoints/IdentityEndpoints.cs b/src/Modules/Identity/Presentation/Endpoints/IdentityEndpoints.cs index 779655a..d9b5082 100644 --- a/src/Modules/Identity/Presentation/Endpoints/IdentityEndpoints.cs +++ b/src/Modules/Identity/Presentation/Endpoints/IdentityEndpoints.cs @@ -106,6 +106,48 @@ public static class IdentityEndpoints }) .WithName("SearchCompetencies") .WithOpenApi(operation => new(operation) { Summary = "Поиск компетенций" }); + + // Эндпоинт для смены пароля + profileGroup.MapPost("/change-password", async (ChangePasswordRequest request, ISender sender) => + { + await sender.Send(new ChangePasswordCommand + { + AccountId = request.AccountId, + OldPassword = request.OldPassword, + NewPassword = request.NewPassword + }); + return Results.Ok(new { success = true, message = "Пароль успешно изменён" }); + }) + .WithName("ChangePassword") + .WithOpenApi(operation => new(operation) { Summary = "Сменить пароль" }); + + // Эндпоинт для обновления локации + profileGroup.MapPut("/location", async (UpdateLocationRequest request, ISender sender) => + { + await sender.Send(new UpdateLocationCommand + { + AccountId = request.AccountId, + Location = request.Location, + CurrentLocation = request.CurrentLocation + }); + return Results.NoContent(); + }) + .WithName("UpdateLocation") + .WithOpenApi(operation => new(operation) { Summary = "Обновить локацию" }); + + // Эндпоинт для обновления расписания + profileGroup.MapPut("/schedule", async (UpdateScheduleRequest request, ISender sender) => + { + await sender.Send(new UpdateScheduleCommand + { + AccountId = request.AccountId, + IsAlwaysReady = request.IsAlwaysReady, + WorkingDaysJson = request.WorkingDaysJson ?? "[]" + }); + return Results.NoContent(); + }) + .WithName("UpdateSchedule") + .WithOpenApi(operation => new(operation) { Summary = "Обновить расписание" }); } } @@ -119,3 +161,21 @@ public record BecomePerformerRequest( bool IsAlwaysReady, string? WorkingDays ); + +public record ChangePasswordRequest( + Guid AccountId, + string OldPassword, + string NewPassword +); + +public record UpdateLocationRequest( + Guid AccountId, + string? Location, + string? CurrentLocation +); + +public record UpdateScheduleRequest( + Guid AccountId, + bool IsAlwaysReady, + string? WorkingDaysJson +); diff --git a/src/Modules/Identity/Tests/Application/BecomePerformerCommandHandlerTests.cs b/src/Modules/Identity/Tests/Application/BecomePerformerCommandHandlerTests.cs index 4a1e681..b4f1fdf 100644 --- a/src/Modules/Identity/Tests/Application/BecomePerformerCommandHandlerTests.cs +++ b/src/Modules/Identity/Tests/Application/BecomePerformerCommandHandlerTests.cs @@ -14,13 +14,18 @@ public class BecomePerformerCommandHandlerTests { private readonly Mock _mockRepo; private readonly Mock _mockUserService; + private readonly Mock _mockCompetencyRepo; private readonly BecomePerformerCommandHandler _handler; public BecomePerformerCommandHandlerTests() { _mockRepo = new Mock(); _mockUserService = new Mock(); - _handler = new BecomePerformerCommandHandler(_mockRepo.Object, _mockUserService.Object); + _mockCompetencyRepo = new Mock(); + _handler = new BecomePerformerCommandHandler( + _mockRepo.Object, + _mockUserService.Object, + _mockCompetencyRepo.Object); } [Fact]