This commit is contained in:
2025-10-27 17:39:18 -04:00
commit 31f723bea4
1579 changed files with 642409 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
using Prefab.Handler;
using Prefab.Shared.Catalog.Categories;
namespace Prefab.Catalog.App.Categories;
public sealed class CategoryClient(HandlerInvoker handler) : ICategoryClient
{
public async Task<Shared.Catalog.Categories.GetCategories.Response> GetCategories(CancellationToken cancellationToken)
{
var response = await handler.Execute<Shared.Catalog.Categories.GetCategories.Response>(cancellationToken);
return response;
}
public async Task<Shared.Catalog.Categories.CreateCategory.Response> CreateCategory(Shared.Catalog.Categories.CreateCategory.Request request, CancellationToken cancellationToken)
{
var response =await handler.Execute<Shared.Catalog.Categories.CreateCategory.Request, Shared.Catalog.Categories.CreateCategory.Response>(request, cancellationToken);
return response;
}
}

View File

@@ -0,0 +1,173 @@
using System.Text.RegularExpressions;
using Prefab.Catalog.Domain.Entities;
using CategoryDtos = Prefab.Shared.Catalog.Products.GetCategoryModels;
namespace Prefab.Catalog.App.Categories;
/// <summary>
/// Builds ordered category hierarchies and provides access helpers for rendering browse DTOs.
/// </summary>
internal sealed class CategoryTreeBuilder
{
private static readonly Regex NonSlugCharacters = new("[^a-z0-9]+", RegexOptions.Compiled);
private readonly List<Category> _rootCategories;
private readonly Dictionary<Guid, List<Category>> _childrenByParent;
private readonly Dictionary<Guid, Category> _categoryById;
private readonly Dictionary<string, Category> _categoryBySlug;
internal CategoryTreeBuilder(IEnumerable<Category> categories)
{
var active = categories
.Where(category => category.DeletedOn == null)
.ToList();
_categoryById = active.ToDictionary(category => category.Id);
_rootCategories = OrderCategories(active.Where(category => category.ParentId is null)).ToList();
_childrenByParent = active
.Where(category => category.ParentId.HasValue)
.GroupBy(category => category.ParentId!.Value)
.ToDictionary(
group => group.Key,
group => OrderCategories(group).ToList());
_categoryBySlug = new Dictionary<string, Category>(StringComparer.OrdinalIgnoreCase);
foreach (var category in active)
{
var slug = CreateSlug(category);
if (_categoryBySlug.ContainsKey(slug))
{
continue;
}
_categoryBySlug[slug] = category;
}
}
internal Category? FindBySlug(string slug)
{
if (string.IsNullOrWhiteSpace(slug))
{
return null;
}
var normalized = NormalizeSlug(slug);
return _categoryBySlug.TryGetValue(normalized, out var category) ? category : null;
}
internal bool TryGetCategory(Guid id, out Category category) => _categoryById.TryGetValue(id, out category!);
internal bool IsLeaf(Category category)
{
ArgumentNullException.ThrowIfNull(category);
return !_childrenByParent.TryGetValue(category.Id, out var children) || children.Count == 0;
}
internal string GetSlug(Category category) => CreateSlug(category);
internal IReadOnlyList<CategoryDtos.CategoryNodeDto> BuildTree(Guid? parentId, int? depth) =>
BuildNodes(parentId, NormalizeDepth(depth));
internal CategoryDtos.CategoryDetailDto CreateDetail(Category category, int? depth)
{
ArgumentNullException.ThrowIfNull(category);
var slug = GetSlug(category);
var children = BuildNodes(category.Id, NormalizeDepth(depth));
return new CategoryDtos.CategoryDetailDto(
category.Id,
category.Name,
slug,
category.Description,
category.HeroImageUrl,
category.Icon,
IsLeaf(category),
children);
}
private IReadOnlyList<CategoryDtos.CategoryNodeDto> BuildNodes(Guid? parentId, int depth)
{
if (depth <= 0)
{
return [];
}
IReadOnlyList<Category> source;
if (parentId is null)
{
source = _rootCategories;
}
else if (!_childrenByParent.TryGetValue(parentId.Value, out var children) || children.Count == 0)
{
return [];
}
else
{
source = children;
}
if (source.Count == 0)
{
return [];
}
var remainingDepth = depth == int.MaxValue ? int.MaxValue : depth - 1;
var nodes = new List<CategoryDtos.CategoryNodeDto>(source.Count);
foreach (var child in source)
{
var childNodes = remainingDepth <= 0
? []
: BuildNodes(child.Id, remainingDepth);
nodes.Add(new CategoryDtos.CategoryNodeDto(
child.Id,
child.Name,
GetSlug(child),
child.Description,
child.HeroImageUrl,
child.Icon,
child.DisplayOrder,
child.IsFeatured,
IsLeaf(child),
childNodes));
}
return nodes;
}
private static IEnumerable<Category> OrderCategories(IEnumerable<Category> categories) =>
categories
.OrderBy(category => category.DisplayOrder)
.ThenBy(category => category.Name);
private static int NormalizeDepth(int? depth) =>
depth.HasValue && depth.Value > 0 ? depth.Value : int.MaxValue;
private static string CreateSlug(Category category)
{
if (!string.IsNullOrWhiteSpace(category.Slug))
{
return NormalizeSlug(category.Slug);
}
return Slugify(category.Name);
}
private static string NormalizeSlug(string slug) => slug.Trim().ToLowerInvariant();
private static string Slugify(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var trimmed = value.Trim().ToLowerInvariant();
var sanitized = NonSlugCharacters.Replace(trimmed, "-");
return sanitized.Trim('-');
}
}

