Стать исполнителем новая версия
This commit is contained in:
2
check_schedule.sql
Normal file
2
check_schedule.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
SET search_path TO identity;
|
||||
SELECT "WorkingDays" FROM "WorkSchedules" LIMIT 3;
|
||||
2
get_schedule.sql
Normal file
2
get_schedule.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
SET search_path TO "identity";
|
||||
SELECT * FROM "WorkSchedules";
|
||||
@@ -0,0 +1,68 @@
|
||||
using MediatR;
|
||||
using Nashel.Modules.Identity.Domain.Repositories;
|
||||
using Nashel.Modules.Identity.Domain.Services;
|
||||
|
||||
namespace Nashel.Modules.Identity.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Команда смены пароля.
|
||||
/// </summary>
|
||||
public record ChangePasswordCommand : IRequest<bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// ID аккаунта.
|
||||
/// </summary>
|
||||
public Guid AccountId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Текущий пароль.
|
||||
/// </summary>
|
||||
public string OldPassword { get; init; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Новый пароль.
|
||||
/// </summary>
|
||||
public string NewPassword { get; init; } = default!;
|
||||
}
|
||||
|
||||
public class ChangePasswordCommandHandler : IRequestHandler<ChangePasswordCommand, bool>
|
||||
{
|
||||
private readonly IAccountRepository _accountRepository;
|
||||
private readonly IPasswordHasher _passwordHasher;
|
||||
|
||||
public ChangePasswordCommandHandler(IAccountRepository accountRepository, IPasswordHasher passwordHasher)
|
||||
{
|
||||
_accountRepository = accountRepository;
|
||||
_passwordHasher = passwordHasher;
|
||||
}
|
||||
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
}
|
||||
@@ -70,23 +70,59 @@ public class RegisterUserCommandHandler : IRequestHandler<RegisterUserCommand, G
|
||||
|
||||
public async Task<Guid> 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<RegisterUserCommand, G
|
||||
request.Inn,
|
||||
request.Description);
|
||||
|
||||
account.SetProfile(profile);
|
||||
account.SetProfile(newProfile);
|
||||
|
||||
await _accountRepository.AddAsync(account, cancellationToken);
|
||||
// Предполагаем, что AccountRepository.AddAsync сохранит всё дерево сущностей
|
||||
|
||||
return account.Id;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using MediatR;
|
||||
using Nashel.Modules.Identity.Domain.Repositories;
|
||||
|
||||
namespace Nashel.Modules.Identity.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Команда обновления локации пользователя.
|
||||
/// </summary>
|
||||
public record UpdateLocationCommand : IRequest<bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// ID аккаунта.
|
||||
/// </summary>
|
||||
public Guid AccountId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Локация (город, район).
|
||||
/// </summary>
|
||||
public string? Location { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Текущая геолокация.
|
||||
/// </summary>
|
||||
public string? CurrentLocation { get; init; }
|
||||
}
|
||||
|
||||
public class UpdateLocationCommandHandler : IRequestHandler<UpdateLocationCommand, bool>
|
||||
{
|
||||
private readonly IAccountRepository _accountRepository;
|
||||
|
||||
public UpdateLocationCommandHandler(IAccountRepository accountRepository)
|
||||
{
|
||||
_accountRepository = accountRepository;
|
||||
}
|
||||
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Команда обновления расписания работы.
|
||||
/// </summary>
|
||||
public record UpdateScheduleCommand : IRequest<bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// ID аккаунта.
|
||||
/// </summary>
|
||||
public Guid AccountId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Всегда на связи (24/7).
|
||||
/// </summary>
|
||||
public bool IsAlwaysReady { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Рабочие дни в формате JSON.
|
||||
/// Пример: [{"day":1,"startTime":"09:00","endTime":"18:00"},{"day":2,"startTime":"09:00","endTime":"18:00"}]
|
||||
/// </summary>
|
||||
public string WorkingDaysJson { get; init; } = default!;
|
||||
}
|
||||
|
||||
public class UpdateScheduleCommandHandler : IRequestHandler<UpdateScheduleCommand, bool>
|
||||
{
|
||||
private readonly IAccountRepository _accountRepository;
|
||||
|
||||
public UpdateScheduleCommandHandler(IAccountRepository accountRepository)
|
||||
{
|
||||
_accountRepository = accountRepository;
|
||||
}
|
||||
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, List<TimePeriod>>? 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<GetProfileQuery, ProfileRe
|
||||
Dictionary<string, List<TimePeriod>>? schedulePerDay = null;
|
||||
if (account.Profile.WorkSchedule.WorkingDays != null)
|
||||
{
|
||||
schedulePerDay = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, List<TimePeriod>>>(account.Profile.WorkSchedule.WorkingDays);
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
schedulePerDay = JsonSerializer.Deserialize<Dictionary<string, List<TimePeriod>>>(account.Profile.WorkSchedule.WorkingDays, options);
|
||||
}
|
||||
|
||||
workScheduleResponse = new WorkScheduleResponse(
|
||||
|
||||
@@ -12,6 +12,10 @@ public class Account : AggregateRoot<Guid>
|
||||
public List<Role> 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<Guid>
|
||||
Phone = phone;
|
||||
PasswordHash = passwordHash;
|
||||
Roles.Add(Role.User);
|
||||
IsDeleted = false;
|
||||
AddDomainEvent(new AccountCreatedEvent(Id));
|
||||
}
|
||||
|
||||
@@ -29,6 +34,41 @@ public class Account : AggregateRoot<Guid>
|
||||
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;
|
||||
|
||||
@@ -105,4 +105,10 @@ public class UserProfile : Entity<Guid>
|
||||
{
|
||||
WorkSchedule = workSchedule;
|
||||
}
|
||||
|
||||
public void UpdateLocation(string? location, string? currentLocation)
|
||||
{
|
||||
Location = location;
|
||||
CurrentLocation = currentLocation;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ public enum Role
|
||||
{
|
||||
User = 0,
|
||||
Newbie,
|
||||
Candidate,
|
||||
Master,
|
||||
Company,
|
||||
Admin
|
||||
|
||||
@@ -8,4 +8,5 @@ public interface IAccountRepository
|
||||
Task<Account?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task AddAsync(Account account, CancellationToken cancellationToken);
|
||||
Task UpdateAsync(Account account, CancellationToken cancellationToken);
|
||||
Task<Account?> GetByPhoneIncludingDeletedAsync(string phone, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -37,5 +37,16 @@ public class AccountConfiguration : IEntityTypeConfiguration<Account>
|
||||
.WithOne()
|
||||
.HasForeignKey<UserProfile>(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,14 @@ public class AccountRepository : IAccountRepository
|
||||
.FirstOrDefaultAsync(a => a.Phone == phone, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Account?> 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}");
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -14,13 +14,18 @@ public class BecomePerformerCommandHandlerTests
|
||||
{
|
||||
private readonly Mock<IAccountRepository> _mockRepo;
|
||||
private readonly Mock<ICurrentUserService> _mockUserService;
|
||||
private readonly Mock<ICompetencyRepository> _mockCompetencyRepo;
|
||||
private readonly BecomePerformerCommandHandler _handler;
|
||||
|
||||
public BecomePerformerCommandHandlerTests()
|
||||
{
|
||||
_mockRepo = new Mock<IAccountRepository>();
|
||||
_mockUserService = new Mock<ICurrentUserService>();
|
||||
_handler = new BecomePerformerCommandHandler(_mockRepo.Object, _mockUserService.Object);
|
||||
_mockCompetencyRepo = new Mock<ICompetencyRepository>();
|
||||
_handler = new BecomePerformerCommandHandler(
|
||||
_mockRepo.Object,
|
||||
_mockUserService.Object,
|
||||
_mockCompetencyRepo.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user