Init
46
Prefab.Web/Components/App.razor
Normal 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>
|
||||
36
Prefab.Web/Components/Pages/Error.razor
Normal 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;
|
||||
}
|
||||
18
Prefab.Web/Components/_Imports.razor
Normal 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
@@ -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>();
|
||||
}
|
||||
20
Prefab.Web/Data/AppDbReadOnly.cs
Normal 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.");
|
||||
}
|
||||
16
Prefab.Web/Data/CatalogDbContextFactory.cs
Normal 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);
|
||||
}
|
||||
|
||||
1026
Prefab.Web/Data/Migrations/20251024202413_Initial.Designer.cs
generated
Normal file
653
Prefab.Web/Data/Migrations/20251024202413_Initial.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1023
Prefab.Web/Data/Migrations/AppDbModelSnapshot.cs
Normal file
64
Prefab.Web/Gateway/Categories.cs
Normal 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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
120
Prefab.Web/Gateway/Products.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
129
Prefab.Web/Gateway/Shared/NavMenu.cs
Normal 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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
30
Prefab.Web/Prefab.Web.csproj
Normal 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>
|
||||
46
Prefab.Web/Pricing/FromPriceCalculator.cs
Normal 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
@@ -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;
|
||||
25
Prefab.Web/Properties/launchSettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
229
Prefab.Web/Shared/ExceptionHandler.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
215
Prefab.Web/UrlPolicy/UrlPolicy.cs
Normal 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();
|
||||
}
|
||||
102
Prefab.Web/Workers/DataSeeder.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
Prefab.Web/appsettings.Development.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
Prefab.Web/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
9
Prefab.Web/wwwroot/app.css
Normal 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."
|
||||
}
|
||||
8464
Prefab.Web/wwwroot/css/style.css
Normal file
1
Prefab.Web/wwwroot/css/style.css.map
Normal file
7074
Prefab.Web/wwwroot/css/style.ltr.css
Normal file
1
Prefab.Web/wwwroot/css/style.ltr.css.map
Normal file
7548
Prefab.Web/wwwroot/css/style.rtl.css
Normal file
1
Prefab.Web/wwwroot/css/style.rtl.css.map
Normal file
BIN
Prefab.Web/wwwroot/favicon.png
Normal file
BIN
Prefab.Web/wwwroot/images/article/article1.jpg
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
Prefab.Web/wwwroot/images/article/article1@2x.jpg
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
Prefab.Web/wwwroot/images/article/article2.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
Prefab.Web/wwwroot/images/article/article2@2x.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
Prefab.Web/wwwroot/images/avatars/avatar1.jpg
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
Prefab.Web/wwwroot/images/avatars/avatar1@2x.jpg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
Prefab.Web/wwwroot/images/avatars/avatar2.jpg
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
Prefab.Web/wwwroot/images/avatars/avatar2@2x.jpg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
Prefab.Web/wwwroot/images/avatars/avatar3.jpg
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
Prefab.Web/wwwroot/images/avatars/avatar3@2x.jpg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
Prefab.Web/wwwroot/images/avatars/avatar4.jpg
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
Prefab.Web/wwwroot/images/avatars/avatar4@2x.jpg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
Prefab.Web/wwwroot/images/banners/megamenu-banner.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
Prefab.Web/wwwroot/images/banners/megamenu-banner@2x.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
Prefab.Web/wwwroot/images/banners/sidebar-banner-wide.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
Prefab.Web/wwwroot/images/banners/sidebar-banner-wide@2x.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
Prefab.Web/wwwroot/images/banners/sidebar-banner.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
Prefab.Web/wwwroot/images/banners/sidebar-banner@2x.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
Prefab.Web/wwwroot/images/categories/category1.jpg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
Prefab.Web/wwwroot/images/categories/category10.jpg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
Prefab.Web/wwwroot/images/categories/category10@2x.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
Prefab.Web/wwwroot/images/categories/category11.jpg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
Prefab.Web/wwwroot/images/categories/category11@2x.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
Prefab.Web/wwwroot/images/categories/category12.jpg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
Prefab.Web/wwwroot/images/categories/category12@2x.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
Prefab.Web/wwwroot/images/categories/category1@2x.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
Prefab.Web/wwwroot/images/categories/category2.jpg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
Prefab.Web/wwwroot/images/categories/category2@2x.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
Prefab.Web/wwwroot/images/categories/category3.jpg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
Prefab.Web/wwwroot/images/categories/category3@2x.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
Prefab.Web/wwwroot/images/categories/category4.jpg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
Prefab.Web/wwwroot/images/categories/category4@2x.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
Prefab.Web/wwwroot/images/categories/category5.jpg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
Prefab.Web/wwwroot/images/categories/category5@2x.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
Prefab.Web/wwwroot/images/categories/category6.jpg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
Prefab.Web/wwwroot/images/categories/category6@2x.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
Prefab.Web/wwwroot/images/categories/category7.jpg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
Prefab.Web/wwwroot/images/categories/category7@2x.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
Prefab.Web/wwwroot/images/categories/category8.jpg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
Prefab.Web/wwwroot/images/categories/category8@2x.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
Prefab.Web/wwwroot/images/categories/category9.jpg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
Prefab.Web/wwwroot/images/categories/category9@2x.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
Prefab.Web/wwwroot/images/collections/collection1-lg.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
Prefab.Web/wwwroot/images/collections/collection1-lg@2x.jpg
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
Prefab.Web/wwwroot/images/collections/collection1.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
Prefab.Web/wwwroot/images/collections/collection1@2x.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
Prefab.Web/wwwroot/images/collections/collection2-lg.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
Prefab.Web/wwwroot/images/collections/collection2-lg@2x.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
Prefab.Web/wwwroot/images/collections/collection2.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
Prefab.Web/wwwroot/images/collections/collection2@2x.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
Prefab.Web/wwwroot/images/favicon.png
Normal file
BIN
Prefab.Web/wwwroot/images/gallery/gallery1.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
Prefab.Web/wwwroot/images/gallery/gallery1@2x.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
Prefab.Web/wwwroot/images/gallery/gallery2.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
Prefab.Web/wwwroot/images/gallery/gallery2@2x.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
Prefab.Web/wwwroot/images/gallery/gallery3.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
Prefab.Web/wwwroot/images/gallery/gallery3@2x.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
Prefab.Web/wwwroot/images/gallery/gallery4.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
Prefab.Web/wwwroot/images/gallery/gallery4@2x.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
Prefab.Web/wwwroot/images/payments.png
Normal file
BIN
Prefab.Web/wwwroot/images/payments@2x.png
Normal file
BIN
Prefab.Web/wwwroot/images/posts/post1-thumbnail.jpg
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
Prefab.Web/wwwroot/images/posts/post1-thumbnail@2x.jpg
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
Prefab.Web/wwwroot/images/posts/post1.jpg
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
Prefab.Web/wwwroot/images/posts/post10.jpg
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
Prefab.Web/wwwroot/images/posts/post10@2x.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
Prefab.Web/wwwroot/images/posts/post1@2x.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
Prefab.Web/wwwroot/images/posts/post2-thumbnail.jpg
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
Prefab.Web/wwwroot/images/posts/post2-thumbnail@2x.jpg
Normal file
|
After Width: | Height: | Size: 2.7 KiB |