View File

@@ -0,0 +1,50 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Prefab.Catalog.Data;
using Prefab.Catalog.Domain.Entities;
using Prefab.Catalog.Domain.Services;
using Prefab.Endpoints;
using Prefab.Shared.Catalog;
using Prefab.Shared.Catalog.Categories;
using Prefab.Handler;
namespace Prefab.Catalog.App.Categories;
internal class CreateCategory : Shared.Catalog.Categories.CreateCategory
{
public sealed class Endpoint : IEndpointRegistrar
{
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
endpoints.MapPost("/api/catalog/categories", async (ICategoryClient categoryClient, Request request, CancellationToken cancellationToken) =>
{
var response = await categoryClient.CreateCategory(request, cancellationToken);
return Results.Ok(response);
})
.WithModuleName<IModuleClient>(nameof(CreateCategory))
.WithTags(nameof(Categories));
}
}
internal sealed class Handler(ICatalogDbContextFactory dbFactory, IUniqueChecker checker, IHandlerContextAccessor accessor) : HandlerBase(accessor), IHandler<Request, Response>
{
public async Task<Response> Execute(Request request, CancellationToken cancellationToken)
{
await using var db = await dbFactory.CreateWritableAsync(cancellationToken);
var category = await Category.Create(
request.Name,
request.Description,
checker, cancellationToken);
db.Categories.Add(category);
await db.SaveChangesAsync(cancellationToken);
var response = new Response(category.Id);
return response;
}
}
}

View File

@@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Prefab.Catalog.Data;
using Prefab.Endpoints;
using Prefab.Handler;
using Prefab.Module;
using Prefab.Shared.Catalog.Categories;
namespace Prefab.Catalog.App.Categories;
public class GetCategories : Prefab.Shared.Catalog.Categories.GetCategories
{
public sealed class Endpoint : IEndpointRegistrar
{
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
endpoints.MapGet("/api/catalog/categories", async (ICategoryClient categoryClient, CancellationToken cancellationToken) =>
{
var response = await categoryClient.GetCategories(cancellationToken);
return response;
})
.WithModuleName<IModule>(nameof(GetCategories))
.WithTags(nameof(Categories));
}
}
internal sealed class Handler(ICatalogDbContextFactory dbFactory, IHandlerContextAccessor accessor) : HandlerBase(accessor), IHandler<Response>
{
public async Task<Response> Execute(CancellationToken cancellationToken)
{
await using var db = await dbFactory.CreateReadOnlyAsync(cancellationToken);
var categories = await db.Categories
.GroupJoin(
db.Categories,
child => child.ParentId,
parent => parent.Id,
(child, parents) => new
{
Child = child,
ParentName = parents.Select(parent => parent.Name).FirstOrDefault()
})
.Select(x => new ListItem(
x.Child.Id,
x.Child.Name,
x.ParentName ?? string.Empty))
.ToListAsync(cancellationToken);
var response = new Response(categories);
return response;
}
}
}

View File

@@ -0,0 +1,69 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Prefab.Catalog.Data;
using Prefab.Catalog.Domain.Exceptions;
using Prefab.Endpoints;
using Prefab.Handler;
using Prefab.Module;
using Prefab.Shared.Catalog.Products;
namespace Prefab.Catalog.App.Categories;
/// <summary>
/// Returns a single category node with its direct children.
/// </summary>
public class GetCategory : Prefab.Shared.Catalog.Products.GetCategory
{
public sealed class Endpoint : IEndpointRegistrar
{
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
endpoints.MapGet(
"/api/catalog/categories/{slug}",
async (IProductClient productClient, string slug, CancellationToken cancellationToken) =>
{
try
{
var response = await productClient.GetCategory(slug, cancellationToken);
return Results.Ok(response);
}
catch (CatalogNotFoundException ex)
{
return Results.NotFound(ApiProblemDetails.NotFound(ex.Resource, ex.Identifier, ex.Message));
}
})
.WithModuleName<IModule>(nameof(GetCategory))
.WithTags("Categories");
}
}
internal sealed class Handler(ICatalogDbContextFactory dbFactory, IHandlerContextAccessor accessor)
: HandlerBase(accessor), IHandler<Request, Response>
{
public async Task<Response> Execute(Request request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.Slug);
var normalizedSlug = request.Slug.Trim();
await using var db = await dbFactory.CreateReadOnlyAsync(cancellationToken);
var categories = await db.Categories
.Where(category => category.DeletedOn == null)
.OrderBy(category => category.DisplayOrder)
.ThenBy(category => category.Name)
.ToListAsync(cancellationToken);
var tree = new CategoryTreeBuilder(categories);
var category = tree.FindBySlug(normalizedSlug) ??
throw new CatalogNotFoundException("Category", normalizedSlug);
var detail = tree.CreateDetail(category, depth: null);
return new Response(detail);
}
}
}

