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,46 @@
@using System.Globalization
<!DOCTYPE html>
<html lang="en">
<head>
<title>Prefab</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="/"/>
<ResourcePreloader/>
<script src="_content/Telerik.UI.for.Blazor/js/telerik-blazor.js"></script>
<!-- fonts -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Open+Sans:400,400i,600,600i,700,700i">
<!-- css -->
<link rel="stylesheet" href="@Assets["vendor/bootstrap/css/bootstrap.min.css"]"/>
<link rel="stylesheet" href="_content/Telerik.UI.for.Blazor/css/kendo-theme-default/all.css"/>
<link rel="stylesheet" href="@Assets["css/style.css"]" />
@if (CultureInfo.CurrentUICulture.TextInfo.IsRightToLeft)
{
<link rel="stylesheet" href="@Assets["css/style.rtl.css"]" />
}
else
{
<link rel="stylesheet" href="@Assets["css/style.ltr.css"]" />
}
<link rel="stylesheet" href="@Assets["app.css"]"/>
<link rel="stylesheet" href="@Assets["_content/Prefab.Web.Client/Prefab.Web.Client.styles.css"]"/>
<link rel="stylesheet" href="@Assets["Prefab.Web.styles.css"]"/>
<ImportMap/>
<link rel="icon" type="image/png" href="favicon.png"/>
<HeadOutlet @rendermode="InteractiveAuto"/>
</head>
<body>
<Routes @rendermode="InteractiveAuto" />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
</html>

View File

@@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}

View File

@@ -0,0 +1,18 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using Prefab.Web
@using Prefab.Web.Client
@using Prefab.Web.Client.Layout
@using Prefab.Web.Components
@using Telerik.Blazor
@using Telerik.Blazor.Components
@using Telerik.SvgIcons
@using Telerik.FontIcons

52
Prefab.Web/Data/AppDb.cs Normal file
View File

@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore;
using Prefab.Catalog.Domain.Entities;
using Prefab.Data;
using Prefab.Handler;
namespace Prefab.Web.Data;
public class AppDb : PrefabDb, IPrefabDb,
Prefab.Catalog.Data.IModuleDb
{
public AppDb(DbContextOptions<AppDb> options, IHandlerContextAccessor accessor)
: base(options, accessor)
{
}
protected AppDb(DbContextOptions options, IHandlerContextAccessor accessor)
: base(options, accessor)
{
}
protected override void PrefabOnModelCreating(ModelBuilder builder)
{
builder.ApplyConfigurationsFromAssembly(typeof(Prefab.Catalog.Data.IModuleDb).Assembly);
}
protected override void PrefabOnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
// Additional write-context configuration can be applied here if needed.
}
public DbSet<Category> Categories => Set<Category>();
public DbSet<Product> Products => Set<Product>();
public DbSet<OptionDefinition> OptionDefinitions => Set<OptionDefinition>();
public DbSet<OptionValue> OptionValues => Set<OptionValue>();
public DbSet<OptionTier> OptionTiers => Set<OptionTier>();
public DbSet<OptionRuleSet> OptionRuleSets => Set<OptionRuleSet>();
public DbSet<OptionRuleCondition> OptionRuleConditions => Set<OptionRuleCondition>();
public DbSet<VariantAxisValue> VariantAxisValues => Set<VariantAxisValue>();
public DbSet<AttributeDefinition> AttributeDefinitions => Set<AttributeDefinition>();
public DbSet<ProductAttributeValue> ProductAttributeValues => Set<ProductAttributeValue>();
public DbSet<ProductCategory> ProductCategories => Set<ProductCategory>();
}

View File

@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Prefab.Handler;
namespace Prefab.Web.Data;
public class AppDbReadOnly(DbContextOptions<AppDbReadOnly> options, IHandlerContextAccessor accessor) : AppDb(options, accessor), Prefab.Data.IPrefabDbReadOnly,
Prefab.Catalog.Data.IModuleDbReadOnly
{
protected override void PrefabOnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.PrefabOnConfiguring(optionsBuilder);
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}
public override int SaveChanges()
=> throw new InvalidOperationException("This database context is read-only. Saving changes is not allowed.");
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("This database context is read-only. Saving changes is not allowed.");
}

View File

