From 6e1a1b24293f0687dd7b9902e40a611cbb27d6a9 Mon Sep 17 00:00:00 2001 From: lumijiez <59575049+lumijiez@users.noreply.github.com> Date: Mon, 5 May 2025 00:01:30 +0300 Subject: [PATCH] Add two basic CQRS commands/queries --- .../Printbase.Application.csproj | 5 - .../CreateProduct/CreateProductCommand.cs | 12 +++ .../CreateProductCommandHandler.cs | 95 +++++++++++++++++++ .../CreateProduct/CreateProductVariantDto.cs | 11 +++ .../Products/Dtos/ProductDto.cs | 15 +++ .../Products/Dtos/ProductVariantDto.cs | 14 +++ .../GetProductById/GetProductByIdQuery.cs | 10 ++ .../GetProductByIdQueryHandler.cs | 56 +++++++++++ .../Mapping/ProductMappingProfile.cs | 72 +++++++++----- .../Printbase.Infrastructure.csproj | 1 + .../Controllers/ProductsController.cs | 50 ++++++++++ 11 files changed, 312 insertions(+), 29 deletions(-) create mode 100644 src/Printbase.Application/Products/Commands/CreateProduct/CreateProductCommand.cs create mode 100644 src/Printbase.Application/Products/Commands/CreateProduct/CreateProductCommandHandler.cs create mode 100644 src/Printbase.Application/Products/Commands/CreateProduct/CreateProductVariantDto.cs create mode 100644 src/Printbase.Application/Products/Dtos/ProductDto.cs create mode 100644 src/Printbase.Application/Products/Dtos/ProductVariantDto.cs create mode 100644 src/Printbase.Application/Products/Queries/GetProductById/GetProductByIdQuery.cs create mode 100644 src/Printbase.Application/Products/Queries/GetProductById/GetProductByIdQueryHandler.cs create mode 100644 src/Printbase.WebApi/Controllers/ProductsController.cs diff --git a/src/Printbase.Application/Printbase.Application.csproj b/src/Printbase.Application/Printbase.Application.csproj index c4abccb..da6040d 100644 --- a/src/Printbase.Application/Printbase.Application.csproj +++ b/src/Printbase.Application/Printbase.Application.csproj @@ -6,11 +6,6 @@ enable - - - - - diff --git a/src/Printbase.Application/Products/Commands/CreateProduct/CreateProductCommand.cs b/src/Printbase.Application/Products/Commands/CreateProduct/CreateProductCommand.cs new file mode 100644 index 0000000..f107b64 --- /dev/null +++ b/src/Printbase.Application/Products/Commands/CreateProduct/CreateProductCommand.cs @@ -0,0 +1,12 @@ +using MediatR; +using Printbase.Application.Products.Dtos; + +namespace Printbase.Application.Products.Commands.CreateProduct; + +public class CreateProductCommand : IRequest +{ + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public Guid TypeId { get; set; } + public List? Variants { get; set; } +} \ No newline at end of file diff --git a/src/Printbase.Application/Products/Commands/CreateProduct/CreateProductCommandHandler.cs b/src/Printbase.Application/Products/Commands/CreateProduct/CreateProductCommandHandler.cs new file mode 100644 index 0000000..5cd63eb --- /dev/null +++ b/src/Printbase.Application/Products/Commands/CreateProduct/CreateProductCommandHandler.cs @@ -0,0 +1,95 @@ +using MediatR; +using Printbase.Application.Products.Dtos; +using Printbase.Domain.Entities.Products; +using Printbase.Domain.Repositories; + +namespace Printbase.Application.Products.Commands.CreateProduct; + +public class CreateProductCommandHandler( + IProductRepository productRepository, + IProductVariantRepository variantRepository, + IProductTypeRepository typeRepository) + : IRequestHandler +{ + private readonly IProductRepository _productRepository = productRepository ?? throw new ArgumentNullException(nameof(productRepository)); + private readonly IProductVariantRepository _variantRepository = variantRepository ?? throw new ArgumentNullException(nameof(variantRepository)); + private readonly IProductTypeRepository _typeRepository = typeRepository ?? throw new ArgumentNullException(nameof(typeRepository)); + + public async Task Handle(CreateProductCommand request, CancellationToken cancellationToken) + { + var productType = await _typeRepository.GetByIdAsync(request.TypeId, includeRelations: true, cancellationToken); + if (productType == null) + { + throw new ArgumentException($"Product type with ID {request.TypeId} not found"); + } + + var product = new Product + { + Id = Guid.NewGuid(), + Name = request.Name, + Description = request.Description, + TypeId = request.TypeId, + CreatedAt = DateTime.UtcNow, + IsActive = true + }; + + var createdProduct = await _productRepository.AddAsync(product, cancellationToken); + + var productVariants = new List(); + if (request.Variants != null && request.Variants.Count != 0) + { + foreach (var variant in request.Variants.Select(variantDto => new ProductVariant + { + Id = Guid.NewGuid(), + ProductId = createdProduct.Id, + Color = variantDto.Color, + Size = variantDto.Size, + Price = variantDto.Price, + Discount = variantDto.Discount, + Stock = variantDto.Stock, + SKU = variantDto.SKU ?? GenerateSku(createdProduct.Name, variantDto.Color, variantDto.Size), + CreatedAt = DateTime.UtcNow, + IsActive = true + })) + { + var createdVariant = await _variantRepository.AddAsync(variant, cancellationToken); + productVariants.Add(createdVariant); + } + } + + var productDto = new ProductDto + { + Id = createdProduct.Id, + Name = createdProduct.Name, + Description = createdProduct.Description, + TypeId = createdProduct.TypeId, + TypeName = productType.Name, + GroupName = productType.Group.Name, + CreatedAt = createdProduct.CreatedAt, + IsActive = createdProduct.IsActive, + Variants = productVariants.Select(v => new ProductVariantDto + { + Id = v.Id, + Color = v.Color, + Size = v.Size, + Price = v.Price, + Discount = v.Discount, + Stock = v.Stock, + SKU = v.SKU, + IsActive = v.IsActive + }).ToList() + }; + + return productDto; + } + + private static string GenerateSku(string productName, string? color, string? size) + { + var prefix = productName.Length >= 3 ? productName[..3].ToUpper() : productName.ToUpper(); + var colorPart = !string.IsNullOrEmpty(color) ? color[..Math.Min(3, color.Length)].ToUpper() : "XXX"; + var sizePart = !string.IsNullOrEmpty(size) ? size.ToUpper() : "OS"; + var randomPart = new Random().Next(100, 999).ToString(); + + return $"{prefix}-{colorPart}-{sizePart}-{randomPart}"; + } +} \ No newline at end of file diff --git a/src/Printbase.Application/Products/Commands/CreateProduct/CreateProductVariantDto.cs b/src/Printbase.Application/Products/Commands/CreateProduct/CreateProductVariantDto.cs new file mode 100644 index 0000000..ac2ffbb --- /dev/null +++ b/src/Printbase.Application/Products/Commands/CreateProduct/CreateProductVariantDto.cs @@ -0,0 +1,11 @@ +namespace Printbase.Application.Products.Commands.CreateProduct; + +public class CreateProductVariantDto +{ + public string? Color { get; set; } + public string? Size { get; set; } + public decimal Price { get; set; } + public decimal? Discount { get; set; } + public int Stock { get; set; } + public string? SKU { get; set; } +} \ No newline at end of file diff --git a/src/Printbase.Application/Products/Dtos/ProductDto.cs b/src/Printbase.Application/Products/Dtos/ProductDto.cs new file mode 100644 index 0000000..6351059 --- /dev/null +++ b/src/Printbase.Application/Products/Dtos/ProductDto.cs @@ -0,0 +1,15 @@ +namespace Printbase.Application.Products.Dtos; + +public class ProductDto +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public Guid TypeId { get; set; } + public string TypeName { get; set; } = string.Empty; + public string GroupName { get; set; } = string.Empty; + public ICollection? Variants { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public bool IsActive { get; set; } +} \ No newline at end of file diff --git a/src/Printbase.Application/Products/Dtos/ProductVariantDto.cs b/src/Printbase.Application/Products/Dtos/ProductVariantDto.cs new file mode 100644 index 0000000..78fab47 --- /dev/null +++ b/src/Printbase.Application/Products/Dtos/ProductVariantDto.cs @@ -0,0 +1,14 @@ +namespace Printbase.Application.Products.Dtos; + +public class ProductVariantDto +{ + public Guid Id { get; set; } + public string? Color { get; set; } + public string? Size { get; set; } + public decimal Price { get; set; } + public decimal? Discount { get; set; } + public decimal DiscountedPrice => Discount is > 0 ? Price - Price * Discount.Value / 100m : Price; + public int Stock { get; set; } + public string? SKU { get; set; } + public bool IsActive { get; set; } +} \ No newline at end of file diff --git a/src/Printbase.Application/Products/Queries/GetProductById/GetProductByIdQuery.cs b/src/Printbase.Application/Products/Queries/GetProductById/GetProductByIdQuery.cs new file mode 100644 index 0000000..dea9e4a --- /dev/null +++ b/src/Printbase.Application/Products/Queries/GetProductById/GetProductByIdQuery.cs @@ -0,0 +1,10 @@ +using MediatR; +using Printbase.Application.Products.Dtos; + +namespace Printbase.Application.Products.Queries; + +public class GetProductByIdQuery(Guid id, bool includeVariants = true) : IRequest +{ + public Guid Id { get; } = id; + public bool IncludeVariants { get; } = includeVariants; +} \ No newline at end of file diff --git a/src/Printbase.Application/Products/Queries/GetProductById/GetProductByIdQueryHandler.cs b/src/Printbase.Application/Products/Queries/GetProductById/GetProductByIdQueryHandler.cs new file mode 100644 index 0000000..87ee864 --- /dev/null +++ b/src/Printbase.Application/Products/Queries/GetProductById/GetProductByIdQueryHandler.cs @@ -0,0 +1,56 @@ +using AutoMapper; +using MediatR; +using Printbase.Application.Products.Dtos; +using Printbase.Domain.Repositories; + +namespace Printbase.Application.Products.Queries.GetProductById; + +public class GetProductByIdQueryHandler(IProductRepository productRepository, IMapper mapper) + : IRequestHandler +{ + private readonly IProductRepository _productRepository = productRepository + ?? throw new ArgumentNullException(nameof(productRepository)); + private readonly IMapper _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + + public async Task Handle(GetProductByIdQuery request, CancellationToken cancellationToken) + { + var product = await _productRepository.GetByIdAsync(request.Id, includeRelations: true, cancellationToken); + + if (product == null) + { + return null; + } + + var productDto = new ProductDto + { + Id = product.Id, + Name = product.Name, + Description = product.Description, + TypeId = product.TypeId, + TypeName = product.Type.Name, + GroupName = product.Type.Group.Name, + CreatedAt = product.CreatedAt, + UpdatedAt = product.UpdatedAt, + IsActive = product.IsActive + }; + + if (request.IncludeVariants) + { + productDto.Variants = product.Variants + .Select(v => new ProductVariantDto + { + Id = v.Id, + Color = v.Color, + Size = v.Size, + Price = v.Price, + Discount = v.Discount, + Stock = v.Stock, + SKU = v.SKU, + IsActive = v.IsActive + }) + .ToList(); + } + + return productDto; + } +} \ No newline at end of file diff --git a/src/Printbase.Infrastructure/Mapping/ProductMappingProfile.cs b/src/Printbase.Infrastructure/Mapping/ProductMappingProfile.cs index 2bcc594..8adab9c 100644 --- a/src/Printbase.Infrastructure/Mapping/ProductMappingProfile.cs +++ b/src/Printbase.Infrastructure/Mapping/ProductMappingProfile.cs @@ -1,4 +1,6 @@ using AutoMapper; +using Printbase.Application.Products.Commands.CreateProduct; +using Printbase.Application.Products.Dtos; using Printbase.Domain.Entities.Products; using Printbase.Infrastructure.DbEntities.Products; @@ -9,43 +11,65 @@ public class ProductMappingProfile : Profile public ProductMappingProfile() { CreateMap() - .ForMember(dest => dest.Type, - opt => opt.MapFrom(src => src.Type)) - .ForMember(dest => dest.Variants, - opt => opt.MapFrom(src => src.Variants)); + .ForMember(dest => dest.Type, opt => opt.MapFrom(src => src.Type)) + .ForMember(dest => dest.Variants, opt => opt.MapFrom(src => src.Variants)); CreateMap() - .ForMember(dest => dest.Type, - opt => opt.Ignore()) // in repo - .ForMember(dest => dest.Variants, - opt => opt.Ignore()); // in repo + .ForMember(dest => dest.Type, opt => opt.Ignore()) // Handle in repository + .ForMember(dest => dest.Variants, opt => opt.Ignore()); // Handle in repository + // ProductVariant mapping CreateMap() - .ForMember(dest => dest.Product, - opt => opt.MapFrom(src => src.Product)); + .ForMember(dest => dest.Product, opt => opt.MapFrom(src => src.Product)); CreateMap() - .ForMember(dest => dest.Product, - opt => opt.Ignore()); // in repo + .ForMember(dest => dest.Product, opt => opt.Ignore()); // Handle in repository + // ProductType mapping CreateMap() - .ForMember(dest => dest.Group, - opt => opt.MapFrom(src => src.Group)) - .ForMember(dest => dest.Products, - opt => opt.MapFrom(src => src.Products)); + .ForMember(dest => dest.Group, opt => opt.MapFrom(src => src.Group)) + .ForMember(dest => dest.Products, opt => opt.MapFrom(src => src.Products)); CreateMap() - .ForMember(dest => dest.Group, - opt => opt.Ignore()) // in repo - .ForMember(dest => dest.Products, - opt => opt.Ignore()); // in repo + .ForMember(dest => dest.Group, opt => opt.Ignore()) // Handle in repository + .ForMember(dest => dest.Products, opt => opt.Ignore()); // Handle in repository + // ProductGroup mapping CreateMap() - .ForMember(dest => dest.Types, - opt => opt.MapFrom(src => src.Types)); + .ForMember(dest => dest.Types, opt => opt.MapFrom(src => src.Types)); CreateMap() - .ForMember(dest => dest.Types, - opt => opt.Ignore()); // in repo + .ForMember(dest => dest.Types, opt => opt.Ignore()); // Handle in repository + + // Domain <-> DTO mappings + + // Product to DTO mapping + CreateMap() + .ForMember(dest => dest.TypeName, opt => opt.MapFrom(src => src.Type.Name)) + .ForMember(dest => dest.GroupName, opt => opt.MapFrom(src => src.Type.Group.Name)) + .ForMember(dest => dest.Variants, opt => opt.MapFrom(src => src.Variants)); + + // ProductVariant to DTO mapping + CreateMap(); + + // Command to Domain mappings + + // CreateProductCommand to Product mapping + CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()) + .ForMember(dest => dest.Type, opt => opt.Ignore()) + .ForMember(dest => dest.Variants, opt => opt.Ignore()) + .ForMember(dest => dest.CreatedAt, opt => opt.Ignore()) + .ForMember(dest => dest.UpdatedAt, opt => opt.Ignore()) + .ForMember(dest => dest.IsActive, opt => opt.Ignore()); + + // CreateProductVariantDto to ProductVariant mapping + CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()) + .ForMember(dest => dest.ProductId, opt => opt.Ignore()) + .ForMember(dest => dest.Product, opt => opt.Ignore()) + .ForMember(dest => dest.CreatedAt, opt => opt.Ignore()) + .ForMember(dest => dest.UpdatedAt, opt => opt.Ignore()) + .ForMember(dest => dest.IsActive, opt => opt.Ignore()); } } \ No newline at end of file diff --git a/src/Printbase.Infrastructure/Printbase.Infrastructure.csproj b/src/Printbase.Infrastructure/Printbase.Infrastructure.csproj index 044a29f..c6bbce1 100644 --- a/src/Printbase.Infrastructure/Printbase.Infrastructure.csproj +++ b/src/Printbase.Infrastructure/Printbase.Infrastructure.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Printbase.WebApi/Controllers/ProductsController.cs b/src/Printbase.WebApi/Controllers/ProductsController.cs new file mode 100644 index 0000000..acdb870 --- /dev/null +++ b/src/Printbase.WebApi/Controllers/ProductsController.cs @@ -0,0 +1,50 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Printbase.Application.Products.Commands.CreateProduct; +using Printbase.Application.Products.Queries; + +namespace Printbase.WebApi.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ProductsController(IMediator mediator) : ControllerBase +{ + private readonly IMediator _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + + [HttpGet("{id}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetProductById(Guid id, [FromQuery] bool includeVariants = true) + { + var query = new GetProductByIdQuery(id, includeVariants); + var result = await _mediator.Send(query); + + if (result == null) + { + return NotFound(); + } + + return Ok(result); + } + + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task CreateProduct([FromBody] CreateProductCommand command) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + try + { + var result = await _mediator.Send(command); + return CreatedAtAction(nameof(GetProductById), new { id = result.Id }, result); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } +} \ No newline at end of file