View File

@@ -0,0 +1,330 @@
using System.Text.RegularExpressions;
using FluentValidation;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Prefab.Base.Catalog.Products;
using Prefab.Catalog.Data;
using Prefab.Catalog.Domain.Entities;
using Prefab.Catalog.Domain.Exceptions;
using Prefab.Endpoints;
using Prefab.Handler;
using Prefab.Module;
using Prefab.Shared.Catalog.Products;
using SharedBrowse = Prefab.Shared.Catalog.Products.GetCategoryModels;
namespace Prefab.Catalog.App.Categories;
/// <summary>
/// Lists product model cards for a category slug, including a computed “from price” for browse screens.
/// </summary>
public class GetCategoryModels : SharedBrowse
{
private static readonly Regex SlugRegex = new(ProductRules.SlugPattern, RegexOptions.Compiled);
private static readonly Regex NonSlugCharacters = new("[^a-z0-9]+", RegexOptions.Compiled);
public sealed class Endpoint : IEndpointRegistrar
{
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
endpoints.MapGet(
"/api/catalog/categories/{slug}/models",
async (
IProductClient productClient,
string slug,
int? page,
int? pageSize,
string? sort,
string? dir,
CancellationToken cancellationToken) =>
{
try
{
var response = await productClient.GetCategoryModels(slug, page, pageSize, sort, dir, cancellationToken);
return Results.Ok(response);
}
catch (CatalogConflictException conflict)
{
return Results.Conflict(ApiProblemDetails.Conflict(conflict.Message, conflict.Resource, conflict.Identifier));
}
catch (CatalogNotFoundException ex)
{
return Results.NotFound(ApiProblemDetails.NotFound(ex.Resource, ex.Identifier, ex.Message));
}
})
.WithModuleName<IModule>(nameof(GetCategoryModels))
.WithTags("Products");
}
}
internal sealed class Handler(ICatalogDbContextFactory dbFactory, IHandlerContextAccessor accessor)
: HandlerBase(accessor), IHandler<Request, Response>
{
public async Task<Response> Execute(Request request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.Slug);
var normalizedSlug = request.Slug.Trim();
await using var db = await dbFactory.CreateReadOnlyAsync(cancellationToken);
var categories = await db.Categories
.Where(category => category.DeletedOn == null)
.OrderBy(category => category.DisplayOrder)
.ThenBy(category => category.Name)
.ToListAsync(cancellationToken);
if (categories.Count == 0)
{
throw new CatalogNotFoundException("Category", normalizedSlug);
}
var tree = new CategoryTreeBuilder(categories);
var category = tree.FindBySlug(normalizedSlug) ??
throw new CatalogNotFoundException("Category", normalizedSlug);
if (!tree.IsLeaf(category))
{
throw new CatalogConflictException("Category", tree.GetSlug(category), "Category is not a leaf.");
}
var models = await db.Products
.Where(product => product.DeletedOn == null &&
product.Kind == ProductKind.Model &&
product.Categories.Any(placement => placement.CategoryId == category.Id && placement.IsPrimary))
.Include(product => product.Variants)
.ToListAsync(cancellationToken);
var cards = models
.Select(CreateCard)
.ToList();
var orderedCards = OrderCards(cards, request.Sort, request.Direction);
var page = request.Page.HasValue && request.Page.Value > 0 ? request.Page.Value : 1;
var pageSize = request.PageSize.HasValue && request.PageSize.Value > 0
? request.PageSize.Value
: orderedCards.Count == 0 ? 1 : orderedCards.Count;
var skip = (page - 1) * pageSize;
var pagedCards = skip >= orderedCards.Count
? new List<SharedBrowse.ProductCardDto>()
: orderedCards.Skip(skip).Take(pageSize).ToList();
var categoryDetail = tree.CreateDetail(category, depth: 1);
var payload = new SharedBrowse.CategoryProductsResponse(
categoryDetail,
pagedCards,
orderedCards.Count,
page,
pageSize);
return new Response(payload);
}
private static SharedBrowse.ProductCardDto CreateCard(Product model)
{
var fromPrice = CalculateFromPrice(model);
var slug = !string.IsNullOrWhiteSpace(model.Slug)
? model.Slug.Trim().ToLowerInvariant()
: Slugify(model.Name);
return new SharedBrowse.ProductCardDto(
model.Id,
model.Name,
slug,
fromPrice,
PrimaryImageUrl: null,
model.LastModifiedOn);
}
private static List<SharedBrowse.ProductCardDto> OrderCards(
List<SharedBrowse.ProductCardDto> cards,
string? sort,
string? direction)
{
var sortKey = string.IsNullOrWhiteSpace(sort) ? "name" : sort.Trim().ToLowerInvariant();
var dirKey = string.IsNullOrWhiteSpace(direction) ? "asc" : direction.Trim().ToLowerInvariant();
IEnumerable<SharedBrowse.ProductCardDto> ordered = sortKey switch
{
"price" => cards
.OrderBy(card => card.FromPrice ?? decimal.MaxValue)
.ThenBy(card => card.Name),
_ => cards
.OrderBy(card => card.Name, StringComparer.OrdinalIgnoreCase)
};
if (dirKey == "desc")
{
ordered = sortKey switch
{
"price" => cards
.OrderByDescending(card => card.FromPrice ?? decimal.MinValue)
.ThenByDescending(card => card.Name, StringComparer.OrdinalIgnoreCase),
_ => cards
.OrderByDescending(card => card.Name, StringComparer.OrdinalIgnoreCase)
};
}
return ordered.ToList();
}
private static decimal CalculatePercentScopeBase(PercentScope? scope, decimal basePrice, decimal numericTotal) =>
scope switch
{
PercentScope.NumericOnly => numericTotal,
PercentScope.BasePlusNumeric => basePrice + numericTotal,
_ => basePrice
};
private static decimal? CalculateFromPrice(Product model)
{
var activeOptions = model.Options
.Where(option => option.DeletedOn == null)
.ToList();
var basePrice = model.Price ?? 0m;
var numericTotal = activeOptions
.Where(option => option.DataType == OptionDataType.Number)
.Sum(CalculateNumberOptionMinDelta);
var choiceTotal = activeOptions
.Where(option => option.DataType == OptionDataType.Choice)
.Sum(option => CalculateChoiceOptionMinDelta(option, basePrice, numericTotal));
var total = basePrice + numericTotal + choiceTotal;
return Math.Round(total, 2, MidpointRounding.AwayFromZero);
}
private static decimal CalculateNumberOptionMinDelta(OptionDefinition option)
{
var quantity = option.Min ?? 0m;
if (option.Step is > 0)
{
var step = option.Step.Value;
quantity = Math.Ceiling(quantity / step) * step;
if (option.Min.HasValue)
{
quantity = Math.Max(quantity, option.Min.Value);
}
}
if (option.Max.HasValue && quantity > option.Max.Value)
{
quantity = option.Max.Value;
}
var applicableTier = option.Tiers
.Where(tier => tier.DeletedOn == null && quantity >= tier.FromInclusive && (tier.ToInclusive == null || quantity <= tier.ToInclusive.Value))
.OrderByDescending(tier => tier.FromInclusive)
.FirstOrDefault();
var rate = applicableTier?.UnitRate ?? option.PricePerUnit ?? 0m;
var flat = applicableTier?.FlatDelta ?? 0m;
return (rate * quantity) + flat;
}
private static decimal CalculateChoiceOptionMinDelta(OptionDefinition option, decimal basePrice, decimal numericTotal)
{
var values = option.Values
.Where(value => value.DeletedOn == null)
.ToList();
if (values.Count == 0)
{
return 0m;
}
decimal? minAbsolute = null;
decimal? minPercent = null;
foreach (var value in values)
{
var delta = value.PriceDelta ?? 0m;
if (value.PriceDeltaKind == PriceDeltaKind.Absolute)
{
minAbsolute = minAbsolute.HasValue ? Math.Min(minAbsolute.Value, delta) : delta;
continue;
}
var scopeBase = CalculatePercentScopeBase(option.PercentScope, basePrice, numericTotal);
var percentDelta = scopeBase * (delta / 100m);
minPercent = minPercent.HasValue ? Math.Min(minPercent.Value, percentDelta) : percentDelta;
}
if (minAbsolute.HasValue && minPercent.HasValue)
{
return Math.Min(minAbsolute.Value, minPercent.Value);
}
return minAbsolute ?? minPercent ?? 0m;
}
}
internal sealed class Validator : AbstractValidator<Request>
{
private static readonly string[] AllowedSorts = ["name", "price"];
private static readonly string[] AllowedDirections = ["asc", "desc"];
public Validator()
{
RuleFor(request => request.Slug)
.Cascade(CascadeMode.Stop)
.NotEmpty().WithMessage("Slug is required.")
.Must(slug => slug is null || slug.Trim().Length <= ProductRules.SlugMaxLength)
.WithMessage($"Slug cannot exceed {ProductRules.SlugMaxLength} characters.")
.Must(IsValidSlug)
.WithMessage("Slug must use lowercase letters, numbers, and hyphens.");
RuleFor(request => request.Page)
.GreaterThan(0)
.When(request => request.Page.HasValue)
.WithMessage("Page must be greater than zero.");
RuleFor(request => request.PageSize)
.GreaterThan(0)
.When(request => request.PageSize.HasValue)
.WithMessage("PageSize must be greater than zero.");
RuleFor(request => request.Sort)
.Must(sort => string.IsNullOrWhiteSpace(sort) || AllowedSorts.Contains(sort.Trim().ToLowerInvariant()))
.WithMessage("Sort must be 'name' or 'price'.");
RuleFor(request => request.Direction)
.Must(dir => string.IsNullOrWhiteSpace(dir) || AllowedDirections.Contains(dir.Trim().ToLowerInvariant()))
.WithMessage("Direction must be 'asc' or 'desc'.");
}
private static bool IsValidSlug(string? slug)
{
if (string.IsNullOrWhiteSpace(slug))
{
return false;
}
var trimmed = slug.Trim();
return SlugRegex.IsMatch(trimmed);
}
}
private static string Slugify(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var trimmed = value.Trim().ToLowerInvariant();
var sanitized = NonSlugCharacters.Replace(trimmed, "-");
return sanitized.Trim('-');
}
}

