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
8 changes: 6 additions & 2 deletions src/BuildingBlocks/Mailing/Services/SmtpMailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,12 @@ 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);
}
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
2 changes: 1 addition & 1 deletion src/BuildingBlocks/Quota/InMemoryQuotaStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +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>
internal sealed class InMemoryQuotaStore
public sealed class InMemoryQuotaStore
{
public ConcurrentDictionary<string, long> Counters { get; } = new();
}
2 changes: 1 addition & 1 deletion src/BuildingBlocks/Quota/NoopQuotaService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +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>
internal sealed class NoopQuotaService : IQuotaService
public sealed class NoopQuotaService : IQuotaService
{
public ValueTask<QuotaCheckResult> CheckAsync(string tenantId, QuotaResource resource, long amount, CancellationToken ct = default)
=> ValueTask.FromResult(QuotaCheckResult.Unlimited(resource, 0));
Expand Down
7 changes: 0 additions & 7 deletions src/BuildingBlocks/Quota/Quota.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,6 @@
<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
3 changes: 1 addition & 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,6 @@
<ItemGroup>
<PackageReference Include="Finbuckle.MultiTenant" />
<PackageReference Include="Finbuckle.MultiTenant.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>

<ItemGroup>
Expand Down
18 changes: 16 additions & 2 deletions src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Diagnostics;
using System;
using FSH.Framework.Core.Exceptions;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
Expand Down Expand Up @@ -52,10 +53,23 @@ public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception e
problemDetails.Extensions["errors"] = e.ErrorMessages;
}
}
else if (exception is UnauthorizedAccessException)
{
statusCode = StatusCodes.Status401Unauthorized;
problemDetails.Status = statusCode;
problemDetails.Title = "Unauthorized";
problemDetails.Detail = exception.Message;
}
else if (exception is KeyNotFoundException)
{
statusCode = StatusCodes.Status404NotFound;
problemDetails.Status = statusCode;
problemDetails.Title = "Not Found";
problemDetails.Detail = exception.Message;
}
else
{
statusCode = StatusCodes.Status500InternalServerError;

problemDetails.Status = statusCode;
problemDetails.Title = "An unexpected error occurred";
problemDetails.Detail = "An unexpected error occurred. Please try again later.";
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;CA1054;CA1056</NoWarn>
<!-- Suppress warning about missing XML docs -->

<!-- Container tags (if using container builds) -->
Expand Down
64 changes: 44 additions & 20 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 @@ -74,7 +74,10 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
// Default-on in Development unless explicitly disabled.
if (!_config.GetValue("Seed:Demo", true))
{
_logger.LogInformation("[DevDataSeeder] disabled via Seed:Demo=false");
if (_logger.IsEnabled(LogLevel.Information))
{
_logger.LogInformation("[DevDataSeeder] disabled via Seed:Demo=false");
}
return;
}

Expand All @@ -87,7 +90,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 +113,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 @@ -142,7 +151,10 @@ private async Task WaitForProvisioningAsync(
}
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false);
}
_logger.LogWarning("[DevDataSeeder] tenant '{TenantId}' did not finish provisioning within 2 minutes; skipping user seed", tenantId);
if (_logger.IsEnabled(LogLevel.Warning))
{
_logger.LogWarning("[DevDataSeeder] tenant '{TenantId}' did not finish provisioning within 2 minutes; skipping user seed", tenantId);
}
}

private async Task SeedRootSuperAdminAsync(CancellationToken cancellationToken)
Expand Down Expand Up @@ -195,7 +207,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 @@ -237,11 +252,14 @@ private async Task SeedUsersInTenantAsync(
var created = await userManager.CreateAsync(user).ConfigureAwait(false);
if (!created.Succeeded)
{
_logger.LogWarning(
"[DevDataSeeder] [{Tenant}] failed to create '{Email}': {Errors}",
tenant.Id,
demoUser.Email,
string.Join("; ", created.Errors.Select(e => e.Description)));
if (_logger.IsEnabled(LogLevel.Warning))
{
_logger.LogWarning(
"[DevDataSeeder] [{Tenant}] failed to create '{Email}': {Errors}",
tenant.Id,
demoUser.Email,
string.Join("; ", created.Errors.Select(e => e.Description)));
}
continue;
}
existing = user;
Expand Down Expand Up @@ -305,27 +323,33 @@ private async Task EnsureSharedPasswordAsync(
var result = await userManager.UpdateAsync(user).ConfigureAwait(false);
if (!result.Succeeded)
{
_logger.LogWarning(
"[DevDataSeeder] failed to reset password for '{Email}': {Errors}",
user.Email,
string.Join("; ", result.Errors.Select(e => e.Description)));
if (_logger.IsEnabled(LogLevel.Warning))
{
_logger.LogWarning(
"[DevDataSeeder] failed to reset password for '{Email}': {Errors}",
user.Email,
string.Join("; ", result.Errors.Select(e => e.Description)));
}
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
1 change: 1 addition & 0 deletions src/Modules/Auditing/Modules.Auditing/AuditingModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ 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,17 @@

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

/// <summary>
/// Search for brands with pagination and sorting.
/// </summary>
/// <param name="Search">Search term.</param>
/// <param name="PageNumber">Page number.</param>
/// <param name="PageSize">Page size.</param>
/// <param name="SortBy">Sort column. One of: name | slug | createdAtUtc.</param>
/// <param name="SortDir">Sort direction. One of: asc | desc.</param>
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,19 @@

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

/// <summary>
/// Search for categories with pagination and sorting.
/// </summary>
/// <param name="Search">Search term.</param>
/// <param name="ParentCategoryId">Optional parent category ID filter.</param>
/// <param name="PageNumber">Page number.</param>
/// <param name="PageSize">Page size.</param>
/// <param name="SortBy">Sort column. One of: name | slug | createdAtUtc.</param>
/// <param name="SortDir">Sort direction. One of: asc | desc.</param>
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>>;
Loading
Loading