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]