View File

@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Prefab.Catalog.Data;
using Prefab.Endpoints;
using Prefab.Handler;
using Prefab.Module;
using Prefab.Shared.Catalog.Products;
namespace Prefab.Catalog.App.Categories;
/// <summary>
/// Returns the category hierarchy for browse experiences.
/// </summary>
public class GetCategoryTree : Prefab.Shared.Catalog.Products.GetCategoryTree
{
public sealed class Endpoint : IEndpointRegistrar
{
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
endpoints.MapGet(
"/api/catalog/categories/tree",
async (IProductClient productClient, int? depth, CancellationToken cancellationToken) =>
{
var response = await productClient.GetCategoryTree(depth, cancellationToken);
return Results.Ok(response);
})
.WithModuleName<IModule>(nameof(GetCategoryTree))
.WithTags("Categories");
}
}
internal sealed class Handler(ICatalogDbContextFactory dbFactory, IHandlerContextAccessor accessor) : HandlerBase(accessor), IHandler<Request, Response>
{
public async Task<Response> Execute(Request request, CancellationToken cancellationToken)
{
await using var db = await dbFactory.CreateReadOnlyAsync(cancellationToken);
var categories = await db.Categories
.Where(category => category.DeletedOn == null)
.OrderBy(category => category.DisplayOrder)
.ThenBy(category => category.Name)
.ToListAsync(cancellationToken);
var tree = new CategoryTreeBuilder(categories);
var nodes = tree.BuildTree(parentId: null, request.Depth);
return new Response(nodes);
}
}
}

