Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/.editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,9 @@ dotnet_diagnostic.CA1724.severity = none
dotnet_diagnostic.CA1819.severity = none
dotnet_diagnostic.CA1040.severity = none
dotnet_diagnostic.CA1848.severity = none
dotnet_diagnostic.CA1054.severity = none
dotnet_diagnostic.CA1056.severity = none
dotnet_diagnostic.MSG0005.severity = none

[**/Migrations.PostgreSQL/**/*.cs]
dotnet_diagnostic.CA1062.severity = none
Expand Down
13 changes: 11 additions & 2 deletions src/BuildingBlocks/Mailing/Services/SmtpMailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,17 @@ private async Task SendEmailAsync(MimeMessage email, CancellationToken ct)

try
{
await client.ConnectAsync(_settings.Smtp!.Host, _settings.Smtp.Port, SecureSocketOptions.StartTls, ct);
await client.AuthenticateAsync(_settings.Smtp.UserName, _settings.Smtp.Password, ct);
await client.ConnectAsync(_settings.Smtp!.Host!, _settings.Smtp.Port, SecureSocketOptions.StartTls, ct);

if (!string.IsNullOrWhiteSpace(_settings.Smtp.UserName) && !string.IsNullOrWhiteSpace(_settings.Smtp.Password))
{
await client.AuthenticateAsync(_settings.Smtp.UserName, _settings.Smtp.Password, ct);
}
else if (!string.IsNullOrWhiteSpace(_settings.Smtp.UserName) || !string.IsNullOrWhiteSpace(_settings.Smtp.Password))
{
await client.AuthenticateAsync(_settings.Smtp.UserName ?? string.Empty, _settings.Smtp.Password ?? string.Empty, ct);
}

await client.SendAsync(email, ct);
}
// Broad catch is intentional: any SMTP failure (auth, network, protocol) is logged
Expand Down
2 changes: 1 addition & 1 deletion src/BuildingBlocks/Quota/InMemoryQuotaService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public sealed class InMemoryQuotaService : IQuotaService
private readonly QuotaOptions _options;
private readonly QuotaPlanResolver _planResolver;
private readonly IMultiTenantContextAccessor<AppTenantInfo>? _tenantAccessor;
private readonly IReadOnlyDictionary<QuotaResource, IQuotaGaugeProvider> _gauges;
private readonly Dictionary<QuotaResource, IQuotaGaugeProvider> _gauges;
private readonly TimeProvider _timeProvider;

