diff --git a/src/.editorconfig b/src/.editorconfig index a0e607577a..1d809f0742 100644 --- a/src/.editorconfig +++ b/src/.editorconfig @@ -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 diff --git a/src/BuildingBlocks/Mailing/Services/SmtpMailService.cs b/src/BuildingBlocks/Mailing/Services/SmtpMailService.cs index dc890e5631..f4d2c1a7f7 100644 --- a/src/BuildingBlocks/Mailing/Services/SmtpMailService.cs +++ b/src/BuildingBlocks/Mailing/Services/SmtpMailService.cs @@ -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 diff --git a/src/BuildingBlocks/Quota/InMemoryQuotaService.cs b/src/BuildingBlocks/Quota/InMemoryQuotaService.cs index eed5e5e702..af93a51db1 100644 --- a/src/BuildingBlocks/Quota/InMemoryQuotaService.cs +++ b/src/BuildingBlocks/Quota/InMemoryQuotaService.cs @@ -15,7 +15,7 @@ public sealed class InMemoryQuotaService : IQuotaService private readonly QuotaOptions _options; private readonly QuotaPlanResolver _planResolver; private readonly IMultiTenantContextAccessor? _tenantAccessor; - private readonly IReadOnlyDictionary _gauges; + private readonly Dictionary _gauges; private readonly TimeProvider _timeProvider; internal InMemoryQuotaService( diff --git a/src/BuildingBlocks/Quota/InMemoryQuotaStore.cs b/src/BuildingBlocks/Quota/InMemoryQuotaStore.cs index 0a8591522a..9372342b8d 100644 --- a/src/BuildingBlocks/Quota/InMemoryQuotaStore.cs +++ b/src/BuildingBlocks/Quota/InMemoryQuotaStore.cs @@ -6,6 +6,7 @@ namespace FSH.Framework.Quota; /// Singleton backing store for so counters survive request scopes. /// Keyed by quota:{tenantId}:{resource}:{period} exactly like the Redis backend. /// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by dependency injection")] internal sealed class InMemoryQuotaStore { public ConcurrentDictionary Counters { get; } = new(); diff --git a/src/BuildingBlocks/Quota/NoopQuotaService.cs b/src/BuildingBlocks/Quota/NoopQuotaService.cs index 0cb69c5e83..8b95556d56 100644 --- a/src/BuildingBlocks/Quota/NoopQuotaService.cs +++ b/src/BuildingBlocks/Quota/NoopQuotaService.cs @@ -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. /// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by dependency injection")] internal sealed class NoopQuotaService : IQuotaService { public ValueTask CheckAsync(string tenantId, QuotaResource resource, long amount, CancellationToken ct = default) diff --git a/src/BuildingBlocks/Quota/Quota.csproj b/src/BuildingBlocks/Quota/Quota.csproj index b2539b2158..75fa9a0bab 100644 --- a/src/BuildingBlocks/Quota/Quota.csproj +++ b/src/BuildingBlocks/Quota/Quota.csproj @@ -13,13 +13,7 @@ - - - - - - - + diff --git a/src/BuildingBlocks/Quota/QuotaOptions.cs b/src/BuildingBlocks/Quota/QuotaOptions.cs index 40a4e6a8b2..885847963e 100644 --- a/src/BuildingBlocks/Quota/QuotaOptions.cs +++ b/src/BuildingBlocks/Quota/QuotaOptions.cs @@ -20,7 +20,7 @@ public sealed class QuotaOptions public string DefaultPlan { get; set; } = "free"; /// Plan name → per-resource limit map. Use -1 or long.MaxValue for "unlimited". - public Dictionary> Plans { get; set; } = new(); + public Dictionary> Plans { get; } = new(); /// /// Whether the root/platform tenant is exempt from quota enforcement. Defaults to true; platform diff --git a/src/BuildingBlocks/Quota/RedisQuotaService.cs b/src/BuildingBlocks/Quota/RedisQuotaService.cs index 80f3a25689..9016e6a05a 100644 --- a/src/BuildingBlocks/Quota/RedisQuotaService.cs +++ b/src/BuildingBlocks/Quota/RedisQuotaService.cs @@ -18,7 +18,7 @@ public sealed class RedisQuotaService : IQuotaService private readonly QuotaOptions _options; private readonly QuotaPlanResolver _planResolver; private readonly IMultiTenantContextAccessor? _tenantAccessor; - private readonly IReadOnlyDictionary _gauges; + private readonly Dictionary _gauges; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; diff --git a/src/BuildingBlocks/Storage/Storage.csproj b/src/BuildingBlocks/Storage/Storage.csproj index 7d84e5d548..45e9f8e15c 100644 --- a/src/BuildingBlocks/Storage/Storage.csproj +++ b/src/BuildingBlocks/Storage/Storage.csproj @@ -1,4 +1,4 @@ - + FSH.Framework.Storage @@ -16,7 +16,7 @@ - + diff --git a/src/BuildingBlocks/Web/Versioning/Extensions.cs b/src/BuildingBlocks/Web/Versioning/Extensions.cs index 3a8caf2326..9621a89c20 100644 --- a/src/BuildingBlocks/Web/Versioning/Extensions.cs +++ b/src/BuildingBlocks/Web/Versioning/Extensions.cs @@ -1,4 +1,4 @@ -using Asp.Versioning; +using Asp.Versioning; using Microsoft.Extensions.DependencyInjection; namespace FSH.Framework.Web.Versioning; @@ -7,6 +7,7 @@ public static class Extensions { public static IServiceCollection AddHeroVersioning(this IServiceCollection services) { + ArgumentNullException.ThrowIfNull(services); services .AddApiVersioning(options => { diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 43f3547218..7708a94702 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -17,7 +17,7 @@ true - $(NoWarn);CS1591 + $(NoWarn);CS1591;MSG0005 diff --git a/src/Host/FSH.Starter.Api/DevSeeding/DevDataSeeder.cs b/src/Host/FSH.Starter.Api/DevSeeding/DevDataSeeder.cs index 4c9d64be95..a88ab56608 100644 --- a/src/Host/FSH.Starter.Api/DevSeeding/DevDataSeeder.cs +++ b/src/Host/FSH.Starter.Api/DevSeeding/DevDataSeeder.cs @@ -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 . +/// - Only registered when IHostEnvironment.IsDevelopment(). /// - Additionally gated on Seed:Demo == true in configuration so a developer can /// opt out without code changes. /// @@ -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) { @@ -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, @@ -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); @@ -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 Roles); - public sealed record DemoRole(string Name, string Description, IReadOnlyList Permissions); + internal sealed record DemoRole(string Name, string Description, IReadOnlyList Permissions); private static IReadOnlyList BuildRootUsers() => [ diff --git a/src/Modules/Auditing/Modules.Auditing/AuditingModule.cs b/src/Modules/Auditing/Modules.Auditing/AuditingModule.cs index 3093baa5a9..145fb842a7 100644 --- a/src/Modules/Auditing/Modules.Auditing/AuditingModule.cs +++ b/src/Modules/Auditing/Modules.Auditing/AuditingModule.cs @@ -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() diff --git a/src/Modules/Billing/Modules.Billing/Services/BillingService.cs b/src/Modules/Billing/Modules.Billing/Services/BillingService.cs index 6e39d185e8..199cf73377 100644 --- a/src/Modules/Billing/Modules.Billing/Services/BillingService.cs +++ b/src/Modules/Billing/Modules.Billing/Services/BillingService.cs @@ -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; } @@ -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; } diff --git a/src/Modules/Billing/Modules.Billing/Services/MonthlyInvoiceJob.cs b/src/Modules/Billing/Modules.Billing/Services/MonthlyInvoiceJob.cs index ab170cdedb..5f3e329676 100644 --- a/src/Modules/Billing/Modules.Billing/Services/MonthlyInvoiceJob.cs +++ b/src/Modules/Billing/Modules.Billing/Services/MonthlyInvoiceJob.cs @@ -21,11 +21,17 @@ public MonthlyInvoiceJob(IBillingService billing, ILogger 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); + } } } diff --git a/src/Modules/Billing/Modules.Billing/Services/UsageReporter.cs b/src/Modules/Billing/Modules.Billing/Services/UsageReporter.cs index 1ec676019b..7d527f6ed6 100644 --- a/src/Modules/Billing/Modules.Billing/Services/UsageReporter.cs +++ b/src/Modules/Billing/Modules.Billing/Services/UsageReporter.cs @@ -62,8 +62,11 @@ public async Task> 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; } } diff --git a/src/Modules/Catalog/Modules.Catalog.Contracts/v1/Brands/SearchBrandsQuery.cs b/src/Modules/Catalog/Modules.Catalog.Contracts/v1/Brands/SearchBrandsQuery.cs index 4a44048226..224eff02c8 100644 --- a/src/Modules/Catalog/Modules.Catalog.Contracts/v1/Brands/SearchBrandsQuery.cs +++ b/src/Modules/Catalog/Modules.Catalog.Contracts/v1/Brands/SearchBrandsQuery.cs @@ -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, - /// Sort column. One of: name | slug | createdAtUtc. string? SortBy = null, - /// Sort direction. One of: asc | desc. string? SortDir = null) : IQuery>; diff --git a/src/Modules/Catalog/Modules.Catalog.Contracts/v1/Categories/SearchCategoriesQuery.cs b/src/Modules/Catalog/Modules.Catalog.Contracts/v1/Categories/SearchCategoriesQuery.cs index fc2c381ffd..112ef3eea4 100644 --- a/src/Modules/Catalog/Modules.Catalog.Contracts/v1/Categories/SearchCategoriesQuery.cs +++ b/src/Modules/Catalog/Modules.Catalog.Contracts/v1/Categories/SearchCategoriesQuery.cs @@ -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, - /// Sort column. One of: name | slug | createdAtUtc. string? SortBy = null, - /// Sort direction. One of: asc | desc. string? SortDir = null) : IQuery>; diff --git a/src/Modules/Catalog/Modules.Catalog.Contracts/v1/Products/SearchProductsQuery.cs b/src/Modules/Catalog/Modules.Catalog.Contracts/v1/Products/SearchProductsQuery.cs index f64c50c93c..3341c7eafb 100644 --- a/src/Modules/Catalog/Modules.Catalog.Contracts/v1/Products/SearchProductsQuery.cs +++ b/src/Modules/Catalog/Modules.Catalog.Contracts/v1/Products/SearchProductsQuery.cs @@ -4,6 +4,8 @@ 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, @@ -11,7 +13,5 @@ public sealed record SearchProductsQuery( bool? IsActive = null, int PageNumber = 1, int PageSize = 20, - /// Sort column. One of: name | sku | createdAtUtc | stock | price. string? SortBy = null, - /// Sort direction. One of: asc | desc. string? SortDir = null) : IQuery>; diff --git a/src/Modules/Catalog/Modules.Catalog/Data/CatalogDbInitializer.cs b/src/Modules/Catalog/Modules.Catalog/Data/CatalogDbInitializer.cs index 68bcb7be42..a4fb42b9ae 100644 --- a/src/Modules/Catalog/Modules.Catalog/Data/CatalogDbInitializer.cs +++ b/src/Modules/Catalog/Modules.Catalog/Data/CatalogDbInitializer.cs @@ -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); + } } } diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/ListTrashedBrands/ListTrashedBrandsQueryHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/ListTrashedBrands/ListTrashedBrandsQueryHandler.cs index 01458ee2b2..791ba1a9a3 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/ListTrashedBrands/ListTrashedBrandsQueryHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/ListTrashedBrands/ListTrashedBrandsQueryHandler.cs @@ -19,7 +19,7 @@ public async ValueTask> 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 diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/SearchBrands/SearchBrandsQueryHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/SearchBrands/SearchBrandsQueryHandler.cs index 72a7f46fd7..de4cde12e2 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/SearchBrands/SearchBrandsQueryHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/SearchBrands/SearchBrandsQueryHandler.cs @@ -55,10 +55,10 @@ public async ValueTask> Handle(SearchBrandsQuery query, private static IQueryable ApplySort(IQueryable 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), diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/GetCategoryTree/GetCategoryTreeQueryHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/GetCategoryTree/GetCategoryTreeQueryHandler.cs index 07cf9ff0d3..bd312aecb5 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/GetCategoryTree/GetCategoryTreeQueryHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/GetCategoryTree/GetCategoryTreeQueryHandler.cs @@ -20,17 +20,11 @@ public async ValueTask> 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 Build(Guid? parentId) { - if (!byParent.TryGetValue(parentId, out var children)) - { - return Array.Empty(); - } - return children + return byParent[parentId] .Select(c => new CategoryTreeNodeDto(c.Id, c.Name, c.Slug, c.Description, Build(c.Id))) .ToList(); } diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/SearchCategories/SearchCategoriesQueryHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/SearchCategories/SearchCategoriesQueryHandler.cs index f6ab79fc4a..6fba2e4779 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/SearchCategories/SearchCategoriesQueryHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/SearchCategories/SearchCategoriesQueryHandler.cs @@ -57,10 +57,10 @@ public async ValueTask> Handle(SearchCategoriesQuery private static IQueryable ApplySort(IQueryable 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), diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/SearchProducts/SearchProductsQueryHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/SearchProducts/SearchProductsQueryHandler.cs index 6eb84ae2a2..63af43314f 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/SearchProducts/SearchProductsQueryHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/SearchProducts/SearchProductsQueryHandler.cs @@ -68,12 +68,12 @@ private static IQueryable ApplySort(IQueryable q, string? sort // Default to descending unless caller explicitly opts into ascending — // admins typically want newest-first when they don't pick a direction. bool desc = !string.Equals(sortDir, "asc", StringComparison.OrdinalIgnoreCase); - return (sortBy?.ToLowerInvariant()) switch + return (sortBy?.ToUpperInvariant()) switch { - "name" => desc ? q.OrderByDescending(p => p.Name) : q.OrderBy(p => p.Name), - "sku" => desc ? q.OrderByDescending(p => p.Sku) : q.OrderBy(p => p.Sku), - "stock" => desc ? q.OrderByDescending(p => p.Stock) : q.OrderBy(p => p.Stock), - "price" => desc + "NAME" => desc ? q.OrderByDescending(p => p.Name) : q.OrderBy(p => p.Name), + "SKU" => desc ? q.OrderByDescending(p => p.Sku) : q.OrderBy(p => p.Sku), + "STOCK" => desc ? q.OrderByDescending(p => p.Stock) : q.OrderBy(p => p.Stock), + "PRICE" => desc ? q.OrderByDescending(p => p.Price.Amount) : q.OrderBy(p => p.Price.Amount), _ => desc diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IIdentityService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IIdentityService.cs index 2442838a68..081c4c8959 100644 --- a/src/Modules/Identity/Modules.Identity.Contracts/Services/IIdentityService.cs +++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IIdentityService.cs @@ -1,4 +1,4 @@ -using System.Security.Claims; +using System.Security.Claims; namespace FSH.Modules.Identity.Contracts.Services; @@ -9,7 +9,7 @@ public interface IIdentityService /// /// User email or username /// User password - /// Optional tenant ID + /// Optional two-factor authentication code /// Cancellation token /// Subject ID and claims, or null if invalid Task<(string Subject, IEnumerable Claims)?> diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/ISessionService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/ISessionService.cs index 3e12edc528..429d5bee10 100644 --- a/src/Modules/Identity/Modules.Identity.Contracts/Services/ISessionService.cs +++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/ISessionService.cs @@ -27,6 +27,7 @@ Task> GetUserSessionsForAdminAsync( /// Optional substring filter applied to user name, email, or IP address. /// Pagination offset. /// Pagination size (capped server-side). + /// Cancellation token. Task<(List Items, long TotalCount)> GetTenantSessionsAsync( bool includeInactive, string? search, diff --git a/src/Modules/Identity/Modules.Identity/Authorization/RolePermissionSyncHostedService.cs b/src/Modules/Identity/Modules.Identity/Authorization/RolePermissionSyncHostedService.cs index 222de3960b..92a1412c77 100644 --- a/src/Modules/Identity/Modules.Identity/Authorization/RolePermissionSyncHostedService.cs +++ b/src/Modules/Identity/Modules.Identity/Authorization/RolePermissionSyncHostedService.cs @@ -76,8 +76,10 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } catch (Exception ex) when (ex is not OperationCanceledException) { - // Catalog DB likely not migrated yet — keep waiting. - logger.LogDebug(ex, "Tenant store not ready yet; retrying in {Interval}", PollInterval); + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug(ex, "Tenant store not ready yet; retrying in {Interval}", PollInterval); + } } await Task.Delay(PollInterval, stoppingToken).ConfigureAwait(false); diff --git a/src/Modules/Identity/Modules.Identity/Authorization/RolePermissionSyncer.cs b/src/Modules/Identity/Modules.Identity/Authorization/RolePermissionSyncer.cs index c8be3b4a34..d1148c9045 100644 --- a/src/Modules/Identity/Modules.Identity/Authorization/RolePermissionSyncer.cs +++ b/src/Modules/Identity/Modules.Identity/Authorization/RolePermissionSyncer.cs @@ -84,11 +84,14 @@ private async Task SyncRoleAsync(string roleName, IReadOnlyList +{ + public GetTenantSessionsValidator() + { + RuleFor(x => x.PageNumber) + .GreaterThanOrEqualTo(1).WithMessage("Page number must be greater than or equal to 1."); + + RuleFor(x => x.PageSize) + .GreaterThanOrEqualTo(1).WithMessage("Page size must be greater than or equal to 1."); + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Data/Configurations/AppTenantInfoConfiguration.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Data/Configurations/AppTenantInfoConfiguration.cs index 8dedbe04a3..18de72c0fe 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Data/Configurations/AppTenantInfoConfiguration.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Data/Configurations/AppTenantInfoConfiguration.cs @@ -11,6 +11,8 @@ public class AppTenantInfoConfiguration : IEntityTypeConfiguration builder) { + ArgumentNullException.ThrowIfNull(builder); + builder.ToTable("Tenants", MultitenancyConstants.Schema); builder.Property(t => t.Plan).HasMaxLength(64); diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyModule.cs b/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyModule.cs index 80fb91e1c2..5941bd0fc3 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyModule.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyModule.cs @@ -97,6 +97,8 @@ public void ConfigureServices(IHostApplicationBuilder builder) public void MapEndpoints(IEndpointRouteBuilder endpoints) { + ArgumentNullException.ThrowIfNull(endpoints); + var versionSet = endpoints.NewApiVersionSet() .HasApiVersion(new ApiVersion(1)) .ReportApiVersions() diff --git a/src/Modules/Tickets/Modules.Tickets/Domain/TicketComment.cs b/src/Modules/Tickets/Modules.Tickets/Domain/TicketComment.cs index 76feed9ad3..9dee70a7ce 100644 --- a/src/Modules/Tickets/Modules.Tickets/Domain/TicketComment.cs +++ b/src/Modules/Tickets/Modules.Tickets/Domain/TicketComment.cs @@ -14,9 +14,13 @@ public sealed class TicketComment : BaseEntity, ISoftDeletable public string Body { get; private set; } = default!; public DateTime CreatedAtUtc { get; private set; } + // Setters are populated by AuditableEntitySaveChangesInterceptor via EF Core's + // entry.Property(...).CurrentValue — invisible to static analysis. +#pragma warning disable S1144 // EF Core writes these setters via reflection public bool IsDeleted { get; private set; } public DateTimeOffset? DeletedOnUtc { get; private set; } public string? DeletedBy { get; private set; } +#pragma warning restore S1144 private TicketComment() { } diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/SearchTickets/SearchTicketsQueryHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/SearchTickets/SearchTicketsQueryHandler.cs index 7c46f1d3d1..93429bf1ce 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/SearchTickets/SearchTicketsQueryHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/SearchTickets/SearchTicketsQueryHandler.cs @@ -75,12 +75,12 @@ public async ValueTask> Handle(SearchTicketsQuery query private static IQueryable ApplySort(IQueryable q, string? sortBy, string? sortDir) { bool desc = !string.Equals(sortDir, "asc", StringComparison.OrdinalIgnoreCase); - return (sortBy?.ToLowerInvariant()) switch + return (sortBy?.ToUpperInvariant()) switch { - "title" => desc ? q.OrderByDescending(t => t.Title) : q.OrderBy(t => t.Title), - "priority" => desc ? q.OrderByDescending(t => t.Priority) : q.OrderBy(t => t.Priority), - "status" => desc ? q.OrderByDescending(t => t.Status) : q.OrderBy(t => t.Status), - "number" => desc ? q.OrderByDescending(t => t.Number) : q.OrderBy(t => t.Number), + "TITLE" => desc ? q.OrderByDescending(t => t.Title) : q.OrderBy(t => t.Title), + "PRIORITY" => desc ? q.OrderByDescending(t => t.Priority) : q.OrderBy(t => t.Priority), + "STATUS" => desc ? q.OrderByDescending(t => t.Status) : q.OrderBy(t => t.Status), + "NUMBER" => desc ? q.OrderByDescending(t => t.Number) : q.OrderBy(t => t.Number), _ => desc ? q.OrderByDescending(t => t.CreatedAtUtc) : q.OrderBy(t => t.CreatedAtUtc), }; } diff --git a/src/Modules/Webhooks/Modules.Webhooks/Services/WebhookDispatchJob.cs b/src/Modules/Webhooks/Modules.Webhooks/Services/WebhookDispatchJob.cs index 152c3e11a1..ed209cb714 100644 --- a/src/Modules/Webhooks/Modules.Webhooks/Services/WebhookDispatchJob.cs +++ b/src/Modules/Webhooks/Modules.Webhooks/Services/WebhookDispatchJob.cs @@ -81,9 +81,12 @@ public async Task DispatchAsync( if (subscription is null || !subscription.IsActive) { - _logger.LogInformation( - "Skipping webhook dispatch for subscription {SubscriptionId} (not found or inactive).", - subscriptionId); + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Skipping webhook dispatch for subscription {SubscriptionId} (not found or inactive).", + subscriptionId); + } return; } @@ -164,6 +167,7 @@ private static bool IsTransient(int statusCode) => public sealed class WebhookDeliveryFailedException : Exception { + public WebhookDeliveryFailedException() { } public WebhookDeliveryFailedException(string message) : base(message) { } public WebhookDeliveryFailedException(string message, Exception innerException) : base(message, innerException) { } } diff --git a/src/Tests/Integration.Tests/Tests/Webhooks/WebhookDispatchJobTests.cs b/src/Tests/Integration.Tests/Tests/Webhooks/WebhookDispatchJobTests.cs index 3647bb61b2..25e35a7ab8 100644 --- a/src/Tests/Integration.Tests/Tests/Webhooks/WebhookDispatchJobTests.cs +++ b/src/Tests/Integration.Tests/Tests/Webhooks/WebhookDispatchJobTests.cs @@ -117,13 +117,13 @@ public async Task DispatchAsync_Should_CompleteSilently_When_SubscriptionInactiv // Unknown subscription — job must NOT throw (avoids Hangfire retry loop on a // permanent condition). - await job.DispatchAsync( + await Should.NotThrowAsync(() => job.DispatchAsync( Guid.NewGuid(), TestConstants.RootTenantId, "noop", "{}", context: null, - cancellationToken: CancellationToken.None); + cancellationToken: CancellationToken.None)); } [Fact]