View File

@@ -0,0 +1,267 @@
using System.Text.RegularExpressions;
using FluentValidation;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Prefab.Base.Catalog.Products;
using Prefab.Catalog.Data;
using Prefab.Catalog.Domain.Entities;
using Prefab.Catalog.Domain.Exceptions;
using Prefab.Endpoints;
using Prefab.Handler;
using Prefab.Module;
using Prefab.Shared.Catalog.Products;
using SharedProductDetail = Prefab.Shared.Catalog.Products.GetProductDetail;
namespace Prefab.Catalog.App.Products;
/// <summary>
/// Exposes the PDP configuration endpoint that surfaces model details, options, specs, and metadata for a given product slug.
/// </summary>
public class GetProductDetail : SharedProductDetail
{
private static readonly Regex SlugRegex = new(ProductRules.SlugPattern, RegexOptions.Compiled);
public sealed class Endpoint : IEndpointRegistrar
{
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
endpoints.MapGet(
"/api/catalog/products/{slug}",
async (IProductClient productClient, string slug, CancellationToken cancellationToken) =>
{
try
{
var response = await productClient.GetProductDetail(slug, cancellationToken);
return Results.Ok(response);
}
catch (CatalogNotFoundException ex)
{
return Results.NotFound(ApiProblemDetails.NotFound(ex.Resource, ex.Identifier, ex.Message));
}
})
.WithModuleName<IModule>(nameof(GetProductDetail))
.WithTags("Products");
}
}
/// <summary>
/// Handler responsible for loading the requested product model and projecting it into the shared DTO that powers the PDP.
/// </summary>
internal sealed class Handler(ICatalogDbContextFactory dbFactory, IHandlerContextAccessor accessor)
: HandlerBase(accessor), IHandler<string, Response>
{
public async Task<Response> Execute(string slug, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(slug);
var normalizedSlug = slug.Trim();
await using var db = await dbFactory.CreateReadOnlyAsync(cancellationToken);
var product = await db.Products
.Where(model => model.DeletedOn == null && model.Kind == ProductKind.Model && model.Slug == normalizedSlug)
.Include(model => model.Variants).ThenInclude(variant => variant.AxisValues)
.Include(model => model.Options).ThenInclude(option => option.Values)
.Include(model => model.Options).ThenInclude(option => option.Tiers)
.Include(model => model.RuleSets).ThenInclude(rule => rule.Conditions)
.Include(model => model.Attributes).ThenInclude(attribute => attribute.AttributeDefinition)
.FirstOrDefaultAsync(cancellationToken);
if (product is null)
{
throw new CatalogNotFoundException("Product", normalizedSlug);
}
var optionDtos = MapOptions(product);
var specDtos = MapSpecs(product);
var genericAttributes = await LoadGenericAttributesAsync(db, product.Id, cancellationToken);
var payload = new SharedProductDetail.ProductDetailDto(
product.Id,
product.Name,
product.Slug ?? normalizedSlug,
product.Description,
product.Price,
optionDtos,
specDtos,
genericAttributes);
return new Response(payload);
}
private static IReadOnlyList<SharedProductDetail.OptionDefinitionDto> MapOptions(Product product)
{
var activeOptions = product.Options
.Where(option => option.DeletedOn == null)
.OrderBy(option => option.Name)
.ToList();
var optionLookup = activeOptions.ToDictionary(option => option.Id);
var valueLookup = activeOptions
.SelectMany(option => option.Values.Where(value => value.DeletedOn == null))
.ToDictionary(value => value.Id);
var (definitionRules, valueRules) = MapRuleSets(product, optionLookup, valueLookup);
var optionDtos = new List<SharedProductDetail.OptionDefinitionDto>(activeOptions.Count);
foreach (var option in activeOptions)
{
var tiers = option.Tiers
.Where(tier => tier.DeletedOn == null)
.OrderBy(tier => tier.FromInclusive)
.Select(tier => new SharedProductDetail.OptionTierDto(tier.FromInclusive, tier.ToInclusive, tier.UnitRate, tier.FlatDelta))
.ToList();
var valueDtos = option.Values
.Where(value => value.DeletedOn == null)
.OrderBy(value => value.Label)
.Select(value => new SharedProductDetail.OptionValueDto(
value.Id,
value.Code,
value.Label,
value.PriceDelta,
(int)value.PriceDeltaKind,
TryGetRules(valueRules, value.Id)))
.ToList();
optionDtos.Add(new SharedProductDetail.OptionDefinitionDto(
option.Id,
option.Code,
option.Name,
(int)option.DataType,
option.IsVariantAxis,
option.Unit,
option.Min,
option.Max,
option.Step,
option.PricePerUnit,
tiers,
valueDtos,
TryGetRules(definitionRules, option.Id)));
}
return optionDtos;
}
private static (Dictionary<Guid, List<SharedProductDetail.RuleSetDto>> DefinitionRules, Dictionary<Guid, List<SharedProductDetail.RuleSetDto>> ValueRules) MapRuleSets(
Product product,
IReadOnlyDictionary<Guid, OptionDefinition> optionLookup,
IReadOnlyDictionary<Guid, OptionValue> valueLookup)
{
var definitionRules = new Dictionary<Guid, List<SharedProductDetail.RuleSetDto>>();
var valueRules = new Dictionary<Guid, List<SharedProductDetail.RuleSetDto>>();
foreach (var ruleSet in product.RuleSets.Where(rule => rule.DeletedOn == null))
{
var conditions = new List<SharedProductDetail.ConditionDto>();
foreach (var condition in ruleSet.Conditions.Where(condition => condition.DeletedOn == null))
{
if (!optionLookup.TryGetValue(condition.LeftOptionDefinitionId, out var leftOption))
{
continue;
}
string? rightValueCode = null;
if (condition.RightOptionValueId.HasValue && valueLookup.TryGetValue(condition.RightOptionValueId.Value, out var rightValue))
{
rightValueCode = rightValue.Code;
}
conditions.Add(new SharedProductDetail.ConditionDto(
leftOption.Code,
condition.Operator.ToString(),
rightValueCode,
condition.RightList,
condition.RightNumber,
condition.RightMin,
condition.RightMax));
}
if (conditions.Count == 0)
{
continue;
}
var dto = new SharedProductDetail.RuleSetDto(
ruleSet.Effect.ToString(),
ruleSet.Mode.ToString(),
conditions);
var target = ruleSet.TargetKind == OptionRuleTargetKind.OptionDefinition
? definitionRules
: valueRules;
if (!target.TryGetValue(ruleSet.TargetId, out var bucket))
{
bucket = new List<SharedProductDetail.RuleSetDto>();
target[ruleSet.TargetId] = bucket;
}
bucket.Add(dto);
}
return (definitionRules, valueRules);
}
private static IReadOnlyList<SharedProductDetail.RuleSetDto>? TryGetRules(
IReadOnlyDictionary<Guid, List<SharedProductDetail.RuleSetDto>> source,
Guid targetId) =>
source.TryGetValue(targetId, out var rules) ? rules : null;
private static IReadOnlyList<SharedProductDetail.SpecDto> MapSpecs(Product product) =>
product.Attributes
.Where(attribute => attribute.DeletedOn == null && attribute.AttributeDefinition.DeletedOn == null)
.Select(attribute => new SharedProductDetail.SpecDto(
attribute.AttributeDefinition.Name,
attribute.Value,
attribute.NumericValue,
attribute.UnitCode))
.ToList();
private static async Task<IReadOnlyDictionary<string, string>?> LoadGenericAttributesAsync(
IModuleDbReadOnly db,
Guid productId,
CancellationToken cancellationToken)
{
var attributes = await db.GenericAttributes
.Where(attribute =>
attribute.DeletedOn == null &&
attribute.EntityId == productId &&
attribute.KeyGroup == typeof(Product).FullName)
.Select(attribute => new { attribute.Key, attribute.Value })
.ToDictionaryAsync(attribute => attribute.Key, attribute => attribute.Value, cancellationToken);
return attributes.Count == 0 ? null : attributes;
}
}
internal sealed class Validator : AbstractValidator<string>
{
public Validator()
{
RuleFor(slug => slug)
.Cascade(CascadeMode.Stop)
.NotEmpty().WithMessage("Slug is required.")
.Must(slug => !string.IsNullOrWhiteSpace(slug?.Trim()))
.WithMessage("Slug is required.")
.Must(slug => slug is null || slug.Trim().Length <= ProductRules.SlugMaxLength)
.WithMessage($"Slug cannot exceed {ProductRules.SlugMaxLength} characters.")
.Must(IsValidSlug)
.WithMessage("Slug must use lowercase letters, numbers, and hyphens.");
}
private static bool IsValidSlug(string? slug)
{
if (string.IsNullOrWhiteSpace(slug))
{
return false;
}
var trimmed = slug.Trim();
return SlugRegex.IsMatch(trimmed);
}
}
}

