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,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.");
}
}
}
}