internal InMemoryQuotaService(
Expand Down
1 change: 1 addition & 0 deletions src/BuildingBlocks/Quota/InMemoryQuotaStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ namespace FSH.Framework.Quota;
/// Singleton backing store for <see cref="InMemoryQuotaService"/> so counters survive request scopes.
/// Keyed by <c>quota:{tenantId}:{resource}:{period}</c> exactly like the Redis backend.
/// </summary>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by dependency injection")]
internal sealed class InMemoryQuotaStore
{
public ConcurrentDictionary<string, long> Counters { get; } = new();
Expand Down
1 change: 1 addition & 0 deletions src/BuildingBlocks/Quota/NoopQuotaService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ namespace FSH.Framework.Quota;
/// Used when quota enforcement is disabled via configuration. Every check returns allowed with
/// an unlimited result so calling code remains unchanged.
/// </summary>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by dependency injection")]
internal sealed class NoopQuotaService : IQuotaService
{
public ValueTask<QuotaCheckResult> CheckAsync(string tenantId, QuotaResource resource, long amount, CancellationToken ct = default)
Expand Down
8 changes: 1 addition & 7 deletions src/BuildingBlocks/Quota/Quota.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,7 @@
<ItemGroup>
<PackageReference Include="Finbuckle.MultiTenant" />
<PackageReference Include="Finbuckle.MultiTenant.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />

<PackageReference Include="StackExchange.Redis" />
</ItemGroup>

Expand Down
2 changes: 1 addition & 1 deletion src/BuildingBlocks/Quota/QuotaOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public sealed class QuotaOptions
public string DefaultPlan { get; set; } = "free";

/// <summary>Plan name → per-resource limit map. Use -1 or long.MaxValue for "unlimited".</summary>
public Dictionary<string, Dictionary<QuotaResource, long>> Plans { get; set; } = new();
public Dictionary<string, Dictionary<QuotaResource, long>> Plans { get; } = new();

/// <summary>
/// Whether the root/platform tenant is exempt from quota enforcement. Defaults to true; platform
Expand Down
2 changes: 1 addition & 1 deletion src/BuildingBlocks/Quota/RedisQuotaService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public sealed class RedisQuotaService : IQuotaService
private readonly QuotaOptions _options;
private readonly QuotaPlanResolver _planResolver;
private readonly IMultiTenantContextAccessor<AppTenantInfo>? _tenantAccessor;
private readonly IReadOnlyDictionary<QuotaResource, IQuotaGaugeProvider> _gauges;
private readonly Dictionary<QuotaResource, IQuotaGaugeProvider> _gauges;
private readonly TimeProvider _timeProvider;
private readonly ILogger<RedisQuotaService> _logger;

Expand Down
4 changes: 2 additions & 2 deletions src/BuildingBlocks/Storage/Storage.csproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<RootNamespace>FSH.Framework.Storage</RootNamespace>
Expand All @@ -16,7 +16,7 @@
<ItemGroup>
<PackageReference Include="Finbuckle.MultiTenant" />
<PackageReference Include="Finbuckle.MultiTenant.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />

</ItemGroup>

<ItemGroup>
Expand Down
3 changes: 2 additions & 1 deletion src/BuildingBlocks/Web/Versioning/Extensions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using Asp.Versioning;
using Asp.Versioning;
using Microsoft.Extensions.DependencyInjection;

namespace FSH.Framework.Web.Versioning;
Expand All @@ -7,6 +7,7 @@ public static class Extensions
{
public static IServiceCollection AddHeroVersioning(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services
.AddApiVersioning(options =>
{
Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

<!-- Docs -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<NoWarn>$(NoWarn);CS1591;MSG0005</NoWarn>
<!-- Suppress warning about missing XML docs -->

<!-- Container tags (if using container builds) -->
Expand Down
30 changes: 21 additions & 9 deletions src/Host/FSH.Starter.Api/DevSeeding/DevDataSeeder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ namespace FSH.Starter.Api.DevSeeding;
/// idempotent — every step checks before creating, so subsequent restarts are no-ops.
///
/// Activation:
/// - Only registered when <see cref="IHostEnvironment.IsDevelopment"/>.
/// - Only registered when <c>IHostEnvironment.IsDevelopment()</c>.
/// - Additionally gated on <c>Seed:Demo == true</c> in configuration so a developer can
/// opt out without code changes.
///
Expand Down Expand Up @@ -87,7 +87,10 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
await SeedRootSuperAdminAsync(stoppingToken).ConfigureAwait(false);
await SeedTenantUsersAsync(Acme, stoppingToken).ConfigureAwait(false);
await SeedTenantUsersAsync(Globex, stoppingToken).ConfigureAwait(false);
_logger.LogInformation("[DevDataSeeder] complete · superadmin@root.com · acme + globex demo users · password '{Password}'", SharedPassword);
if (_logger.IsEnabled(LogLevel.Information))
{
_logger.LogInformation("[DevDataSeeder] complete · superadmin@root.com · acme + globex demo users seeded (shared dev password configured)");
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Expand All @@ -107,7 +110,10 @@ private async Task EnsureTenantsAsync(CancellationToken cancellationToken)
continue;
}

_logger.LogInformation("[DevDataSeeder] creating demo tenant '{TenantId}'", demo.Id);
if (_logger.IsEnabled(LogLevel.Information))
{
_logger.LogInformation("[DevDataSeeder] creating demo tenant '{TenantId}'", demo.Id);
}
await tenantService.CreateAsync(
demo.Id,
demo.Name,
Expand Down Expand Up @@ -195,7 +201,10 @@ private async Task SeedUsersInTenantAsync(
{
role = new FshRole(demoRole.Name, demoRole.Description);
await roleManager.CreateAsync(role).ConfigureAwait(false);
_logger.LogInformation("[DevDataSeeder] [{Tenant}] created custom role '{Role}'", tenant.Id, demoRole.Name);
if (_logger.IsEnabled(LogLevel.Information))
{
_logger.LogInformation("[DevDataSeeder] [{Tenant}] created custom role '{Role}'", tenant.Id, demoRole.Name);
}
}

var existingClaims = await roleManager.GetClaimsAsync(role).ConfigureAwait(false);
Expand Down Expand Up @@ -312,20 +321,23 @@ private async Task EnsureSharedPasswordAsync(
return;
}

_logger.LogInformation(
"[DevDataSeeder] aligned '{Email}' to shared dev password", user.Email);
if (_logger.IsEnabled(LogLevel.Information))
{
_logger.LogInformation(
"[DevDataSeeder] aligned '{Email}' to shared dev password", user.Email);
}
}

// ─── Demo content (mirrors clients/dashboard/src/pages/login.demo-accounts.ts) ───

public sealed record DemoTenant(string Id, string Name, string AdminEmail, string Issuer, bool Populated);
public sealed record DemoUser(
internal sealed record DemoTenant(string Id, string Name, string AdminEmail, string Issuer, bool Populated);
internal sealed record DemoUser(
string UserName,
string Email,
string FirstName,
string LastName,
IReadOnlyList<string> Roles);
public sealed record DemoRole(string Name, string Description, IReadOnlyList<string> Permissions);
internal sealed record DemoRole(string Name, string Description, IReadOnlyList<string> Permissions);

private static IReadOnlyList<DemoUser> BuildRootUsers() =>
[
Expand Down
2 changes: 2 additions & 0 deletions src/Modules/Auditing/Modules.Auditing/AuditingModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ public void ConfigureMiddleware(IApplicationBuilder app)

public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);

var apiVersionSet = endpoints.NewApiVersionSet()
.HasApiVersion(new ApiVersion(1))
.ReportApiVersions()
Expand Down
14 changes: 10 additions & 4 deletions src/Modules/Billing/Modules.Billing/Services/BillingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,11 @@ public BillingService(
.ConfigureAwait(false);
if (existing is not null)
{
_logger.LogInformation("[Billing] invoice already exists for tenant {TenantId} period {Year}-{Month:00}, skipping",
tenantId, periodYear, periodMonth);
if (_logger.IsEnabled(LogLevel.Information))
{
_logger.LogInformation("[Billing] invoice already exists for tenant {TenantId} period {Year}-{Month:00}, skipping",
tenantId, periodYear, periodMonth);
}
return existing;
}

Expand Down Expand Up @@ -90,8 +93,11 @@ public BillingService(

_db.Invoices.Add(invoice);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("[Billing] generated draft invoice {InvoiceNumber} for tenant {TenantId} period {Year}-{Month:00} total={Total} {Currency}",
invoice.InvoiceNumber, tenantId, periodYear, periodMonth, invoice.SubtotalAmount, invoice.Currency);
if (_logger.IsEnabled(LogLevel.Information))
{
_logger.LogInformation("[Billing] generated draft invoice {InvoiceNumber} for tenant {TenantId} period {Year}-{Month:00} total={Total} {Currency}",
invoice.InvoiceNumber, tenantId, periodYear, periodMonth, invoice.SubtotalAmount, invoice.Currency);
}
return invoice;
}

Expand Down
14 changes: 10 additions & 4 deletions src/Modules/Billing/Modules.Billing/Services/MonthlyInvoiceJob.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,17 @@ public MonthlyInvoiceJob(IBillingService billing, ILogger<MonthlyInvoiceJob> log
public async Task RunAsync(CancellationToken cancellationToken)
{
var previous = DateTime.UtcNow.AddMonths(-1);
_logger.LogInformation("[Billing] MonthlyInvoiceJob generating invoices for period {Year}-{Month:00}",
previous.Year, previous.Month);
if (_logger.IsEnabled(LogLevel.Information))
{
_logger.LogInformation("[Billing] MonthlyInvoiceJob generating invoices for period {Year}-{Month:00}",
previous.Year, previous.Month);
}

var count = await _billing.GenerateInvoicesForAllTenantsAsync(previous.Year, previous.Month, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("[Billing] MonthlyInvoiceJob generated {Count} draft invoices for {Year}-{Month:00}",
count, previous.Year, previous.Month);
if (_logger.IsEnabled(LogLevel.Information))
{
_logger.LogInformation("[Billing] MonthlyInvoiceJob generated {Count} draft invoices for {Year}-{Month:00}",
count, previous.Year, previous.Month);
}
}
}
7 changes: 5 additions & 2 deletions src/Modules/Billing/Modules.Billing/Services/UsageReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,11 @@ public async Task<IReadOnlyList<UsageSnapshot>> CaptureForPeriodAsync(
}

await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("[Billing] captured {Count} usage snapshots for tenant {TenantId} period {Year}-{Month:00}",
snapshots.Count, tenantId, periodYear, periodMonth);
if (_logger.IsEnabled(LogLevel.Information))
{
_logger.LogInformation("[Billing] captured {Count} usage snapshots for tenant {TenantId} period {Year}-{Month:00}",
snapshots.Count, tenantId, periodYear, periodMonth);
}
return snapshots;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@

namespace FSH.Modules.Catalog.Contracts.v1.Brands;

// SortBy: Sort column. One of: name | slug | createdAtUtc.
// SortDir: Sort direction. One of: asc | desc.
public sealed record SearchBrandsQuery(
string? Search = null,
int PageNumber = 1,
int PageSize = 20,
/// <summary>Sort column. One of: name | slug | createdAtUtc.</summary>
string? SortBy = null,
/// <summary>Sort direction. One of: asc | desc.</summary>
string? SortDir = null) : IQuery<PagedResponse<BrandDto>>;
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@

namespace FSH.Modules.Catalog.Contracts.v1.Categories;

// SortBy: Sort column. One of: name | slug | createdAtUtc.
// SortDir: Sort direction. One of: asc | desc.
public sealed record SearchCategoriesQuery(
string? Search = null,
Guid? ParentCategoryId = null,
int PageNumber = 1,
int PageSize = 50,
/// <summary>Sort column. One of: name | slug | createdAtUtc.</summary>
string? SortBy = null,
/// <summary>Sort direction. One of: asc | desc.</summary>
string? SortDir = null) : IQuery<PagedResponse<CategoryDto>>;
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@

namespace FSH.Modules.Catalog.Contracts.v1.Products;

// SortBy: Sort column. One of: name | sku | createdAtUtc | stock | price.
// SortDir: Sort direction. One of: asc | desc.
public sealed record SearchProductsQuery(
string? Search = null,
Guid? BrandId = null,
Guid? CategoryId = null,
bool? IsActive = null,
int PageNumber = 1,
int PageSize = 20,
/// <summary>Sort column. One of: name | sku | createdAtUtc | stock | price.</summary>
string? SortBy = null,
/// <summary>Sort direction. One of: asc | desc.</summary>
string? SortDir = null) : IQuery<PagedResponse<ProductDto>>;
13 changes: 8 additions & 5 deletions src/Modules/Catalog/Modules.Catalog/Data/CatalogDbInitializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,13 @@ public async Task SeedAsync(CancellationToken cancellationToken)

await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

logger.LogInformation(
"[Catalog] seeded demo data: {BrandCount} brands, {CategoryCount} categories, {ProductCount} products",
brands.Count,
roots.Count + children.Count,
products.Count);
if (logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation(
"[Catalog] seeded demo data: {BrandCount} brands, {CategoryCount} categories, {ProductCount} products",
brands.Count,
roots.Count + children.Count,
products.Count);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public async ValueTask<PagedResponse<BrandDto>> Handle(
int page = query.PageNumber < 1 ? 1 : query.PageNumber;
int size = query.PageSize is < 1 or > 200 ? 20 : query.PageSize;

// IgnoreQueryFilters([SoftDelete]) bypasses ONLY the soft-delete filter;
// Bypasses the soft-delete filter only. Finbuckle tenant scoping remains active.
// tenant scoping (Finbuckle) stays in force, so a tenant only sees its
// own trashed rows. Most-recently-deleted first.
var q = dbContext.Brands
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,10 @@ public async ValueTask<PagedResponse<BrandDto>> Handle(SearchBrandsQuery query,
private static IQueryable<Brand> ApplySort(IQueryable<Brand> q, string? sortBy, string? sortDir)
{
bool desc = string.Equals(sortDir, "desc", StringComparison.OrdinalIgnoreCase);
return (sortBy?.ToLowerInvariant()) switch
return (sortBy?.ToUpperInvariant()) switch
{
"slug" => desc ? q.OrderByDescending(b => b.Slug) : q.OrderBy(b => b.Slug),
"createdatutc" or "created" => desc
"SLUG" => desc ? q.OrderByDescending(b => b.Slug) : q.OrderBy(b => b.Slug),
"CREATEDATUTC" or "CREATED" => desc
? q.OrderByDescending(b => b.CreatedAtUtc)
: q.OrderBy(b => b.CreatedAtUtc),
_ => desc ? q.OrderByDescending(b => b.Name) : q.OrderBy(b => b.Name),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,11 @@ public async ValueTask<IReadOnlyList<CategoryTreeNodeDto>> Handle(GetCategoryTre
.ToListAsync(cancellationToken)
.ConfigureAwait(false);

var byParent = all
.GroupBy(c => c.ParentCategoryId)
.ToDictionary(g => g.Key, g => g.ToList());
var byParent = all.ToLookup(c => c.ParentCategoryId);

IReadOnlyList<CategoryTreeNodeDto> Build(Guid? parentId)
{
if (!byParent.TryGetValue(parentId, out var children))
{
return Array.Empty<CategoryTreeNodeDto>();
}
return children
return byParent[parentId]
.Select(c => new CategoryTreeNodeDto(c.Id, c.Name, c.Slug, c.Description, Build(c.Id)))
.ToList();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,10 @@ public async ValueTask<PagedResponse<CategoryDto>> Handle(SearchCategoriesQuery
private static IQueryable<Category> ApplySort(IQueryable<Category> q, string? sortBy, string? sortDir)
{
bool desc = string.Equals(sortDir, "desc", StringComparison.OrdinalIgnoreCase);
return (sortBy?.ToLowerInvariant()) switch
return (sortBy?.ToUpperInvariant()) switch
{
"slug" => desc ? q.OrderByDescending(c => c.Slug) : q.OrderBy(c => c.Slug),
"createdatutc" or "created" => desc
"SLUG" => desc ? q.OrderByDescending(c => c.Slug) : q.OrderBy(c => c.Slug),
"CREATEDATUTC" or "CREATED" => desc
? q.OrderByDescending(c => c.CreatedAtUtc)
: q.OrderBy(c => c.CreatedAtUtc),
_ => desc ? q.OrderByDescending(c => c.Name) : q.OrderBy(c => c.Name),
Expand Down
Loading
Loading