Split Features to create space for more projects
continuous-integration/drone/pr Build is passing

This commit is contained in:
Khwezi Mngoma
2026-05-24 13:19:09 +02:00
parent 032b9e1818
commit 70c6e0bfbc
95 changed files with 621 additions and 314 deletions
@@ -0,0 +1,8 @@
namespace LiteCharms.Features.TechShop.Products.Entities;
[EntityTypeConfiguration<ProductConfiguration, Product>]
public class Product : Models.Product
{
public virtual ICollection<ProductPrice>? ProductPrices { get; set; }
}
@@ -0,0 +1,20 @@
namespace LiteCharms.Features.TechShop.Products.Entities;
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.ToTable("Products");
builder.HasKey(f => f.Id);
builder.Property(f => f.Name).IsRequired();
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
builder.Property(f => f.UpdatedAt).IsRequired(false);
builder.Property(f => f.Summary).IsRequired().HasMaxLength(512);
builder.Property(f => f.Description).IsRequired().HasMaxLength(2048);
builder.Property(f => f.ImageUrl).IsRequired(false).HasMaxLength(2048);
builder.Property(f => f.Thumbnails).HasColumnType("jsonb").IsRequired(false);
builder.Property(f => f.Active).HasDefaultValue(false);
builder.Property(f => f.Metadata).HasColumnType("jsonb").IsRequired(false);
}
}
@@ -0,0 +1,7 @@
namespace LiteCharms.Features.TechShop.Products.Entities;
[EntityTypeConfiguration<ProductPriceConfiguration, ProductPrice>]
public class ProductPrice : Models.ProductPrice
{
public virtual Product? Product { get; set; }
}
@@ -0,0 +1,23 @@
namespace LiteCharms.Features.TechShop.Products.Entities;
public class ProductPriceConfiguration : IEntityTypeConfiguration<ProductPrice>
{
public void Configure(EntityTypeBuilder<ProductPrice> builder)
{
builder.ToTable("ProductPrices");
builder.HasKey(f => f.Id);
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
builder.Property(f => f.UpdatedAt).IsRequired(false);
builder.Property(f => f.ProductId).IsRequired();
builder.Property(f => f.Price).IsRequired().HasPrecision(18, 2);
builder.Property(f => f.Discount).HasPrecision(18, 2);
builder.Property(f => f.Active);
builder.HasOne(f => f.Product)
.WithMany(f => f.ProductPrices)
.HasForeignKey(pp => pp.ProductId)
.IsRequired()
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,42 @@
using System.ComponentModel.DataAnnotations;
namespace LiteCharms.Features.TechShop.Products.Models;
public class CreateProductModel
{
[MaxLength(128)]
[Required(ErrorMessage = "Product name is required.")]
public string? Name { get; set; }
[MaxLength(512)]
[Required(ErrorMessage = "Summary is required.")]
public string? Summary { get; set; }
[MaxLength(2048)]
[Required(ErrorMessage = "Description is required.")]
public string? Description { get; set; }
[Range(0.01, double.MaxValue, ErrorMessage = "Price must be greater than zero.")]
public decimal Price { get; set; }
[MaxLength(128)]
[Required(ErrorMessage = "Author metadata is required.")]
public string? Author { get; set; }
[Required(ErrorMessage = "Publication Date is required.")]
public DateTime PublishDate { get; set; } = DateTime.Today;
[MaxLength(255)]
[Required(ErrorMessage = "Copyright Information field is required.")]
public string? CopyrightInfo { get; set; }
[MaxLength(128)]
[Required(ErrorMessage = "ISBN code is required.")]
[RegularExpression(@"^(?=(?:\D*\d){10}(?:(?:\D*\d){3})?$)[\d-]+$", ErrorMessage = "Please enter a valid ISBN-10 or ISBN-13 string.")]
public string? Isbn { get; set; }
[Required(ErrorMessage = "Primary image is required.")]
public string? ImageUrl { get; set; }
public List<string> Thumbnails { get; set; } = [];
}
@@ -0,0 +1,24 @@
namespace LiteCharms.Features.TechShop.Products.Models;
public class Product
{
public Guid Id { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public string? Name { get; set; }
public string? Summary { get; set; }
public string? Description { get; set; }
public string? ImageUrl { get; set; }
public string[]? Thumbnails { get; set; }
public bool Active { get; set; }
public ProductMetadata? Metadata { get; set; }
}
@@ -0,0 +1,12 @@
namespace LiteCharms.Features.TechShop.Products.Models;
public class ProductMetadata
{
public string? Manufacturer { get; set; }
public string? ManufactureDate { get; set; }
public string? CopyrightInfo { get; set; }
public string? SerialNumber { get; set; }
}
@@ -0,0 +1,18 @@
namespace LiteCharms.Features.TechShop.Products.Models;
public class ProductPrice
{
public Guid Id { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public Guid ProductId { get; set; }
public decimal Price { get; set; }
public decimal Discount { get; set; }
public bool Active { get; set; }
}
@@ -0,0 +1,16 @@
namespace LiteCharms.Features.TechShop.Products.Models;
public record CreateProduct
{
public required string Name { get; set; }
public required string Summary { get; set; }
public required string Description { get; set; }
public required string ImageUrl { get; set; }
public string[]? Thumbnails { get; set; }
public ProductMetadata? Metadata { get; set; }
}
@@ -0,0 +1,277 @@
using LiteCharms.Features.TechShop.Extensions;
using LiteCharms.Features.TechShop.Postgres;
using LiteCharms.Features.TechShop.Products.Models;
namespace LiteCharms.Features.TechShop.Products;
public class ProductService(IDbContextFactory<ShopDbContext> contextFactory)
{
public async ValueTask<Result> ChangeProductPriceStatusAsync(Guid productPriceId, bool active, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var price = await context.ProductPrices.FirstOrDefaultAsync(p => p.Id == productPriceId, cancellationToken);
if (price is null)
return Result.Fail($"Could not find product price with ID {productPriceId}");
price.Active = active;
price.UpdatedAt = DateTime.UtcNow;
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok()
: Result.Fail($"Failed to change product price by ID {productPriceId}");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> ChangeProductStatusAsync(Guid productId, bool active, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var product = await context.Products.FirstOrDefaultAsync(p => p.Id == productId, cancellationToken);
if (product is null)
return Result.Fail($"Could not find product with ID {productId}");
product.Active = active;
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok()
: Result.Fail($"Failed to change product status by ID {productId}");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Guid>> CreateProductAsync(CreateProduct request, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (!await context.Products.AnyAsync(p => p.Name == request.Name, cancellationToken))
return Result.Fail<Guid>($"A product by the same name '{request.Name}' already exists");
var newProduct = context.Products.Add(new Entities.Product
{
Name = request.Name,
Summary = request.Summary,
Description = request.Description,
ImageUrl = request.ImageUrl,
Thumbnails = request.Thumbnails,
Metadata = request.Metadata
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok(newProduct.Entity.Id)
: Result.Fail($"Failed to create new product '{request.Name}'");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Guid>> CreateProductPriceAsync(Guid productId, decimal price, decimal discount = 0, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var newProductPrice = context.ProductPrices.Add(new Entities.ProductPrice
{
Price = price,
Discount = discount,
ProductId = productId
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok(newProductPrice.Entity.Id)
: Result.Fail($"Failed to create new product price for product id {productId}");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Product>> GetProductAsync(Guid productId, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var product = await context.Products.FirstOrDefaultAsync(p => p.Id == productId, cancellationToken);
return product is not null
? Result.Ok(product.ToModel())
: Result.Fail<Product>(new Error($"Product with ID {productId} not found."));
}
catch (Exception ex)
{
return Result.Fail<Product>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<ProductPrice>> GetProductPriceAsync(Guid productPriceId, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (!await context.Products.AnyAsync(p => p.Id == productPriceId, cancellationToken))
return Result.Fail<ProductPrice>(new Error($"Product {productPriceId} not found."));
var productPrice = await context.ProductPrices.AsNoTracking()
.OrderByDescending(pp => pp.CreatedAt)
.FirstOrDefaultAsync(pp => pp.Id == productPriceId, cancellationToken);
return productPrice is not null
? Result.Ok(productPrice.ToModel())
: Result.Fail<ProductPrice>(new Error($"Product price {productPriceId} not found."));
}
catch (Exception ex)
{
return Result.Fail<ProductPrice>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<ProductPrice[]>> GetProductPricesAsync(int maxRecords = 1000, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var products = await context.ProductPrices.AsNoTracking()
.OrderByDescending(o => o.Id)
.Take(maxRecords)
.ToArrayAsync(cancellationToken);
return Result.Ok(products.Select(p => p.ToModel()).ToArray());
}
catch (Exception ex)
{
return Result.Fail<ProductPrice[]>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Product[]>> GetProductsAsync(int maxRecords = 1000, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var products = await context.Products.AsNoTracking()
.OrderByDescending(o => o.Id)
.Take(maxRecords)
.ToArrayAsync(cancellationToken);
return Result.Ok(products.Select(p => p.ToModel()).ToArray());
}
catch (Exception ex)
{
return Result.Fail<Product[]>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Guid>> ReplaceProductPriceAsync(Guid productPriceId, decimal price, decimal discount = 0, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var existingPrice = await context.ProductPrices.FirstOrDefaultAsync(p => p.Id == productPriceId, cancellationToken);
if (existingPrice is null)
return Result.Fail($"Could not find product price with ID {productPriceId}");
existingPrice.Active = false;
existingPrice.UpdatedAt = DateTime.UtcNow;
if (!(await context.SaveChangesAsync(cancellationToken) > 0))
return Result.Fail<Guid>($"Failed to deactivate existing price of ID {productPriceId}, try again later");
var result = await CreateProductPriceAsync(existingPrice.ProductId, price, discount, cancellationToken);
if (result.IsFailed)
{
var deactivatedPrice = await context.ProductPrices.FirstOrDefaultAsync(p => p.Id == productPriceId, cancellationToken);
existingPrice.Active = true;
existingPrice.UpdatedAt = DateTime.UtcNow;
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Fail<Guid>("Reverted to old price, creation of new price failed")
: Result.Fail<Guid>($"Failed to reactivate price of ID {productPriceId} after new price creation failed");
}
return Result.Ok(result.Value);
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> SetProductPriceStatusAsync(Guid productPriceId, bool active, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var productPrice = await context.ProductPrices.FirstOrDefaultAsync(p => p.Id == productPriceId, cancellationToken);
if (productPrice is null)
return Result.Fail($"Could not find product price with ID {productPriceId}");
productPrice.Active = active;
productPrice.UpdatedAt = DateTime.UtcNow;
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok()
: Result.Fail($"Failed to change product price status by ID {productPriceId}");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> UpdateProductMetadataAsync(Guid productId, ProductMetadata metadata, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var product = await context.Products.FirstOrDefaultAsync(p => p.Id == productId, cancellationToken);
if (product is null)
return Result.Fail($"Could not find product with ID {productId}");
product.Metadata = metadata;
product.UpdatedAt = DateTime.UtcNow;
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok()
: Result.Fail($"Failed to update product metadata by ID {productId}");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
}