@@ -0,0 +1,16 @@
using Microsoft.EntityFrameworkCore;
using Prefab.Catalog.Data;
namespace Prefab.Web.Data;
internal sealed class CatalogDbContextFactory(
IDbContextFactory<AppDb> writeFactory,
IDbContextFactory<AppDbReadOnly> readFactory) : ICatalogDbContextFactory
{
public async ValueTask<IModuleDb> CreateWritableAsync(CancellationToken cancellationToken = default) =>
await writeFactory.CreateDbContextAsync(cancellationToken);
public async ValueTask<IModuleDbReadOnly> CreateReadOnlyAsync(CancellationToken cancellationToken = default) =>
await readFactory.CreateDbContextAsync(cancellationToken);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,653 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Prefab.Web.Data.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "catalog");
migrationBuilder.CreateTable(
name: "AttributeDefinitions",
schema: "catalog",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
DataType = table.Column<int>(type: "int", nullable: false),
Unit = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CreatedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
LastModifiedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LastModifiedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
InactivatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
InactivatedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DeletedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AttributeDefinitions", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AuditLogs",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CorrelationId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Entity = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
State = table.Column<string>(type: "nvarchar(max)", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CreatedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AuditLogs", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Categories",
schema: "catalog",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ParentId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
Slug = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
DisplayOrder = table.Column<int>(type: "int", nullable: false),
IsFeatured = table.Column<bool>(type: "bit", nullable: false),
HeroImageUrl = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
Icon = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CreatedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
LastModifiedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LastModifiedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
InactivatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
InactivatedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DeletedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Categories", x => x.Id);
});
migrationBuilder.CreateTable(
name: "GenericAttributes",
columns: table => new
{
EntityId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
KeyGroup = table.Column<string>(type: "nvarchar(400)", maxLength: 400, nullable: false),
Key = table.Column<string>(type: "nvarchar(400)", maxLength: 400, nullable: false),
Type = table.Column<string>(type: "nvarchar(max)", nullable: false),
Value = table.Column<string>(type: "nvarchar(max)", maxLength: 2147483647, nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CreatedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
LastModifiedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LastModifiedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
InactivatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
InactivatedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DeletedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GenericAttributes", x => new { x.EntityId, x.KeyGroup, x.Key });
});
migrationBuilder.CreateTable(
name: "Products",
schema: "catalog",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Kind = table.Column<int>(type: "int", nullable: false),
Sku = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
Name = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
Slug = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
Description = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: true),
Price = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: true),
ParentProductId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CreatedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
LastModifiedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LastModifiedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
InactivatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
InactivatedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DeletedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Products", x => x.Id);
table.ForeignKey(
name: "FK_Products_Products_ParentProductId",
column: x => x.ParentProductId,
principalSchema: "catalog",
principalTable: "Products",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "SeederLogs",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
SeederName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
RunMode = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
RunAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SeederLogs", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AuditLogItems",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
AuditLogId = table.Column<int>(type: "int", nullable: false),
Property = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
OldValue = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
NewValue = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AuditLogItems", x => x.Id);
table.ForeignKey(
name: "FK_AuditLogItems_AuditLogs_AuditLogId",
column: x => x.AuditLogId,
principalTable: "AuditLogs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "OptionDefinitions",
schema: "catalog",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ProductId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Code = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
DataType = table.Column<int>(type: "int", nullable: false),
IsVariantAxis = table.Column<bool>(type: "bit", nullable: false),
Unit = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: true),
Min = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
Max = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
Step = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
PricePerUnit = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
PercentScope = table.Column<int>(type: "int", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CreatedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
LastModifiedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LastModifiedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
InactivatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
InactivatedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DeletedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OptionDefinitions", x => x.Id);
table.ForeignKey(
name: "FK_OptionDefinitions_Products_ProductId",
column: x => x.ProductId,
principalSchema: "catalog",
principalTable: "Products",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "OptionRuleSets",
schema: "catalog",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ProductId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
TargetKind = table.Column<byte>(type: "tinyint", nullable: false),
TargetId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Effect = table.Column<byte>(type: "tinyint", nullable: false),
Mode = table.Column<byte>(type: "tinyint", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CreatedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
LastModifiedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LastModifiedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
InactivatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
InactivatedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DeletedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OptionRuleSets", x => x.Id);
table.ForeignKey(
name: "FK_OptionRuleSets_Products_ProductId",
column: x => x.ProductId,
principalSchema: "catalog",
principalTable: "Products",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ProductAttributeValues",
schema: "catalog",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ProductId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
AttributeDefinitionId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Value = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
NumericValue = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
UnitCode = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: true),
EnumCode = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CreatedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
LastModifiedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LastModifiedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
InactivatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
InactivatedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DeletedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ProductAttributeValues", x => x.Id);
table.ForeignKey(
name: "FK_ProductAttributeValues_AttributeDefinitions_AttributeDefinitionId",
column: x => x.AttributeDefinitionId,
principalSchema: "catalog",
principalTable: "AttributeDefinitions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ProductAttributeValues_Products_ProductId",
column: x => x.ProductId,
principalSchema: "catalog",
principalTable: "Products",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ProductCategories",
schema: "catalog",
columns: table => new
{
ProductId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CategoryId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
IsPrimary = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ProductCategories", x => new { x.ProductId, x.CategoryId });
table.ForeignKey(
name: "FK_ProductCategories_Categories_CategoryId",
column: x => x.CategoryId,
principalSchema: "catalog",
principalTable: "Categories",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ProductCategories_Products_ProductId",
column: x => x.ProductId,
principalSchema: "catalog",
principalTable: "Products",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "OptionTiers",
schema: "catalog",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
OptionDefinitionId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
FromInclusive = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
ToInclusive = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
UnitRate = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
FlatDelta = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CreatedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
LastModifiedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LastModifiedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
InactivatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
InactivatedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DeletedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OptionTiers", x => x.Id);
table.ForeignKey(
name: "FK_OptionTiers_OptionDefinitions_OptionDefinitionId",
column: x => x.OptionDefinitionId,
principalSchema: "catalog",
principalTable: "OptionDefinitions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "OptionValues",
schema: "catalog",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
OptionDefinitionId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Code = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Label = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
PriceDelta = table.Column<decimal>(type: "decimal(9,4)", precision: 9, scale: 4, nullable: true),
PriceDeltaKind = table.Column<int>(type: "int", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CreatedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
LastModifiedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LastModifiedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
InactivatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
InactivatedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DeletedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OptionValues", x => x.Id);
table.ForeignKey(
name: "FK_OptionValues_OptionDefinitions_OptionDefinitionId",
column: x => x.OptionDefinitionId,
principalSchema: "catalog",
principalTable: "OptionDefinitions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "OptionRuleConditions",
schema: "catalog",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
RuleSetId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LeftOptionDefinitionId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Operator = table.Column<byte>(type: "tinyint", nullable: false),
RightOptionValueId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
RightList = table.Column<string>(type: "nvarchar(max)", nullable: true),
RightNumber = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
RightMin = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
RightMax = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CreatedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
LastModifiedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LastModifiedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
InactivatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
InactivatedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DeletedOn = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OptionRuleConditions", x => x.Id);
table.ForeignKey(
name: "FK_OptionRuleConditions_OptionDefinitions_LeftOptionDefinitionId",
column: x => x.LeftOptionDefinitionId,
principalSchema: "catalog",
principalTable: "OptionDefinitions",
principalColumn: "Id");
table.ForeignKey(
name: "FK_OptionRuleConditions_OptionRuleSets_RuleSetId",
column: x => x.RuleSetId,
principalSchema: "catalog",
principalTable: "OptionRuleSets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_OptionRuleConditions_OptionValues_RightOptionValueId",
column: x => x.RightOptionValueId,
principalSchema: "catalog",
principalTable: "OptionValues",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "VariantAxisValues",
schema: "catalog",
columns: table => new
{
ProductVariantId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
OptionDefinitionId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
OptionValueId = table.Column<Guid>(type: "uniqueidentifier", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VariantAxisValues", x => new { x.ProductVariantId, x.OptionDefinitionId });
table.ForeignKey(
name: "FK_VariantAxisValues_OptionDefinitions_OptionDefinitionId",
column: x => x.OptionDefinitionId,
principalSchema: "catalog",
principalTable: "OptionDefinitions",
principalColumn: "Id");
table.ForeignKey(
name: "FK_VariantAxisValues_OptionValues_OptionValueId",
column: x => x.OptionValueId,
principalSchema: "catalog",
principalTable: "OptionValues",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_VariantAxisValues_Products_ProductVariantId",
column: x => x.ProductVariantId,
principalSchema: "catalog",
principalTable: "Products",
principalColumn: "Id");
});
migrationBuilder.CreateIndex(
name: "IX_AuditLogItems_AuditLogId",
table: "AuditLogItems",
column: "AuditLogId");
migrationBuilder.CreateIndex(
name: "IX_Categories_Slug",
schema: "catalog",
table: "Categories",
column: "Slug",
unique: true,
filter: "[Slug] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_GenericAttributes_Active",
table: "GenericAttributes",
column: "DeletedOn",
filter: "[DeletedOn] IS NULL");
migrationBuilder.CreateIndex(
name: "IX_GenericAttributes_Entity_Group",
table: "GenericAttributes",
columns: new[] { "EntityId", "KeyGroup" });
migrationBuilder.CreateIndex(
name: "IX_OptionDefinitions_ProductId_Code",
schema: "catalog",
table: "OptionDefinitions",
columns: new[] { "ProductId", "Code" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OptionRuleConditions_LeftOptionDefinitionId",
schema: "catalog",
table: "OptionRuleConditions",
column: "LeftOptionDefinitionId");
migrationBuilder.CreateIndex(
name: "IX_OptionRuleConditions_RightOptionValueId",
schema: "catalog",
table: "OptionRuleConditions",
column: "RightOptionValueId");
migrationBuilder.CreateIndex(
name: "IX_OptionRuleConditions_RuleSetId",
schema: "catalog",
table: "OptionRuleConditions",
column: "RuleSetId");
migrationBuilder.CreateIndex(
name: "IX_OptionRuleSets_ProductId_TargetKind_TargetId",
schema: "catalog",
table: "OptionRuleSets",
columns: new[] { "ProductId", "TargetKind", "TargetId" });
migrationBuilder.CreateIndex(
name: "IX_OptionTiers_OptionDefinitionId_FromInclusive_ToInclusive",
schema: "catalog",
table: "OptionTiers",
columns: new[] { "OptionDefinitionId", "FromInclusive", "ToInclusive" });
migrationBuilder.CreateIndex(
name: "IX_OptionValues_OptionDefinitionId_Code",
schema: "catalog",
table: "OptionValues",
columns: new[] { "OptionDefinitionId", "Code" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ProductAttributeValues_AttributeDefinitionId",
schema: "catalog",
table: "ProductAttributeValues",
column: "AttributeDefinitionId");
migrationBuilder.CreateIndex(
name: "IX_ProductAttributeValues_ProductId_AttributeDefinitionId",
schema: "catalog",
table: "ProductAttributeValues",
columns: new[] { "ProductId", "AttributeDefinitionId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ProductCategories_CategoryId",
schema: "catalog",
table: "ProductCategories",
column: "CategoryId");
migrationBuilder.CreateIndex(
name: "IX_Products_ParentProductId",
schema: "catalog",
table: "Products",
column: "ParentProductId");
migrationBuilder.CreateIndex(
name: "IX_Products_Sku",
schema: "catalog",
table: "Products",
column: "Sku",
unique: true,
filter: "[Sku] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Products_Slug",
schema: "catalog",
table: "Products",
column: "Slug",
unique: true,
filter: "[Slug] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_VariantAxisValues_OptionDefinitionId",
schema: "catalog",
table: "VariantAxisValues",
column: "OptionDefinitionId");
migrationBuilder.CreateIndex(
name: "IX_VariantAxisValues_OptionValueId",
schema: "catalog",
table: "VariantAxisValues",
column: "OptionValueId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AuditLogItems");
migrationBuilder.DropTable(
name: "GenericAttributes");
migrationBuilder.DropTable(
name: "OptionRuleConditions",
schema: "catalog");
migrationBuilder.DropTable(
name: "OptionTiers",
schema: "catalog");
migrationBuilder.DropTable(
name: "ProductAttributeValues",
schema: "catalog");
migrationBuilder.DropTable(
name: "ProductCategories",
schema: "catalog");
migrationBuilder.DropTable(
name: "SeederLogs");
migrationBuilder.DropTable(
name: "VariantAxisValues",
schema: "catalog");
migrationBuilder.DropTable(
name: "AuditLogs");
migrationBuilder.DropTable(
name: "OptionRuleSets",
schema: "catalog");
migrationBuilder.DropTable(
name: "AttributeDefinitions",
schema: "catalog");
migrationBuilder.DropTable(
name: "Categories",
schema: "catalog");
migrationBuilder.DropTable(
name: "OptionValues",
schema: "catalog");
migrationBuilder.DropTable(
name: "OptionDefinitions",
schema: "catalog");
migrationBuilder.DropTable(
name: "Products",
schema: "catalog");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,64 @@
using System.Net;
using FluentValidation;
using Prefab.Domain.Exceptions;
using Prefab.Endpoints;
using Prefab.Web.Client.Models;
using Prefab.Web.Client.Models.Shared;
using Prefab.Web.Client.Pages;
using Prefab.Web.Client.Services;
namespace Prefab.Web.Gateway;
public static class Categories
{
public class Endpoints : IEndpointRegistrar
{
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
endpoints.MapGet("/bff/categories/page",
async (ICategoriesPageService service, CancellationToken cancellationToken) =>
{
var result = await service.GetPage(cancellationToken);
var status = result.Problem?.StatusCode ?? (result.IsSuccess
? StatusCodes.Status200OK
: StatusCodes.Status500InternalServerError);
return Results.Json(result, statusCode: status);
})
.WithTags(nameof(Categories));
}
}
public class PageService(Prefab.Shared.Catalog.IModuleClient moduleClient) : ICategoriesPageService
{
public async Task<Result<CategoriesPageModel>> GetPage(CancellationToken cancellationToken)
{
try
{
var response = await moduleClient.Category.GetCategories(cancellationToken);
var model = new CategoriesPageModel
{
Categories = response.Categories.Select(x => new CategoryListItemModel
{
Id = x.Id,
Name = x.Name,
ParentName = x.ParentName
})
};
return Result<CategoriesPageModel>.Success(model);
}
catch (Exception exception)
{
var problem = exception switch
{
ValidationException validationException => ResultProblem.FromValidation(validationException),
DomainException domainException => ResultProblem.Unexpected(domainException.Message, domainException, HttpStatusCode.UnprocessableEntity),
_ => ResultProblem.Unexpected("Failed to retrieve categories.", exception)
};
return Result<CategoriesPageModel>.Failure(problem);
}
}
}
}

206
Prefab.Web/Gateway/Home.cs Normal file
View File

@@ -0,0 +1,206 @@
using System.Net;
using FluentValidation;
using Prefab.Domain.Exceptions;
using Prefab.Endpoints;
using Prefab.Shared.Catalog;
using Prefab.Shared.Catalog.Products;
using Prefab.Web.Client.Models.Catalog;
using Prefab.Web.Client.Models.Home;
using Prefab.Web.Client.Models.Shared;
using Prefab.Web.Client.Services;
using Prefab.Web.Client.ViewModels.Catalog;
using UrlBuilder = Prefab.Web.UrlPolicy.UrlPolicy;
namespace Prefab.Web.Gateway;
public static class Home
{
public sealed class Endpoints : IEndpointRegistrar
{
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
endpoints.MapGet(
"/bff/home",
async (Service service, CancellationToken cancellationToken) =>
{
var result = await service.Get(cancellationToken);
var status = result.Problem?.StatusCode ?? (result.IsSuccess
? StatusCodes.Status200OK
: StatusCodes.Status500InternalServerError);
return Results.Json(result, statusCode: status);
})
.WithTags(nameof(Home));
}
}
public sealed class Service(IModuleClient moduleClient, IProductListingService listingService) : IHomePageService
{
private const int MaxCategories = 8;
private const int MaxFeaturedProducts = 8;
public async Task<Result<HomePageModel>> Get(CancellationToken cancellationToken)
{
try
{
var response = await moduleClient.Product.GetCategoryTree(depth: 3, cancellationToken);
var nodes = response.Categories ?? [];
var leafNodes = Flatten(nodes)
.Where(node => node.IsLeaf && !string.IsNullOrWhiteSpace(node.Slug))
.ToList();
var categories = new List<CategoryCardModel>(MaxCategories);
var featuredProducts = new List<ProductCardModel>(MaxFeaturedProducts);
var seenCategoryIds = new HashSet<Guid>();
var seenProductKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
await CollectAsync(
OrderCandidates(leafNodes.Where(node => node.IsFeatured)),
categories,
featuredProducts,
seenCategoryIds,
seenProductKeys,
cancellationToken);
if (categories.Count < MaxCategories || featuredProducts.Count < MaxFeaturedProducts)
{
await CollectAsync(
OrderCandidates(leafNodes.Where(node => !node.IsFeatured)),
categories,
featuredProducts,
seenCategoryIds,
seenProductKeys,
cancellationToken);
}
var model = new HomePageModel
{
LatestCategories = categories,
FeaturedProducts = featuredProducts
};
return Result<HomePageModel>.Success(model);
}
catch (ValidationException validationException)
{
var problem = ResultProblem.FromValidation(validationException);
return Result<HomePageModel>.Failure(problem);
}
catch (DomainException domainException)
{
var problem = ResultProblem.Unexpected(domainException.Message, domainException, HttpStatusCode.UnprocessableEntity);
return Result<HomePageModel>.Failure(problem);
}
catch (Exception exception)
{
var problem = ResultProblem.Unexpected("Failed to retrieve home page content.", exception);
return Result<HomePageModel>.Failure(problem);
}
}
private async Task CollectAsync(
IEnumerable<GetCategoryModels.CategoryNodeDto> candidates,
ICollection<CategoryCardModel> categories,
ICollection<ProductCardModel> featuredProducts,
ISet<Guid> seenCategoryIds,
ISet<string> seenProductKeys,
CancellationToken cancellationToken)
{
foreach (var candidate in candidates)
{
if (categories.Count >= MaxCategories && featuredProducts.Count >= MaxFeaturedProducts)
{
break;
}
Result<ProductListingModel>? listingResult = null;
try
{
listingResult = await listingService.GetCategoryProducts(candidate.Slug, cancellationToken);
}
catch (DomainException)
{
continue;
}
if (!listingResult.IsSuccess || listingResult.Value is not { Products.Count: > 0 } listing)
{
continue;
}
if (categories.Count < MaxCategories && seenCategoryIds.Add(candidate.Id))
{
categories.Add(new CategoryCardModel
{
Title = candidate.Name,
Url = UrlBuilder.BrowseProducts(candidate.Slug),
ImageUrl = string.IsNullOrWhiteSpace(candidate.HeroImageUrl) ? null : candidate.HeroImageUrl,
SecondaryText = FormatProductCount(listing.Total)
});
}
if (featuredProducts.Count >= MaxFeaturedProducts)
{
continue;
}
foreach (var product in listing.Products)
{
var key = GetProductKey(product);
if (!seenProductKeys.Add(key))
{
continue;
}
featuredProducts.Add(product);
if (featuredProducts.Count >= MaxFeaturedProducts)
{
break;
}
}
}
}
private static string GetProductKey(ProductCardModel product)
{
if (!string.IsNullOrWhiteSpace(product.Sku))
{
return product.Sku!;
}
if (!string.IsNullOrWhiteSpace(product.Url))
{
return product.Url!;
}
return product.Title;
}
private static IEnumerable<GetCategoryModels.CategoryNodeDto> OrderCandidates(
IEnumerable<GetCategoryModels.CategoryNodeDto> candidates) =>
candidates
.OrderBy(candidate => candidate.DisplayOrder)
.ThenBy(candidate => candidate.Name, StringComparer.OrdinalIgnoreCase);
private static string FormatProductCount(int total) =>
total == 1 ? "1 Product" : $"{total} Products";
private static IEnumerable<GetCategoryModels.CategoryNodeDto> Flatten(
IEnumerable<GetCategoryModels.CategoryNodeDto> nodes)
{
foreach (var node in nodes)
{
yield return node;
if (node.Children is { Count: > 0 })
{
foreach (var child in Flatten(node.Children))
{
yield return child;
}
}
}
}
}
}

View File

@@ -0,0 +1,120 @@
using System.Net;
using FluentValidation;
using Prefab.Domain.Exceptions;
using Prefab.Endpoints;
using Prefab.Shared.Catalog;
using Prefab.Shared.Catalog.Products;
using Prefab.Web.Client.Models.Catalog;
using Prefab.Web.Client.Models.Shared;
using Prefab.Web.Client.ViewModels.Catalog;
using Prefab.Web.Client.Services;
using UrlBuilder = Prefab.Web.UrlPolicy.UrlPolicy;
namespace Prefab.Web.Gateway;
public static class Products
{
public sealed class Endpoints : IEndpointRegistrar
{
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
endpoints.MapGet(
"/bff/catalog/categories/{slug}/models",
async (ListingService service, string slug, CancellationToken cancellationToken) =>
{
var result = await service.GetCategoryProducts(slug, cancellationToken);
var status = result.Problem?.StatusCode ?? (result.IsSuccess
? StatusCodes.Status200OK
: StatusCodes.Status500InternalServerError);
return Results.Json(result, statusCode: status);
})
.WithTags(nameof(Products));
}
}
public sealed class ListingService(IModuleClient moduleClient) : IProductListingService
{
public async Task<Result<ProductListingModel>> GetCategoryProducts(string categorySlug, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(categorySlug);
try
{
var response = await moduleClient.Product.GetCategoryModels(
categorySlug.Trim(),
page: null,
pageSize: null,
sort: null,
direction: null,
cancellationToken);
var listing = MapListing(response.Result);
return Result<ProductListingModel>.Success(listing);
}
catch (ValidationException validationException)
{
var problem = ResultProblem.FromValidation(validationException);
return Result<ProductListingModel>.Failure(problem);
}
catch (DomainException domainException)
{
var problem = ResultProblem.Unexpected(domainException.Message, domainException, HttpStatusCode.UnprocessableEntity);
return Result<ProductListingModel>.Failure(problem);
}
catch (Exception exception)
{
var problem = ResultProblem.Unexpected("Failed to retrieve category products.", exception);
return Result<ProductListingModel>.Failure(problem);
}
}
private static ProductListingModel MapListing(GetCategoryModels.CategoryProductsResponse payload)
{
var category = payload.Category;
var categoryModel = new ProductCategoryModel
{
Id = category.Id,
Name = category.Name,
Slug = category.Slug,
Description = category.Description,
HeroImageUrl = category.HeroImageUrl,
Icon = category.Icon
};
var cards = payload.Products
.Select(product => new ProductCardModel
{
Id = product.Id,
Title = product.Name,
Url = UrlBuilder.BrowseProduct(product.Slug),
Slug = product.Slug,
PrimaryImageUrl = product.PrimaryImageUrl,
CategoryName = category.Name,
CategoryUrl = UrlBuilder.BrowseCategory(category.Slug),
FromPrice = product.FromPrice.HasValue
? new MoneyModel
{
Amount = product.FromPrice.Value
}
: null,
IsPriced = product.FromPrice.HasValue,
OldPrice = null,
IsOnSale = false,
Rating = 0,
ReviewCount = 0,
Badges = new List<string>(),
LastModifiedOn = product.LastModifiedOn
})
.ToList();
return new ProductListingModel
{
Category = categoryModel,
Products = cards,
Total = payload.Total,
Page = payload.Page,
PageSize = payload.PageSize
};
}
}
}

View File

@@ -0,0 +1,129 @@
using FluentValidation;
using Prefab.Endpoints;
using Prefab.Shared.Catalog;
using Prefab.Shared.Catalog.Products;
using Prefab.Web.Client.Models.Shared;
using Prefab.Web.Client.Services;
using UrlBuilder = Prefab.Web.UrlPolicy.UrlPolicy;
namespace Prefab.Web.Gateway.Shared;
public static class NavMenu
{
public class Endpoints : IEndpointRegistrar
{
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
endpoints.MapGet("/bff/nav-menu/{depth:int}", async (INavMenuService service, int depth, CancellationToken cancellationToken) =>
{
var result = await service.Get(depth, cancellationToken);
var status = result.Problem?.StatusCode ?? (result.IsSuccess
? StatusCodes.Status200OK
: StatusCodes.Status500InternalServerError);
return Results.Json(result, statusCode: status);
})
.WithName(nameof(NavMenu));
}
}
public class Service(IModuleClient moduleClient) : INavMenuService
{
private const int MaxItemsPerColumn = 8;
public async Task<Result<NavMenuModel>> Get(int depth, CancellationToken cancellationToken)
{
var normalizedDepth = Math.Clamp(depth, 1, 2);
try
{
var response = await moduleClient.Product.GetCategoryTree(normalizedDepth, cancellationToken);
var columns = BuildColumns(response.Categories);
var model = new NavMenuModel(normalizedDepth, MaxItemsPerColumn, columns);
return Result<NavMenuModel>.Success(model);
}
catch (ValidationException exception)
{
var problem = ResultProblem.FromValidation(exception);
return Result<NavMenuModel>.Failure(problem);
}
catch (Exception exception)
{
var problem = ResultProblem.Unexpected("Failed to load navigation menu.", exception);
return Result<NavMenuModel>.Failure(problem);
}
}
private static IReadOnlyList<NavMenuColumnModel> BuildColumns(IReadOnlyList<GetCategoryModels.CategoryNodeDto>? roots)
{
if (roots is null || roots.Count == 0)
{
return [];
}
var columns = new List<NavMenuColumnModel>(roots.Count);
columns.AddRange(roots.Select(BuildColumn));
return columns;
}
private static NavMenuColumnModel BuildColumn(GetCategoryModels.CategoryNodeDto root)
{
var rootSlug = NormalizeSlug(root.Slug);
var isRootLeaf = root.IsLeaf;
var rootUrl = isRootLeaf
? UrlBuilder.BrowseProducts(rootSlug)
: UrlBuilder.BrowseCategory(rootSlug);
var rootLink = new NavMenuLinkModel(
root.Id.ToString(),
root.Name,
rootSlug,
isRootLeaf,
rootUrl);
var children = (root.Children ?? [])
.OrderBy(child => child.DisplayOrder)
.ThenBy(child => child.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
var featured = children
.Where(child => child.IsFeatured)
.ToList();
var menuCandidates = featured.Count > 0 ? featured : children;
var items = menuCandidates
.Take(MaxItemsPerColumn)
.Select(ToLink)
.ToList();
var hasMore = menuCandidates.Count > items.Count;
var seeAllUrl = isRootLeaf
? UrlBuilder.BrowseProducts(rootSlug)
: UrlBuilder.BrowseCategory(rootSlug);
return new NavMenuColumnModel(rootLink, items, hasMore, seeAllUrl);
}
private static NavMenuLinkModel ToLink(GetCategoryModels.CategoryNodeDto node)
{
var slug = NormalizeSlug(node.Slug);
var isLeaf = node.IsLeaf;
var url = isLeaf
? UrlBuilder.BrowseProducts(slug)
: UrlBuilder.BrowseCategory(slug);
return new NavMenuLinkModel(
node.Id.ToString(),
node.Name,
slug,
isLeaf,
url);
}
private static string NormalizeSlug(string slug) =>
string.IsNullOrWhiteSpace(slug)
? string.Empty
: slug.Trim().ToLowerInvariant();
}
}

68
Prefab.Web/Module.cs Normal file
View File

@@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore;
using Prefab.Catalog.Data;
using Prefab.Data;
using Prefab.Module;
using Prefab.Web.Client.Services;
using Prefab.Web.Data;
using Prefab.Web.Gateway;
using Prefab.Web.Shared;
using Prefab.Web.Workers;
namespace Prefab.Web;
public class Module : IModule
{
public WebApplicationBuilder Build(WebApplicationBuilder builder)
{
var writeConnection = builder.Configuration.GetConnectionString("PrefabDb")
?? throw new InvalidOperationException("Connection string 'PrefabDb' not found. Ensure the Aspire AppHost exposes the SQL resource.");
var readConnection = builder.Configuration.GetConnectionString("PrefabDbReadOnly") ?? writeConnection;
builder.Services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
builder.Services.AddDbContext<IPrefabDb, AppDb>(options =>
options.UseSqlServer(writeConnection, sqlOptions => sqlOptions.EnableRetryOnFailure()));
builder.Services.AddDbContext<IPrefabDbReadOnly, AppDbReadOnly>(options =>
options.UseSqlServer(readConnection, sqlOptions => sqlOptions.EnableRetryOnFailure()));
builder.Services.AddDbContextFactory<AppDb>(options =>
options.UseSqlServer(writeConnection, sqlOptions => sqlOptions.EnableRetryOnFailure()), ServiceLifetime.Scoped);
builder.Services.AddDbContextFactory<AppDbReadOnly>(options =>
options.UseSqlServer(readConnection, sqlOptions => sqlOptions.EnableRetryOnFailure()), ServiceLifetime.Scoped);
builder.Services.AddModuleDbInterfaces(typeof(AppDb));
builder.Services.AddModuleDbInterfaces(typeof(AppDbReadOnly));
builder.Services.AddScoped<ICatalogDbContextFactory, CatalogDbContextFactory>();
builder.Services.AddHostedService<DataSeeder>();
builder.Services.AddScoped<INotificationService, NotificationService>();
builder.Services.AddScoped<INavMenuService, Prefab.Web.Gateway.Shared.NavMenu.Service>();
builder.Services.AddScoped<ICategoriesPageService, Categories.PageService>();
builder.Services.AddScoped<Home.Service>();
builder.Services.AddScoped<Products.ListingService>();
builder.Services.AddScoped<IProductListingService>(sp => sp.GetRequiredService<Products.ListingService>());
builder.Services.AddScoped<IHomePageService>(sp => sp.GetRequiredService<Home.Service>());
return builder;
}
public WebApplication Configure(WebApplication app)
{
app.UseExceptionHandling();
return app;
}
}

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Prefab\Prefab.ServiceDefaults\Prefab.ServiceDefaults.csproj" />
<ProjectReference Include="..\Prefab.Catalog\Prefab.Catalog.csproj" />
<ProjectReference Include="..\Prefab.Web.Client\Prefab.Web.Client.csproj" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
<!-- REMOVE THIS WHEN .NET 10 IS RELEASED -->
<PropertyGroup>
<NoWarn>$(NoWarn);NU1903</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Aspire.Microsoft.EntityFrameworkCore.SqlServer" Version="9.5.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0-rc.2.25502.107">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,46 @@
using System.Collections.Generic;
using System.Linq;
using Prefab.Catalog.Domain.Entities;
namespace Prefab.Web.Pricing;
public sealed record FromPriceResult(decimal? Amount, string? Currency, bool IsPriced);
/// <summary>
/// Computes a display-friendly "from price" for catalog products by examining model and variant pricing.
/// </summary>
public sealed class FromPriceCalculator
{
private const string DefaultCurrency = "USD";
private readonly string _currencyCode;
public FromPriceCalculator(string? currencyCode = null)
{
_currencyCode = string.IsNullOrWhiteSpace(currencyCode) ? DefaultCurrency : currencyCode.Trim();
}
public FromPriceResult Compute(Product product)
{
ArgumentNullException.ThrowIfNull(product);
if (product.Price.HasValue)
{
return new FromPriceResult(product.Price.Value, _currencyCode, true);
}
var variantPrices = ExtractVariantPrices(product.Variants);
if (variantPrices.Count > 0)
{
var minimum = variantPrices.Min();
return new FromPriceResult(minimum, _currencyCode, true);
}
return new FromPriceResult(null, null, false);
}
private static List<decimal> ExtractVariantPrices(IEnumerable<Product> variants) =>
variants
.Where(variant => variant is { Kind: ProductKind.Variant, Price: not null })
.Select(variant => variant.Price!.Value)
.ToList();
}

47
Prefab.Web/Program.cs Normal file
View File

@@ -0,0 +1,47 @@
using Prefab.Module;
using Prefab.Web.Components;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents();
builder.Services.AddHttpClient();
builder.Services.AddTelerikBlazor();
builder.AddPrefab();
var app = builder.Build();
app.UsePrefab();
app.MapDefaultEndpoints();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseWebAssemblyDebugging();
app.UseMigrationsEndPoint();
}
else
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(Prefab.Web.Client._Imports).Assembly);
app.Run();
public partial class Program;

View File

@@ -0,0 +1,25 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://prefab_web.dev.localhost:5271",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "https://prefab_web.dev.localhost:7252;http://prefab_web.dev.localhost:5271",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,229 @@
using System.Text.Json;
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Prefab.Handler;
using Prefab.Catalog.Domain.Exceptions;
using Prefab.Shared;
using Microsoft.EntityFrameworkCore;
namespace Prefab.Web.Shared;
public class ExceptionHandler(RequestDelegate next, ILogger<ExceptionHandler> logger, IOptions<Microsoft.AspNetCore.Http.Json.JsonOptions> jsonOptions)
{
private readonly JsonSerializerOptions _jsonOptions = jsonOptions?.Value?.SerializerOptions ?? new JsonSerializerOptions(JsonSerializerDefaults.Web);
public async Task InvokeAsync(HttpContext context)
{
try
{
await next(context);
}
catch (Exception ex)
{
if (context.Response.HasStarted)
{
logger.LogWarning("Response already started for {Method} {Path}. Rethrowing exception.", context.Request.Method, context.Request.Path);
throw;
}
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
var (status, problem) = MapException(context, exception);
problem.Status ??= status;
problem.Instance ??= context.Request.Path;
problem.Extensions["traceId"] = context.TraceIdentifier;
if (!problem.Extensions.ContainsKey("method"))
{
problem.Extensions["method"] = context.Request.Method;
}
if (context.RequestServices.GetService<IHandlerContextAccessor>()?.Current is { } handlerContext)
{
if (!string.IsNullOrWhiteSpace(handlerContext.CorrelationId))
{
problem.Extensions["correlationId"] = handlerContext.CorrelationId;
}
if (!string.IsNullOrWhiteSpace(handlerContext.RequestId))
{
problem.Extensions["requestId"] = handlerContext.RequestId;
}
}
else if (context.Items.TryGetValue(HandlerContextItems.HttpContextKey, out var ctxObj) && ctxObj is HandlerContext storedContext)
{
if (!string.IsNullOrWhiteSpace(storedContext.CorrelationId))
{
problem.Extensions["correlationId"] = storedContext.CorrelationId;
}
if (!string.IsNullOrWhiteSpace(storedContext.RequestId))
{
problem.Extensions["requestId"] = storedContext.RequestId;
}
}
else if (!problem.Extensions.ContainsKey("correlationId"))
{
problem.Extensions["correlationId"] = context.TraceIdentifier;
}
if (problem is ValidationProblemDetails validation)
{
if (!problem.Extensions.ContainsKey("errors"))
{
problem.Extensions["errors"] = validation.Errors;
}
if (string.IsNullOrWhiteSpace(problem.Detail))
{
var firstError = validation.Errors.Values.SelectMany(values => values)
.FirstOrDefault(message => !string.IsNullOrWhiteSpace(message));
if (!string.IsNullOrWhiteSpace(firstError))
{
problem.Detail = firstError;
}
}
}
context.Response.StatusCode = status;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsync(JsonSerializer.Serialize(problem, _jsonOptions));
}
private (int StatusCode, ProblemDetails Problem) MapException(HttpContext context, Exception exception) =>
exception switch
{
ValidationException validationException => MapValidationException(validationException),
CatalogNotFoundException notFound => MapNotFoundException(notFound),
RemoteProblemException remoteException => MapRemoteProblemException(remoteException),
DomainException domainException => MapDomainException(domainException),
DbUpdateConcurrencyException concurrencyException => MapConcurrencyException(concurrencyException),
_ => MapUnexpectedException(exception)
};
private static (int StatusCode, ProblemDetails Problem) MapValidationException(ValidationException exception)
{
var errors = exception.Errors
.GroupBy(error => string.IsNullOrWhiteSpace(error.PropertyName) ? string.Empty : error.PropertyName)
.ToDictionary(
group => group.Key,
group => group.Select(error => error.ErrorMessage ?? string.Empty).ToArray());
var details = new ValidationProblemDetails(errors)
{
Title = "Validation failed.",
Type = "https://prefab.dev/problems/validation-error",
Status = StatusCodes.Status400BadRequest
};
return (details.Status.Value, details);
}
private static (int StatusCode, ProblemDetails Problem) MapNotFoundException(CatalogNotFoundException exception)
{
var details = new ProblemDetails
{
Title = "Resource not found.",
Detail = exception.Message,
Type = "https://prefab.dev/problems/not-found",
Status = StatusCodes.Status404NotFound
};
details.Extensions["resource"] = exception.Resource;
details.Extensions["identifier"] = exception.Identifier;
return (details.Status.Value, details);
}
private (int StatusCode, ProblemDetails Problem) MapRemoteProblemException(RemoteProblemException exception)
{
var status = (int)exception.StatusCode;
ProblemDetails details;
if (!string.IsNullOrWhiteSpace(exception.ResponseBody))
{
try
{
details = JsonSerializer.Deserialize<ProblemDetails>(exception.ResponseBody, new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? new ProblemDetails();
}
catch (JsonException)
{
details = new ProblemDetails
{
Detail = exception.ResponseBody
};
}
}
else
{
details = new ProblemDetails();
}
details.Status ??= status;
details.Title ??= "Remote request failed.";
details.Detail ??= "The downstream service returned an error.";
details.Type ??= "https://prefab.dev/problems/remote-error";
return (details.Status.Value, details);
}
private static (int StatusCode, ProblemDetails Problem) MapDomainException(DomainException exception)
{
var details = new ProblemDetails
{
Title = "Request could not be processed.",
Detail = exception.Message,
Type = "https://prefab.dev/problems/domain-error",
Status = StatusCodes.Status400BadRequest
};
return (details.Status.Value, details);
}
private (int StatusCode, ProblemDetails Problem) MapConcurrencyException(DbUpdateConcurrencyException exception)
{
logger.LogWarning(exception, "Concurrency conflict encountered.");
var details = new ProblemDetails
{
Title = "Concurrency conflict.",
Detail = "The resource was modified by another process. Refresh and retry your request.",
Type = "https://prefab.dev/problems/concurrency-conflict",
Status = StatusCodes.Status409Conflict
};
return (details.Status.Value, details);
}
private (int StatusCode, ProblemDetails Problem) MapUnexpectedException(Exception exception)
{
logger.LogError(exception, "Unhandled exception encountered.");
var details = new ProblemDetails
{
Title = "An unexpected error occurred.",
Detail = exception.Message,
Type = "https://prefab.dev/problems/unexpected-error",
Status = StatusCodes.Status500InternalServerError
};
return (details.Status.Value, details);
}
}
public static class ExceptionHandlingExtensions
{
public static IApplicationBuilder UseExceptionHandling(this IApplicationBuilder app)
{
return app.UseMiddleware<ExceptionHandler>();
}
}

View File

@@ -0,0 +1,215 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Primitives;
namespace Prefab.Web.UrlPolicy;
/// <summary>
/// Provides centralized helpers for building canonical catalog URLs used by the BFF.
/// </summary>
public static class UrlPolicy
{
public const int DefaultPage = 1;
public const int DefaultPageSize = 24;
public const string DefaultSort = "name:asc";
public const string DefaultView = "grid";
private static readonly int[] AllowedPageSizes = [12, 24, 48];
/// <summary>
/// Builds a canonical catalog URL for navigating to a category landing page.
/// </summary>
/// <param name="slug">The category slug to embed in the query string.</param>
public static string BrowseCategory(string slug)
{
ArgumentException.ThrowIfNullOrWhiteSpace(slug);
return $"/catalog/category?category-slug={Uri.EscapeDataString(slug)}";
}
/// <summary>
/// Builds a canonical catalog URL for navigating to the products grid for a category.
/// </summary>
/// <param name="categorySlug">The slug of the category whose products should be displayed.</param>
public static string BrowseProducts(string categorySlug)
{
ArgumentException.ThrowIfNullOrWhiteSpace(categorySlug);
return $"/catalog/products?category-slug={Uri.EscapeDataString(categorySlug)}";
}
/// <summary>
/// Builds a canonical catalog URL for navigating to a specific product detail page.
/// </summary>
/// <param name="productSlug">The slug of the product.</param>
public static string BrowseProduct(string productSlug)
{
ArgumentException.ThrowIfNullOrWhiteSpace(productSlug);
return $"/catalog/product?product-slug={Uri.EscapeDataString(productSlug)}";
}
/// <summary>
/// Builds a canonical catalog URL for the category browsing experience including paging state.
/// </summary>
public static string Category(
string slug,
int page = DefaultPage,
int pageSize = DefaultPageSize,
string? sort = null,
string? view = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(slug);
var normalizedPage = NormalizePage(page);
var normalizedPageSize = NormalizePageSize(pageSize);
var normalizedSort = NormalizeSort(sort);
var normalizedView = NormalizeView(view);
var parameters = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["slug"] = slug,
["page"] = normalizedPage.ToString(CultureInfo.InvariantCulture),
["pageSize"] = normalizedPageSize.ToString(CultureInfo.InvariantCulture),
["sort"] = normalizedSort,
["view"] = normalizedView
};
return QueryHelpers.AddQueryString("/catalog/category", parameters);
}
/// <summary>
/// Attempts to parse the catalog category browse URL parameters.
/// </summary>
public static bool TryParseCategory(
Uri uri,
out string slug,
out int page,
out int pageSize,
out string sort,
out string view)
{
ArgumentNullException.ThrowIfNull(uri);
slug = string.Empty;
page = DefaultPage;
pageSize = DefaultPageSize;
sort = DefaultSort;
view = DefaultView;
var queryValues = ParseQuery(uri);
if (!queryValues.TryGetValue("slug", out var slugValues))
{
return false;
}
var candidateSlug = slugValues.ToString();
if (string.IsNullOrWhiteSpace(candidateSlug))
{
return false;
}
slug = candidateSlug;
page = NormalizePage(ParseInt(queryValues, "page", DefaultPage));
pageSize = NormalizePageSize(ParseInt(queryValues, "pageSize", DefaultPageSize));
sort = NormalizeSort(queryValues.TryGetValue("sort", out var sortValues) ? sortValues.ToString() : null);
view = NormalizeView(queryValues.TryGetValue("view", out var viewValues) ? viewValues.ToString() : null);
return true;
}
/// <summary>
/// Builds a canonical catalog URL for browsing products without a category context.
/// </summary>
public static string Products(
int page = DefaultPage,
int pageSize = DefaultPageSize,
string? sort = null,
string? view = null)
{
var normalizedPage = NormalizePage(page);
var normalizedPageSize = NormalizePageSize(pageSize);
var normalizedSort = NormalizeSort(sort);
var normalizedView = NormalizeView(view);
var parameters = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["page"] = normalizedPage.ToString(CultureInfo.InvariantCulture),
["pageSize"] = normalizedPageSize.ToString(CultureInfo.InvariantCulture),
["sort"] = normalizedSort,
["view"] = normalizedView
};
return QueryHelpers.AddQueryString("/catalog/products", parameters);
}
/// <summary>
/// Attempts to parse the products browse URL parameters.
/// </summary>
public static bool TryParseProducts(
Uri uri,
out int page,
out int pageSize,
out string sort,
out string view)
{
ArgumentNullException.ThrowIfNull(uri);
page = DefaultPage;
pageSize = DefaultPageSize;
sort = DefaultSort;
view = DefaultView;
var queryValues = ParseQuery(uri);
page = NormalizePage(ParseInt(queryValues, "page", DefaultPage));
pageSize = NormalizePageSize(ParseInt(queryValues, "pageSize", DefaultPageSize));
sort = NormalizeSort(queryValues.TryGetValue("sort", out var sortValues) ? sortValues.ToString() : null);
view = NormalizeView(queryValues.TryGetValue("view", out var viewValues) ? viewValues.ToString() : null);
return true;
}
private static IDictionary<string, StringValues> ParseQuery(Uri uri)
{
var querySegment = uri.IsAbsoluteUri ? uri.Query : uri.OriginalString;
if (string.IsNullOrEmpty(querySegment))
{
return new Dictionary<string, StringValues>(StringComparer.OrdinalIgnoreCase);
}
if (!uri.IsAbsoluteUri)
{
var questionIndex = querySegment.IndexOf('?', StringComparison.Ordinal);
querySegment = questionIndex >= 0 ? querySegment[questionIndex..] : string.Empty;
}
if (string.IsNullOrEmpty(querySegment))
{
return new Dictionary<string, StringValues>(StringComparer.OrdinalIgnoreCase);
}
return QueryHelpers.ParseQuery(querySegment);
}
private static int ParseInt(IDictionary<string, StringValues> values, string key, int fallback)
{
if (values.TryGetValue(key, out var raw) &&
int.TryParse(raw.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
{
return parsed;
}
return fallback;
}
private static int NormalizePage(int page) => page < 1 ? DefaultPage : page;
private static int NormalizePageSize(int pageSize) =>
AllowedPageSizes.Contains(pageSize) ? pageSize : DefaultPageSize;
private static string NormalizeSort(string? sort) =>
string.IsNullOrWhiteSpace(sort) ? DefaultSort : sort.Trim();
private static string NormalizeView(string? view) =>
string.IsNullOrWhiteSpace(view) ? DefaultView : view.Trim().ToLowerInvariant();
}

View File

@@ -0,0 +1,102 @@
using Microsoft.EntityFrameworkCore;
using Prefab.Web.Data;
namespace Prefab.Web.Workers;
/// <summary>
/// Background service for seeding data. Collects seed tasks from a channel and executes them concurrently.
/// </summary>
public class DataSeeder(IServiceProvider serviceProvider, IConfiguration configuration, ILogger<DataSeeder> logger) : BackgroundService
{
private readonly int _workerCount = configuration.GetValue("DataSeederWorkerCount", 4);
private bool _databaseIsAvailable;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
if (_databaseIsAvailable)
{
var tasks = new List<Task>();
for (var i = 0; i < _workerCount; i++)
{
tasks.Add(Task.Run(async () =>
{
await foreach (var seedTask in Prefab.Data.Seeder.Extensions.Channel.Reader.ReadAllAsync(stoppingToken))
{
try
{
await seedTask(serviceProvider, stoppingToken);
logger.LogInformation("Seed task executed successfully.");
}
catch (Exception ex)
{
logger.LogError(ex, "Error executing seed task.");
// Optionally, add retry logic here.
}
}
}, stoppingToken));
}
await Task.WhenAll(tasks);
}
else
{
await WaitForDatabaseToAvailable(stoppingToken);
}
}
}
private async Task WaitForDatabaseToAvailable(CancellationToken stoppingToken)
{
using var scope = serviceProvider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDb>();
var contextName = db.GetType().Name;
var loggerHasLoggedWaitingOnDatabaseMessage = false;
while (!stoppingToken.IsCancellationRequested)
{
try
{
if (await db.Database.CanConnectAsync(stoppingToken))
{
if (loggerHasLoggedWaitingOnDatabaseMessage)
{
logger.LogInformation("Database connectivity confirmed for context {Context}.", contextName);
}
try
{
logger.LogInformation("Applying migrations for context {Context}...", contextName);
await db.Database.MigrateAsync(stoppingToken);
logger.LogInformation("Migrations applied for context {Context}.", contextName);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to apply migrations for context {Context}. Retrying shortly.", contextName);
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
continue;
}
_databaseIsAvailable = true;
return;
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Database connectivity probe failed for context {Context}.", contextName);
}
if (!loggerHasLoggedWaitingOnDatabaseMessage)
{
logger.LogInformation("Waiting for database availability before applying migrations for context {Context}...", contextName);
loggerHasLoggedWaitingOnDatabaseMessage = true;
}
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
}
}
}

View File

@@ -0,0 +1,19 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"PrefabDb": "Server=127.0.0.1,14330;Initial Catalog=prefab-db;User ID=sa;Password=Nq7K3YR2;TrustServerCertificate=True;Encrypt=False;MultipleActiveResultSets=True",
"PrefabDbReadOnly": "Server=127.0.0.1,14330;Initial Catalog=prefab-db;User ID=sa;Password=Nq7K3YR2;TrustServerCertificate=True;Encrypt=False;MultipleActiveResultSets=True"
},
"Prefab": {
"Catalog": {
"Client": {
"Transport": "InProcess"
}
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,9 @@
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Some files were not shown because too many files have changed in this diff Show More