diff --git a/Dockerfile.migrations b/Dockerfile.migrations new file mode 100644 index 0000000..812480a --- /dev/null +++ b/Dockerfile.migrations @@ -0,0 +1,26 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build +WORKDIR /src + +# Устанавливаем инструмент для миграций EF Core +RUN dotnet tool install --global dotnet-ef --version 9.0.0 +ENV PATH="${PATH}:/root/.dotnet/tools" + +# Копируем решение и проекты +COPY ["Nashel.sln", "./"] +COPY ["src/", "src/"] + +# Восстанавливаем зависимости +RUN dotnet restore "Nashel.sln" + +WORKDIR "/src/src/Host" +ENTRYPOINT ["sh", "-c", "\ + echo 'Running migrations...' && \ + export PATH=\"$PATH:/root/.dotnet/tools\" && \ + dotnet ef database update --project ../Modules/Identity/Infrastructure --startup-project . --context IdentityDbContext && \ + dotnet ef database update --project ../Modules/Catalog/Infrastructure --startup-project . --context CatalogDbContext && \ + dotnet ef database update --project ../Modules/Collaboration/Infrastructure --startup-project . --context CollaborationDbContext && \ + dotnet ef database update --project ../Modules/Order/Infrastructure --startup-project . --context OrderDbContext && \ + dotnet ef database update --project ../Modules/Reputation/Infrastructure --startup-project . --context ReputationDbContext && \ + dotnet ef database update --project ../Modules/Geo/Infrastructure --startup-project . --context GeoDbContext && \ + echo 'All migrations completed successfully!'\ + "] diff --git a/docker-compose.yml b/docker-compose.yml index cb9c675..a9978c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ services: migrations: build: context: . - dockerfile: Dockerfile + dockerfile: Dockerfile.migrations container_name: nashel-migrations environment: - ASPNETCORE_ENVIRONMENT=Development @@ -10,8 +10,6 @@ services: depends_on: db: condition: service_healthy - command: > - sh -c "cd /app/src/Host && dotnet ef database update --project ../Modules/Identity/Infrastructure --startup-project . --context IdentityDbContext" networks: - nashel-network diff --git a/src/BuildingBlocks/Domain/Result.cs b/src/BuildingBlocks/Domain/Result.cs new file mode 100644 index 0000000..0f3a70a --- /dev/null +++ b/src/BuildingBlocks/Domain/Result.cs @@ -0,0 +1,28 @@ +namespace Nashel.BuildingBlocks.Domain; + +public class Result +{ + public bool IsSuccess { get; } + public T? Value { get; } + public string? Error { get; } + + protected Result(bool isSuccess, T? value, string? error) + { + IsSuccess = isSuccess; + Value = value; + Error = error; + } + + public static Result Success(T value) => new Result(true, value, null); + public static Result Failure(string error) => new Result(false, default, error); +} + +public class Result : Result +{ + protected Result(bool isSuccess, string? error) : base(isSuccess, null, error) + { + } + + public static Result Success() => new Result(true, null); + public static new Result Failure(string error) => new Result(false, error); +} diff --git a/src/Modules/Catalog/Application/Commands/CreateCategoryCommand.cs b/src/Modules/Catalog/Application/Commands/CreateCategoryCommand.cs index 2e6c756..e2daa8b 100644 --- a/src/Modules/Catalog/Application/Commands/CreateCategoryCommand.cs +++ b/src/Modules/Catalog/Application/Commands/CreateCategoryCommand.cs @@ -1,5 +1,6 @@ using System.Text.Json; using MediatR; +using Nashel.BuildingBlocks.Domain; using Nashel.Modules.Catalog.Domain.Aggregates; using Nashel.Modules.Catalog.Domain.Repositories; @@ -8,17 +9,17 @@ namespace Nashel.Modules.Catalog.Application.Commands; /// /// Команда создания категории. /// -public record CreateCategoryCommand : IRequest +public record CreateCategoryCommand : IRequest> { /// /// Название категории. /// - public string Title { get; init; } + public string Name { get; init; } = default!; /// /// URL-friendly идентификатор (слаг). /// - public string Slug { get; init; } + public string Slug { get; init; } = default!; /// /// ID родительской категории (null для корневых). @@ -30,9 +31,9 @@ public record CreateCategoryCommand : IRequest /// public JsonDocument? AttributeSchema { get; init; } - public CreateCategoryCommand(string title, string slug, Guid? parentId, JsonDocument? attributeSchema) + public CreateCategoryCommand(string name, string slug, Guid? parentId, JsonDocument? attributeSchema) { - Title = title; + Name = name; Slug = slug; ParentId = parentId; AttributeSchema = attributeSchema; @@ -41,7 +42,7 @@ public record CreateCategoryCommand : IRequest public CreateCategoryCommand() { } // For deserialization } -public class CreateCategoryCommandHandler : IRequestHandler +public class CreateCategoryCommandHandler : IRequestHandler> { private readonly ICategoryRepository _repository; @@ -50,19 +51,19 @@ public class CreateCategoryCommandHandler : IRequestHandler Handle(CreateCategoryCommand request, CancellationToken cancellationToken) + public async Task> Handle(CreateCategoryCommand request, CancellationToken cancellationToken) { if (request.ParentId.HasValue) { var parentCategory = await _repository.GetByIdAsync(request.ParentId.Value, cancellationToken); if (parentCategory == null) { - throw new ApplicationException($"Родительская категория с ID {request.ParentId} не найдена."); + return Result.Failure($"Родительская категория с ID {request.ParentId} не найдена."); } } var category = new Category( - request.Title, + request.Name, request.Slug, request.ParentId, request.AttributeSchema @@ -70,6 +71,6 @@ public class CreateCategoryCommandHandler : IRequestHandler.Success(category.Id); } } diff --git a/src/Modules/Catalog/Application/Commands/CreateOfferCommand.cs b/src/Modules/Catalog/Application/Commands/CreateOfferCommand.cs index 099b18d..7ed5b79 100644 --- a/src/Modules/Catalog/Application/Commands/CreateOfferCommand.cs +++ b/src/Modules/Catalog/Application/Commands/CreateOfferCommand.cs @@ -1,5 +1,8 @@ using System.Text.Json; using MediatR; +using MediatR; +using Nashel.BuildingBlocks.Application.Abstractions; +using Nashel.BuildingBlocks.Domain; using Nashel.Modules.Catalog.Domain.Aggregates; using Nashel.Modules.Catalog.Domain.Repositories; using Nashel.Modules.Catalog.Domain.ValueObjects; @@ -9,16 +12,8 @@ namespace Nashel.Modules.Catalog.Application.Commands; /// /// Команда создания услуги (оффера). /// -public record CreateOfferCommand : IRequest +public record CreateOfferCommand : IRequest> { - /// - /// ID владельца (пользователя/исполнителя). - /// - public Guid OwnerId { get; init; } - - /// - /// ID категории услуги. - /// public Guid CategoryId { get; init; } /// @@ -41,9 +36,8 @@ public record CreateOfferCommand : IRequest /// public JsonDocument? Attributes { get; init; } - public CreateOfferCommand(Guid ownerId, Guid categoryId, string title, string description, Price price, JsonDocument? attributes) + public CreateOfferCommand(Guid categoryId, string title, string description, Price price, JsonDocument? attributes) { - OwnerId = ownerId; CategoryId = categoryId; Title = title; Description = description; @@ -54,19 +48,24 @@ public record CreateOfferCommand : IRequest public CreateOfferCommand() { } } -public class CreateOfferCommandHandler : IRequestHandler +public class CreateOfferCommandHandler : IRequestHandler> { private readonly IOfferRepository _repository; + private readonly ICurrentUserService _currentUserService; - public CreateOfferCommandHandler(IOfferRepository repository) + public CreateOfferCommandHandler(IOfferRepository repository, ICurrentUserService currentUserService) { _repository = repository; + _currentUserService = currentUserService; } - public async Task Handle(CreateOfferCommand request, CancellationToken cancellationToken) + public async Task> Handle(CreateOfferCommand request, CancellationToken cancellationToken) { + var userId = _currentUserService.UserId; + if (userId == null) return Result.Failure("Неавторизован"); + var offer = new Offer( - request.OwnerId, + userId.Value, request.CategoryId, request.Title, request.Description, @@ -76,6 +75,6 @@ public class CreateOfferCommandHandler : IRequestHandler.Success(offer.Id); } } diff --git a/src/Modules/Catalog/Application/Common/Dtos.cs b/src/Modules/Catalog/Application/Common/Dtos.cs index 44bd5cb..d68f57f 100644 --- a/src/Modules/Catalog/Application/Common/Dtos.cs +++ b/src/Modules/Catalog/Application/Common/Dtos.cs @@ -16,7 +16,7 @@ public record CategoryDto /// /// Название категории. /// - public string Title { get; init; } = default!; + public string Name { get; private set; } = default!; /// /// URL-совместимый идентификатор (slug). @@ -33,10 +33,10 @@ public record CategoryDto /// public JsonDocument? AttributeSchema { get; init; } - public CategoryDto(Guid id, string title, string slug, Guid? parentId, JsonDocument? attributeSchema) + public CategoryDto(Guid id, string name, string slug, Guid? parentId, JsonDocument? attributeSchema) { Id = id; - Title = title; + Name = name; Slug = slug; ParentId = parentId; AttributeSchema = attributeSchema; @@ -58,7 +58,7 @@ public record OfferDto /// /// Идентификатор владельца (исполнителя). /// - public Guid OwnerId { get; init; } + public Guid PerformerId { get; init; } /// /// Идентификатор категории. @@ -90,10 +90,10 @@ public record OfferDto /// public bool IsActive { get; init; } - public OfferDto(Guid id, Guid ownerId, Guid categoryId, string title, string description, Price price, JsonDocument? attributes, bool isActive) + public OfferDto(Guid id, Guid performerId, Guid categoryId, string title, string description, Price price, JsonDocument? attributes, bool isActive) { Id = id; - OwnerId = ownerId; + PerformerId = performerId; CategoryId = categoryId; Title = title; Description = description; diff --git a/src/Modules/Catalog/Application/Queries/GetCategoriesQuery.cs b/src/Modules/Catalog/Application/Queries/GetCategoriesQuery.cs index a71aaab..6cd0587 100644 --- a/src/Modules/Catalog/Application/Queries/GetCategoriesQuery.cs +++ b/src/Modules/Catalog/Application/Queries/GetCategoriesQuery.cs @@ -1,5 +1,6 @@ using System.Text.Json; using MediatR; +using Nashel.BuildingBlocks.Domain; using Nashel.Modules.Catalog.Application.Common; using Nashel.Modules.Catalog.Domain.Repositories; @@ -8,9 +9,9 @@ namespace Nashel.Modules.Catalog.Application.Queries; /// /// Запрос дерева категорий. /// -public record GetCategoriesQuery() : IRequest>; +public record GetCategoriesQuery() : IRequest>>; -public class GetCategoriesQueryHandler : IRequestHandler> +public class GetCategoriesQueryHandler : IRequestHandler>> { private readonly ICategoryRepository _repository; @@ -19,7 +20,7 @@ public class GetCategoriesQueryHandler : IRequestHandler> Handle(GetCategoriesQuery request, CancellationToken cancellationToken) + public async Task>> Handle(GetCategoriesQuery request, CancellationToken cancellationToken) { var rawCategories = await _repository.GetAllAsync(cancellationToken); @@ -27,12 +28,14 @@ public class GetCategoriesQueryHandler : IRequestHandler Children // Для MVP возвращаем плоский список, фронтенд сам строит дерево по ParentId - return rawCategories.Select(c => new CategoryDto( + var list = rawCategories.Select(c => new CategoryDto( c.Id, - c.Title, + c.Name, c.Slug, c.ParentId, c.AttributeSchema )).ToList(); + + return Result>.Success(list); } } diff --git a/src/Modules/Catalog/Application/Queries/GetMyOffersQuery.cs b/src/Modules/Catalog/Application/Queries/GetMyOffersQuery.cs new file mode 100644 index 0000000..cdbf504 --- /dev/null +++ b/src/Modules/Catalog/Application/Queries/GetMyOffersQuery.cs @@ -0,0 +1,45 @@ +using MediatR; +using Nashel.BuildingBlocks.Application.Abstractions; +using Nashel.BuildingBlocks.Domain; +using Nashel.Modules.Catalog.Application.Common; +using Nashel.Modules.Catalog.Domain.Repositories; + +namespace Nashel.Modules.Catalog.Application.Queries; + +/// +/// Запрос получения услуг текущего пользователя. +/// +public record GetMyOffersQuery() : IRequest>>; + +public class GetMyOffersQueryHandler : IRequestHandler>> +{ + private readonly IOfferRepository _repository; + private readonly ICurrentUserService _currentUserService; + + public GetMyOffersQueryHandler(IOfferRepository repository, ICurrentUserService currentUserService) + { + _repository = repository; + _currentUserService = currentUserService; + } + + public async Task>> Handle(GetMyOffersQuery request, CancellationToken cancellationToken) + { + var userId = _currentUserService.UserId; + if (userId == null) return Result>.Failure("Неавторизован"); + + var offers = await _repository.GetByPerformerIdAsync(userId.Value, cancellationToken); + + var list = offers.Select(offer => new OfferDto( + offer.Id, + offer.PerformerId, + offer.CategoryId, + offer.Title, + offer.Description ?? string.Empty, + offer.Price, + offer.Attributes, + offer.IsActive + )).ToList(); + + return Result>.Success(list); + } +} diff --git a/src/Modules/Catalog/Application/Queries/GetOfferByIdQuery.cs b/src/Modules/Catalog/Application/Queries/GetOfferByIdQuery.cs index 14dabcb..5482052 100644 --- a/src/Modules/Catalog/Application/Queries/GetOfferByIdQuery.cs +++ b/src/Modules/Catalog/Application/Queries/GetOfferByIdQuery.cs @@ -1,4 +1,5 @@ using MediatR; +using Nashel.BuildingBlocks.Domain; using Nashel.Modules.Catalog.Application.Common; using Nashel.Modules.Catalog.Domain.Repositories; @@ -7,9 +8,9 @@ namespace Nashel.Modules.Catalog.Application.Queries; /// /// Запрос деталей услуги по ID. /// -public record GetOfferByIdQuery(Guid Id) : IRequest; +public record GetOfferByIdQuery(Guid Id) : IRequest>; -public class GetOfferByIdQueryHandler : IRequestHandler +public class GetOfferByIdQueryHandler : IRequestHandler> { private readonly IOfferRepository _repository; @@ -18,20 +19,22 @@ public class GetOfferByIdQueryHandler : IRequestHandler Handle(GetOfferByIdQuery request, CancellationToken cancellationToken) + public async Task> Handle(GetOfferByIdQuery request, CancellationToken cancellationToken) { var offer = await _repository.GetByIdAsync(request.Id, cancellationToken); - if (offer == null) return null; + if (offer == null) return Result.Failure("Offer not found"); - return new OfferDto( + var dto = new OfferDto( offer.Id, - offer.OwnerId, + offer.PerformerId, offer.CategoryId, offer.Title, - offer.Description, + offer.Description ?? string.Empty, offer.Price, offer.Attributes, offer.IsActive ); + + return Result.Success(dto); } } diff --git a/src/Modules/Catalog/Domain/Aggregates/Category.cs b/src/Modules/Catalog/Domain/Aggregates/Category.cs index 9dc1e76..b131313 100644 --- a/src/Modules/Catalog/Domain/Aggregates/Category.cs +++ b/src/Modules/Catalog/Domain/Aggregates/Category.cs @@ -16,7 +16,7 @@ public class Category /// /// Название категории. /// - public string Title { get; private set; } + public string Name { get; private set; } /// /// URL-friendly идентификатор (слаг). @@ -39,10 +39,10 @@ public class Category /// /// Создает новую категорию. /// - public Category(string title, string slug, Guid? parentId, JsonDocument? attributeSchema = null) + public Category(string name, string slug, Guid? parentId, JsonDocument? attributeSchema = null) { Id = Guid.NewGuid(); - Title = title; + Name = name; Slug = slug; ParentId = parentId; AttributeSchema = attributeSchema; diff --git a/src/Modules/Catalog/Domain/Aggregates/Offer.cs b/src/Modules/Catalog/Domain/Aggregates/Offer.cs index d61c32d..270625c 100644 --- a/src/Modules/Catalog/Domain/Aggregates/Offer.cs +++ b/src/Modules/Catalog/Domain/Aggregates/Offer.cs @@ -16,7 +16,7 @@ public class Offer /// /// ID владельца (пользователя/исполнителя). /// - public Guid OwnerId { get; private set; } + public Guid PerformerId { get; private set; } /// /// ID услуги/категории. @@ -48,21 +48,27 @@ public class Offer /// public bool IsActive { get; private set; } + /// + /// Дата создания. + /// + public DateTimeOffset CreatedAt { get; private set; } + // Конструктор по умолчанию для EF Core private Offer() { } /// /// Создает новый оффер. /// - public Offer(Guid ownerId, Guid categoryId, string title, string description, Price price, JsonDocument? attributes = null) + public Offer(Guid performerId, Guid categoryId, string title, string description, Price price, JsonDocument? attributes = null) { Id = Guid.NewGuid(); - OwnerId = ownerId; + PerformerId = performerId; CategoryId = categoryId; Title = title; Description = description; Price = price; Attributes = attributes; IsActive = true; + CreatedAt = DateTimeOffset.UtcNow; } } diff --git a/src/Modules/Catalog/Domain/Repositories/IOfferRepository.cs b/src/Modules/Catalog/Domain/Repositories/IOfferRepository.cs index 91eb766..3a9da4e 100644 --- a/src/Modules/Catalog/Domain/Repositories/IOfferRepository.cs +++ b/src/Modules/Catalog/Domain/Repositories/IOfferRepository.cs @@ -5,5 +5,6 @@ namespace Nashel.Modules.Catalog.Domain.Repositories; public interface IOfferRepository { Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task> GetByPerformerIdAsync(Guid performerId, CancellationToken cancellationToken = default); Task AddAsync(Offer offer, CancellationToken cancellationToken = default); } diff --git a/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260302095500_CatalogRefactor.Designer.cs b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260302095500_CatalogRefactor.Designer.cs new file mode 100644 index 0000000..d55a101 --- /dev/null +++ b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260302095500_CatalogRefactor.Designer.cs @@ -0,0 +1,140 @@ +// +using System; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Catalog.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Catalog.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(CatalogDbContext))] + [Migration("20260302095500_CatalogRefactor")] + partial class CatalogRefactor + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Catalog.Domain.Aggregates.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttributeSchema") + .HasColumnType("jsonb"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Categories", "catalog"); + }); + + modelBuilder.Entity("Nashel.Modules.Catalog.Domain.Aggregates.Offer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attributes") + .HasColumnType("jsonb"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PerformerId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Attributes"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Attributes"), "gin"); + + b.ToTable("Offers", "catalog"); + }); + + modelBuilder.Entity("Nashel.Modules.Catalog.Domain.Aggregates.Category", b => + { + b.HasOne("Nashel.Modules.Catalog.Domain.Aggregates.Category", null) + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("Nashel.Modules.Catalog.Domain.Aggregates.Offer", b => + { + b.OwnsOne("Nashel.Modules.Catalog.Domain.ValueObjects.Price", "Price", b1 => + { + b1.Property("OfferId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasColumnType("numeric") + .HasColumnName("PriceAmount"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("PriceCurrency"); + + b1.Property("Type") + .HasColumnType("integer") + .HasColumnName("PriceType"); + + b1.HasKey("OfferId"); + + b1.ToTable("Offers", "catalog"); + + b1.WithOwner() + .HasForeignKey("OfferId"); + }); + + b.Navigation("Price") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260302095500_CatalogRefactor.cs b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260302095500_CatalogRefactor.cs new file mode 100644 index 0000000..1d28987 --- /dev/null +++ b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260302095500_CatalogRefactor.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Catalog.Infrastructure.Persistence.Migrations +{ + /// + public partial class CatalogRefactor : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "OwnerId", + schema: "catalog", + table: "Offers", + newName: "PerformerId"); + + migrationBuilder.RenameColumn( + name: "Title", + schema: "catalog", + table: "Categories", + newName: "Name"); + + migrationBuilder.AddColumn( + name: "CreatedAt", + schema: "catalog", + table: "Offers", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CreatedAt", + schema: "catalog", + table: "Offers"); + + migrationBuilder.RenameColumn( + name: "PerformerId", + schema: "catalog", + table: "Offers", + newName: "OwnerId"); + + migrationBuilder.RenameColumn( + name: "Name", + schema: "catalog", + table: "Categories", + newName: "Title"); + } + } +} diff --git a/src/Modules/Catalog/Infrastructure/Persistence/Migrations/CatalogDbContextModelSnapshot.cs b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/CatalogDbContextModelSnapshot.cs index 4233490..1a10d82 100644 --- a/src/Modules/Catalog/Infrastructure/Persistence/Migrations/CatalogDbContextModelSnapshot.cs +++ b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/CatalogDbContextModelSnapshot.cs @@ -33,6 +33,10 @@ namespace Nashel.Modules.Catalog.Infrastructure.Persistence.Migrations b.Property("AttributeSchema") .HasColumnType("jsonb"); + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + b.Property("ParentId") .HasColumnType("uuid"); @@ -40,10 +44,6 @@ namespace Nashel.Modules.Catalog.Infrastructure.Persistence.Migrations .IsRequired() .HasColumnType("text"); - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - b.HasKey("Id"); b.HasIndex("ParentId"); @@ -66,13 +66,16 @@ namespace Nashel.Modules.Catalog.Infrastructure.Persistence.Migrations b.Property("CategoryId") .HasColumnType("uuid"); + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + b.Property("Description") .HasColumnType("text"); b.Property("IsActive") .HasColumnType("boolean"); - b.Property("OwnerId") + b.Property("PerformerId") .HasColumnType("uuid"); b.Property("Title") diff --git a/src/Modules/Catalog/Infrastructure/Repositories/OfferRepository.cs b/src/Modules/Catalog/Infrastructure/Repositories/OfferRepository.cs index fb13abe..20aa42e 100644 --- a/src/Modules/Catalog/Infrastructure/Repositories/OfferRepository.cs +++ b/src/Modules/Catalog/Infrastructure/Repositories/OfferRepository.cs @@ -20,6 +20,13 @@ public class OfferRepository : IOfferRepository .FirstOrDefaultAsync(o => o.Id == id, cancellationToken); } + public async Task> GetByPerformerIdAsync(Guid performerId, CancellationToken cancellationToken = default) + { + return await _context.Offers + .Where(o => o.PerformerId == performerId) + .ToListAsync(cancellationToken); + } + public async Task AddAsync(Offer offer, CancellationToken cancellationToken = default) { await _context.Offers.AddAsync(offer, cancellationToken); diff --git a/src/Modules/Catalog/Presentation/Endpoints/CatalogEndpoints.cs b/src/Modules/Catalog/Presentation/Endpoints/CatalogEndpoints.cs index fa4cce2..6996275 100644 --- a/src/Modules/Catalog/Presentation/Endpoints/CatalogEndpoints.cs +++ b/src/Modules/Catalog/Presentation/Endpoints/CatalogEndpoints.cs @@ -21,8 +21,8 @@ public static class CatalogEndpoints catalogGroup.MapPost("/categories", async ([FromBody] CreateCategoryCommand command, ISender sender) => { - var id = await sender.Send(command); - return Results.Ok(id); + var result = await sender.Send(command); + return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error); }) .WithName("CreateCategory") .WithOpenApi(operation => new(operation) { Summary = "Создать категорию (Admin)", Description = "Создает новую категорию. Требуются права администратора." }); @@ -30,17 +30,27 @@ public static class CatalogEndpoints catalogGroup.MapGet("/categories", async (ISender sender) => { var result = await sender.Send(new GetCategoriesQuery()); - return Results.Ok(result); + return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error); }) .WithName("GetCategories") .WithOpenApi(operation => new(operation) { Summary = "Получить все категории", Description = "Возвращает плоский список категорий с указанием ParentId для иерархии." }); // --- Услуги (Offers) --- - catalogGroup.MapPost("/offers", async ([FromBody] CreateOfferCommand command, ISender sender) => + var protectedOffersGroup = catalogGroup.MapGroup("").RequireAuthorization(); + + protectedOffersGroup.MapGet("/offers/my", async (ISender sender) => { - var id = await sender.Send(command); - return Results.Ok(id); + var result = await sender.Send(new GetMyOffersQuery()); + return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error); + }) + .WithName("GetMyOffers") + .WithOpenApi(operation => new(operation) { Summary = "Получить мои услуги", Description = "Возвращает список услуг текущего пользователя." }); + + protectedOffersGroup.MapPost("/offers", async ([FromBody] CreateOfferCommand command, ISender sender) => + { + var result = await sender.Send(command); + return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error); }) .WithName("CreateOffer") .WithOpenApi(operation => new(operation) { Summary = "Создать оффер/услугу", Description = "Создает новое предложение услуги в указанной категории." }); @@ -48,7 +58,7 @@ public static class CatalogEndpoints catalogGroup.MapGet("/offers/{id:guid}", async (Guid id, ISender sender) => { var result = await sender.Send(new GetOfferByIdQuery(id)); - return result is not null ? Results.Ok(result) : Results.NotFound(); + return result.IsSuccess && result.Value is not null ? Results.Ok(result.Value) : Results.NotFound(); }) .WithName("GetOfferById") .WithOpenApi(operation => new(operation) { Summary = "Получить детали услуги по ID", Description = "Возвращает полную информацию об услуге." }); diff --git a/src/Modules/Identity/Application/Commands/RegisterUserCommand.cs b/src/Modules/Identity/Application/Commands/RegisterUserCommand.cs index 3b215bc..d59d5c1 100644 --- a/src/Modules/Identity/Application/Commands/RegisterUserCommand.cs +++ b/src/Modules/Identity/Application/Commands/RegisterUserCommand.cs @@ -24,12 +24,12 @@ public record RegisterUserCommand : IRequest /// /// Имя. /// - public string FirstName { get; init; } = default!; + public string? FirstName { get; init; } /// /// Фамилия. /// - public string LastName { get; init; } = default!; + public string? LastName { get; init; } /// /// Отчество (необязательно). @@ -97,8 +97,8 @@ public class RegisterUserCommandHandler : IRequestHandler Roles, - string FirstName, - string LastName, + string? FirstName, + string? LastName, string? Patronymic, string? CompanyName, string? Inn, string? Description, string? Location, string? CurrentLocation, - string FullName, + string? FullName, string? AvatarUrl, List Competencies, WorkScheduleResponse? WorkSchedule); @@ -60,10 +60,13 @@ public class GetProfileQueryHandler : IRequestHandler !string.IsNullOrWhiteSpace(x)); + var fullName = string.Join(" ", names); + + if (string.IsNullOrWhiteSpace(fullName)) { - fullName += $" {account.Profile.Patronymic}"; + fullName = account.Profile.CompanyName ?? ""; } // Парсинг WorkSchedule diff --git a/src/Modules/Identity/Application/Validators/RegisterUserCommandValidator.cs b/src/Modules/Identity/Application/Validators/RegisterUserCommandValidator.cs index 0a6c891..2b3bf9c 100644 --- a/src/Modules/Identity/Application/Validators/RegisterUserCommandValidator.cs +++ b/src/Modules/Identity/Application/Validators/RegisterUserCommandValidator.cs @@ -17,12 +17,12 @@ public class RegisterUserCommandValidator : AbstractValidator x.FirstName) - .NotEmpty().WithMessage("Имя обязательно") - .MinimumLength(2).WithMessage("Имя должно содержать минимум 2 символа"); + .NotEmpty().WithMessage("Имя обязательно").When(x => x.Role != Role.Company) + .MinimumLength(2).WithMessage("Имя должно содержать минимум 2 символа").When(x => x.Role != Role.Company && !string.IsNullOrEmpty(x.FirstName)); RuleFor(x => x.LastName) - .NotEmpty().WithMessage("Фамилия обязательна") - .MinimumLength(2).WithMessage("Фамилия должна содержать минимум 2 символа"); + .NotEmpty().WithMessage("Фамилия обязательна").When(x => x.Role != Role.Company) + .MinimumLength(2).WithMessage("Фамилия должна содержать минимум 2 символа").When(x => x.Role != Role.Company && !string.IsNullOrEmpty(x.LastName)); RuleFor(x => x.CompanyName) .NotEmpty().WithMessage("Название компании обязательно для юридических лиц") diff --git a/src/Modules/Identity/Domain/Aggregates/Account.cs b/src/Modules/Identity/Domain/Aggregates/Account.cs index 6033c6f..d5fae16 100644 --- a/src/Modules/Identity/Domain/Aggregates/Account.cs +++ b/src/Modules/Identity/Domain/Aggregates/Account.cs @@ -81,17 +81,17 @@ public class Account : AggregateRoot public void BecomePerformer() { - if (!Roles.Contains(Role.Newbie) && !Roles.Contains(Role.Master)) + if (!Roles.Contains(Role.Candidate) && !Roles.Contains(Role.Master)) { - Roles.Add(Role.Newbie); + Roles.Add(Role.Candidate); } } public void PromoteToMaster() { - if (Roles.Contains(Role.Newbie)) + if (Roles.Contains(Role.Candidate)) { - Roles.Remove(Role.Newbie); + Roles.Remove(Role.Candidate); } if (!Roles.Contains(Role.Master)) @@ -102,6 +102,10 @@ public class Account : AggregateRoot public void UpdatePerformerData(string description, IEnumerable competencies, string? location, string? currentLocation, WorkSchedule? workSchedule) { + if (Profile == null) + { + throw new InvalidOperationException("Profile not found"); + } Profile.UpdatePerformerData(description, competencies, location, currentLocation, workSchedule); } @@ -112,6 +116,6 @@ public class Account : AggregateRoot public bool IsPerformer() { - return Roles.Contains(Role.Newbie) || Roles.Contains(Role.Master); + return 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 index d7ec513..dc2ab88 100644 --- a/src/Modules/Identity/Domain/Aggregates/UserProfile.cs +++ b/src/Modules/Identity/Domain/Aggregates/UserProfile.cs @@ -9,8 +9,8 @@ public class UserProfile : Entity private const int MaxDescriptionLength = 2048; private readonly List _competencies = new(); - public string FirstName { get; private set; } - public string LastName { get; private set; } + 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? Inn { get; private set; } @@ -24,7 +24,7 @@ public class UserProfile : Entity // EF Core constructor private UserProfile() { } - private UserProfile(Guid id, string firstName, string lastName, string? patronymic, string? companyName, string? inn, string? description) + private UserProfile(Guid id, string? firstName, string? lastName, string? patronymic, string? companyName, string? inn, string? description) { Id = id; FirstName = firstName; @@ -35,7 +35,7 @@ public class UserProfile : Entity Description = description; } - public static UserProfile Create(Guid id, string firstName, string lastName, string? patronymic, string? companyName, string? inn, string? description) + public static UserProfile Create(Guid id, string? firstName, string? lastName, string? patronymic, string? companyName, string? inn, string? description) { if (!string.IsNullOrEmpty(description) && description.Length > MaxDescriptionLength) throw new ArgumentException($"Описание не может превышать {MaxDescriptionLength} символов", nameof(description)); @@ -43,7 +43,7 @@ public class UserProfile : Entity return new UserProfile(id, firstName, lastName, patronymic, companyName, inn, description); } - public void Update(string firstName, string lastName, string? patronymic, string? companyName, string? inn, string? description, string? location = null, string? currentLocation = null) + public void Update(string? firstName, string? lastName, string? patronymic, string? companyName, string? inn, string? description, string? location = null, string? currentLocation = null) { if (!string.IsNullOrEmpty(description) && description.Length > MaxDescriptionLength) throw new ArgumentException($"Описание не может превышать {MaxDescriptionLength} символов", nameof(description)); diff --git a/src/Modules/Identity/Domain/Enums/Role.cs b/src/Modules/Identity/Domain/Enums/Role.cs index 06732e5..eaba16a 100644 --- a/src/Modules/Identity/Domain/Enums/Role.cs +++ b/src/Modules/Identity/Domain/Enums/Role.cs @@ -6,7 +6,6 @@ namespace Nashel.Modules.Identity.Domain.Enums; public enum Role { User = 0, - Newbie, Candidate, Master, Company, diff --git a/src/Modules/Identity/Infrastructure/Persistence/Configurations/UserProfileConfiguration.cs b/src/Modules/Identity/Infrastructure/Persistence/Configurations/UserProfileConfiguration.cs index e84dadb..f96580f 100644 --- a/src/Modules/Identity/Infrastructure/Persistence/Configurations/UserProfileConfiguration.cs +++ b/src/Modules/Identity/Infrastructure/Persistence/Configurations/UserProfileConfiguration.cs @@ -15,11 +15,9 @@ public class UserProfileConfiguration : IEntityTypeConfiguration 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) diff --git a/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260302184149_RemoveNewbieRole.Designer.cs b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260302184149_RemoveNewbieRole.Designer.cs new file mode 100644 index 0000000..48c47c1 --- /dev/null +++ b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260302184149_RemoveNewbieRole.Designer.cs @@ -0,0 +1,207 @@ +// +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("20260302184149_RemoveNewbieRole")] + partial class RemoveNewbieRole + { + /// + 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("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + 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("CurrentLocation") + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Inn") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Location") + .HasColumnType("text"); + + 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("Nashel.Modules.Identity.Domain.Entities.WorkSchedule", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("IsAlwaysReady") + .HasColumnType("boolean"); + + b.Property("WorkingDays") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.ToTable("WorkSchedules", "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("Nashel.Modules.Identity.Domain.Entities.WorkSchedule", b => + { + b.HasOne("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", null) + .WithOne("WorkSchedule") + .HasForeignKey("Nashel.Modules.Identity.Domain.Entities.WorkSchedule", "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(); + }); + + modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", b => + { + b.Navigation("WorkSchedule"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260302184149_RemoveNewbieRole.cs b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260302184149_RemoveNewbieRole.cs new file mode 100644 index 0000000..4b0bdae --- /dev/null +++ b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260302184149_RemoveNewbieRole.cs @@ -0,0 +1,44 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations +{ + /// + public partial class RemoveNewbieRole : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DeletedAt", + schema: "identity", + table: "Accounts", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + schema: "identity", + table: "Accounts", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DeletedAt", + schema: "identity", + table: "Accounts"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + schema: "identity", + table: "Accounts"); + } + } +} diff --git a/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260302192325_MakeNamesOptional.Designer.cs b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260302192325_MakeNamesOptional.Designer.cs new file mode 100644 index 0000000..c2db03a --- /dev/null +++ b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260302192325_MakeNamesOptional.Designer.cs @@ -0,0 +1,205 @@ +// +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("20260302192325_MakeNamesOptional")] + partial class MakeNamesOptional + { + /// + 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("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + 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("CurrentLocation") + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FirstName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Inn") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("LastName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Location") + .HasColumnType("text"); + + 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("Nashel.Modules.Identity.Domain.Entities.WorkSchedule", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("IsAlwaysReady") + .HasColumnType("boolean"); + + b.Property("WorkingDays") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.ToTable("WorkSchedules", "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("Nashel.Modules.Identity.Domain.Entities.WorkSchedule", b => + { + b.HasOne("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", null) + .WithOne("WorkSchedule") + .HasForeignKey("Nashel.Modules.Identity.Domain.Entities.WorkSchedule", "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(); + }); + + modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", b => + { + b.Navigation("WorkSchedule"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260302192325_MakeNamesOptional.cs b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260302192325_MakeNamesOptional.cs new file mode 100644 index 0000000..bebbfec --- /dev/null +++ b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260302192325_MakeNamesOptional.cs @@ -0,0 +1,66 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations +{ + /// + public partial class MakeNamesOptional : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "LastName", + schema: "identity", + table: "UserProfiles", + type: "character varying(100)", + maxLength: 100, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(100)", + oldMaxLength: 100); + + migrationBuilder.AlterColumn( + name: "FirstName", + schema: "identity", + table: "UserProfiles", + type: "character varying(100)", + maxLength: 100, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(100)", + oldMaxLength: 100); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "LastName", + schema: "identity", + table: "UserProfiles", + type: "character varying(100)", + maxLength: 100, + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "character varying(100)", + oldMaxLength: 100, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "FirstName", + schema: "identity", + table: "UserProfiles", + type: "character varying(100)", + maxLength: 100, + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "character varying(100)", + oldMaxLength: 100, + oldNullable: true); + } + } +} diff --git a/src/Modules/Identity/Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs b/src/Modules/Identity/Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs index 7c88abe..1d1a773 100644 --- a/src/Modules/Identity/Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs +++ b/src/Modules/Identity/Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs @@ -28,6 +28,14 @@ namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations .ValueGeneratedOnAdd() .HasColumnType("uuid"); + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + b.Property("PasswordHash") .IsRequired() .HasColumnType("text"); @@ -70,7 +78,6 @@ namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations .HasColumnType("character varying(2048)"); b.Property("FirstName") - .IsRequired() .HasMaxLength(100) .HasColumnType("character varying(100)"); @@ -79,7 +86,6 @@ namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations .HasColumnType("character varying(10)"); b.Property("LastName") - .IsRequired() .HasMaxLength(100) .HasColumnType("character varying(100)"); diff --git a/src/Modules/Identity/Tests/Application/BecomePerformerCommandHandlerTests.cs b/src/Modules/Identity/Tests/Application/BecomePerformerCommandHandlerTests.cs index b4f1fdf..b5dc32b 100644 --- a/src/Modules/Identity/Tests/Application/BecomePerformerCommandHandlerTests.cs +++ b/src/Modules/Identity/Tests/Application/BecomePerformerCommandHandlerTests.cs @@ -33,6 +33,8 @@ public class BecomePerformerCommandHandlerTests { // Arrange var account = Account.Create("123", "pass"); // Has User role only + var profile = UserProfile.Create(account.Id, "TestName", "TestSurname", null, null, null, null); + account.SetProfile(profile); var userId = account.Id; _mockUserService.Setup(s => s.UserId).Returns(userId); @@ -40,7 +42,8 @@ public class BecomePerformerCommandHandlerTests .ReturnsAsync(account); // Act - await _handler.Handle(new BecomePerformerCommand(), CancellationToken.None); + var command = new BecomePerformerCommand { Description = new string('x', 51) }; + await _handler.Handle(command, CancellationToken.None); // Assert account.Roles.Should().Contain(Role.Candidate); diff --git a/src/Modules/Identity/Tests/Application/RegisterUserCommandHandlerTests.cs b/src/Modules/Identity/Tests/Application/RegisterUserCommandHandlerTests.cs index ba781fb..1d1cd9b 100644 --- a/src/Modules/Identity/Tests/Application/RegisterUserCommandHandlerTests.cs +++ b/src/Modules/Identity/Tests/Application/RegisterUserCommandHandlerTests.cs @@ -26,7 +26,7 @@ public class RegisterUserCommandHandlerTests { // Arrange var phone = "123"; - _mockRepo.Setup(r => r.GetByPhoneAsync(phone, It.IsAny())) + _mockRepo.Setup(r => r.GetByPhoneIncludingDeletedAsync(phone, It.IsAny())) .ReturnsAsync((Account?)null); _mockPasswordHasher.Setup(p => p.HashPassword("pass")) @@ -51,10 +51,10 @@ public class RegisterUserCommandHandlerTests { // Arrange var phone = "123"; - _mockRepo.Setup(r => r.GetByPhoneAsync(phone, It.IsAny())) + _mockRepo.Setup(r => r.GetByPhoneIncludingDeletedAsync(phone, It.IsAny())) .ReturnsAsync(Account.Create(phone, "pass")); - // Act + // Assert var act = async () => await _handler.Handle(new RegisterUserCommand { Phone = phone, @@ -63,8 +63,8 @@ public class RegisterUserCommandHandlerTests LastName = "Ivanov" }, CancellationToken.None); - // Assert - await act.Should().ThrowAsync().WithMessage("Пользователь уже существует"); + var ex = await Assert.ThrowsAsync(act); + ex.Message.Should().Be("Пользователь с таким номером телефона уже существует"); _mockRepo.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Never); } }