View File

@@ -0,0 +1,44 @@
using Prefab.Handler;
using Prefab.Shared.Catalog.Products;
namespace Prefab.Catalog.App.Products;
public sealed class ProductClient(HandlerInvoker handler) : IProductClient, IPriceQuoteClient
{
public Task<GetCategoryTree.Response> GetCategoryTree(int? depth, CancellationToken cancellationToken)
{
return handler.Execute<Categories.GetCategoryTree.Request, GetCategoryTree.Response>(
new Categories.GetCategoryTree.Request(depth),
cancellationToken);
}
public Task<GetCategory.Response> GetCategory(string slug, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(slug);
return handler.Execute<Categories.GetCategory.Request, GetCategory.Response>(
new Categories.GetCategory.Request(slug.Trim()),
cancellationToken);
}
public Task<GetCategoryModels.Response> GetCategoryModels(string slug, int? page, int? pageSize, string? sort, string? direction, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(slug);
var request = new Categories.GetCategoryModels.Request(slug.Trim(), page, pageSize, sort, direction);
return handler.Execute<Categories.GetCategoryModels.Request, GetCategoryModels.Response>(request, cancellationToken);
}
public Task<GetProductDetail.Response> GetProductDetail(string slug, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(slug);
return handler.Execute<string, GetProductDetail.Response>(slug.Trim(), cancellationToken);
}
public Task<QuotePrice.Response> QuotePrice(QuotePrice.Request request, CancellationToken cancellationToken) =>
Quote(request, cancellationToken);
public Task<QuotePrice.Response> Quote(QuotePrice.Request request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
return handler.Execute<QuotePrice.Request, QuotePrice.Response>(request, cancellationToken);
}
}

