Страница профиля

This commit is contained in:
Халимов Рустам
2026-02-13 14:24:01 +03:00
parent b24b9d697f
commit 1e72d006c4
34 changed files with 1169 additions and 27 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
using MediatR;
namespace Nashel.Modules.Identity.Application.Commands;
public record UpdateProfileCompetenciesCommand(List<string> CompetencyNames) : IRequest;

View File

@@ -0,0 +1,6 @@
using MediatR;
using Microsoft.AspNetCore.Http;
namespace Nashel.Modules.Identity.Application.Commands;
public record UploadAvatarCommand(IFormFile File) : IRequest<string>;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
using MediatR;
namespace Nashel.Modules.Identity.Application.Queries;
public record SearchCompetenciesQuery(string Query) : IRequest<List<string>>;

View File

@@ -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 цифр)");
}
}

View File

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

View File

@@ -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 символа");
}
}

View File

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

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

View 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() };
}
}

View File

@@ -0,0 +1,7 @@
namespace Nashel.Modules.Identity.Domain.Services;
public interface IPasswordHasher
{
string HashPassword(string password);
bool VerifyPassword(string password, string hash);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
}
}
}

View File

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

View 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
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("Пользователь уже существует");