Услуги

This commit is contained in:
Халимов Рустам
2026-03-05 15:24:33 +03:00
parent 36e7c07a0d
commit e41692a6e2
31 changed files with 963 additions and 102 deletions
+26
View File
@@ -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!'\
"]
+1 -3
View File
@@ -2,7 +2,7 @@ services:
migrations: migrations:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile.migrations
container_name: nashel-migrations container_name: nashel-migrations
environment: environment:
- ASPNETCORE_ENVIRONMENT=Development - ASPNETCORE_ENVIRONMENT=Development
@@ -10,8 +10,6 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
command: >
sh -c "cd /app/src/Host && dotnet ef database update --project ../Modules/Identity/Infrastructure --startup-project . --context IdentityDbContext"
networks: networks:
- nashel-network - nashel-network
+28
View File
@@ -0,0 +1,28 @@
namespace Nashel.BuildingBlocks.Domain;
public class Result<T>
{
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<T> Success(T value) => new Result<T>(true, value, null);
public static Result<T> Failure(string error) => new Result<T>(false, default, error);
}
public class Result : Result<object>
{
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);
}
@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using MediatR; using MediatR;
using Nashel.BuildingBlocks.Domain;
using Nashel.Modules.Catalog.Domain.Aggregates; using Nashel.Modules.Catalog.Domain.Aggregates;
using Nashel.Modules.Catalog.Domain.Repositories; using Nashel.Modules.Catalog.Domain.Repositories;
@@ -8,17 +9,17 @@ namespace Nashel.Modules.Catalog.Application.Commands;
/// <summary> /// <summary>
/// Команда создания категории. /// Команда создания категории.
/// </summary> /// </summary>
public record CreateCategoryCommand : IRequest<Guid> public record CreateCategoryCommand : IRequest<Result<Guid>>
{ {
/// <summary> /// <summary>
/// Название категории. /// Название категории.
/// </summary> /// </summary>
public string Title { get; init; } public string Name { get; init; } = default!;
/// <summary> /// <summary>
/// URL-friendly идентификатор (слаг). /// URL-friendly идентификатор (слаг).
/// </summary> /// </summary>
public string Slug { get; init; } public string Slug { get; init; } = default!;
/// <summary> /// <summary>
/// ID родительской категории (null для корневых). /// ID родительской категории (null для корневых).
@@ -30,9 +31,9 @@ public record CreateCategoryCommand : IRequest<Guid>
/// </summary> /// </summary>
public JsonDocument? AttributeSchema { get; init; } 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; Slug = slug;
ParentId = parentId; ParentId = parentId;
AttributeSchema = attributeSchema; AttributeSchema = attributeSchema;
@@ -41,7 +42,7 @@ public record CreateCategoryCommand : IRequest<Guid>
public CreateCategoryCommand() { } // For deserialization public CreateCategoryCommand() { } // For deserialization
} }
public class CreateCategoryCommandHandler : IRequestHandler<CreateCategoryCommand, Guid> public class CreateCategoryCommandHandler : IRequestHandler<CreateCategoryCommand, Result<Guid>>
{ {
private readonly ICategoryRepository _repository; private readonly ICategoryRepository _repository;
@@ -50,19 +51,19 @@ public class CreateCategoryCommandHandler : IRequestHandler<CreateCategoryComman
_repository = repository; _repository = repository;
} }
public async Task<Guid> Handle(CreateCategoryCommand request, CancellationToken cancellationToken) public async Task<Result<Guid>> Handle(CreateCategoryCommand request, CancellationToken cancellationToken)
{ {
if (request.ParentId.HasValue) if (request.ParentId.HasValue)
{ {
var parentCategory = await _repository.GetByIdAsync(request.ParentId.Value, cancellationToken); var parentCategory = await _repository.GetByIdAsync(request.ParentId.Value, cancellationToken);
if (parentCategory == null) if (parentCategory == null)
{ {
throw new ApplicationException($"Родительская категория с ID {request.ParentId} не найдена."); return Result<Guid>.Failure($"Родительская категория с ID {request.ParentId} не найдена.");
} }
} }
var category = new Category( var category = new Category(
request.Title, request.Name,
request.Slug, request.Slug,
request.ParentId, request.ParentId,
request.AttributeSchema request.AttributeSchema
@@ -70,6 +71,6 @@ public class CreateCategoryCommandHandler : IRequestHandler<CreateCategoryComman
await _repository.AddAsync(category, cancellationToken); await _repository.AddAsync(category, cancellationToken);
return category.Id; return Result<Guid>.Success(category.Id);
} }
} }
@@ -1,5 +1,8 @@
using System.Text.Json; using System.Text.Json;
using MediatR; using MediatR;
using MediatR;
using Nashel.BuildingBlocks.Application.Abstractions;
using Nashel.BuildingBlocks.Domain;
using Nashel.Modules.Catalog.Domain.Aggregates; using Nashel.Modules.Catalog.Domain.Aggregates;
using Nashel.Modules.Catalog.Domain.Repositories; using Nashel.Modules.Catalog.Domain.Repositories;
using Nashel.Modules.Catalog.Domain.ValueObjects; using Nashel.Modules.Catalog.Domain.ValueObjects;
@@ -9,16 +12,8 @@ namespace Nashel.Modules.Catalog.Application.Commands;
/// <summary> /// <summary>
/// Команда создания услуги (оффера). /// Команда создания услуги (оффера).
/// </summary> /// </summary>
public record CreateOfferCommand : IRequest<Guid> public record CreateOfferCommand : IRequest<Result<Guid>>
{ {
/// <summary>
/// ID владельца (пользователя/исполнителя).
/// </summary>
public Guid OwnerId { get; init; }
/// <summary>
/// ID категории услуги.
/// </summary>
public Guid CategoryId { get; init; } public Guid CategoryId { get; init; }
/// <summary> /// <summary>
@@ -41,9 +36,8 @@ public record CreateOfferCommand : IRequest<Guid>
/// </summary> /// </summary>
public JsonDocument? Attributes { get; init; } 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; CategoryId = categoryId;
Title = title; Title = title;
Description = description; Description = description;
@@ -54,19 +48,24 @@ public record CreateOfferCommand : IRequest<Guid>
public CreateOfferCommand() { } public CreateOfferCommand() { }
} }
public class CreateOfferCommandHandler : IRequestHandler<CreateOfferCommand, Guid> public class CreateOfferCommandHandler : IRequestHandler<CreateOfferCommand, Result<Guid>>
{ {
private readonly IOfferRepository _repository; private readonly IOfferRepository _repository;
private readonly ICurrentUserService _currentUserService;
public CreateOfferCommandHandler(IOfferRepository repository) public CreateOfferCommandHandler(IOfferRepository repository, ICurrentUserService currentUserService)
{ {
_repository = repository; _repository = repository;
_currentUserService = currentUserService;
} }
public async Task<Guid> Handle(CreateOfferCommand request, CancellationToken cancellationToken) public async Task<Result<Guid>> Handle(CreateOfferCommand request, CancellationToken cancellationToken)
{ {
var userId = _currentUserService.UserId;
if (userId == null) return Result<Guid>.Failure("Неавторизован");
var offer = new Offer( var offer = new Offer(
request.OwnerId, userId.Value,
request.CategoryId, request.CategoryId,
request.Title, request.Title,
request.Description, request.Description,
@@ -76,6 +75,6 @@ public class CreateOfferCommandHandler : IRequestHandler<CreateOfferCommand, Gui
await _repository.AddAsync(offer, cancellationToken); await _repository.AddAsync(offer, cancellationToken);
return offer.Id; return Result<Guid>.Success(offer.Id);
} }
} }
@@ -16,7 +16,7 @@ public record CategoryDto
/// <summary> /// <summary>
/// Название категории. /// Название категории.
/// </summary> /// </summary>
public string Title { get; init; } = default!; public string Name { get; private set; } = default!;
/// <summary> /// <summary>
/// URL-совместимый идентификатор (slug). /// URL-совместимый идентификатор (slug).
@@ -33,10 +33,10 @@ public record CategoryDto
/// </summary> /// </summary>
public JsonDocument? AttributeSchema { get; init; } 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; Id = id;
Title = title; Name = name;
Slug = slug; Slug = slug;
ParentId = parentId; ParentId = parentId;
AttributeSchema = attributeSchema; AttributeSchema = attributeSchema;
@@ -58,7 +58,7 @@ public record OfferDto
/// <summary> /// <summary>
/// Идентификатор владельца (исполнителя). /// Идентификатор владельца (исполнителя).
/// </summary> /// </summary>
public Guid OwnerId { get; init; } public Guid PerformerId { get; init; }
/// <summary> /// <summary>
/// Идентификатор категории. /// Идентификатор категории.
@@ -90,10 +90,10 @@ public record OfferDto
/// </summary> /// </summary>
public bool IsActive { get; init; } 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; Id = id;
OwnerId = ownerId; PerformerId = performerId;
CategoryId = categoryId; CategoryId = categoryId;
Title = title; Title = title;
Description = description; Description = description;
@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using MediatR; using MediatR;
using Nashel.BuildingBlocks.Domain;
using Nashel.Modules.Catalog.Application.Common; using Nashel.Modules.Catalog.Application.Common;
using Nashel.Modules.Catalog.Domain.Repositories; using Nashel.Modules.Catalog.Domain.Repositories;
@@ -8,9 +9,9 @@ namespace Nashel.Modules.Catalog.Application.Queries;
/// <summary> /// <summary>
/// Запрос дерева категорий. /// Запрос дерева категорий.
/// </summary> /// </summary>
public record GetCategoriesQuery() : IRequest<List<CategoryDto>>; public record GetCategoriesQuery() : IRequest<Result<List<CategoryDto>>>;
public class GetCategoriesQueryHandler : IRequestHandler<GetCategoriesQuery, List<CategoryDto>> public class GetCategoriesQueryHandler : IRequestHandler<GetCategoriesQuery, Result<List<CategoryDto>>>
{ {
private readonly ICategoryRepository _repository; private readonly ICategoryRepository _repository;
@@ -19,7 +20,7 @@ public class GetCategoriesQueryHandler : IRequestHandler<GetCategoriesQuery, Lis
_repository = repository; _repository = repository;
} }
public async Task<List<CategoryDto>> Handle(GetCategoriesQuery request, CancellationToken cancellationToken) public async Task<Result<List<CategoryDto>>> Handle(GetCategoriesQuery request, CancellationToken cancellationToken)
{ {
var rawCategories = await _repository.GetAllAsync(cancellationToken); var rawCategories = await _repository.GetAllAsync(cancellationToken);
@@ -27,12 +28,14 @@ public class GetCategoriesQueryHandler : IRequestHandler<GetCategoriesQuery, Lis
// Если нужно дерево: нужно иметь DTO с List<CategoryDto> Children // Если нужно дерево: нужно иметь DTO с List<CategoryDto> Children
// Для MVP возвращаем плоский список, фронтенд сам строит дерево по ParentId // Для MVP возвращаем плоский список, фронтенд сам строит дерево по ParentId
return rawCategories.Select(c => new CategoryDto( var list = rawCategories.Select(c => new CategoryDto(
c.Id, c.Id,
c.Title, c.Name,
c.Slug, c.Slug,
c.ParentId, c.ParentId,
c.AttributeSchema c.AttributeSchema
)).ToList(); )).ToList();
return Result<List<CategoryDto>>.Success(list);
} }
} }
@@ -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;
/// <summary>
/// Запрос получения услуг текущего пользователя.
/// </summary>
public record GetMyOffersQuery() : IRequest<Result<List<OfferDto>>>;
public class GetMyOffersQueryHandler : IRequestHandler<GetMyOffersQuery, Result<List<OfferDto>>>
{
private readonly IOfferRepository _repository;
private readonly ICurrentUserService _currentUserService;
public GetMyOffersQueryHandler(IOfferRepository repository, ICurrentUserService currentUserService)
{
_repository = repository;
_currentUserService = currentUserService;
}
public async Task<Result<List<OfferDto>>> Handle(GetMyOffersQuery request, CancellationToken cancellationToken)
{
var userId = _currentUserService.UserId;
if (userId == null) return Result<List<OfferDto>>.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<List<OfferDto>>.Success(list);
}
}
@@ -1,4 +1,5 @@
using MediatR; using MediatR;
using Nashel.BuildingBlocks.Domain;
using Nashel.Modules.Catalog.Application.Common; using Nashel.Modules.Catalog.Application.Common;
using Nashel.Modules.Catalog.Domain.Repositories; using Nashel.Modules.Catalog.Domain.Repositories;
@@ -7,9 +8,9 @@ namespace Nashel.Modules.Catalog.Application.Queries;
/// <summary> /// <summary>
/// Запрос деталей услуги по ID. /// Запрос деталей услуги по ID.
/// </summary> /// </summary>
public record GetOfferByIdQuery(Guid Id) : IRequest<OfferDto?>; public record GetOfferByIdQuery(Guid Id) : IRequest<Result<OfferDto>>;
public class GetOfferByIdQueryHandler : IRequestHandler<GetOfferByIdQuery, OfferDto?> public class GetOfferByIdQueryHandler : IRequestHandler<GetOfferByIdQuery, Result<OfferDto>>
{ {
private readonly IOfferRepository _repository; private readonly IOfferRepository _repository;
@@ -18,20 +19,22 @@ public class GetOfferByIdQueryHandler : IRequestHandler<GetOfferByIdQuery, Offer
_repository = repository; _repository = repository;
} }
public async Task<OfferDto?> Handle(GetOfferByIdQuery request, CancellationToken cancellationToken) public async Task<Result<OfferDto>> Handle(GetOfferByIdQuery request, CancellationToken cancellationToken)
{ {
var offer = await _repository.GetByIdAsync(request.Id, cancellationToken); var offer = await _repository.GetByIdAsync(request.Id, cancellationToken);
if (offer == null) return null; if (offer == null) return Result<OfferDto>.Failure("Offer not found");
return new OfferDto( var dto = new OfferDto(
offer.Id, offer.Id,
offer.OwnerId, offer.PerformerId,
offer.CategoryId, offer.CategoryId,
offer.Title, offer.Title,
offer.Description, offer.Description ?? string.Empty,
offer.Price, offer.Price,
offer.Attributes, offer.Attributes,
offer.IsActive offer.IsActive
); );
return Result<OfferDto>.Success(dto);
} }
} }
@@ -16,7 +16,7 @@ public class Category
/// <summary> /// <summary>
/// Название категории. /// Название категории.
/// </summary> /// </summary>
public string Title { get; private set; } public string Name { get; private set; }
/// <summary> /// <summary>
/// URL-friendly идентификатор (слаг). /// URL-friendly идентификатор (слаг).
@@ -39,10 +39,10 @@ public class Category
/// <summary> /// <summary>
/// Создает новую категорию. /// Создает новую категорию.
/// </summary> /// </summary>
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(); Id = Guid.NewGuid();
Title = title; Name = name;
Slug = slug; Slug = slug;
ParentId = parentId; ParentId = parentId;
AttributeSchema = attributeSchema; AttributeSchema = attributeSchema;
@@ -16,7 +16,7 @@ public class Offer
/// <summary> /// <summary>
/// ID владельца (пользователя/исполнителя). /// ID владельца (пользователя/исполнителя).
/// </summary> /// </summary>
public Guid OwnerId { get; private set; } public Guid PerformerId { get; private set; }
/// <summary> /// <summary>
/// ID услуги/категории. /// ID услуги/категории.
@@ -48,21 +48,27 @@ public class Offer
/// </summary> /// </summary>
public bool IsActive { get; private set; } public bool IsActive { get; private set; }
/// <summary>
/// Дата создания.
/// </summary>
public DateTimeOffset CreatedAt { get; private set; }
// Конструктор по умолчанию для EF Core // Конструктор по умолчанию для EF Core
private Offer() { } private Offer() { }
/// <summary> /// <summary>
/// Создает новый оффер. /// Создает новый оффер.
/// </summary> /// </summary>
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(); Id = Guid.NewGuid();
OwnerId = ownerId; PerformerId = performerId;
CategoryId = categoryId; CategoryId = categoryId;
Title = title; Title = title;
Description = description; Description = description;
Price = price; Price = price;
Attributes = attributes; Attributes = attributes;
IsActive = true; IsActive = true;
CreatedAt = DateTimeOffset.UtcNow;
} }
} }
@@ -5,5 +5,6 @@ namespace Nashel.Modules.Catalog.Domain.Repositories;
public interface IOfferRepository public interface IOfferRepository
{ {
Task<Offer?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default); Task<Offer?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<List<Offer>> GetByPerformerIdAsync(Guid performerId, CancellationToken cancellationToken = default);
Task AddAsync(Offer offer, CancellationToken cancellationToken = default); Task AddAsync(Offer offer, CancellationToken cancellationToken = default);
} }
@@ -0,0 +1,140 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<JsonDocument>("AttributeSchema")
.HasColumnType("jsonb");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<JsonDocument>("Attributes")
.HasColumnType("jsonb");
b.Property<Guid>("CategoryId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<Guid>("PerformerId")
.HasColumnType("uuid");
b.Property<string>("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<Guid>("OfferId")
.HasColumnType("uuid");
b1.Property<decimal>("Amount")
.HasColumnType("numeric")
.HasColumnName("PriceAmount");
b1.Property<string>("Currency")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("character varying(3)")
.HasColumnName("PriceCurrency");
b1.Property<int>("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
}
}
}
@@ -0,0 +1,56 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nashel.Modules.Catalog.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class CatalogRefactor : Migration
{
/// <inheritdoc />
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<DateTimeOffset>(
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)));
}
/// <inheritdoc />
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");
}
}
}
@@ -33,6 +33,10 @@ namespace Nashel.Modules.Catalog.Infrastructure.Persistence.Migrations
b.Property<JsonDocument>("AttributeSchema") b.Property<JsonDocument>("AttributeSchema")
.HasColumnType("jsonb"); .HasColumnType("jsonb");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ParentId") b.Property<Guid?>("ParentId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@@ -40,10 +44,6 @@ namespace Nashel.Modules.Catalog.Infrastructure.Persistence.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ParentId"); b.HasIndex("ParentId");
@@ -66,13 +66,16 @@ namespace Nashel.Modules.Catalog.Infrastructure.Persistence.Migrations
b.Property<Guid>("CategoryId") b.Property<Guid>("CategoryId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description") b.Property<string>("Description")
.HasColumnType("text"); .HasColumnType("text");
b.Property<bool>("IsActive") b.Property<bool>("IsActive")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<Guid>("OwnerId") b.Property<Guid>("PerformerId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("Title") b.Property<string>("Title")
@@ -20,6 +20,13 @@ public class OfferRepository : IOfferRepository
.FirstOrDefaultAsync(o => o.Id == id, cancellationToken); .FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
} }
public async Task<List<Offer>> 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) public async Task AddAsync(Offer offer, CancellationToken cancellationToken = default)
{ {
await _context.Offers.AddAsync(offer, cancellationToken); await _context.Offers.AddAsync(offer, cancellationToken);
@@ -21,8 +21,8 @@ public static class CatalogEndpoints
catalogGroup.MapPost("/categories", async ([FromBody] CreateCategoryCommand command, ISender sender) => catalogGroup.MapPost("/categories", async ([FromBody] CreateCategoryCommand command, ISender sender) =>
{ {
var id = await sender.Send(command); var result = await sender.Send(command);
return Results.Ok(id); return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
}) })
.WithName("CreateCategory") .WithName("CreateCategory")
.WithOpenApi(operation => new(operation) { Summary = "Создать категорию (Admin)", Description = "Создает новую категорию. Требуются права администратора." }); .WithOpenApi(operation => new(operation) { Summary = "Создать категорию (Admin)", Description = "Создает новую категорию. Требуются права администратора." });
@@ -30,17 +30,27 @@ public static class CatalogEndpoints
catalogGroup.MapGet("/categories", async (ISender sender) => catalogGroup.MapGet("/categories", async (ISender sender) =>
{ {
var result = await sender.Send(new GetCategoriesQuery()); var result = await sender.Send(new GetCategoriesQuery());
return Results.Ok(result); return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
}) })
.WithName("GetCategories") .WithName("GetCategories")
.WithOpenApi(operation => new(operation) { Summary = "Получить все категории", Description = "Возвращает плоский список категорий с указанием ParentId для иерархии." }); .WithOpenApi(operation => new(operation) { Summary = "Получить все категории", Description = "Возвращает плоский список категорий с указанием ParentId для иерархии." });
// --- Услуги (Offers) --- // --- Услуги (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); var result = await sender.Send(new GetMyOffersQuery());
return Results.Ok(id); 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") .WithName("CreateOffer")
.WithOpenApi(operation => new(operation) { Summary = "Создать оффер/услугу", Description = "Создает новое предложение услуги в указанной категории." }); .WithOpenApi(operation => new(operation) { Summary = "Создать оффер/услугу", Description = "Создает новое предложение услуги в указанной категории." });
@@ -48,7 +58,7 @@ public static class CatalogEndpoints
catalogGroup.MapGet("/offers/{id:guid}", async (Guid id, ISender sender) => catalogGroup.MapGet("/offers/{id:guid}", async (Guid id, ISender sender) =>
{ {
var result = await sender.Send(new GetOfferByIdQuery(id)); 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") .WithName("GetOfferById")
.WithOpenApi(operation => new(operation) { Summary = "Получить детали услуги по ID", Description = "Возвращает полную информацию об услуге." }); .WithOpenApi(operation => new(operation) { Summary = "Получить детали услуги по ID", Description = "Возвращает полную информацию об услуге." });
@@ -24,12 +24,12 @@ public record RegisterUserCommand : IRequest<Guid>
/// <summary> /// <summary>
/// Имя. /// Имя.
/// </summary> /// </summary>
public string FirstName { get; init; } = default!; public string? FirstName { get; init; }
/// <summary> /// <summary>
/// Фамилия. /// Фамилия.
/// </summary> /// </summary>
public string LastName { get; init; } = default!; public string? LastName { get; init; }
/// <summary> /// <summary>
/// Отчество (необязательно). /// Отчество (необязательно).
@@ -97,8 +97,8 @@ public class RegisterUserCommandHandler : IRequestHandler<RegisterUserCommand, G
existingAccount.Roles.Clear(); existingAccount.Roles.Clear();
existingAccount.Roles.Add(Role.User); existingAccount.Roles.Add(Role.User);
// Добавляем роль только для Company // Добавляем запрошенную роль (кроме User)
if (request.Role == Role.Company) if (request.Role != Role.User)
{ {
existingAccount.Roles.Add(request.Role); existingAccount.Roles.Add(request.Role);
} }
@@ -116,8 +116,8 @@ public class RegisterUserCommandHandler : IRequestHandler<RegisterUserCommand, G
var passwordHashNew = _passwordHasher.HashPassword(request.Password); var passwordHashNew = _passwordHasher.HashPassword(request.Password);
var account = Account.Create(request.Phone, passwordHashNew); var account = Account.Create(request.Phone, passwordHashNew);
// Добавляем роль только для Company. Newbie не добавляем - она добавляется через BecomePerformerCommand // Добавляем запрошенную роль (кроме User, так как он добавляется по умолчанию)
if (request.Role == Role.Company) if (request.Role != Role.User)
{ {
account.Roles.Add(request.Role); account.Roles.Add(request.Role);
} }
@@ -22,15 +22,15 @@ public record ProfileResponse(
Guid Id, Guid Id,
string Phone, string Phone,
List<string> Roles, List<string> Roles,
string FirstName, string? FirstName,
string LastName, string? LastName,
string? Patronymic, string? Patronymic,
string? CompanyName, string? CompanyName,
string? Inn, string? Inn,
string? Description, string? Description,
string? Location, string? Location,
string? CurrentLocation, string? CurrentLocation,
string FullName, string? FullName,
string? AvatarUrl, string? AvatarUrl,
List<string> Competencies, List<string> Competencies,
WorkScheduleResponse? WorkSchedule); WorkScheduleResponse? WorkSchedule);
@@ -60,10 +60,13 @@ public class GetProfileQueryHandler : IRequestHandler<GetProfileQuery, ProfileRe
throw new Exception("Account not found"); throw new Exception("Account not found");
} }
var fullName = $"{account.Profile.FirstName} {account.Profile.LastName}"; var names = new[] { account.Profile.FirstName, account.Profile.LastName, account.Profile.Patronymic }
if (!string.IsNullOrEmpty(account.Profile.Patronymic)) .Where(x => !string.IsNullOrWhiteSpace(x));
var fullName = string.Join(" ", names);
if (string.IsNullOrWhiteSpace(fullName))
{ {
fullName += $" {account.Profile.Patronymic}"; fullName = account.Profile.CompanyName ?? "";
} }
// Парсинг WorkSchedule // Парсинг WorkSchedule
@@ -17,12 +17,12 @@ public class RegisterUserCommandValidator : AbstractValidator<RegisterUserComman
.MinimumLength(6).WithMessage("Пароль должен быть не менее 6 символов"); .MinimumLength(6).WithMessage("Пароль должен быть не менее 6 символов");
RuleFor(x => x.FirstName) RuleFor(x => x.FirstName)
.NotEmpty().WithMessage("Имя обязательно") .NotEmpty().WithMessage("Имя обязательно").When(x => x.Role != Role.Company)
.MinimumLength(2).WithMessage("Имя должно содержать минимум 2 символа"); .MinimumLength(2).WithMessage("Имя должно содержать минимум 2 символа").When(x => x.Role != Role.Company && !string.IsNullOrEmpty(x.FirstName));
RuleFor(x => x.LastName) RuleFor(x => x.LastName)
.NotEmpty().WithMessage("Фамилия обязательна") .NotEmpty().WithMessage("Фамилия обязательна").When(x => x.Role != Role.Company)
.MinimumLength(2).WithMessage("Фамилия должна содержать минимум 2 символа"); .MinimumLength(2).WithMessage("Фамилия должна содержать минимум 2 символа").When(x => x.Role != Role.Company && !string.IsNullOrEmpty(x.LastName));
RuleFor(x => x.CompanyName) RuleFor(x => x.CompanyName)
.NotEmpty().WithMessage("Название компании обязательно для юридических лиц") .NotEmpty().WithMessage("Название компании обязательно для юридических лиц")
@@ -81,17 +81,17 @@ public class Account : AggregateRoot<Guid>
public void BecomePerformer() 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() 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)) if (!Roles.Contains(Role.Master))
@@ -102,6 +102,10 @@ public class Account : AggregateRoot<Guid>
public void UpdatePerformerData(string description, IEnumerable<Competency> competencies, string? location, string? currentLocation, WorkSchedule? workSchedule) public void UpdatePerformerData(string description, IEnumerable<Competency> competencies, string? location, string? currentLocation, WorkSchedule? workSchedule)
{ {
if (Profile == null)
{
throw new InvalidOperationException("Profile not found");
}
Profile.UpdatePerformerData(description, competencies, location, currentLocation, workSchedule); Profile.UpdatePerformerData(description, competencies, location, currentLocation, workSchedule);
} }
@@ -112,6 +116,6 @@ public class Account : AggregateRoot<Guid>
public bool IsPerformer() public bool IsPerformer()
{ {
return Roles.Contains(Role.Newbie) || Roles.Contains(Role.Master); return Roles.Contains(Role.Candidate) || Roles.Contains(Role.Master);
} }
} }
@@ -9,8 +9,8 @@ public class UserProfile : Entity<Guid>
private const int MaxDescriptionLength = 2048; private const int MaxDescriptionLength = 2048;
private readonly List<Competency> _competencies = new(); private readonly List<Competency> _competencies = new();
public string FirstName { get; private set; } public string? FirstName { get; private set; }
public string LastName { get; private set; } public string? LastName { get; private set; }
public string? Patronymic { get; private set; } public string? Patronymic { get; private set; }
public string? CompanyName { get; private set; } public string? CompanyName { get; private set; }
public string? Inn { get; private set; } public string? Inn { get; private set; }
@@ -24,7 +24,7 @@ public class UserProfile : Entity<Guid>
// EF Core constructor // EF Core constructor
private UserProfile() { } 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; Id = id;
FirstName = firstName; FirstName = firstName;
@@ -35,7 +35,7 @@ public class UserProfile : Entity<Guid>
Description = description; 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) if (!string.IsNullOrEmpty(description) && description.Length > MaxDescriptionLength)
throw new ArgumentException($"Описание не может превышать {MaxDescriptionLength} символов", nameof(description)); throw new ArgumentException($"Описание не может превышать {MaxDescriptionLength} символов", nameof(description));
@@ -43,7 +43,7 @@ public class UserProfile : Entity<Guid>
return new UserProfile(id, firstName, lastName, patronymic, companyName, inn, description); 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) if (!string.IsNullOrEmpty(description) && description.Length > MaxDescriptionLength)
throw new ArgumentException($"Описание не может превышать {MaxDescriptionLength} символов", nameof(description)); throw new ArgumentException($"Описание не может превышать {MaxDescriptionLength} символов", nameof(description));
@@ -6,7 +6,6 @@ namespace Nashel.Modules.Identity.Domain.Enums;
public enum Role public enum Role
{ {
User = 0, User = 0,
Newbie,
Candidate, Candidate,
Master, Master,
Company, Company,
@@ -15,11 +15,9 @@ public class UserProfileConfiguration : IEntityTypeConfiguration<UserProfile>
builder.HasKey(x => x.Id); builder.HasKey(x => x.Id);
builder.Property(x => x.FirstName) builder.Property(x => x.FirstName)
.IsRequired()
.HasMaxLength(100); .HasMaxLength(100);
builder.Property(x => x.LastName) builder.Property(x => x.LastName)
.IsRequired()
.HasMaxLength(100); .HasMaxLength(100);
builder.Property(x => x.Patronymic) builder.Property(x => x.Patronymic)
@@ -0,0 +1,207 @@
// <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("20260302184149_RemoveNewbieRole")]
partial class RemoveNewbieRole
{
/// <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<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
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>("CurrentLocation")
.HasColumnType("text");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("FirstName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Inn")
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("LastName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Location")
.HasColumnType("text");
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("Nashel.Modules.Identity.Domain.Entities.WorkSchedule", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<bool>("IsAlwaysReady")
.HasColumnType("boolean");
b.Property<string>("WorkingDays")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.HasKey("Id");
b.ToTable("WorkSchedules", "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("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
}
}
}
@@ -0,0 +1,44 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class RemoveNewbieRole : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "DeletedAt",
schema: "identity",
table: "Accounts",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsDeleted",
schema: "identity",
table: "Accounts",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DeletedAt",
schema: "identity",
table: "Accounts");
migrationBuilder.DropColumn(
name: "IsDeleted",
schema: "identity",
table: "Accounts");
}
}
}
@@ -0,0 +1,205 @@
// <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("20260302192325_MakeNamesOptional")]
partial class MakeNamesOptional
{
/// <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<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
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>("CurrentLocation")
.HasColumnType("text");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("FirstName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Inn")
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("LastName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Location")
.HasColumnType("text");
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("Nashel.Modules.Identity.Domain.Entities.WorkSchedule", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<bool>("IsAlwaysReady")
.HasColumnType("boolean");
b.Property<string>("WorkingDays")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.HasKey("Id");
b.ToTable("WorkSchedules", "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("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
}
}
}
@@ -0,0 +1,66 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class MakeNamesOptional : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
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<string>(
name: "FirstName",
schema: "identity",
table: "UserProfiles",
type: "character varying(100)",
maxLength: 100,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(100)",
oldMaxLength: 100);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
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<string>(
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);
}
}
}
@@ -28,6 +28,14 @@ namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("PasswordHash") b.Property<string>("PasswordHash")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -70,7 +78,6 @@ namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(2048)"); .HasColumnType("character varying(2048)");
b.Property<string>("FirstName") b.Property<string>("FirstName")
.IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("character varying(100)"); .HasColumnType("character varying(100)");
@@ -79,7 +86,6 @@ namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(10)"); .HasColumnType("character varying(10)");
b.Property<string>("LastName") b.Property<string>("LastName")
.IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("character varying(100)"); .HasColumnType("character varying(100)");
@@ -33,6 +33,8 @@ public class BecomePerformerCommandHandlerTests
{ {
// Arrange // Arrange
var account = Account.Create("123", "pass"); // Has User role only 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; var userId = account.Id;
_mockUserService.Setup(s => s.UserId).Returns(userId); _mockUserService.Setup(s => s.UserId).Returns(userId);
@@ -40,7 +42,8 @@ public class BecomePerformerCommandHandlerTests
.ReturnsAsync(account); .ReturnsAsync(account);
// Act // Act
await _handler.Handle(new BecomePerformerCommand(), CancellationToken.None); var command = new BecomePerformerCommand { Description = new string('x', 51) };
await _handler.Handle(command, CancellationToken.None);
// Assert // Assert
account.Roles.Should().Contain(Role.Candidate); account.Roles.Should().Contain(Role.Candidate);
@@ -26,7 +26,7 @@ public class RegisterUserCommandHandlerTests
{ {
// Arrange // Arrange
var phone = "123"; var phone = "123";
_mockRepo.Setup(r => r.GetByPhoneAsync(phone, It.IsAny<CancellationToken>())) _mockRepo.Setup(r => r.GetByPhoneIncludingDeletedAsync(phone, It.IsAny<CancellationToken>()))
.ReturnsAsync((Account?)null); .ReturnsAsync((Account?)null);
_mockPasswordHasher.Setup(p => p.HashPassword("pass")) _mockPasswordHasher.Setup(p => p.HashPassword("pass"))
@@ -51,10 +51,10 @@ public class RegisterUserCommandHandlerTests
{ {
// Arrange // Arrange
var phone = "123"; var phone = "123";
_mockRepo.Setup(r => r.GetByPhoneAsync(phone, It.IsAny<CancellationToken>())) _mockRepo.Setup(r => r.GetByPhoneIncludingDeletedAsync(phone, It.IsAny<CancellationToken>()))
.ReturnsAsync(Account.Create(phone, "pass")); .ReturnsAsync(Account.Create(phone, "pass"));
// Act // Assert
var act = async () => await _handler.Handle(new RegisterUserCommand var act = async () => await _handler.Handle(new RegisterUserCommand
{ {
Phone = phone, Phone = phone,
@@ -63,8 +63,8 @@ public class RegisterUserCommandHandlerTests
LastName = "Ivanov" LastName = "Ivanov"
}, CancellationToken.None); }, CancellationToken.None);
// Assert var ex = await Assert.ThrowsAsync<Exception>(act);
await act.Should().ThrowAsync<Exception>().WithMessage("Пользователь уже существует"); ex.Message.Should().Be("Пользователь с таким номером телефона уже существует");
_mockRepo.Verify(r => r.AddAsync(It.IsAny<Account>(), It.IsAny<CancellationToken>()), Times.Never); _mockRepo.Verify(r => r.AddAsync(It.IsAny<Account>(), It.IsAny<CancellationToken>()), Times.Never);
} }
} }