View File

@@ -0,0 +1,211 @@
using FluentValidation;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Prefab.Base.Catalog.Products;
using Prefab.Catalog.Data;
using Prefab.Catalog.Domain.Entities;
using Prefab.Catalog.Domain.Exceptions;
using Prefab.Catalog.Domain.Services;
using Prefab.Endpoints;
using Prefab.Handler;
using Prefab.Module;
using Prefab.Shared.Catalog.Products;
using SharedQuotePrice = Prefab.Shared.Catalog.Products.QuotePrice;
namespace Prefab.Catalog.App.Products;
public class QuotePrice : SharedQuotePrice
{
public sealed class Endpoint : IEndpointRegistrar
{
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
endpoints.MapPost(
"/api/catalog/price-quotes",
async (IPriceQuoteClient client, Request request, CancellationToken cancellationToken) =>
{
var response = await client.Quote(request, cancellationToken);
return Results.Ok(response);
})
.WithModuleName<IModule>(nameof(QuotePrice))
.WithTags("Products");
}
}
internal sealed class Handler(
ICatalogDbContextFactory dbFactory,
RuleEvaluator ruleEvaluator,
VariantResolver variantResolver,
IPricingService pricingService) : IHandler<Request, Response>
{
public async Task<Response> Execute(Request request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var trimmedSku = string.IsNullOrWhiteSpace(request.Sku) ? null : request.Sku.Trim();
await using var db = await dbFactory.CreateReadOnlyAsync(cancellationToken);
var (model, initialVariant) = await LoadProductContextAsync(db, request, trimmedSku, cancellationToken);
var optionSelections = (request.Selections ?? Array.Empty<Selection>())
.Select(selection => new OptionSelection(selection.OptionDefinitionId, selection.OptionValueId, selection.NumericValue))
.ToList();
var evaluation = ruleEvaluator.Evaluate(model, optionSelections);
if (!evaluation.IsValid)
{
throw new DomainValidationException(string.Join("; ", evaluation.Errors));
}
var variantResult = variantResolver.Resolve(model, evaluation.Selections, trimmedSku);
if (!variantResult.IsSuccess)
{
throw new DomainValidationException(string.Join("; ", variantResult.Errors));
}
var resolvedVariant = variantResult.Variant ?? initialVariant;
var sanitizedSelections = evaluation.Selections
.Select(selection => new Selection(selection.OptionDefinitionId, selection.OptionValueId, selection.NumericValue))
.ToList();
var pricingRequest = new Request(
resolvedVariant?.Id ?? request.ProductId ?? model.Id,
resolvedVariant?.Sku ?? trimmedSku,
sanitizedSelections);
return await pricingService.QuoteAsync(pricingRequest, cancellationToken);
}
private static async Task<(Product Model, Product? Variant)> LoadProductContextAsync(
IModuleDbReadOnly db,
Request request,
string? trimmedSku,
CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(trimmedSku))
{
var variantInfo = await db.Products
.Where(product => product.DeletedOn == null && product.Sku != null && product.Sku == trimmedSku)
.Select(product => new { product.Id, product.Kind, product.ParentProductId })
.FirstOrDefaultAsync(cancellationToken);
if (variantInfo is null)
{
throw new CatalogNotFoundException("Product", trimmedSku);
}
if (variantInfo.Kind == ProductKind.Model)
{
var modelForSku = await LoadModelAsync(db, variantInfo.Id, cancellationToken);
return (modelForSku, null);
}
if (variantInfo.ParentProductId is null)
{
throw new CatalogNotFoundException("Product", trimmedSku);
}
var model = await LoadModelAsync(db, variantInfo.ParentProductId.Value, cancellationToken);
var variant = model.Variants.FirstOrDefault(product => product.Id == variantInfo.Id && product.DeletedOn == null)
?? throw new CatalogNotFoundException("Product", trimmedSku);
return (model, variant);
}
if (request.ProductId.HasValue)
{
var identifier = request.ProductId.Value;
var productInfo = await db.Products
.Where(product => product.DeletedOn == null && product.Id == identifier)
.Select(product => new { product.Id, product.Kind, product.ParentProductId })
.FirstOrDefaultAsync(cancellationToken);
if (productInfo is null)
{
throw new CatalogNotFoundException("Product", identifier.ToString());
}
if (productInfo.Kind == ProductKind.Model)
{
var model = await LoadModelAsync(db, productInfo.Id, cancellationToken);
return (model, null);
}
if (productInfo.ParentProductId is null)
{
throw new CatalogNotFoundException("Product", identifier.ToString());
}
var parentModel = await LoadModelAsync(db, productInfo.ParentProductId.Value, cancellationToken);
var variant = parentModel.Variants.FirstOrDefault(product => product.Id == productInfo.Id && product.DeletedOn == null)
?? throw new CatalogNotFoundException("Product", identifier.ToString());
return (parentModel, variant);
}
throw new DomainValidationException("Either productId or sku must be provided.");
}
private static async Task<Product> LoadModelAsync(IModuleDbReadOnly db, Guid modelId, CancellationToken cancellationToken)
{
var model = await db.Products
.Where(product => product.DeletedOn == null && product.Id == modelId && product.Kind == ProductKind.Model)
.Include(product => product.Options).ThenInclude(option => option.Values)
.Include(product => product.Options).ThenInclude(option => option.Tiers)
.Include(product => product.RuleSets).ThenInclude(rule => rule.Conditions)
.Include(product => product.Variants).ThenInclude(variant => variant.AxisValues)
.Include(product => product.AxisValues)
.FirstOrDefaultAsync(cancellationToken);
if (model is null)
{
throw new CatalogNotFoundException("Product", modelId.ToString());
}
return model;
}
}
internal sealed class Validator : AbstractValidator<Request>
{
public Validator()
{
RuleFor(request => request)
.Must(HasLookupKey)
.WithMessage("Either productId or sku must be provided.");
RuleFor(request => request.Sku)
.Cascade(CascadeMode.Stop)
.Must(sku => string.IsNullOrWhiteSpace(sku) || sku.Trim().Length > 0)
.WithMessage("Sku cannot be empty.")
.MaximumLength(ProductRules.SkuMaxLength)
.When(request => !string.IsNullOrWhiteSpace(request.Sku));
RuleFor(request => request.Selections)
.NotNull().WithMessage("Selections cannot be null.");
RuleForEach(request => request.Selections)
.SetValidator(new SelectionValidator());
}
private static bool HasLookupKey(Request request) =>
request.ProductId.HasValue || !string.IsNullOrWhiteSpace(request.Sku);
private sealed class SelectionValidator : AbstractValidator<Selection>
{
public SelectionValidator()
{
RuleFor(selection => selection.OptionDefinitionId)
.NotEmpty().WithMessage("OptionDefinitionId is required.");
RuleFor(selection => selection)
.Must(selection => selection.OptionValueId.HasValue || selection.NumericValue.HasValue)
.WithMessage("Each selection must include optionValueId or numericValue.");
}
}
}
}