Страница профиля
This commit is contained in:
@@ -95,7 +95,7 @@ builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("FrontendPolicy", policy =>
|
||||
{
|
||||
policy.WithOrigins("http://localhost:3000", "http://127.0.0.1:3000")
|
||||
policy.WithOrigins("http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:3001", "http://127.0.0.1:3001")
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader()
|
||||
.AllowCredentials();
|
||||
@@ -116,6 +116,9 @@ if (app.Environment.IsDevelopment())
|
||||
|
||||
app.UseCors("FrontendPolicy");
|
||||
|
||||
// Включение статических файлов для аватаров
|
||||
app.UseStaticFiles();
|
||||
|
||||
// Включение аутентификации и авторизации
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using MediatR;
|
||||
using Nashel.BuildingBlocks.Application.Abstractions;
|
||||
using Nashel.Modules.Identity.Domain.Repositories;
|
||||
|
||||
namespace Nashel.Modules.Identity.Application.Commands;
|
||||
|
||||
public record ChangePhoneNumberCommand : IRequest
|
||||
{
|
||||
public string NewPhone { get; init; } = default!;
|
||||
}
|
||||
|
||||
public class ChangePhoneNumberCommandHandler : IRequestHandler<ChangePhoneNumberCommand>
|
||||
{
|
||||
private readonly IAccountRepository _accountRepository;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
|
||||
public ChangePhoneNumberCommandHandler(IAccountRepository accountRepository, ICurrentUserService currentUserService)
|
||||
{
|
||||
_accountRepository = accountRepository;
|
||||
_currentUserService = currentUserService;
|
||||
}
|
||||
|
||||
public async Task Handle(ChangePhoneNumberCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = _currentUserService.UserId;
|
||||
if (userId == null)
|
||||
{
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
// Проверка уникальности нового номера
|
||||
if (await _accountRepository.GetByPhoneAsync(request.NewPhone, cancellationToken) != null)
|
||||
{
|
||||
throw new Exception("Этот номер телефона уже используется");
|
||||
}
|
||||
|
||||
var account = await _accountRepository.GetByIdAsync(userId.Value, cancellationToken);
|
||||
if (account == null)
|
||||
{
|
||||
throw new Exception("Account not found");
|
||||
}
|
||||
|
||||
account.ChangePhone(request.NewPhone);
|
||||
|
||||
await _accountRepository.UpdateAsync(account, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -34,19 +34,24 @@ public class LoginCommandHandler : IRequestHandler<LoginCommand, string>
|
||||
{
|
||||
private readonly IAccountRepository _accountRepository;
|
||||
private readonly IJwtTokenGenerator _jwtTokenGenerator;
|
||||
private readonly IPasswordHasher _passwordHasher;
|
||||
|
||||
public LoginCommandHandler(IAccountRepository accountRepository, IJwtTokenGenerator jwtTokenGenerator)
|
||||
public LoginCommandHandler(
|
||||
IAccountRepository accountRepository,
|
||||
IJwtTokenGenerator jwtTokenGenerator,
|
||||
IPasswordHasher passwordHasher)
|
||||
{
|
||||
_accountRepository = accountRepository;
|
||||
_jwtTokenGenerator = jwtTokenGenerator;
|
||||
_passwordHasher = passwordHasher;
|
||||
}
|
||||
|
||||
public async Task<string> Handle(LoginCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var account = await _accountRepository.GetByPhoneAsync(request.Phone, cancellationToken);
|
||||
if (account == null || account.PasswordHash != request.Password) // Сравнение хешей в реальной жизни
|
||||
if (account == null || !_passwordHasher.VerifyPassword(request.Password, account.PasswordHash))
|
||||
{
|
||||
throw new Exception("Неверные учетные данные"); // Использовать паттерн Result
|
||||
throw new Exception("Неверные учетные данные");
|
||||
}
|
||||
|
||||
return _jwtTokenGenerator.GenerateToken(account.Id, account.Roles);
|
||||
|
||||
@@ -2,6 +2,7 @@ using MediatR;
|
||||
using Nashel.Modules.Identity.Domain.Aggregates;
|
||||
using Nashel.Modules.Identity.Domain.Enums;
|
||||
using Nashel.Modules.Identity.Domain.Repositories;
|
||||
using Nashel.Modules.Identity.Domain.Services;
|
||||
|
||||
namespace Nashel.Modules.Identity.Application.Commands;
|
||||
|
||||
@@ -20,22 +21,41 @@ public record RegisterUserCommand : IRequest<Guid>
|
||||
/// </summary>
|
||||
public string Password { get; init; } = default!;
|
||||
|
||||
public RegisterUserCommand(string phone, string password)
|
||||
{
|
||||
Phone = phone;
|
||||
Password = password;
|
||||
}
|
||||
/// <summary>
|
||||
/// Имя.
|
||||
/// </summary>
|
||||
public string FirstName { get; init; } = default!;
|
||||
|
||||
public RegisterUserCommand() { }
|
||||
/// <summary>
|
||||
/// Фамилия.
|
||||
/// </summary>
|
||||
public string LastName { get; init; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Отчество (необязательно).
|
||||
/// </summary>
|
||||
public string? Patronymic { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Название компании (необязательно).
|
||||
/// </summary>
|
||||
public string? CompanyName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Роль (по умолчанию User).
|
||||
/// </summary>
|
||||
public Role Role { get; init; } = Role.User;
|
||||
}
|
||||
|
||||
public class RegisterUserCommandHandler : IRequestHandler<RegisterUserCommand, Guid>
|
||||
{
|
||||
private readonly IAccountRepository _accountRepository;
|
||||
private readonly IPasswordHasher _passwordHasher;
|
||||
|
||||
public RegisterUserCommandHandler(IAccountRepository accountRepository)
|
||||
public RegisterUserCommandHandler(IAccountRepository accountRepository, IPasswordHasher passwordHasher)
|
||||
{
|
||||
_accountRepository = accountRepository;
|
||||
_passwordHasher = passwordHasher;
|
||||
}
|
||||
|
||||
public async Task<Guid> Handle(RegisterUserCommand request, CancellationToken cancellationToken)
|
||||
@@ -43,16 +63,31 @@ public class RegisterUserCommandHandler : IRequestHandler<RegisterUserCommand, G
|
||||
// Проверка существования пользователя
|
||||
if (await _accountRepository.GetByPhoneAsync(request.Phone, cancellationToken) != null)
|
||||
{
|
||||
throw new Exception("Пользователь уже существует"); // Использовать паттерн Result в будущем
|
||||
throw new Exception("Пользователь уже существует");
|
||||
}
|
||||
|
||||
var account = Account.Create(request.Phone, request.Password); // Хешировать пароль в хендлере или конструкторе?
|
||||
// Конструктор сущности обычно принимает хешированный пароль.
|
||||
// Предположим, что хешируем здесь или используем сервис.
|
||||
// Для простоты пока передаем как есть, но нужно хешировать.
|
||||
// TODO: Правильно хешировать пароль.
|
||||
var passwordHash = _passwordHasher.HashPassword(request.Password);
|
||||
var account = Account.Create(request.Phone, passwordHash);
|
||||
|
||||
// Добавляем роль, если она отличается от User
|
||||
if (request.Role != Role.User && request.Role != Role.Admin)
|
||||
{
|
||||
// Здесь можно добавить логику проверки прав, но для MVP разрешим
|
||||
account.Roles.Add(request.Role);
|
||||
}
|
||||
|
||||
var profile = UserProfile.Create(
|
||||
account.Id,
|
||||
request.FirstName,
|
||||
request.LastName,
|
||||
request.Patronymic,
|
||||
request.CompanyName);
|
||||
|
||||
account.SetProfile(profile);
|
||||
|
||||
await _accountRepository.AddAsync(account, cancellationToken);
|
||||
// Предполагаем, что AccountRepository.AddAsync сохранит всё дерево сущностей
|
||||
|
||||
return account.Id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using MediatR;
|
||||
using Nashel.BuildingBlocks.Application.Abstractions;
|
||||
using Nashel.Modules.Identity.Domain.Repositories;
|
||||
|
||||
namespace Nashel.Modules.Identity.Application.Commands;
|
||||
|
||||
public record UpdateProfileCommand : IRequest
|
||||
{
|
||||
public string FirstName { get; init; } = default!;
|
||||
public string LastName { get; init; } = default!;
|
||||
public string? Patronymic { get; init; }
|
||||
public string? CompanyName { get; init; }
|
||||
}
|
||||
|
||||
public class UpdateProfileCommandHandler : IRequestHandler<UpdateProfileCommand>
|
||||
{
|
||||
private readonly IAccountRepository _accountRepository;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
|
||||
public UpdateProfileCommandHandler(IAccountRepository accountRepository, ICurrentUserService currentUserService)
|
||||
{
|
||||
_accountRepository = accountRepository;
|
||||
_currentUserService = currentUserService;
|
||||
}
|
||||
|
||||
public async Task Handle(UpdateProfileCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = _currentUserService.UserId;
|
||||
if (userId == null)
|
||||
{
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
var account = await _accountRepository.GetByIdAsync(userId.Value, cancellationToken);
|
||||
if (account == null)
|
||||
{
|
||||
throw new Exception("Account not found");
|
||||
}
|
||||
|
||||
if (account.Profile == null)
|
||||
{
|
||||
throw new Exception("Profile not found");
|
||||
}
|
||||
|
||||
account.Profile.Update(
|
||||
request.FirstName,
|
||||
request.LastName,
|
||||
request.Patronymic,
|
||||
request.CompanyName);
|
||||
|
||||
await _accountRepository.UpdateAsync(account, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using MediatR;
|
||||
|
||||
namespace Nashel.Modules.Identity.Application.Commands;
|
||||
|
||||
public record UpdateProfileCompetenciesCommand(List<string> CompetencyNames) : IRequest;
|
||||
@@ -0,0 +1,6 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Nashel.Modules.Identity.Application.Commands;
|
||||
|
||||
public record UploadAvatarCommand(IFormFile File) : IRequest<string>;
|
||||
@@ -0,0 +1,41 @@
|
||||
using MediatR;
|
||||
|
||||
namespace Nashel.Modules.Identity.Application.Commands;
|
||||
|
||||
public class UploadAvatarCommandHandler : IRequestHandler<UploadAvatarCommand, string>
|
||||
{
|
||||
private const long MaxFileSize = 5 * 1024 * 1024; // 5MB
|
||||
private static readonly string[] AllowedExtensions = { ".jpg", ".jpeg", ".png" };
|
||||
|
||||
public async Task<string> Handle(UploadAvatarCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var file = request.File;
|
||||
|
||||
// Валидация размера
|
||||
if (file.Length > MaxFileSize)
|
||||
throw new InvalidOperationException("Размер файла не должен превышать 5 МБ");
|
||||
|
||||
// Валидация формата
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (Array.IndexOf(AllowedExtensions, extension) == -1)
|
||||
throw new InvalidOperationException("Допустимые форматы: JPG, PNG");
|
||||
|
||||
// Создаем папку uploads если её нет
|
||||
var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads");
|
||||
if (!Directory.Exists(uploadsPath))
|
||||
Directory.CreateDirectory(uploadsPath);
|
||||
|
||||
// Генерируем уникальное имя файла
|
||||
var fileName = $"{Guid.NewGuid()}{extension}";
|
||||
var filePath = Path.Combine(uploadsPath, fileName);
|
||||
|
||||
// Сохраняем файл
|
||||
using (var stream = new FileStream(filePath, FileMode.Create))
|
||||
{
|
||||
await file.CopyToAsync(stream, cancellationToken);
|
||||
}
|
||||
|
||||
// Возвращаем URL
|
||||
return $"/uploads/{fileName}";
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MediatR" Version="14.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http.Features" Version="5.0.17" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup>
|
||||
<RootNamespace>Nashel.Modules.Identity.Application</RootNamespace>
|
||||
|
||||
@@ -6,7 +6,17 @@ namespace Nashel.Modules.Identity.Application.Queries.GetProfile;
|
||||
|
||||
public record GetProfileQuery : IRequest<ProfileResponse>;
|
||||
|
||||
public record ProfileResponse(Guid Id, string Phone, List<string> Roles);
|
||||
public record ProfileResponse(
|
||||
Guid Id,
|
||||
string Phone,
|
||||
List<string> Roles,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
string? Patronymic,
|
||||
string? CompanyName,
|
||||
string FullName,
|
||||
string? AvatarUrl,
|
||||
List<string> Competencies);
|
||||
|
||||
public class GetProfileQueryHandler : IRequestHandler<GetProfileQuery, ProfileResponse>
|
||||
{
|
||||
@@ -33,9 +43,22 @@ public class GetProfileQueryHandler : IRequestHandler<GetProfileQuery, ProfileRe
|
||||
throw new Exception("Account not found");
|
||||
}
|
||||
|
||||
var fullName = $"{account.Profile.FirstName} {account.Profile.LastName}";
|
||||
if (!string.IsNullOrEmpty(account.Profile.Patronymic))
|
||||
{
|
||||
fullName += $" {account.Profile.Patronymic}";
|
||||
}
|
||||
|
||||
return new ProfileResponse(
|
||||
account.Id,
|
||||
account.Phone,
|
||||
account.Roles.Select(r => r.ToString()).ToList());
|
||||
account.Id,
|
||||
account.Phone,
|
||||
account.Roles.Select(r => r.ToString()).ToList(),
|
||||
account.Profile.FirstName,
|
||||
account.Profile.LastName,
|
||||
account.Profile.Patronymic,
|
||||
account.Profile.CompanyName,
|
||||
fullName,
|
||||
account.Profile.AvatarUrl,
|
||||
account.Profile.Competencies.Select(c => c.Name).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using MediatR;
|
||||
|
||||
namespace Nashel.Modules.Identity.Application.Queries;
|
||||
|
||||
public record SearchCompetenciesQuery(string Query) : IRequest<List<string>>;
|
||||
@@ -0,0 +1,14 @@
|
||||
using FluentValidation;
|
||||
using Nashel.Modules.Identity.Application.Commands;
|
||||
|
||||
namespace Nashel.Modules.Identity.Application.Validators;
|
||||
|
||||
public class ChangePhoneNumberCommandValidator : AbstractValidator<ChangePhoneNumberCommand>
|
||||
{
|
||||
public ChangePhoneNumberCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.NewPhone)
|
||||
.NotEmpty().WithMessage("Новый телефон обязателен")
|
||||
.Matches(@"^\+\d{11,15}$").WithMessage("Формат телефона должен быть E.164 (начинаться с +, 11-15 цифр)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using FluentValidation;
|
||||
using Nashel.Modules.Identity.Application.Commands;
|
||||
using Nashel.Modules.Identity.Domain.Enums;
|
||||
|
||||
namespace Nashel.Modules.Identity.Application.Validators;
|
||||
|
||||
public class RegisterUserCommandValidator : AbstractValidator<RegisterUserCommand>
|
||||
{
|
||||
public RegisterUserCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Phone)
|
||||
.NotEmpty().WithMessage("Телефон обязателен")
|
||||
.Matches(@"^\+\d{11,15}$").WithMessage("Формат телефона должен быть E.164 (начинаться с +, 11-15 цифр)");
|
||||
|
||||
RuleFor(x => x.Password)
|
||||
.NotEmpty().WithMessage("Пароль обязателен")
|
||||
.MinimumLength(6).WithMessage("Пароль должен быть не менее 6 символов");
|
||||
|
||||
RuleFor(x => x.FirstName)
|
||||
.NotEmpty().WithMessage("Имя обязательно")
|
||||
.MinimumLength(2).WithMessage("Имя должно содержать минимум 2 символа");
|
||||
|
||||
RuleFor(x => x.LastName)
|
||||
.NotEmpty().WithMessage("Фамилия обязательна")
|
||||
.MinimumLength(2).WithMessage("Фамилия должна содержать минимум 2 символа");
|
||||
|
||||
RuleFor(x => x.CompanyName)
|
||||
.NotEmpty().WithMessage("Название компании обязательно для юридических лиц")
|
||||
.When(x => x.Role == Role.Company);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using FluentValidation;
|
||||
using Nashel.Modules.Identity.Application.Commands;
|
||||
|
||||
namespace Nashel.Modules.Identity.Application.Validators;
|
||||
|
||||
public class UpdateProfileCommandValidator : AbstractValidator<UpdateProfileCommand>
|
||||
{
|
||||
public UpdateProfileCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.FirstName)
|
||||
.NotEmpty().WithMessage("Имя обязательно")
|
||||
.MinimumLength(2).WithMessage("Имя должно содержать минимум 2 символа");
|
||||
|
||||
RuleFor(x => x.LastName)
|
||||
.NotEmpty().WithMessage("Фамилия обязательна")
|
||||
.MinimumLength(2).WithMessage("Фамилия должна содержать минимум 2 символа");
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ public class Account : AggregateRoot<Guid>
|
||||
public string Phone { get; private set; }
|
||||
public string PasswordHash { get; private set; }
|
||||
public List<Role> Roles { get; private set; } = new();
|
||||
public UserProfile Profile { get; private set; } = default!;
|
||||
|
||||
// Конструктор для EF Core
|
||||
private Account() { }
|
||||
@@ -27,6 +28,16 @@ public class Account : AggregateRoot<Guid>
|
||||
return new Account(Guid.NewGuid(), phone, passwordHash);
|
||||
}
|
||||
|
||||
public void SetProfile(UserProfile profile)
|
||||
{
|
||||
Profile = profile;
|
||||
}
|
||||
|
||||
public void ChangePhone(string newPhone)
|
||||
{
|
||||
Phone = newPhone;
|
||||
}
|
||||
|
||||
public void BecomePerformer()
|
||||
{
|
||||
if (!Roles.Contains(Role.Candidate) && !Roles.Contains(Role.Master))
|
||||
|
||||
58
src/Modules/Identity/Domain/Aggregates/UserProfile.cs
Normal file
58
src/Modules/Identity/Domain/Aggregates/UserProfile.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using Nashel.BuildingBlocks.Domain;
|
||||
using Nashel.Modules.Identity.Domain.Entities;
|
||||
|
||||
namespace Nashel.Modules.Identity.Domain.Aggregates;
|
||||
|
||||
public class UserProfile : Entity<Guid>
|
||||
{
|
||||
private const int MaxCompetencies = 50;
|
||||
private readonly List<Competency> _competencies = new();
|
||||
|
||||
public string FirstName { get; private set; }
|
||||
public string LastName { get; private set; }
|
||||
public string? Patronymic { get; private set; }
|
||||
public string? CompanyName { get; private set; }
|
||||
public string? AvatarUrl { get; private set; }
|
||||
public IReadOnlyCollection<Competency> Competencies => _competencies.AsReadOnly();
|
||||
|
||||
// EF Core constructor
|
||||
private UserProfile() { }
|
||||
|
||||
private UserProfile(Guid id, string firstName, string lastName, string? patronymic, string? companyName)
|
||||
{
|
||||
Id = id;
|
||||
FirstName = firstName;
|
||||
LastName = lastName;
|
||||
Patronymic = patronymic;
|
||||
CompanyName = companyName;
|
||||
}
|
||||
|
||||
public static UserProfile Create(Guid id, string firstName, string lastName, string? patronymic, string? companyName)
|
||||
{
|
||||
return new UserProfile(id, firstName, lastName, patronymic, companyName);
|
||||
}
|
||||
|
||||
public void Update(string firstName, string lastName, string? patronymic, string? companyName)
|
||||
{
|
||||
FirstName = firstName;
|
||||
LastName = lastName;
|
||||
Patronymic = patronymic;
|
||||
CompanyName = companyName;
|
||||
}
|
||||
|
||||
public void UpdateAvatar(string? avatarUrl)
|
||||
{
|
||||
AvatarUrl = avatarUrl;
|
||||
}
|
||||
|
||||
public void UpdateCompetencies(IEnumerable<Competency> competencies)
|
||||
{
|
||||
var competencyList = competencies.ToList();
|
||||
|
||||
if (competencyList.Count > MaxCompetencies)
|
||||
throw new InvalidOperationException($"Нельзя добавить больше {MaxCompetencies} компетенций");
|
||||
|
||||
_competencies.Clear();
|
||||
_competencies.AddRange(competencyList);
|
||||
}
|
||||
}
|
||||
24
src/Modules/Identity/Domain/Entities/Competency.cs
Normal file
24
src/Modules/Identity/Domain/Entities/Competency.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace Nashel.Modules.Identity.Domain.Entities;
|
||||
|
||||
public class Competency
|
||||
{
|
||||
public Guid Id { get; private set; }
|
||||
public required string Name { get; set; }
|
||||
|
||||
// EF Core constructor
|
||||
private Competency() { }
|
||||
|
||||
private Competency(string name)
|
||||
{
|
||||
Id = Guid.NewGuid();
|
||||
Name = name;
|
||||
}
|
||||
|
||||
public static Competency Create(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException("Название компетенции не может быть пустым", nameof(name));
|
||||
|
||||
return new Competency { Id = Guid.NewGuid(), Name = name.Trim() };
|
||||
}
|
||||
}
|
||||
7
src/Modules/Identity/Domain/Services/IPasswordHasher.cs
Normal file
7
src/Modules/Identity/Domain/Services/IPasswordHasher.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Nashel.Modules.Identity.Domain.Services;
|
||||
|
||||
public interface IPasswordHasher
|
||||
{
|
||||
string HashPassword(string password);
|
||||
bool VerifyPassword(string password, string hash);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Nashel.BuildingBlocks.Application.Abstractions;
|
||||
using Nashel.Modules.Identity.Application.Commands;
|
||||
using Nashel.Modules.Identity.Domain.Entities;
|
||||
using Nashel.Modules.Identity.Infrastructure.Persistence;
|
||||
|
||||
namespace Nashel.Modules.Identity.Infrastructure.CommandHandlers;
|
||||
|
||||
public class UpdateProfileCompetenciesCommandHandler : IRequestHandler<UpdateProfileCompetenciesCommand>
|
||||
{
|
||||
private readonly IdentityDbContext _context;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
|
||||
public UpdateProfileCompetenciesCommandHandler(IdentityDbContext context, ICurrentUserService currentUserService)
|
||||
{
|
||||
_context = context;
|
||||
_currentUserService = currentUserService;
|
||||
}
|
||||
|
||||
public async Task Handle(UpdateProfileCompetenciesCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = _currentUserService.UserId ?? throw new UnauthorizedAccessException();
|
||||
|
||||
var profile = await _context.UserProfiles
|
||||
.Include(p => p.Competencies)
|
||||
.FirstOrDefaultAsync(p => p.Id == userId, cancellationToken);
|
||||
|
||||
if (profile == null)
|
||||
throw new InvalidOperationException("Профиль не найден");
|
||||
|
||||
var competencies = new List<Competency>();
|
||||
|
||||
foreach (var name in request.CompetencyNames.Distinct())
|
||||
{
|
||||
// Ищем существующую компетенцию (case-insensitive)
|
||||
var existing = await _context.Competencies
|
||||
.FirstOrDefaultAsync(c => c.Name.ToLower() == name.ToLower(), cancellationToken);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
competencies.Add(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Создаем новую
|
||||
var newCompetency = Competency.Create(name);
|
||||
_context.Competencies.Add(newCompetency);
|
||||
competencies.Add(newCompetency);
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем компетенции профиля (проверка лимита внутри метода)
|
||||
profile.UpdateCompetencies(competencies);
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IAccountRepository, AccountRepository>();
|
||||
services.AddScoped<IJwtTokenGenerator, JwtTokenGenerator>();
|
||||
services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||
services.AddScoped<IPasswordHasher, PasswordHasher>();
|
||||
services.AddHttpContextAccessor();
|
||||
|
||||
services.AddMediatR(cfg =>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Nashel.Modules.Identity.Domain.Entities;
|
||||
|
||||
namespace Nashel.Modules.Identity.Infrastructure.Persistence.Configurations;
|
||||
|
||||
public class CompetencyConfiguration : IEntityTypeConfiguration<Competency>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Competency> builder)
|
||||
{
|
||||
builder.ToTable("Competencies", "identity");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
// Unique index on Name (case-insensitive)
|
||||
builder.HasIndex(x => x.Name)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_Competencies_Name");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Nashel.BuildingBlocks.Domain;
|
||||
using Nashel.Modules.Identity.Domain.Aggregates;
|
||||
using Nashel.Modules.Identity.Domain.Entities;
|
||||
|
||||
namespace Nashel.Modules.Identity.Infrastructure.Persistence.Configurations;
|
||||
|
||||
public class UserProfileConfiguration : IEntityTypeConfiguration<UserProfile>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<UserProfile> builder)
|
||||
{
|
||||
builder.ToTable("UserProfiles", "identity");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.FirstName)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(x => x.LastName)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(x => x.Patronymic)
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(x => x.CompanyName)
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(x => x.AvatarUrl)
|
||||
.HasMaxLength(500);
|
||||
|
||||
// Many-to-Many relationship with Competencies
|
||||
builder.HasMany(x => x.Competencies)
|
||||
.WithMany()
|
||||
.UsingEntity<Dictionary<string, object>>(
|
||||
"UserProfileCompetencies",
|
||||
j => j.HasOne<Competency>().WithMany().HasForeignKey("CompetencyId"),
|
||||
j => j.HasOne<UserProfile>().WithMany().HasForeignKey("UserProfileId"),
|
||||
j =>
|
||||
{
|
||||
j.ToTable("UserProfileCompetencies", "identity");
|
||||
j.HasKey("UserProfileId", "CompetencyId");
|
||||
});
|
||||
|
||||
// 1:1 relationship with Account
|
||||
builder.HasOne<Account>()
|
||||
.WithOne(x => x.Profile)
|
||||
.HasForeignKey<UserProfile>(x => x.Id)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Nashel.Modules.Identity.Domain.Aggregates;
|
||||
using Nashel.Modules.Identity.Domain.Entities;
|
||||
using Nashel.Modules.Identity.Infrastructure.Persistence.Configurations;
|
||||
|
||||
namespace Nashel.Modules.Identity.Infrastructure.Persistence;
|
||||
@@ -11,10 +12,14 @@ public class IdentityDbContext : DbContext
|
||||
}
|
||||
|
||||
public DbSet<Account> Accounts { get; set; }
|
||||
public DbSet<UserProfile> UserProfiles { get; set; }
|
||||
public DbSet<Competency> Competencies { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfiguration(new AccountConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new UserProfileConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new CompetencyConfiguration());
|
||||
base.OnModelCreating(modelBuilder);
|
||||
}
|
||||
}
|
||||
|
||||
104
src/Modules/Identity/Infrastructure/Persistence/Migrations/20260213085826_AddUserProfile.Designer.cs
generated
Normal file
104
src/Modules/Identity/Infrastructure/Persistence/Migrations/20260213085826_AddUserProfile.Designer.cs
generated
Normal file
@@ -0,0 +1,104 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Nashel.Modules.Identity.Infrastructure.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(IdentityDbContext))]
|
||||
[Migration("20260213085826_AddUserProfile")]
|
||||
partial class AddUserProfile
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.2")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.Account", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Roles")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Phone")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Accounts", "identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AvatarUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("CompanyName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Patronymic")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("UserProfiles", "identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", b =>
|
||||
{
|
||||
b.HasOne("Nashel.Modules.Identity.Domain.Aggregates.Account", null)
|
||||
.WithOne("Profile")
|
||||
.HasForeignKey("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.Account", b =>
|
||||
{
|
||||
b.Navigation("Profile")
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUserProfile : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserProfiles",
|
||||
schema: "identity",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FirstName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
LastName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Patronymic = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
CompanyName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
AvatarUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserProfiles", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_UserProfiles_Accounts_Id",
|
||||
column: x => x.Id,
|
||||
principalSchema: "identity",
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserProfiles",
|
||||
schema: "identity");
|
||||
}
|
||||
}
|
||||
}
|
||||
154
src/Modules/Identity/Infrastructure/Persistence/Migrations/20260213103833_AddCompetencies.Designer.cs
generated
Normal file
154
src/Modules/Identity/Infrastructure/Persistence/Migrations/20260213103833_AddCompetencies.Designer.cs
generated
Normal file
@@ -0,0 +1,154 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Nashel.Modules.Identity.Infrastructure.Persistence;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(IdentityDbContext))]
|
||||
[Migration("20260213103833_AddCompetencies")]
|
||||
partial class AddCompetencies
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.2")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.Account", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Roles")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Phone")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Accounts", "identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AvatarUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("CompanyName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Patronymic")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("UserProfiles", "identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nashel.Modules.Identity.Domain.Entities.Competency", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_Competencies_Name");
|
||||
|
||||
b.ToTable("Competencies", "identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UserProfileCompetencies", b =>
|
||||
{
|
||||
b.Property<Guid>("UserProfileId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CompetencyId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("UserProfileId", "CompetencyId");
|
||||
|
||||
b.HasIndex("CompetencyId");
|
||||
|
||||
b.ToTable("UserProfileCompetencies", "identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", b =>
|
||||
{
|
||||
b.HasOne("Nashel.Modules.Identity.Domain.Aggregates.Account", null)
|
||||
.WithOne("Profile")
|
||||
.HasForeignKey("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UserProfileCompetencies", b =>
|
||||
{
|
||||
b.HasOne("Nashel.Modules.Identity.Domain.Entities.Competency", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CompetencyId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserProfileId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.Account", b =>
|
||||
{
|
||||
b.Navigation("Profile")
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCompetencies : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Competencies",
|
||||
schema: "identity",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Competencies", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserProfileCompetencies",
|
||||
schema: "identity",
|
||||
columns: table => new
|
||||
{
|
||||
UserProfileId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CompetencyId = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserProfileCompetencies", x => new { x.UserProfileId, x.CompetencyId });
|
||||
table.ForeignKey(
|
||||
name: "FK_UserProfileCompetencies_Competencies_CompetencyId",
|
||||
column: x => x.CompetencyId,
|
||||
principalSchema: "identity",
|
||||
principalTable: "Competencies",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_UserProfileCompetencies_UserProfiles_UserProfileId",
|
||||
column: x => x.UserProfileId,
|
||||
principalSchema: "identity",
|
||||
principalTable: "UserProfiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Competencies_Name",
|
||||
schema: "identity",
|
||||
table: "Competencies",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserProfileCompetencies_CompetencyId",
|
||||
schema: "identity",
|
||||
table: "UserProfileCompetencies",
|
||||
column: "CompetencyId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserProfileCompetencies",
|
||||
schema: "identity");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Competencies",
|
||||
schema: "identity");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,103 @@ namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.ToTable("Accounts", "identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AvatarUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("CompanyName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Patronymic")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("UserProfiles", "identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nashel.Modules.Identity.Domain.Entities.Competency", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_Competencies_Name");
|
||||
|
||||
b.ToTable("Competencies", "identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UserProfileCompetencies", b =>
|
||||
{
|
||||
b.Property<Guid>("UserProfileId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CompetencyId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("UserProfileId", "CompetencyId");
|
||||
|
||||
b.HasIndex("CompetencyId");
|
||||
|
||||
b.ToTable("UserProfileCompetencies", "identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", b =>
|
||||
{
|
||||
b.HasOne("Nashel.Modules.Identity.Domain.Aggregates.Account", null)
|
||||
.WithOne("Profile")
|
||||
.HasForeignKey("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UserProfileCompetencies", b =>
|
||||
{
|
||||
b.HasOne("Nashel.Modules.Identity.Domain.Entities.Competency", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CompetencyId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserProfileId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.Account", b =>
|
||||
{
|
||||
b.Navigation("Profile")
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Nashel.Modules.Identity.Application.Queries;
|
||||
using Nashel.Modules.Identity.Infrastructure.Persistence;
|
||||
|
||||
namespace Nashel.Modules.Identity.Infrastructure.QueryHandlers;
|
||||
|
||||
public class SearchCompetenciesQueryHandler : IRequestHandler<SearchCompetenciesQuery, List<string>>
|
||||
{
|
||||
private readonly IdentityDbContext _context;
|
||||
|
||||
public SearchCompetenciesQueryHandler(IdentityDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<List<string>> Handle(SearchCompetenciesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Query))
|
||||
return new List<string>();
|
||||
|
||||
var query = request.Query.ToLower();
|
||||
|
||||
var competencies = await _context.Competencies
|
||||
.Where(c => c.Name.ToLower().Contains(query))
|
||||
.Select(c => c.Name)
|
||||
.Take(10) // Ограничиваем результаты
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return competencies;
|
||||
}
|
||||
}
|
||||
@@ -22,12 +22,17 @@ public class AccountRepository : IAccountRepository
|
||||
|
||||
public async Task<Account?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Accounts.FindAsync(new object[] { id }, cancellationToken);
|
||||
return await _context.Accounts
|
||||
.Include(a => a.Profile)
|
||||
.ThenInclude(p => p.Competencies)
|
||||
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Account?> GetByPhoneAsync(string phone, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Accounts.FirstOrDefaultAsync(a => a.Phone == phone, cancellationToken);
|
||||
return await _context.Accounts
|
||||
.Include(a => a.Profile)
|
||||
.FirstOrDefaultAsync(a => a.Phone == phone, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Account account, CancellationToken cancellationToken)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Nashel.Modules.Identity.Domain.Services;
|
||||
|
||||
namespace Nashel.Modules.Identity.Infrastructure.Services;
|
||||
|
||||
public class PasswordHasher : IPasswordHasher
|
||||
{
|
||||
private readonly PasswordHasher<object> _hasher = new();
|
||||
private static readonly object _dummy = new();
|
||||
|
||||
public string HashPassword(string password)
|
||||
{
|
||||
return _hasher.HashPassword(_dummy, password);
|
||||
}
|
||||
|
||||
public bool VerifyPassword(string password, string hash)
|
||||
{
|
||||
var result = _hasher.VerifyHashedPassword(_dummy, hash, password);
|
||||
return result != PasswordVerificationResult.Failed;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Nashel.Modules.Identity.Application.Commands;
|
||||
using Nashel.Modules.Identity.Application.Queries;
|
||||
using Nashel.Modules.Identity.Application.Queries.GetProfile;
|
||||
|
||||
namespace Nashel.Modules.Identity.Presentation.Endpoints;
|
||||
@@ -39,6 +40,22 @@ public static class IdentityEndpoints
|
||||
.WithName("GetProfile")
|
||||
.WithOpenApi(operation => new(operation) { Summary = "Получить профиль" });
|
||||
|
||||
profileGroup.MapPut("/", async (UpdateProfileCommand command, ISender sender) =>
|
||||
{
|
||||
await sender.Send(command);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithName("UpdateProfile")
|
||||
.WithOpenApi(operation => new(operation) { Summary = "Обновить профиль" });
|
||||
|
||||
profileGroup.MapPost("/change-phone", async (ChangePhoneNumberCommand command, ISender sender) =>
|
||||
{
|
||||
await sender.Send(command);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithName("ChangePhone")
|
||||
.WithOpenApi(operation => new(operation) { Summary = "Сменить номер телефона" });
|
||||
|
||||
profileGroup.MapPost("/become-performer", async (ISender sender) =>
|
||||
{
|
||||
await sender.Send(new BecomePerformerCommand());
|
||||
@@ -46,7 +63,35 @@ public static class IdentityEndpoints
|
||||
})
|
||||
.WithName("BecomePerformer")
|
||||
.WithOpenApi(operation => new(operation) { Summary = "Стать исполнителем" });
|
||||
|
||||
profileGroup.MapPost("/avatar", async (IFormFile file, ISender sender) =>
|
||||
{
|
||||
var url = await sender.Send(new UploadAvatarCommand(file));
|
||||
return Results.Ok(new { avatarUrl = url });
|
||||
})
|
||||
.WithName("UploadAvatar")
|
||||
.WithOpenApi(operation => new(operation) { Summary = "Загрузить аватар" })
|
||||
.DisableAntiforgery();
|
||||
|
||||
profileGroup.MapPut("/competencies", async (UpdateCompetenciesRequest request, ISender sender) =>
|
||||
{
|
||||
await sender.Send(new UpdateProfileCompetenciesCommand(request.Competencies));
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithName("UpdateCompetencies")
|
||||
.WithOpenApi(operation => new(operation) { Summary = "Обновить компетенции" });
|
||||
|
||||
var competenciesGroup = app.MapGroup("/api/competencies").WithTags("Competencies");
|
||||
|
||||
competenciesGroup.MapGet("/search", async (string q, ISender sender) =>
|
||||
{
|
||||
var results = await sender.Send(new SearchCompetenciesQuery(q));
|
||||
return Results.Ok(results);
|
||||
})
|
||||
.WithName("SearchCompetencies")
|
||||
.WithOpenApi(operation => new(operation) { Summary = "Поиск компетенций" });
|
||||
}
|
||||
}
|
||||
|
||||
public record LoginResponse(string AccessToken, string RefreshToken);
|
||||
public record UpdateCompetenciesRequest(List<string> Competencies);
|
||||
|
||||
@@ -13,13 +13,18 @@ public class LoginCommandHandlerTests
|
||||
{
|
||||
private readonly Mock<IAccountRepository> _mockRepo;
|
||||
private readonly Mock<IJwtTokenGenerator> _mockTokenGen;
|
||||
private readonly Mock<IPasswordHasher> _mockPasswordHasher;
|
||||
private readonly LoginCommandHandler _handler;
|
||||
|
||||
public LoginCommandHandlerTests()
|
||||
{
|
||||
_mockRepo = new Mock<IAccountRepository>();
|
||||
_mockTokenGen = new Mock<IJwtTokenGenerator>();
|
||||
_handler = new LoginCommandHandler(_mockRepo.Object, _mockTokenGen.Object);
|
||||
_mockPasswordHasher = new Mock<IPasswordHasher>();
|
||||
_handler = new LoginCommandHandler(
|
||||
_mockRepo.Object,
|
||||
_mockTokenGen.Object,
|
||||
_mockPasswordHasher.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -33,6 +38,9 @@ public class LoginCommandHandlerTests
|
||||
_mockRepo.Setup(r => r.GetByPhoneAsync(phone, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(account);
|
||||
|
||||
_mockPasswordHasher.Setup(p => p.VerifyPassword(password, account.PasswordHash))
|
||||
.Returns(true);
|
||||
|
||||
_mockTokenGen.Setup(t => t.GenerateToken(It.IsAny<Guid>(), It.IsAny<List<Role>>()))
|
||||
.Returns("jwt_token");
|
||||
|
||||
@@ -54,6 +62,9 @@ public class LoginCommandHandlerTests
|
||||
_mockRepo.Setup(r => r.GetByPhoneAsync(phone, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(account);
|
||||
|
||||
_mockPasswordHasher.Setup(p => p.VerifyPassword("wrong_password", account.PasswordHash))
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
var act = async () => await _handler.Handle(new LoginCommand(phone, "wrong_password"), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using Moq;
|
||||
using Nashel.Modules.Identity.Application.Commands;
|
||||
using Nashel.Modules.Identity.Domain.Aggregates;
|
||||
using Nashel.Modules.Identity.Domain.Repositories;
|
||||
using Nashel.Modules.Identity.Domain.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Nashel.Modules.Identity.Tests.Application;
|
||||
@@ -10,12 +11,14 @@ namespace Nashel.Modules.Identity.Tests.Application;
|
||||
public class RegisterUserCommandHandlerTests
|
||||
{
|
||||
private readonly Mock<IAccountRepository> _mockRepo;
|
||||
private readonly Mock<IPasswordHasher> _mockPasswordHasher;
|
||||
private readonly RegisterUserCommandHandler _handler;
|
||||
|
||||
public RegisterUserCommandHandlerTests()
|
||||
{
|
||||
_mockRepo = new Mock<IAccountRepository>();
|
||||
_handler = new RegisterUserCommandHandler(_mockRepo.Object);
|
||||
_mockPasswordHasher = new Mock<IPasswordHasher>();
|
||||
_handler = new RegisterUserCommandHandler(_mockRepo.Object, _mockPasswordHasher.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -26,8 +29,17 @@ public class RegisterUserCommandHandlerTests
|
||||
_mockRepo.Setup(r => r.GetByPhoneAsync(phone, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Account?)null);
|
||||
|
||||
_mockPasswordHasher.Setup(p => p.HashPassword("pass"))
|
||||
.Returns("hashed_pass");
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(new RegisterUserCommand(phone, "pass"), CancellationToken.None);
|
||||
var result = await _handler.Handle(new RegisterUserCommand
|
||||
{
|
||||
Phone = phone,
|
||||
Password = "pass",
|
||||
FirstName = "Ivan",
|
||||
LastName = "Ivanov"
|
||||
}, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeEmpty();
|
||||
@@ -43,7 +55,13 @@ public class RegisterUserCommandHandlerTests
|
||||
.ReturnsAsync(Account.Create(phone, "pass"));
|
||||
|
||||
// Act
|
||||
var act = async () => await _handler.Handle(new RegisterUserCommand(phone, "pass"), CancellationToken.None);
|
||||
var act = async () => await _handler.Handle(new RegisterUserCommand
|
||||
{
|
||||
Phone = phone,
|
||||
Password = "pass",
|
||||
FirstName = "Ivan",
|
||||
LastName = "Ivanov"
|
||||
}, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<Exception>().WithMessage("Пользователь уже существует");
|
||||
|
||||
Reference in New Issue
Block a user