Стать исполнителем новая версия

This commit is contained in:
Халимов Рустам
2026-02-27 23:32:09 +03:00
parent c824e26cc0
commit 36e7c07a0d
15 changed files with 379 additions and 12 deletions

2
check_schedule.sql Normal file
View File

@@ -0,0 +1,2 @@
SET search_path TO identity;
SELECT "WorkingDays" FROM "WorkSchedules" LIMIT 3;

2
get_schedule.sql Normal file
View File

@@ -0,0 +1,2 @@
SET search_path TO "identity";
SELECT * FROM "WorkSchedules";

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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(

View File

@@ -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;

View File

@@ -105,4 +105,10 @@ public class UserProfile : Entity<Guid>
{
WorkSchedule = workSchedule;
}
public void UpdateLocation(string? location, string? currentLocation)
{
Location = location;
CurrentLocation = currentLocation;
}
}

View File

@@ -7,6 +7,7 @@ public enum Role
{
User = 0,
Newbie,
Candidate,
Master,
Company,
Admin

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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}");

View File

@@ -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
);

View File

@@ -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]