diff --git a/.gitignore b/.gitignore
index 2ea93b2..f1de997 100644
--- a/.gitignore
+++ b/.gitignore
@@ -136,3 +136,7 @@ src/Modules/Collaboration/Domain/bin/
src/Modules/Collaboration/Presentation/bin/
src/Modules/Identity/Tests/obj/
+
+src/Modules/Catalog/Tests/bin/
+
+src/Modules/Catalog/Tests/obj/
diff --git a/src/Host/Nashel.Host.csproj b/src/Host/Nashel.Host.csproj
index 85f5232..a4bac45 100644
--- a/src/Host/Nashel.Host.csproj
+++ b/src/Host/Nashel.Host.csproj
@@ -11,6 +11,10 @@
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
diff --git a/src/Host/Program.cs b/src/Host/Program.cs
index ec6cccb..0eb2cfc 100644
--- a/src/Host/Program.cs
+++ b/src/Host/Program.cs
@@ -2,6 +2,8 @@ using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
+using Nashel.Modules.Catalog.Infrastructure;
+using Nashel.Modules.Catalog.Presentation.Endpoints;
using Nashel.Modules.Identity.Infrastructure;
using Nashel.Modules.Identity.Presentation.Endpoints;
@@ -41,7 +43,6 @@ builder.Services.AddSwaggerGen(options =>
var xmlFiles = Directory.GetFiles(AppContext.BaseDirectory, "*.xml");
foreach (var xmlFile in xmlFiles)
{
- // Исключаем системные сборки, берем только наши, если нужно, или просто все
options.IncludeXmlComments(xmlFile);
}
});
@@ -49,6 +50,9 @@ builder.Services.AddSwaggerGen(options =>
// Регистрация модуля Identity
builder.Services.AddIdentityModule(builder.Configuration);
+// Регистрация модуля Catalog
+builder.Services.AddCatalogModule(builder.Configuration);
+
// Настройка аутентификации
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
@@ -83,7 +87,8 @@ if (app.Environment.IsDevelopment())
app.UseAuthentication();
app.UseAuthorization();
-// Маппинг эндпоинтов модуля Identity
+// Маппинг эндпоинтов
app.MapIdentityEndpoints();
+app.MapCatalogEndpoints();
app.Run();
diff --git a/src/Modules/Catalog/Application/Commands/CreateCategoryCommand.cs b/src/Modules/Catalog/Application/Commands/CreateCategoryCommand.cs
new file mode 100644
index 0000000..2e6c756
--- /dev/null
+++ b/src/Modules/Catalog/Application/Commands/CreateCategoryCommand.cs
@@ -0,0 +1,75 @@
+using System.Text.Json;
+using MediatR;
+using Nashel.Modules.Catalog.Domain.Aggregates;
+using Nashel.Modules.Catalog.Domain.Repositories;
+
+namespace Nashel.Modules.Catalog.Application.Commands;
+
+///
+/// Команда создания категории.
+///
+public record CreateCategoryCommand : IRequest
+{
+ ///
+ /// Название категории.
+ ///
+ public string Title { get; init; }
+
+ ///
+ /// URL-friendly идентификатор (слаг).
+ ///
+ public string Slug { get; init; }
+
+ ///
+ /// ID родительской категории (null для корневых).
+ ///
+ public Guid? ParentId { get; init; }
+
+ ///
+ /// Шаблон характеристик (JSON).
+ ///
+ public JsonDocument? AttributeSchema { get; init; }
+
+ public CreateCategoryCommand(string title, string slug, Guid? parentId, JsonDocument? attributeSchema)
+ {
+ Title = title;
+ Slug = slug;
+ ParentId = parentId;
+ AttributeSchema = attributeSchema;
+ }
+
+ public CreateCategoryCommand() { } // For deserialization
+}
+
+public class CreateCategoryCommandHandler : IRequestHandler
+{
+ private readonly ICategoryRepository _repository;
+
+ public CreateCategoryCommandHandler(ICategoryRepository repository)
+ {
+ _repository = repository;
+ }
+
+ 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} не найдена.");
+ }
+ }
+
+ var category = new Category(
+ request.Title,
+ request.Slug,
+ request.ParentId,
+ request.AttributeSchema
+ );
+
+ await _repository.AddAsync(category, cancellationToken);
+
+ return category.Id;
+ }
+}
diff --git a/src/Modules/Catalog/Application/Commands/CreateOfferCommand.cs b/src/Modules/Catalog/Application/Commands/CreateOfferCommand.cs
new file mode 100644
index 0000000..099b18d
--- /dev/null
+++ b/src/Modules/Catalog/Application/Commands/CreateOfferCommand.cs
@@ -0,0 +1,81 @@
+using System.Text.Json;
+using MediatR;
+using Nashel.Modules.Catalog.Domain.Aggregates;
+using Nashel.Modules.Catalog.Domain.Repositories;
+using Nashel.Modules.Catalog.Domain.ValueObjects;
+
+namespace Nashel.Modules.Catalog.Application.Commands;
+
+///
+/// Команда создания услуги (оффера).
+///
+public record CreateOfferCommand : IRequest
+{
+ ///
+ /// ID владельца (пользователя/исполнителя).
+ ///
+ public Guid OwnerId { get; init; }
+
+ ///
+ /// ID категории услуги.
+ ///
+ public Guid CategoryId { get; init; }
+
+ ///
+ /// Заголовок объявления.
+ ///
+ public string Title { get; init; } = default!;
+
+ ///
+ /// Полное описание услуги.
+ ///
+ public string Description { get; init; } = default!;
+
+ ///
+ /// Цена услуги.
+ ///
+ public Price Price { get; init; } = default!;
+
+ ///
+ /// Характеристики услуги (JSON).
+ ///
+ public JsonDocument? Attributes { get; init; }
+
+ public CreateOfferCommand(Guid ownerId, Guid categoryId, string title, string description, Price price, JsonDocument? attributes)
+ {
+ OwnerId = ownerId;
+ CategoryId = categoryId;
+ Title = title;
+ Description = description;
+ Price = price;
+ Attributes = attributes;
+ }
+
+ public CreateOfferCommand() { }
+}
+
+public class CreateOfferCommandHandler : IRequestHandler
+{
+ private readonly IOfferRepository _repository;
+
+ public CreateOfferCommandHandler(IOfferRepository repository)
+ {
+ _repository = repository;
+ }
+
+ public async Task Handle(CreateOfferCommand request, CancellationToken cancellationToken)
+ {
+ var offer = new Offer(
+ request.OwnerId,
+ request.CategoryId,
+ request.Title,
+ request.Description,
+ request.Price,
+ request.Attributes
+ );
+
+ await _repository.AddAsync(offer, cancellationToken);
+
+ return offer.Id;
+ }
+}
diff --git a/src/Modules/Catalog/Application/Common/Dtos.cs b/src/Modules/Catalog/Application/Common/Dtos.cs
new file mode 100644
index 0000000..3e51daf
--- /dev/null
+++ b/src/Modules/Catalog/Application/Common/Dtos.cs
@@ -0,0 +1,29 @@
+using System.Text.Json;
+using Nashel.Modules.Catalog.Domain.ValueObjects;
+
+namespace Nashel.Modules.Catalog.Application.Common;
+
+///
+/// DTO категории.
+///
+public record CategoryDto(
+ Guid Id,
+ string Title,
+ string Slug,
+ Guid? ParentId,
+ JsonDocument? AttributeSchema
+);
+
+///
+/// DTO услуги/оффера.
+///
+public record OfferDto(
+ Guid Id,
+ Guid OwnerId,
+ Guid CategoryId,
+ string Title,
+ string Description,
+ Price Price,
+ JsonDocument? Attributes,
+ bool IsActive
+);
diff --git a/src/Modules/Catalog/Application/Nashel.Modules.Catalog.Application.csproj b/src/Modules/Catalog/Application/Nashel.Modules.Catalog.Application.csproj
index a23d790..e9112a5 100644
--- a/src/Modules/Catalog/Application/Nashel.Modules.Catalog.Application.csproj
+++ b/src/Modules/Catalog/Application/Nashel.Modules.Catalog.Application.csproj
@@ -11,5 +11,7 @@
net10.0
enable
enable
+ true
+ $(NoWarn);1591
\ No newline at end of file
diff --git a/src/Modules/Catalog/Application/Queries/GetCategoriesQuery.cs b/src/Modules/Catalog/Application/Queries/GetCategoriesQuery.cs
new file mode 100644
index 0000000..a71aaab
--- /dev/null
+++ b/src/Modules/Catalog/Application/Queries/GetCategoriesQuery.cs
@@ -0,0 +1,38 @@
+using System.Text.Json;
+using MediatR;
+using Nashel.Modules.Catalog.Application.Common;
+using Nashel.Modules.Catalog.Domain.Repositories;
+
+namespace Nashel.Modules.Catalog.Application.Queries;
+
+///
+/// Запрос дерева категорий.
+///
+public record GetCategoriesQuery() : IRequest>;
+
+public class GetCategoriesQueryHandler : IRequestHandler>
+{
+ private readonly ICategoryRepository _repository;
+
+ public GetCategoriesQueryHandler(ICategoryRepository repository)
+ {
+ _repository = repository;
+ }
+
+ public async Task> Handle(GetCategoriesQuery request, CancellationToken cancellationToken)
+ {
+ var rawCategories = await _repository.GetAllAsync(cancellationToken);
+
+ // Преобразование в DTO (для дерева логика нужна сложнее, но пока плоский список для старта)
+ // Если нужно дерево: нужно иметь DTO с List Children
+ // Для MVP возвращаем плоский список, фронтенд сам строит дерево по ParentId
+
+ return rawCategories.Select(c => new CategoryDto(
+ c.Id,
+ c.Title,
+ c.Slug,
+ c.ParentId,
+ c.AttributeSchema
+ )).ToList();
+ }
+}
diff --git a/src/Modules/Catalog/Application/Queries/GetOfferByIdQuery.cs b/src/Modules/Catalog/Application/Queries/GetOfferByIdQuery.cs
new file mode 100644
index 0000000..14dabcb
--- /dev/null
+++ b/src/Modules/Catalog/Application/Queries/GetOfferByIdQuery.cs
@@ -0,0 +1,37 @@
+using MediatR;
+using Nashel.Modules.Catalog.Application.Common;
+using Nashel.Modules.Catalog.Domain.Repositories;
+
+namespace Nashel.Modules.Catalog.Application.Queries;
+
+///
+/// Запрос деталей услуги по ID.
+///
+public record GetOfferByIdQuery(Guid Id) : IRequest;
+
+public class GetOfferByIdQueryHandler : IRequestHandler
+{
+ private readonly IOfferRepository _repository;
+
+ public GetOfferByIdQueryHandler(IOfferRepository repository)
+ {
+ _repository = repository;
+ }
+
+ public async Task Handle(GetOfferByIdQuery request, CancellationToken cancellationToken)
+ {
+ var offer = await _repository.GetByIdAsync(request.Id, cancellationToken);
+ if (offer == null) return null;
+
+ return new OfferDto(
+ offer.Id,
+ offer.OwnerId,
+ offer.CategoryId,
+ offer.Title,
+ offer.Description,
+ offer.Price,
+ offer.Attributes,
+ offer.IsActive
+ );
+ }
+}
diff --git a/src/Modules/Catalog/Domain/Aggregates/Category.cs b/src/Modules/Catalog/Domain/Aggregates/Category.cs
new file mode 100644
index 0000000..9dc1e76
--- /dev/null
+++ b/src/Modules/Catalog/Domain/Aggregates/Category.cs
@@ -0,0 +1,50 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace Nashel.Modules.Catalog.Domain.Aggregates;
+
+///
+/// Категория услуг.
+///
+public class Category
+{
+ ///
+ /// Уникальный идентификатор.
+ ///
+ public Guid Id { get; private set; }
+
+ ///
+ /// Название категории.
+ ///
+ public string Title { get; private set; }
+
+ ///
+ /// URL-friendly идентификатор (слаг).
+ ///
+ public string Slug { get; private set; }
+
+ ///
+ /// ID родительской категории (null для корневых).
+ ///
+ public Guid? ParentId { get; private set; }
+
+ ///
+ /// Шаблон характеристик (JSON).
+ ///
+ public JsonDocument? AttributeSchema { get; private set; }
+
+ // Конструктор по умолчанию для EF Core
+ private Category() { }
+
+ ///
+ /// Создает новую категорию.
+ ///
+ public Category(string title, string slug, Guid? parentId, JsonDocument? attributeSchema = null)
+ {
+ Id = Guid.NewGuid();
+ Title = title;
+ Slug = slug;
+ ParentId = parentId;
+ AttributeSchema = attributeSchema;
+ }
+}
diff --git a/src/Modules/Catalog/Domain/Aggregates/Offer.cs b/src/Modules/Catalog/Domain/Aggregates/Offer.cs
new file mode 100644
index 0000000..d61c32d
--- /dev/null
+++ b/src/Modules/Catalog/Domain/Aggregates/Offer.cs
@@ -0,0 +1,68 @@
+using System.Text.Json;
+using Nashel.Modules.Catalog.Domain.ValueObjects;
+
+namespace Nashel.Modules.Catalog.Domain.Aggregates;
+
+///
+/// Услуга или оффер исполнителя.
+///
+public class Offer
+{
+ ///
+ /// Уникальный идентификатор оффера.
+ ///
+ public Guid Id { get; private set; }
+
+ ///
+ /// ID владельца (пользователя/исполнителя).
+ ///
+ public Guid OwnerId { get; private set; }
+
+ ///
+ /// ID услуги/категории.
+ ///
+ public Guid CategoryId { get; private set; }
+
+ ///
+ /// Заголовок объявления.
+ ///
+ public string Title { get; private set; }
+
+ ///
+ /// Полное описание.
+ ///
+ public string? Description { get; private set; }
+
+ ///
+ /// Цена (сумма, валюта, тип оплаты).
+ ///
+ public Price Price { get; private set; }
+
+ ///
+ /// Характеристики (JSONB).
+ ///
+ public JsonDocument? Attributes { get; private set; }
+
+ ///
+ /// Активно ли объявление.
+ ///
+ public bool IsActive { get; private set; }
+
+ // Конструктор по умолчанию для EF Core
+ private Offer() { }
+
+ ///
+ /// Создает новый оффер.
+ ///
+ public Offer(Guid ownerId, Guid categoryId, string title, string description, Price price, JsonDocument? attributes = null)
+ {
+ Id = Guid.NewGuid();
+ OwnerId = ownerId;
+ CategoryId = categoryId;
+ Title = title;
+ Description = description;
+ Price = price;
+ Attributes = attributes;
+ IsActive = true;
+ }
+}
diff --git a/src/Modules/Catalog/Domain/Enums/OfferType.cs b/src/Modules/Catalog/Domain/Enums/OfferType.cs
new file mode 100644
index 0000000..bc4ce83
--- /dev/null
+++ b/src/Modules/Catalog/Domain/Enums/OfferType.cs
@@ -0,0 +1,22 @@
+namespace Nashel.Modules.Catalog.Domain.Enums;
+
+///
+/// Тип оплаты услуги.
+///
+public enum OfferType
+{
+ ///
+ /// Фиксированная цена.
+ ///
+ Fixed,
+
+ ///
+ /// Почасовая оплата.
+ ///
+ Hourly,
+
+ ///
+ /// Договорная цена.
+ ///
+ Negotiable
+}
diff --git a/src/Modules/Catalog/Domain/Nashel.Modules.Catalog.Domain.csproj b/src/Modules/Catalog/Domain/Nashel.Modules.Catalog.Domain.csproj
index 298795d..e040df2 100644
--- a/src/Modules/Catalog/Domain/Nashel.Modules.Catalog.Domain.csproj
+++ b/src/Modules/Catalog/Domain/Nashel.Modules.Catalog.Domain.csproj
@@ -9,6 +9,8 @@
net10.0
enable
enable
+ true
+ $(NoWarn);1591
diff --git a/src/Modules/Catalog/Domain/Repositories/ICategoryRepository.cs b/src/Modules/Catalog/Domain/Repositories/ICategoryRepository.cs
new file mode 100644
index 0000000..66f88c2
--- /dev/null
+++ b/src/Modules/Catalog/Domain/Repositories/ICategoryRepository.cs
@@ -0,0 +1,10 @@
+using Nashel.Modules.Catalog.Domain.Aggregates;
+
+namespace Nashel.Modules.Catalog.Domain.Repositories;
+
+public interface ICategoryRepository
+{
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
+ Task> GetAllAsync(CancellationToken cancellationToken = default);
+ Task AddAsync(Category category, CancellationToken cancellationToken = default);
+}
diff --git a/src/Modules/Catalog/Domain/Repositories/IOfferRepository.cs b/src/Modules/Catalog/Domain/Repositories/IOfferRepository.cs
new file mode 100644
index 0000000..91eb766
--- /dev/null
+++ b/src/Modules/Catalog/Domain/Repositories/IOfferRepository.cs
@@ -0,0 +1,9 @@
+using Nashel.Modules.Catalog.Domain.Aggregates;
+
+namespace Nashel.Modules.Catalog.Domain.Repositories;
+
+public interface IOfferRepository
+{
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
+ Task AddAsync(Offer offer, CancellationToken cancellationToken = default);
+}
diff --git a/src/Modules/Catalog/Domain/ValueObjects/Price.cs b/src/Modules/Catalog/Domain/ValueObjects/Price.cs
new file mode 100644
index 0000000..44c8b86
--- /dev/null
+++ b/src/Modules/Catalog/Domain/ValueObjects/Price.cs
@@ -0,0 +1,35 @@
+using System.Text.Json.Serialization;
+using Nashel.Modules.Catalog.Domain.Enums;
+
+namespace Nashel.Modules.Catalog.Domain.ValueObjects;
+
+///
+/// Значение цены услуги.
+///
+public record Price
+{
+ ///
+ /// Сумма.
+ ///
+ public decimal Amount { get; init; }
+
+ ///
+ /// Валюта (по умолчанию RUB).
+ ///
+ public string Currency { get; init; } = "RUB";
+
+ ///
+ /// Тип оплаты (фиксированная, почасовая, договорная).
+ ///
+ public OfferType Type { get; init; }
+
+ // Конструктор по умолчанию для EF Core и сериализации
+ public Price() { }
+
+ public Price(decimal amount, OfferType type, string currency = "RUB")
+ {
+ Amount = amount;
+ Type = type;
+ Currency = currency;
+ }
+}
diff --git a/src/Modules/Catalog/Infrastructure/DependencyInjection.cs b/src/Modules/Catalog/Infrastructure/DependencyInjection.cs
new file mode 100644
index 0000000..65b04de
--- /dev/null
+++ b/src/Modules/Catalog/Infrastructure/DependencyInjection.cs
@@ -0,0 +1,34 @@
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Nashel.Modules.Catalog.Application.Commands;
+using Nashel.Modules.Catalog.Domain.Repositories;
+using Nashel.Modules.Catalog.Infrastructure.Persistence;
+using Nashel.Modules.Catalog.Infrastructure.Repositories;
+
+namespace Nashel.Modules.Catalog.Infrastructure;
+
+public static class DependencyInjection
+{
+ public static IServiceCollection AddCatalogModule(this IServiceCollection services, IConfiguration configuration)
+ {
+ // 1. Регистрация DbContext
+ services.AddDbContext(options =>
+ {
+ options.UseNpgsql(configuration.GetConnectionString("DefaultConnection"));
+ });
+
+ // 2. Репозитории
+ services.AddScoped();
+ services.AddScoped();
+
+ // 3. MediatR (сканируем сборку Application)
+ services.AddMediatR(cfg =>
+ {
+ cfg.RegisterServicesFromAssembly(typeof(CreateCategoryCommand).Assembly);
+ });
+
+ return services;
+ }
+}
diff --git a/src/Modules/Catalog/Infrastructure/Persistence/CatalogDbContext.cs b/src/Modules/Catalog/Infrastructure/Persistence/CatalogDbContext.cs
new file mode 100644
index 0000000..efca6ba
--- /dev/null
+++ b/src/Modules/Catalog/Infrastructure/Persistence/CatalogDbContext.cs
@@ -0,0 +1,55 @@
+using Microsoft.EntityFrameworkCore;
+using Nashel.Modules.Catalog.Domain.Aggregates;
+
+namespace Nashel.Modules.Catalog.Infrastructure.Persistence;
+
+public class CatalogDbContext : DbContext
+{
+ public DbSet Categories { get; set; } = null!;
+ public DbSet Offers { get; set; } = null!;
+
+ public CatalogDbContext(DbContextOptions options) : base(options) { }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.HasPostgresExtension("postgis"); // Если нужно для чего-то еще, но вообще JSONB встроен.
+ // Но лучше просто не трогать расширения если не уверены.
+ // Для JSONB ничего особенного не нужно, кроме HasColumnType("jsonb").
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("Categories", "catalog");
+ entity.HasKey(e => e.Id);
+ entity.HasIndex(e => e.Slug).IsUnique();
+
+ // Self-referencing
+ entity.HasOne()
+ .WithMany() // Навигационное свойство Children не добавлено в доменную модель явно, но связь есть
+ .HasForeignKey(e => e.ParentId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ entity.Property(e => e.AttributeSchema)
+ .HasColumnType("jsonb");
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("Offers", "catalog");
+ entity.HasKey(e => e.Id);
+
+ entity.Property(e => e.Attributes)
+ .HasColumnType("jsonb");
+
+ // GIN Index
+ entity.HasIndex(e => e.Attributes)
+ .HasMethod("gin");
+
+ entity.OwnsOne(e => e.Price, price =>
+ {
+ price.Property(p => p.Amount).HasColumnName("PriceAmount");
+ price.Property(p => p.Currency).HasColumnName("PriceCurrency").HasMaxLength(3);
+ price.Property(p => p.Type).HasColumnName("PriceType"); // Enum as int by default
+ });
+ });
+ }
+}
diff --git a/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260210114511_InitialCreate.Designer.cs b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260210114511_InitialCreate.Designer.cs
new file mode 100644
index 0000000..d697d61
--- /dev/null
+++ b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260210114511_InitialCreate.Designer.cs
@@ -0,0 +1,137 @@
+//
+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("20260210114511_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ 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("ParentId")
+ .HasColumnType("uuid");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Title")
+ .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("Description")
+ .HasColumnType("text");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("OwnerId")
+ .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/20260210114511_InitialCreate.cs b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260210114511_InitialCreate.cs
new file mode 100644
index 0000000..42f6093
--- /dev/null
+++ b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260210114511_InitialCreate.cs
@@ -0,0 +1,98 @@
+using System;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Nashel.Modules.Catalog.Infrastructure.Persistence.Migrations
+{
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.EnsureSchema(
+ name: "catalog");
+
+ migrationBuilder.AlterDatabase()
+ .Annotation("Npgsql:PostgresExtension:postgis", ",,");
+
+ migrationBuilder.CreateTable(
+ name: "Categories",
+ schema: "catalog",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ Title = table.Column(type: "text", nullable: false),
+ Slug = table.Column(type: "text", nullable: false),
+ ParentId = table.Column(type: "uuid", nullable: true),
+ AttributeSchema = table.Column(type: "jsonb", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Categories", x => x.Id);
+ table.ForeignKey(
+ name: "FK_Categories_Categories_ParentId",
+ column: x => x.ParentId,
+ principalSchema: "catalog",
+ principalTable: "Categories",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Restrict);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Offers",
+ schema: "catalog",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ OwnerId = table.Column(type: "uuid", nullable: false),
+ CategoryId = table.Column(type: "uuid", nullable: false),
+ Title = table.Column(type: "text", nullable: false),
+ Description = table.Column(type: "text", nullable: true),
+ PriceAmount = table.Column(type: "numeric", nullable: false),
+ PriceCurrency = table.Column(type: "character varying(3)", maxLength: 3, nullable: false),
+ PriceType = table.Column(type: "integer", nullable: false),
+ Attributes = table.Column(type: "jsonb", nullable: true),
+ IsActive = table.Column(type: "boolean", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Offers", x => x.Id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Categories_ParentId",
+ schema: "catalog",
+ table: "Categories",
+ column: "ParentId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Categories_Slug",
+ schema: "catalog",
+ table: "Categories",
+ column: "Slug",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Offers_Attributes",
+ schema: "catalog",
+ table: "Offers",
+ column: "Attributes")
+ .Annotation("Npgsql:IndexMethod", "gin");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Categories",
+ schema: "catalog");
+
+ migrationBuilder.DropTable(
+ name: "Offers",
+ schema: "catalog");
+ }
+ }
+}
diff --git a/src/Modules/Catalog/Infrastructure/Persistence/Migrations/CatalogDbContextModelSnapshot.cs b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/CatalogDbContextModelSnapshot.cs
new file mode 100644
index 0000000..4233490
--- /dev/null
+++ b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/CatalogDbContextModelSnapshot.cs
@@ -0,0 +1,134 @@
+//
+using System;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+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))]
+ partial class CatalogDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(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("ParentId")
+ .HasColumnType("uuid");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Title")
+ .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("Description")
+ .HasColumnType("text");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("OwnerId")
+ .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/Repositories/CategoryRepository.cs b/src/Modules/Catalog/Infrastructure/Repositories/CategoryRepository.cs
new file mode 100644
index 0000000..8abf34d
--- /dev/null
+++ b/src/Modules/Catalog/Infrastructure/Repositories/CategoryRepository.cs
@@ -0,0 +1,34 @@
+using Microsoft.EntityFrameworkCore;
+using Nashel.Modules.Catalog.Domain.Aggregates;
+using Nashel.Modules.Catalog.Domain.Repositories;
+using Nashel.Modules.Catalog.Infrastructure.Persistence;
+
+namespace Nashel.Modules.Catalog.Infrastructure.Repositories;
+
+public class CategoryRepository : ICategoryRepository
+{
+ private readonly CatalogDbContext _context;
+
+ public CategoryRepository(CatalogDbContext context)
+ {
+ _context = context;
+ }
+
+ public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
+ {
+ return await _context.Categories
+ .Include(c => c.AttributeSchema) // Not needed as it's a property now, but sometimes needed if navigation property (JsonDocument is property)
+ .FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
+ }
+
+ public async Task> GetAllAsync(CancellationToken cancellationToken = default)
+ {
+ return await _context.Categories.ToListAsync(cancellationToken);
+ }
+
+ public async Task AddAsync(Category category, CancellationToken cancellationToken = default)
+ {
+ await _context.Categories.AddAsync(category, cancellationToken);
+ await _context.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/src/Modules/Catalog/Infrastructure/Repositories/OfferRepository.cs b/src/Modules/Catalog/Infrastructure/Repositories/OfferRepository.cs
new file mode 100644
index 0000000..fb13abe
--- /dev/null
+++ b/src/Modules/Catalog/Infrastructure/Repositories/OfferRepository.cs
@@ -0,0 +1,28 @@
+using Microsoft.EntityFrameworkCore;
+using Nashel.Modules.Catalog.Domain.Aggregates;
+using Nashel.Modules.Catalog.Domain.Repositories;
+using Nashel.Modules.Catalog.Infrastructure.Persistence;
+
+namespace Nashel.Modules.Catalog.Infrastructure.Repositories;
+
+public class OfferRepository : IOfferRepository
+{
+ private readonly CatalogDbContext _context;
+
+ public OfferRepository(CatalogDbContext context)
+ {
+ _context = context;
+ }
+
+ public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
+ {
+ return await _context.Offers
+ .FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
+ }
+
+ public async Task AddAsync(Offer offer, CancellationToken cancellationToken = default)
+ {
+ await _context.Offers.AddAsync(offer, cancellationToken);
+ await _context.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/src/Modules/Catalog/Presentation/Endpoints/CatalogEndpoints.cs b/src/Modules/Catalog/Presentation/Endpoints/CatalogEndpoints.cs
new file mode 100644
index 0000000..a5e76da
--- /dev/null
+++ b/src/Modules/Catalog/Presentation/Endpoints/CatalogEndpoints.cs
@@ -0,0 +1,56 @@
+using MediatR;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Routing;
+using Nashel.Modules.Catalog.Application.Commands;
+using Nashel.Modules.Catalog.Application.Queries;
+
+namespace Nashel.Modules.Catalog.Presentation.Endpoints;
+
+///
+/// API Каталога (Категории и Услуги).
+///
+public static class CatalogEndpoints
+{
+ public static void MapCatalogEndpoints(this IEndpointRouteBuilder app)
+ {
+ var catalogGroup = app.MapGroup("/api/catalog").WithTags("Catalog");
+
+ // --- Категории ---
+
+ catalogGroup.MapPost("/categories", async ([FromBody] CreateCategoryCommand command, ISender sender) =>
+ {
+ var id = await sender.Send(command);
+ return Results.Ok(id);
+ })
+ .WithName("CreateCategory")
+ .WithSummary("Создать категорию (Admin)");
+
+ catalogGroup.MapGet("/categories", async (ISender sender) =>
+ {
+ var result = await sender.Send(new GetCategoriesQuery());
+ return Results.Ok(result);
+ })
+ .WithName("GetCategories")
+ .WithSummary("Получить все категории (плоский список с ParentId)");
+
+ // --- Услуги (Offers) ---
+
+ catalogGroup.MapPost("/offers", async ([FromBody] CreateOfferCommand command, ISender sender) =>
+ {
+ var id = await sender.Send(command);
+ return Results.Ok(id);
+ })
+ .WithName("CreateOffer")
+ .WithSummary("Создать оффер/услугу");
+
+ 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();
+ })
+ .WithName("GetOfferById")
+ .WithSummary("Получить детали услуги по ID");
+ }
+}
diff --git a/src/Modules/Catalog/Presentation/Nashel.Modules.Catalog.Presentation.csproj b/src/Modules/Catalog/Presentation/Nashel.Modules.Catalog.Presentation.csproj
index 2050ec5..7839126 100644
--- a/src/Modules/Catalog/Presentation/Nashel.Modules.Catalog.Presentation.csproj
+++ b/src/Modules/Catalog/Presentation/Nashel.Modules.Catalog.Presentation.csproj
@@ -7,5 +7,10 @@
net10.0
enable
enable
+ true
+ $(NoWarn);1591
+
+
+
\ No newline at end of file
diff --git a/src/Modules/Catalog/Tests/Application/CreateCategoryCommandHandlerTests.cs b/src/Modules/Catalog/Tests/Application/CreateCategoryCommandHandlerTests.cs
new file mode 100644
index 0000000..39ce86d
--- /dev/null
+++ b/src/Modules/Catalog/Tests/Application/CreateCategoryCommandHandlerTests.cs
@@ -0,0 +1,44 @@
+using FluentAssertions;
+using Moq;
+using Nashel.Modules.Catalog.Application.Commands;
+using Nashel.Modules.Catalog.Domain.Aggregates;
+using Nashel.Modules.Catalog.Domain.Repositories;
+using Xunit;
+
+namespace Nashel.Modules.Catalog.Tests.Application;
+
+public class CreateCategoryCommandHandlerTests
+{
+ private readonly Mock _categoryRepositoryMock;
+ private readonly CreateCategoryCommandHandler _handler;
+
+ public CreateCategoryCommandHandlerTests()
+ {
+ _categoryRepositoryMock = new Mock();
+ _handler = new CreateCategoryCommandHandler(_categoryRepositoryMock.Object);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldCreateCategory_WhenCommandIsValid()
+ {
+ // Arrange
+ var command = new CreateCategoryCommand(
+ "Test Category",
+ "test-category",
+ null,
+ null
+ );
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.Should().Be(result); // Guid should be returned
+ _categoryRepositoryMock.Verify(r => r.AddAsync(It.Is(c =>
+ c.Title == command.Title &&
+ c.Slug == command.Slug &&
+ c.ParentId == command.ParentId &&
+ c.AttributeSchema == command.AttributeSchema
+ ), It.IsAny()), Times.Once);
+ }
+}
diff --git a/src/Modules/Catalog/Tests/Application/CreateOfferCommandHandlerTests.cs b/src/Modules/Catalog/Tests/Application/CreateOfferCommandHandlerTests.cs
new file mode 100644
index 0000000..0ca2a6f
--- /dev/null
+++ b/src/Modules/Catalog/Tests/Application/CreateOfferCommandHandlerTests.cs
@@ -0,0 +1,53 @@
+using FluentAssertions;
+using Moq;
+using Nashel.Modules.Catalog.Application.Commands;
+using Nashel.Modules.Catalog.Domain.Aggregates;
+using Nashel.Modules.Catalog.Domain.Enums;
+using Nashel.Modules.Catalog.Domain.Repositories;
+using Nashel.Modules.Catalog.Domain.ValueObjects;
+using System.Text.Json;
+using Xunit;
+
+namespace Nashel.Modules.Catalog.Tests.Application;
+
+public class CreateOfferCommandHandlerTests
+{
+ private readonly Mock _offerRepositoryMock;
+ private readonly CreateOfferCommandHandler _handler;
+
+ public CreateOfferCommandHandlerTests()
+ {
+ _offerRepositoryMock = new Mock();
+ _handler = new CreateOfferCommandHandler(_offerRepositoryMock.Object);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldCreateOffer_WhenCommandIsValid()
+ {
+ // Arrange
+ var command = new CreateOfferCommand(
+ Guid.NewGuid(),
+ Guid.NewGuid(),
+ "Test Offer",
+ "Test Description",
+ new Price(100, OfferType.Fixed, "RUB"),
+ JsonDocument.Parse("{}")
+ );
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.Should().Be(result); // Guid should be returned
+ _offerRepositoryMock.Verify(r => r.AddAsync(It.Is(o =>
+ o.Title == command.Title &&
+ o.OwnerId == command.OwnerId &&
+ o.CategoryId == command.CategoryId &&
+ o.Description == command.Description &&
+ o.Price.Amount == command.Price.Amount &&
+ o.Price.Type == command.Price.Type &&
+ o.Price.Currency == command.Price.Currency &&
+ o.Attributes == command.Attributes
+ ), It.IsAny()), Times.Once);
+ }
+}
diff --git a/src/Modules/Catalog/Tests/Application/GetCategoriesQueryHandlerTests.cs b/src/Modules/Catalog/Tests/Application/GetCategoriesQueryHandlerTests.cs
new file mode 100644
index 0000000..7a54ed0
--- /dev/null
+++ b/src/Modules/Catalog/Tests/Application/GetCategoriesQueryHandlerTests.cs
@@ -0,0 +1,67 @@
+using FluentAssertions;
+using Moq;
+using Nashel.Modules.Catalog.Application.Common;
+using Nashel.Modules.Catalog.Application.Queries;
+using Nashel.Modules.Catalog.Domain.Aggregates;
+using Nashel.Modules.Catalog.Domain.Repositories;
+using System.Text.Json;
+using Xunit;
+
+namespace Nashel.Modules.Catalog.Tests.Application;
+
+public class GetCategoriesQueryHandlerTests
+{
+ private readonly Mock _categoryRepositoryMock;
+ private readonly GetCategoriesQueryHandler _handler;
+
+ public GetCategoriesQueryHandlerTests()
+ {
+ _categoryRepositoryMock = new Mock();
+ _handler = new GetCategoriesQueryHandler(_categoryRepositoryMock.Object);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldReturnCategories_WhenCategoriesExist()
+ {
+ // Arrange
+ var categories = new List
+ {
+ new Category("Category 1", "category-1", null),
+ new Category("Category 2", "category-2", null)
+ };
+ _categoryRepositoryMock.Setup(r => r.GetAllAsync(It.IsAny()))
+ .ReturnsAsync(categories);
+
+ var query = new GetCategoriesQuery();
+
+ // Act
+ var result = await _handler.Handle(query, CancellationToken.None);
+
+ // Assert
+ result.Should().NotBeNull();
+ result.Should().HaveCount(2);
+
+ var expectedDto1 = new CategoryDto(categories[0].Id, categories[0].Title, categories[0].Slug, categories[0].ParentId, categories[0].AttributeSchema);
+ var expectedDto2 = new CategoryDto(categories[1].Id, categories[1].Title, categories[1].Slug, categories[1].ParentId, categories[1].AttributeSchema);
+
+ result.Should().ContainEquivalentOf(expectedDto1);
+ result.Should().ContainEquivalentOf(expectedDto2);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldReturnEmptyList_WhenNoCategoriesExist()
+ {
+ // Arrange
+ _categoryRepositoryMock.Setup(r => r.GetAllAsync(It.IsAny()))
+ .ReturnsAsync(new List());
+
+ var query = new GetCategoriesQuery();
+
+ // Act
+ var result = await _handler.Handle(query, CancellationToken.None);
+
+ // Assert
+ result.Should().NotBeNull();
+ result.Should().BeEmpty();
+ }
+}
diff --git a/src/Modules/Catalog/Tests/Application/GetOfferByIdQueryHandlerTests.cs b/src/Modules/Catalog/Tests/Application/GetOfferByIdQueryHandlerTests.cs
new file mode 100644
index 0000000..d4409e2
--- /dev/null
+++ b/src/Modules/Catalog/Tests/Application/GetOfferByIdQueryHandlerTests.cs
@@ -0,0 +1,79 @@
+using FluentAssertions;
+using Moq;
+using Nashel.Modules.Catalog.Application.Common;
+using Nashel.Modules.Catalog.Application.Queries;
+using Nashel.Modules.Catalog.Domain.Aggregates;
+using Nashel.Modules.Catalog.Domain.Repositories;
+using Nashel.Modules.Catalog.Domain.ValueObjects;
+using Nashel.Modules.Catalog.Domain.Enums;
+using System.Text.Json;
+using Xunit;
+
+namespace Nashel.Modules.Catalog.Tests.Application;
+
+public class GetOfferByIdQueryHandlerTests
+{
+ private readonly Mock _offerRepositoryMock;
+ private readonly GetOfferByIdQueryHandler _handler;
+
+ public GetOfferByIdQueryHandlerTests()
+ {
+ _offerRepositoryMock = new Mock();
+ _handler = new GetOfferByIdQueryHandler(_offerRepositoryMock.Object);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldReturnOffer_WhenOfferExists()
+ {
+ // Arrange
+ var offerId = Guid.NewGuid();
+ var offer = new Offer(
+ Guid.NewGuid(),
+ Guid.NewGuid(),
+ "Test Offer",
+ "Test Description",
+ new Price(100, OfferType.Fixed, "RUB"),
+ JsonDocument.Parse("{}")
+ );
+
+ // Reflection hack to set the ID as it's private set in constructor (Guid.NewGuid())
+ // But wait, the constructor sets a new GUID.
+ // We need to mock the repository to return the offer when GetByIdAsync is called with the offer's generated ID.
+ // Or cleaner: modify the repository setup to ignore the specific ID argument or capture it?
+ // No, let's just use the ID generated by the offer.
+
+ _offerRepositoryMock.Setup(r => r.GetByIdAsync(offer.Id, It.IsAny()))
+ .ReturnsAsync(offer);
+
+ var query = new GetOfferByIdQuery(offer.Id);
+
+ // Act
+ var result = await _handler.Handle(query, CancellationToken.None);
+
+ // Assert
+ result.Should().NotBeNull();
+ result.Id.Should().Be(offer.Id);
+ result.Title.Should().Be("Test Offer");
+ result.Description.Should().Be("Test Description");
+ result.Price.Should().BeEquivalentTo(offer.Price);
+ result.OwnerId.Should().Be(offer.OwnerId);
+ result.CategoryId.Should().Be(offer.CategoryId);
+ result.Attributes.Should().Be(offer.Attributes);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldReturnNull_WhenOfferDoesNotExist()
+ {
+ // Arrange
+ _offerRepositoryMock.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((Offer?)null);
+
+ var query = new GetOfferByIdQuery(Guid.NewGuid());
+
+ // Act
+ var result = await _handler.Handle(query, CancellationToken.None);
+
+ // Assert
+ result.Should().BeNull();
+ }
+}
diff --git a/src/Modules/Catalog/Tests/Domain/CategoryTests.cs b/src/Modules/Catalog/Tests/Domain/CategoryTests.cs
new file mode 100644
index 0000000..2f44dd4
--- /dev/null
+++ b/src/Modules/Catalog/Tests/Domain/CategoryTests.cs
@@ -0,0 +1,40 @@
+using FluentAssertions;
+using Nashel.Modules.Catalog.Domain.Aggregates;
+using System.Text.Json;
+using Xunit;
+
+namespace Nashel.Modules.Catalog.Tests.Domain;
+
+public class CategoryTests
+{
+ [Fact]
+ public void Constructor_ShouldSetPropertiesCorrectly()
+ {
+ // Assemble
+ var title = "Test Category";
+ var slug = "test-category";
+ var parentId = Guid.NewGuid();
+ var jsonDoc = JsonDocument.Parse("{}");
+
+ // Act
+ var category = new Category(title, slug, parentId, jsonDoc);
+
+ // Assert
+ category.Id.Should().NotBeEmpty();
+ category.Title.Should().Be(title);
+ category.Slug.Should().Be(slug);
+ category.ParentId.Should().Be(parentId);
+ category.AttributeSchema.Should().Be(jsonDoc);
+ }
+
+ [Fact]
+ public void Constructor_ShouldSetDefaultValues_WhenOptionalParametersAreNull()
+ {
+ // Act
+ var category = new Category("Test", "test", null, null);
+
+ // Assert
+ category.ParentId.Should().BeNull();
+ category.AttributeSchema.Should().BeNull();
+ }
+}
diff --git a/src/Modules/Catalog/Tests/Domain/OfferTests.cs b/src/Modules/Catalog/Tests/Domain/OfferTests.cs
new file mode 100644
index 0000000..4c0f450
--- /dev/null
+++ b/src/Modules/Catalog/Tests/Domain/OfferTests.cs
@@ -0,0 +1,36 @@
+using FluentAssertions;
+using Nashel.Modules.Catalog.Domain.Aggregates;
+using Nashel.Modules.Catalog.Domain.ValueObjects;
+using Nashel.Modules.Catalog.Domain.Enums;
+using System.Text.Json;
+using Xunit;
+
+namespace Nashel.Modules.Catalog.Tests.Domain;
+
+public class OfferTests
+{
+ [Fact]
+ public void Constructor_ShouldSetPropertiesCorrectly()
+ {
+ // Assemble
+ var ownerId = Guid.NewGuid();
+ var categoryId = Guid.NewGuid();
+ var title = "Test Offer";
+ var description = "Test Description";
+ var price = new Price(100, OfferType.Fixed, "RUB");
+ var attributes = JsonDocument.Parse("{}");
+
+ // Act
+ var offer = new Offer(ownerId, categoryId, title, description, price, attributes);
+
+ // Assert
+ offer.Id.Should().NotBeEmpty();
+ offer.OwnerId.Should().Be(ownerId);
+ offer.CategoryId.Should().Be(categoryId);
+ offer.Title.Should().Be(title);
+ offer.Description.Should().Be(description);
+ offer.Price.Should().Be(price);
+ offer.Attributes.Should().Be(attributes);
+ offer.IsActive.Should().BeTrue();
+ }
+}
diff --git a/src/Modules/Catalog/Tests/Domain/PriceTests.cs b/src/Modules/Catalog/Tests/Domain/PriceTests.cs
new file mode 100644
index 0000000..a4e98e9
--- /dev/null
+++ b/src/Modules/Catalog/Tests/Domain/PriceTests.cs
@@ -0,0 +1,58 @@
+using FluentAssertions;
+using Nashel.Modules.Catalog.Domain.Enums;
+using Nashel.Modules.Catalog.Domain.ValueObjects;
+using Xunit;
+
+namespace Nashel.Modules.Catalog.Tests.Domain;
+
+public class PriceTests
+{
+ [Fact]
+ public void Constructor_ShouldSetPropertiesCorrectly()
+ {
+ // Assemble
+ var amount = 100m;
+ var type = OfferType.Fixed;
+ var currency = "USD";
+
+ // Act
+ var price = new Price(amount, type, currency);
+
+ // Assert
+ price.Amount.Should().Be(amount);
+ price.Type.Should().Be(type);
+ price.Currency.Should().Be(currency);
+ }
+
+ [Fact]
+ public void Constructor_ShouldSetDefaultCurrency_WhenNotProvided()
+ {
+ // Act
+ var price = new Price(100m, OfferType.Fixed);
+
+ // Assert
+ price.Currency.Should().Be("RUB");
+ }
+
+ [Fact]
+ public void Equality_ShouldBeTrue_WhenValuesAreSame()
+ {
+ // Arrange
+ var price1 = new Price(100m, OfferType.Fixed, "USD");
+ var price2 = new Price(100m, OfferType.Fixed, "USD");
+
+ // Act & Assert
+ price1.Should().Be(price2);
+ }
+
+ [Fact]
+ public void Equality_ShouldBeFalse_WhenValuesAreDifferent()
+ {
+ // Arrange
+ var price1 = new Price(100m, OfferType.Fixed, "USD");
+ var price2 = new Price(200m, OfferType.Fixed, "USD");
+
+ // Act & Assert
+ price1.Should().NotBe(price2);
+ }
+}
diff --git a/src/Modules/Catalog/Tests/Nashel.Modules.Catalog.Tests.csproj b/src/Modules/Catalog/Tests/Nashel.Modules.Catalog.Tests.csproj
new file mode 100644
index 0000000..a5d8f9e
--- /dev/null
+++ b/src/Modules/Catalog/Tests/Nashel.Modules.Catalog.Tests.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Modules/Identity/Application/Commands/LoginCommand.cs b/src/Modules/Identity/Application/Commands/LoginCommand.cs
index 1cbc273..e81b6eb 100644
--- a/src/Modules/Identity/Application/Commands/LoginCommand.cs
+++ b/src/Modules/Identity/Application/Commands/LoginCommand.cs
@@ -6,7 +6,29 @@ using Nashel.Modules.Identity.Domain.Services;
namespace Nashel.Modules.Identity.Application.Commands;
-public record LoginCommand(string Phone, string Password) : IRequest; // Возвращает JWT
+///
+/// Команда входа пользователя.
+///
+public record LoginCommand : IRequest
+{
+ ///
+ /// Номер телефона.
+ ///
+ public string Phone { get; init; } = default!;
+
+ ///
+ /// Пароль.
+ ///
+ public string Password { get; init; } = default!;
+
+ public LoginCommand(string phone, string password)
+ {
+ Phone = phone;
+ Password = password;
+ }
+
+ public LoginCommand() { }
+} // Возвращает JWT
public class LoginCommandHandler : IRequestHandler
{
diff --git a/src/Modules/Identity/Application/Commands/RegisterUserCommand.cs b/src/Modules/Identity/Application/Commands/RegisterUserCommand.cs
index 0704514..b1dfa02 100644
--- a/src/Modules/Identity/Application/Commands/RegisterUserCommand.cs
+++ b/src/Modules/Identity/Application/Commands/RegisterUserCommand.cs
@@ -5,7 +5,29 @@ using Nashel.Modules.Identity.Domain.Repositories;
namespace Nashel.Modules.Identity.Application.Commands;
-public record RegisterUserCommand(string Phone, string Password) : IRequest;
+///
+/// Команда регистрации нового пользователя.
+///
+public record RegisterUserCommand : IRequest
+{
+ ///
+ /// Номер телефона пользователя.
+ ///
+ public string Phone { get; init; } = default!;
+
+ ///
+ /// Пароль пользователя.
+ ///
+ public string Password { get; init; } = default!;
+
+ public RegisterUserCommand(string phone, string password)
+ {
+ Phone = phone;
+ Password = password;
+ }
+
+ public RegisterUserCommand() { }
+}
public class RegisterUserCommandHandler : IRequestHandler
{
diff --git a/src/Modules/Identity/Application/Nashel.Modules.Identity.Application.csproj b/src/Modules/Identity/Application/Nashel.Modules.Identity.Application.csproj
index 866ffdf..955cea5 100644
--- a/src/Modules/Identity/Application/Nashel.Modules.Identity.Application.csproj
+++ b/src/Modules/Identity/Application/Nashel.Modules.Identity.Application.csproj
@@ -11,5 +11,7 @@
net10.0
enable
enable
+ true
+ $(NoWarn);1591
\ No newline at end of file
diff --git a/src/Modules/Identity/Domain/Nashel.Modules.Identity.Domain.csproj b/src/Modules/Identity/Domain/Nashel.Modules.Identity.Domain.csproj
index 1628e66..7717082 100644
--- a/src/Modules/Identity/Domain/Nashel.Modules.Identity.Domain.csproj
+++ b/src/Modules/Identity/Domain/Nashel.Modules.Identity.Domain.csproj
@@ -9,6 +9,8 @@
net10.0
enable
enable
+ true
+ $(NoWarn);1591
diff --git a/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260210114447_InitialCreate.Designer.cs b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260210114447_InitialCreate.Designer.cs
new file mode 100644
index 0000000..e05c9b0
--- /dev/null
+++ b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260210114447_InitialCreate.Designer.cs
@@ -0,0 +1,57 @@
+//
+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("20260210114447_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.2")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.Account", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Phone")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Roles")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Phone")
+ .IsUnique();
+
+ b.ToTable("Accounts", "identity");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260210114447_InitialCreate.cs b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260210114447_InitialCreate.cs
new file mode 100644
index 0000000..8259b94
--- /dev/null
+++ b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260210114447_InitialCreate.cs
@@ -0,0 +1,48 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations
+{
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.EnsureSchema(
+ name: "identity");
+
+ migrationBuilder.CreateTable(
+ name: "Accounts",
+ schema: "identity",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ Phone = table.Column(type: "character varying(20)", maxLength: 20, nullable: false),
+ PasswordHash = table.Column(type: "text", nullable: false),
+ Roles = table.Column(type: "jsonb", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Accounts", x => x.Id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Accounts_Phone",
+ schema: "identity",
+ table: "Accounts",
+ column: "Phone",
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Accounts",
+ schema: "identity");
+ }
+ }
+}
diff --git a/src/Modules/Identity/Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs b/src/Modules/Identity/Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs
new file mode 100644
index 0000000..d0a31cb
--- /dev/null
+++ b/src/Modules/Identity/Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs
@@ -0,0 +1,54 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+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))]
+ partial class IdentityDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.2")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.Account", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Phone")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Roles")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Phone")
+ .IsUnique();
+
+ b.ToTable("Accounts", "identity");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}