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