diff --git a/src/Host/Program.cs b/src/Host/Program.cs index 0e1486e..95622fa 100644 --- a/src/Host/Program.cs +++ b/src/Host/Program.cs @@ -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(); diff --git a/src/Modules/Identity/Application/Commands/ChangePhoneNumberCommand.cs b/src/Modules/Identity/Application/Commands/ChangePhoneNumberCommand.cs new file mode 100644 index 0000000..8a899fa --- /dev/null +++ b/src/Modules/Identity/Application/Commands/ChangePhoneNumberCommand.cs @@ -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 +{ + 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); + } +} diff --git a/src/Modules/Identity/Application/Commands/LoginCommand.cs b/src/Modules/Identity/Application/Commands/LoginCommand.cs index e81b6eb..ce5fd29 100644 --- a/src/Modules/Identity/Application/Commands/LoginCommand.cs +++ b/src/Modules/Identity/Application/Commands/LoginCommand.cs @@ -34,19 +34,24 @@ public class LoginCommandHandler : IRequestHandler { 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 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); diff --git a/src/Modules/Identity/Application/Commands/RegisterUserCommand.cs b/src/Modules/Identity/Application/Commands/RegisterUserCommand.cs index b1dfa02..7cf1df0 100644 --- a/src/Modules/Identity/Application/Commands/RegisterUserCommand.cs +++ b/src/Modules/Identity/Application/Commands/RegisterUserCommand.cs @@ -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 /// public string Password { get; init; } = default!; - public RegisterUserCommand(string phone, string password) - { - Phone = phone; - Password = password; - } + /// + /// Имя. + /// + public string FirstName { get; init; } = default!; - public RegisterUserCommand() { } + /// + /// Фамилия. + /// + public string LastName { get; init; } = default!; + + /// + /// Отчество (необязательно). + /// + public string? Patronymic { get; init; } + + /// + /// Название компании (необязательно). + /// + public string? CompanyName { get; init; } + + /// + /// Роль (по умолчанию User). + /// + public Role Role { get; init; } = Role.User; } public class RegisterUserCommandHandler : IRequestHandler { 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 Handle(RegisterUserCommand request, CancellationToken cancellationToken) @@ -43,16 +63,31 @@ public class RegisterUserCommandHandler : IRequestHandler +{ + 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); + } +} diff --git a/src/Modules/Identity/Application/Commands/UpdateProfileCompetenciesCommand.cs b/src/Modules/Identity/Application/Commands/UpdateProfileCompetenciesCommand.cs new file mode 100644 index 0000000..71a3549 --- /dev/null +++ b/src/Modules/Identity/Application/Commands/UpdateProfileCompetenciesCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace Nashel.Modules.Identity.Application.Commands; + +public record UpdateProfileCompetenciesCommand(List CompetencyNames) : IRequest; diff --git a/src/Modules/Identity/Application/Commands/UploadAvatarCommand.cs b/src/Modules/Identity/Application/Commands/UploadAvatarCommand.cs new file mode 100644 index 0000000..4ef45a3 --- /dev/null +++ b/src/Modules/Identity/Application/Commands/UploadAvatarCommand.cs @@ -0,0 +1,6 @@ +using MediatR; +using Microsoft.AspNetCore.Http; + +namespace Nashel.Modules.Identity.Application.Commands; + +public record UploadAvatarCommand(IFormFile File) : IRequest; diff --git a/src/Modules/Identity/Application/Commands/UploadAvatarCommandHandler.cs b/src/Modules/Identity/Application/Commands/UploadAvatarCommandHandler.cs new file mode 100644 index 0000000..2dbe6da --- /dev/null +++ b/src/Modules/Identity/Application/Commands/UploadAvatarCommandHandler.cs @@ -0,0 +1,41 @@ +using MediatR; + +namespace Nashel.Modules.Identity.Application.Commands; + +public class UploadAvatarCommandHandler : IRequestHandler +{ + private const long MaxFileSize = 5 * 1024 * 1024; // 5MB + private static readonly string[] AllowedExtensions = { ".jpg", ".jpeg", ".png" }; + + public async Task 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}"; + } +} diff --git a/src/Modules/Identity/Application/Nashel.Modules.Identity.Application.csproj b/src/Modules/Identity/Application/Nashel.Modules.Identity.Application.csproj index 955cea5..05a38c1 100644 --- a/src/Modules/Identity/Application/Nashel.Modules.Identity.Application.csproj +++ b/src/Modules/Identity/Application/Nashel.Modules.Identity.Application.csproj @@ -5,6 +5,7 @@ + Nashel.Modules.Identity.Application diff --git a/src/Modules/Identity/Application/Queries/GetProfile/GetProfileQuery.cs b/src/Modules/Identity/Application/Queries/GetProfile/GetProfileQuery.cs index ddc6a6e..c5a9d1a 100644 --- a/src/Modules/Identity/Application/Queries/GetProfile/GetProfileQuery.cs +++ b/src/Modules/Identity/Application/Queries/GetProfile/GetProfileQuery.cs @@ -6,7 +6,17 @@ namespace Nashel.Modules.Identity.Application.Queries.GetProfile; public record GetProfileQuery : IRequest; -public record ProfileResponse(Guid Id, string Phone, List Roles); +public record ProfileResponse( + Guid Id, + string Phone, + List Roles, + string FirstName, + string LastName, + string? Patronymic, + string? CompanyName, + string FullName, + string? AvatarUrl, + List Competencies); public class GetProfileQueryHandler : IRequestHandler { @@ -33,9 +43,22 @@ public class GetProfileQueryHandler : IRequestHandler 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()); } } diff --git a/src/Modules/Identity/Application/Queries/SearchCompetenciesQuery.cs b/src/Modules/Identity/Application/Queries/SearchCompetenciesQuery.cs new file mode 100644 index 0000000..d4f05cc --- /dev/null +++ b/src/Modules/Identity/Application/Queries/SearchCompetenciesQuery.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace Nashel.Modules.Identity.Application.Queries; + +public record SearchCompetenciesQuery(string Query) : IRequest>; diff --git a/src/Modules/Identity/Application/Validators/ChangePhoneNumberCommandValidator.cs b/src/Modules/Identity/Application/Validators/ChangePhoneNumberCommandValidator.cs new file mode 100644 index 0000000..984f9bd --- /dev/null +++ b/src/Modules/Identity/Application/Validators/ChangePhoneNumberCommandValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Nashel.Modules.Identity.Application.Commands; + +namespace Nashel.Modules.Identity.Application.Validators; + +public class ChangePhoneNumberCommandValidator : AbstractValidator +{ + public ChangePhoneNumberCommandValidator() + { + RuleFor(x => x.NewPhone) + .NotEmpty().WithMessage("Новый телефон обязателен") + .Matches(@"^\+\d{11,15}$").WithMessage("Формат телефона должен быть E.164 (начинаться с +, 11-15 цифр)"); + } +} diff --git a/src/Modules/Identity/Application/Validators/RegisterUserCommandValidator.cs b/src/Modules/Identity/Application/Validators/RegisterUserCommandValidator.cs new file mode 100644 index 0000000..5bfc320 --- /dev/null +++ b/src/Modules/Identity/Application/Validators/RegisterUserCommandValidator.cs @@ -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 +{ + 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); + } +} diff --git a/src/Modules/Identity/Application/Validators/UpdateProfileCommandValidator.cs b/src/Modules/Identity/Application/Validators/UpdateProfileCommandValidator.cs new file mode 100644 index 0000000..2f195e4 --- /dev/null +++ b/src/Modules/Identity/Application/Validators/UpdateProfileCommandValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; +using Nashel.Modules.Identity.Application.Commands; + +namespace Nashel.Modules.Identity.Application.Validators; + +public class UpdateProfileCommandValidator : AbstractValidator +{ + public UpdateProfileCommandValidator() + { + RuleFor(x => x.FirstName) + .NotEmpty().WithMessage("Имя обязательно") + .MinimumLength(2).WithMessage("Имя должно содержать минимум 2 символа"); + + RuleFor(x => x.LastName) + .NotEmpty().WithMessage("Фамилия обязательна") + .MinimumLength(2).WithMessage("Фамилия должна содержать минимум 2 символа"); + } +} diff --git a/src/Modules/Identity/Domain/Aggregates/Account.cs b/src/Modules/Identity/Domain/Aggregates/Account.cs index 2a8e390..a1e999c 100644 --- a/src/Modules/Identity/Domain/Aggregates/Account.cs +++ b/src/Modules/Identity/Domain/Aggregates/Account.cs @@ -9,6 +9,7 @@ public class Account : AggregateRoot public string Phone { get; private set; } public string PasswordHash { get; private set; } public List Roles { get; private set; } = new(); + public UserProfile Profile { get; private set; } = default!; // Конструктор для EF Core private Account() { } @@ -27,6 +28,16 @@ public class Account : AggregateRoot 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)) diff --git a/src/Modules/Identity/Domain/Aggregates/UserProfile.cs b/src/Modules/Identity/Domain/Aggregates/UserProfile.cs new file mode 100644 index 0000000..ad47bd8 --- /dev/null +++ b/src/Modules/Identity/Domain/Aggregates/UserProfile.cs @@ -0,0 +1,58 @@ +using Nashel.BuildingBlocks.Domain; +using Nashel.Modules.Identity.Domain.Entities; + +namespace Nashel.Modules.Identity.Domain.Aggregates; + +public class UserProfile : Entity +{ + private const int MaxCompetencies = 50; + private readonly List _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 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 competencies) + { + var competencyList = competencies.ToList(); + + if (competencyList.Count > MaxCompetencies) + throw new InvalidOperationException($"Нельзя добавить больше {MaxCompetencies} компетенций"); + + _competencies.Clear(); + _competencies.AddRange(competencyList); + } +} diff --git a/src/Modules/Identity/Domain/Entities/Competency.cs b/src/Modules/Identity/Domain/Entities/Competency.cs new file mode 100644 index 0000000..4ccbb95 --- /dev/null +++ b/src/Modules/Identity/Domain/Entities/Competency.cs @@ -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() }; + } +} diff --git a/src/Modules/Identity/Domain/Services/IPasswordHasher.cs b/src/Modules/Identity/Domain/Services/IPasswordHasher.cs new file mode 100644 index 0000000..63e7e7d --- /dev/null +++ b/src/Modules/Identity/Domain/Services/IPasswordHasher.cs @@ -0,0 +1,7 @@ +namespace Nashel.Modules.Identity.Domain.Services; + +public interface IPasswordHasher +{ + string HashPassword(string password); + bool VerifyPassword(string password, string hash); +} diff --git a/src/Modules/Identity/Infrastructure/CommandHandlers/UpdateProfileCompetenciesCommandHandler.cs b/src/Modules/Identity/Infrastructure/CommandHandlers/UpdateProfileCompetenciesCommandHandler.cs new file mode 100644 index 0000000..2aebb1c --- /dev/null +++ b/src/Modules/Identity/Infrastructure/CommandHandlers/UpdateProfileCompetenciesCommandHandler.cs @@ -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 +{ + 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(); + + 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); + } +} diff --git a/src/Modules/Identity/Infrastructure/DependencyInjection.cs b/src/Modules/Identity/Infrastructure/DependencyInjection.cs index b2c9776..be949b0 100644 --- a/src/Modules/Identity/Infrastructure/DependencyInjection.cs +++ b/src/Modules/Identity/Infrastructure/DependencyInjection.cs @@ -20,6 +20,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddHttpContextAccessor(); services.AddMediatR(cfg => diff --git a/src/Modules/Identity/Infrastructure/Persistence/Configurations/CompetencyConfiguration.cs b/src/Modules/Identity/Infrastructure/Persistence/Configurations/CompetencyConfiguration.cs new file mode 100644 index 0000000..1d87b80 --- /dev/null +++ b/src/Modules/Identity/Infrastructure/Persistence/Configurations/CompetencyConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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"); + } +} diff --git a/src/Modules/Identity/Infrastructure/Persistence/Configurations/UserProfileConfiguration.cs b/src/Modules/Identity/Infrastructure/Persistence/Configurations/UserProfileConfiguration.cs new file mode 100644 index 0000000..03c8b7c --- /dev/null +++ b/src/Modules/Identity/Infrastructure/Persistence/Configurations/UserProfileConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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>( + "UserProfileCompetencies", + j => j.HasOne().WithMany().HasForeignKey("CompetencyId"), + j => j.HasOne().WithMany().HasForeignKey("UserProfileId"), + j => + { + j.ToTable("UserProfileCompetencies", "identity"); + j.HasKey("UserProfileId", "CompetencyId"); + }); + + // 1:1 relationship with Account + builder.HasOne() + .WithOne(x => x.Profile) + .HasForeignKey(x => x.Id) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/Modules/Identity/Infrastructure/Persistence/IdentityDbContext.cs b/src/Modules/Identity/Infrastructure/Persistence/IdentityDbContext.cs index 44ac971..5c58599 100644 --- a/src/Modules/Identity/Infrastructure/Persistence/IdentityDbContext.cs +++ b/src/Modules/Identity/Infrastructure/Persistence/IdentityDbContext.cs @@ -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 Accounts { get; set; } + public DbSet UserProfiles { get; set; } + public DbSet Competencies { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfiguration(new AccountConfiguration()); + modelBuilder.ApplyConfiguration(new UserProfileConfiguration()); + modelBuilder.ApplyConfiguration(new CompetencyConfiguration()); base.OnModelCreating(modelBuilder); } } diff --git a/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260213085826_AddUserProfile.Designer.cs b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260213085826_AddUserProfile.Designer.cs new file mode 100644 index 0000000..53ccdb6 --- /dev/null +++ b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260213085826_AddUserProfile.Designer.cs @@ -0,0 +1,104 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("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("Id") + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompanyName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("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 + } + } +} diff --git a/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260213085826_AddUserProfile.cs b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260213085826_AddUserProfile.cs new file mode 100644 index 0000000..697c675 --- /dev/null +++ b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260213085826_AddUserProfile.cs @@ -0,0 +1,47 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddUserProfile : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserProfiles", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + FirstName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + LastName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Patronymic = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + CompanyName = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + AvatarUrl = table.Column(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); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserProfiles", + schema: "identity"); + } + } +} diff --git a/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260213103833_AddCompetencies.Designer.cs b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260213103833_AddCompetencies.Designer.cs new file mode 100644 index 0000000..150dc2d --- /dev/null +++ b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260213103833_AddCompetencies.Designer.cs @@ -0,0 +1,154 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("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("Id") + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompanyName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("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("UserProfileId") + .HasColumnType("uuid"); + + b.Property("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 + } + } +} diff --git a/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260213103833_AddCompetencies.cs b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260213103833_AddCompetencies.cs new file mode 100644 index 0000000..5682fa9 --- /dev/null +++ b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260213103833_AddCompetencies.cs @@ -0,0 +1,80 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddCompetencies : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Competencies", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(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(type: "uuid", nullable: false), + CompetencyId = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserProfileCompetencies", + schema: "identity"); + + migrationBuilder.DropTable( + name: "Competencies", + schema: "identity"); + } + } +} diff --git a/src/Modules/Identity/Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs b/src/Modules/Identity/Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs index d0a31cb..6d4e3b3 100644 --- a/src/Modules/Identity/Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs +++ b/src/Modules/Identity/Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs @@ -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("Id") + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompanyName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("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("UserProfileId") + .HasColumnType("uuid"); + + b.Property("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 } } diff --git a/src/Modules/Identity/Infrastructure/QueryHandlers/SearchCompetenciesQueryHandler.cs b/src/Modules/Identity/Infrastructure/QueryHandlers/SearchCompetenciesQueryHandler.cs new file mode 100644 index 0000000..7ea059b --- /dev/null +++ b/src/Modules/Identity/Infrastructure/QueryHandlers/SearchCompetenciesQueryHandler.cs @@ -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> +{ + private readonly IdentityDbContext _context; + + public SearchCompetenciesQueryHandler(IdentityDbContext context) + { + _context = context; + } + + public async Task> Handle(SearchCompetenciesQuery request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Query)) + return new List(); + + 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; + } +} diff --git a/src/Modules/Identity/Infrastructure/Repositories/AccountRepository.cs b/src/Modules/Identity/Infrastructure/Repositories/AccountRepository.cs index d107ad5..572eb92 100644 --- a/src/Modules/Identity/Infrastructure/Repositories/AccountRepository.cs +++ b/src/Modules/Identity/Infrastructure/Repositories/AccountRepository.cs @@ -22,12 +22,17 @@ public class AccountRepository : IAccountRepository public async Task 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 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) diff --git a/src/Modules/Identity/Infrastructure/Services/PasswordHasher.cs b/src/Modules/Identity/Infrastructure/Services/PasswordHasher.cs new file mode 100644 index 0000000..c759cec --- /dev/null +++ b/src/Modules/Identity/Infrastructure/Services/PasswordHasher.cs @@ -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 _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; + } +} diff --git a/src/Modules/Identity/Presentation/Endpoints/IdentityEndpoints.cs b/src/Modules/Identity/Presentation/Endpoints/IdentityEndpoints.cs index d074fa1..5cd9c55 100644 --- a/src/Modules/Identity/Presentation/Endpoints/IdentityEndpoints.cs +++ b/src/Modules/Identity/Presentation/Endpoints/IdentityEndpoints.cs @@ -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 Competencies); diff --git a/src/Modules/Identity/Tests/Application/LoginCommandHandlerTests.cs b/src/Modules/Identity/Tests/Application/LoginCommandHandlerTests.cs index b205410..460df04 100644 --- a/src/Modules/Identity/Tests/Application/LoginCommandHandlerTests.cs +++ b/src/Modules/Identity/Tests/Application/LoginCommandHandlerTests.cs @@ -13,13 +13,18 @@ public class LoginCommandHandlerTests { private readonly Mock _mockRepo; private readonly Mock _mockTokenGen; + private readonly Mock _mockPasswordHasher; private readonly LoginCommandHandler _handler; public LoginCommandHandlerTests() { _mockRepo = new Mock(); _mockTokenGen = new Mock(); - _handler = new LoginCommandHandler(_mockRepo.Object, _mockTokenGen.Object); + _mockPasswordHasher = new Mock(); + _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())) .ReturnsAsync(account); + _mockPasswordHasher.Setup(p => p.VerifyPassword(password, account.PasswordHash)) + .Returns(true); + _mockTokenGen.Setup(t => t.GenerateToken(It.IsAny(), It.IsAny>())) .Returns("jwt_token"); @@ -54,6 +62,9 @@ public class LoginCommandHandlerTests _mockRepo.Setup(r => r.GetByPhoneAsync(phone, It.IsAny())) .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); diff --git a/src/Modules/Identity/Tests/Application/RegisterUserCommandHandlerTests.cs b/src/Modules/Identity/Tests/Application/RegisterUserCommandHandlerTests.cs index 650937c..ba781fb 100644 --- a/src/Modules/Identity/Tests/Application/RegisterUserCommandHandlerTests.cs +++ b/src/Modules/Identity/Tests/Application/RegisterUserCommandHandlerTests.cs @@ -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 _mockRepo; + private readonly Mock _mockPasswordHasher; private readonly RegisterUserCommandHandler _handler; public RegisterUserCommandHandlerTests() { _mockRepo = new Mock(); - _handler = new RegisterUserCommandHandler(_mockRepo.Object); + _mockPasswordHasher = new Mock(); + _handler = new RegisterUserCommandHandler(_mockRepo.Object, _mockPasswordHasher.Object); } [Fact] @@ -26,8 +29,17 @@ public class RegisterUserCommandHandlerTests _mockRepo.Setup(r => r.GetByPhoneAsync(phone, It.IsAny())) .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().WithMessage("Пользователь уже существует");