From a02dda820260b10cb2d1c865f0e4957151d5cd1e Mon Sep 17 00:00:00 2001 From: jarvis Date: Mon, 26 Jan 2026 03:47:57 +0000 Subject: [PATCH] refactor: reduce cyclomatic complexity of 12 high-complexity methods Extracted helper methods to reduce complexity in methods with CC 10-15: Method | Before | After ------------------------------------------------|--------|------ AddHeroRateLimiting | 15 | 3 GetQuery (SpecificationEvaluator) | 13 | 1 ExecuteAsync (AuditBackgroundWorker) | 13 | 5 SendAsync (SendGridMailService) | 12 | 1 PublishSingleAsync (InMemoryEventBus) | 11 | 3 PublishAsync (ChannelAuditPublisher) | 11 | 2 Handle (GetAuditSummaryQueryHandler) | 11 | 1 Handle (GetExceptionAuditsQueryHandler) | 11 | 1 Handle (UpdateGroupCommandHandler) | 11 | 1 UpdatePermissionsAsync (RoleService) | 11 | 2 StartAsync (TenantAutoProvisioningHostedService)| 11 | 3 ApplySortingOverride (Specification) | 11 | 3 Refactoring patterns used: - Extract Method: Split large methods into smaller focused helpers - Single Responsibility: Each helper does one thing - Reduced nesting: Flattened control flow structures All changes pass build verification with 0 errors and 0 warnings. --- .../Eventing/InMemory/InMemoryEventBus.cs | 97 +++++++++------ .../Mailing/Services/SendGridMailService.cs | 51 ++++++-- .../Specifications/Specification.cs | 89 +++++++------- .../Specifications/SpecificationEvaluator.cs | 102 ++++++++++------ .../Web/RateLimiting/Extensions.cs | 112 ++++++++++-------- .../Core/AuditBackgroundWorker.cs | 91 ++++++++------ .../Core/ChannelAuditPublisher.cs | 93 ++++++++------- .../GetAuditSummaryQueryHandler.cs | 64 ++++++---- .../GetExceptionAuditsQueryHandler.cs | 34 +++++- .../UpdateGroup/UpdateGroupCommandHandler.cs | 80 ++++++++----- .../Features/v1/Roles/RoleService.cs | 60 +++++++--- .../TenantAutoProvisioningHostedService.cs | 63 ++++++---- 12 files changed, 583 insertions(+), 353 deletions(-) diff --git a/src/BuildingBlocks/Eventing/InMemory/InMemoryEventBus.cs b/src/BuildingBlocks/Eventing/InMemory/InMemoryEventBus.cs index 4f9de0a2d8..40da2673ef 100644 --- a/src/BuildingBlocks/Eventing/InMemory/InMemoryEventBus.cs +++ b/src/BuildingBlocks/Eventing/InMemory/InMemoryEventBus.cs @@ -42,9 +42,7 @@ private async Task PublishSingleAsync(IIntegrationEvent @event, CancellationToke using var scope = _serviceProvider.CreateScope(); var provider = scope.ServiceProvider; - var handlerInterfaceType = typeof(IIntegrationEventHandler<>).MakeGenericType(eventType); - var handlers = provider.GetServices(handlerInterfaceType).ToArray(); - + var handlers = ResolveHandlers(provider, eventType); if (handlers.Length == 0) { _logger.LogDebug("No handlers registered for integration event type {EventType}", eventType.FullName); @@ -52,48 +50,75 @@ private async Task PublishSingleAsync(IIntegrationEvent @event, CancellationToke } var inbox = provider.GetService(); + var handlerInterfaceType = typeof(IIntegrationEventHandler<>).MakeGenericType(eventType); foreach (var handler in handlers) { - if (handler is null) - { - continue; - } + await InvokeHandlerAsync(handler, handlerInterfaceType, eventType, @event, inbox, ct); + } + } - var handlerName = handler.GetType().FullName ?? handler.GetType().Name; + private static object[] ResolveHandlers(IServiceProvider provider, Type eventType) + { + var handlerInterfaceType = typeof(IIntegrationEventHandler<>).MakeGenericType(eventType); + return provider.GetServices(handlerInterfaceType).Where(h => h is not null).ToArray()!; + } - if (inbox != null) - { - if (await inbox.HasProcessedAsync(@event.Id, handlerName, ct).ConfigureAwait(false)) - { - _logger.LogDebug("Skipping already processed integration event {EventId} for handler {Handler}", @event.Id, handlerName); - continue; - } - } + private async Task InvokeHandlerAsync( + object handler, + Type handlerInterfaceType, + Type eventType, + IIntegrationEvent @event, + IInboxStore? inbox, + CancellationToken ct) + { + var handlerName = handler.GetType().FullName ?? handler.GetType().Name; - var method = handlerInterfaceType.GetMethod(nameof(IIntegrationEventHandler.HandleAsync)); - if (method == null) - { - _logger.LogWarning("Handler {Handler} does not implement HandleAsync correctly for {EventType}", handlerName, eventType.FullName); - continue; - } + if (await ShouldSkipProcessedEventAsync(inbox, @event.Id, handlerName, ct)) + { + _logger.LogDebug("Skipping already processed integration event {EventId} for handler {Handler}", @event.Id, handlerName); + return; + } - try - { - var task = (Task)method.Invoke(handler, new object[] { @event, ct })!; - await task.ConfigureAwait(false); - - if (inbox != null) - { - await inbox.MarkProcessedAsync(@event.Id, handlerName, @event.TenantId, eventType.AssemblyQualifiedName ?? eventType.FullName!, ct) - .ConfigureAwait(false); - } - } - catch (Exception ex) + var method = handlerInterfaceType.GetMethod(nameof(IIntegrationEventHandler.HandleAsync)); + if (method == null) + { + _logger.LogWarning("Handler {Handler} does not implement HandleAsync correctly for {EventType}", handlerName, eventType.FullName); + return; + } + + await ExecuteHandlerAsync(handler, method, @event, eventType, handlerName, inbox, ct); + } + + private static async Task ShouldSkipProcessedEventAsync(IInboxStore? inbox, Guid eventId, string handlerName, CancellationToken ct) + { + return inbox != null && await inbox.HasProcessedAsync(eventId, handlerName, ct).ConfigureAwait(false); + } + + private async Task ExecuteHandlerAsync( + object handler, + MethodInfo method, + IIntegrationEvent @event, + Type eventType, + string handlerName, + IInboxStore? inbox, + CancellationToken ct) + { + try + { + var task = (Task)method.Invoke(handler, new object[] { @event, ct })!; + await task.ConfigureAwait(false); + + if (inbox != null) { - _logger.LogError(ex, "Error while handling integration event {EventId} with handler {Handler}", @event.Id, handlerName); - throw; + await inbox.MarkProcessedAsync(@event.Id, handlerName, @event.TenantId, eventType.AssemblyQualifiedName ?? eventType.FullName!, ct) + .ConfigureAwait(false); } } + catch (Exception ex) + { + _logger.LogError(ex, "Error while handling integration event {EventId} with handler {Handler}", @event.Id, handlerName); + throw; + } } } diff --git a/src/BuildingBlocks/Mailing/Services/SendGridMailService.cs b/src/BuildingBlocks/Mailing/Services/SendGridMailService.cs index 78b9747958..5521c7af7b 100644 --- a/src/BuildingBlocks/Mailing/Services/SendGridMailService.cs +++ b/src/BuildingBlocks/Mailing/Services/SendGridMailService.cs @@ -19,11 +19,10 @@ public SendGridMailService(IOptions settings) public async Task SendAsync(MailRequest request, CancellationToken ct) { ArgumentNullException.ThrowIfNull(request); - if (_settings.SendGrid?.ApiKey is null) - throw new InvalidOperationException("SendGrid ApiKey is not configured."); + ValidateConfiguration(); - var client = new SendGridClient(_settings.SendGrid.ApiKey); - var from = new EmailAddress(request.From ?? _settings.SendGrid.From ?? _settings.From, request.DisplayName ?? _settings.SendGrid.DisplayName ?? _settings.DisplayName); + var client = new SendGridClient(_settings.SendGrid!.ApiKey!); + var from = CreateFromAddress(request); var msg = MailHelper.CreateSingleEmail( from, new EmailAddress(request.To[0]), @@ -31,22 +30,50 @@ public async Task SendAsync(MailRequest request, CancellationToken ct) request.Body, request.Body); + ConfigureRecipients(msg, request); + AddAttachments(msg, request); + + await client.SendEmailAsync(msg, ct); + } + + private void ValidateConfiguration() + { + if (_settings.SendGrid?.ApiKey is null) + { + throw new InvalidOperationException("SendGrid ApiKey is not configured."); + } + } + + private EmailAddress CreateFromAddress(MailRequest request) + { + var email = request.From ?? _settings.SendGrid?.From ?? _settings.From; + var displayName = request.DisplayName ?? _settings.SendGrid?.DisplayName ?? _settings.DisplayName; + return new EmailAddress(email, displayName); + } + + private static void ConfigureRecipients(SendGridMessage msg, MailRequest request) + { if (request.Cc.Count > 0) + { msg.AddCcs(request.Cc.Select(cc => new EmailAddress(cc)).ToList()); + } + if (request.Bcc.Count > 0) + { msg.AddBccs(request.Bcc.Select(bcc => new EmailAddress(bcc)).ToList()); + } + if (request.ReplyTo != null) + { msg.ReplyTo = new EmailAddress(request.ReplyTo, request.ReplyToName); + } + } - // Attachments - if (request.AttachmentData.Count > 0) + private static void AddAttachments(SendGridMessage msg, MailRequest request) + { + foreach (var att in request.AttachmentData) { - foreach (var att in request.AttachmentData) - { - msg.AddAttachment(att.Key, Convert.ToBase64String(att.Value)); - } + msg.AddAttachment(att.Key, Convert.ToBase64String(att.Value)); } - - await client.SendEmailAsync(msg, ct); } } diff --git a/src/BuildingBlocks/Persistence/Specifications/Specification.cs b/src/BuildingBlocks/Persistence/Specifications/Specification.cs index 160433449c..8436b0f04b 100644 --- a/src/BuildingBlocks/Persistence/Specifications/Specification.cs +++ b/src/BuildingBlocks/Persistence/Specifications/Specification.cs @@ -153,75 +153,68 @@ protected void ApplySortingOverride( ArgumentNullException.ThrowIfNull(applyDefaultOrdering); ArgumentNullException.ThrowIfNull(sortMappings); + ClearOrderExpressions(); + if (string.IsNullOrWhiteSpace(sortExpression)) { - ClearOrderExpressions(); applyDefaultOrdering(); return; } - ClearOrderExpressions(); + var clauses = ParseSortClauses(sortExpression); + bool anyApplied = ApplySortClauses(clauses, sortMappings); - string[] clauses = sortExpression.Split( - ',', - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (!anyApplied) + { + applyDefaultOrdering(); + } + } + private static IEnumerable ParseSortClauses(string sortExpression) + { + return sortExpression.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(clause => !string.IsNullOrWhiteSpace(clause)); + } + + private bool ApplySortClauses(IEnumerable clauses, IReadOnlyDictionary>> sortMappings) + { bool anyApplied = false; foreach (string rawClause in clauses) { - if (string.IsNullOrWhiteSpace(rawClause)) + var (key, descending) = ParseSortClause(rawClause); + + if (string.IsNullOrWhiteSpace(key) || !sortMappings.TryGetValue(key, out var selector)) { continue; } - string clause = rawClause.Trim(); - bool descending = clause[0] == '-'; - if (clause[0] is '-' or '+') - { - clause = clause[1..]; - } + ApplySortOrder(selector, descending, anyApplied); + anyApplied = true; + } - if (string.IsNullOrWhiteSpace(clause)) - { - continue; - } + return anyApplied; + } - if (!sortMappings.TryGetValue(clause, out Expression>? selector)) - { - // Unknown sort key; skip to keep sorting safe. - continue; - } + private static (string key, bool descending) ParseSortClause(string clause) + { + clause = clause.Trim(); + bool descending = clause[0] == '-'; + string key = clause[0] is '-' or '+' ? clause[1..] : clause; + return (key, descending); + } - if (!anyApplied) - { - if (descending) - { - OrderByDescending(selector); - } - else - { - OrderBy(selector); - } - - anyApplied = true; - } - else - { - if (descending) - { - ThenByDescending(selector); - } - else - { - ThenBy(selector); - } - } + private void ApplySortOrder(Expression> selector, bool descending, bool isSecondary) + { + if (isSecondary) + { + if (descending) ThenByDescending(selector); + else ThenBy(selector); } - - if (!anyApplied) + else { - applyDefaultOrdering(); + if (descending) OrderByDescending(selector); + else OrderBy(selector); } } diff --git a/src/BuildingBlocks/Persistence/Specifications/SpecificationEvaluator.cs b/src/BuildingBlocks/Persistence/Specifications/SpecificationEvaluator.cs index c60070e5a1..511f9b0182 100644 --- a/src/BuildingBlocks/Persistence/Specifications/SpecificationEvaluator.cs +++ b/src/BuildingBlocks/Persistence/Specifications/SpecificationEvaluator.cs @@ -17,6 +17,32 @@ public static IQueryable GetQuery( IQueryable query = inputQuery; + query = ApplyQueryBehaviors(query, specification); + query = ApplyCriteria(query, specification); + query = ApplyIncludes(query, specification); + query = ApplyOrdering(query, specification); + + return query; + } + + public static IQueryable GetQuery( + IQueryable inputQuery, + ISpecification specification) + where T : class + { + ArgumentNullException.ThrowIfNull(inputQuery); + ArgumentNullException.ThrowIfNull(specification); + + var query = GetQuery(inputQuery, (ISpecification)specification); + + // When a selector is configured, includes may be ignored at the EF level, + // but behavior is consistently applied by always projecting at the end. + return query.Select(specification.Selector); + } + + private static IQueryable ApplyQueryBehaviors(IQueryable query, ISpecification specification) + where T : class + { if (specification.IgnoreQueryFilters) { query = query.IgnoreQueryFilters(); @@ -32,11 +58,23 @@ public static IQueryable GetQuery( query = query.AsSplitQuery(); } + return query; + } + + private static IQueryable ApplyCriteria(IQueryable query, ISpecification specification) + where T : class + { if (specification.Criteria is not null) { query = query.Where(specification.Criteria); } + return query; + } + + private static IQueryable ApplyIncludes(IQueryable query, ISpecification specification) + where T : class + { foreach (var include in specification.Includes) { query = query.Include(include); @@ -47,48 +85,42 @@ public static IQueryable GetQuery( query = query.Include(includeString); } - if (specification.OrderExpressions.Count > 0) + return query; + } + + private static IQueryable ApplyOrdering(IQueryable query, ISpecification specification) + where T : class + { + if (specification.OrderExpressions.Count == 0) + { + return query; + } + + IOrderedQueryable? ordered = null; + + foreach (var order in specification.OrderExpressions) { - IOrderedQueryable? ordered = null; - - foreach (var order in specification.OrderExpressions) - { - if (ordered is null) - { - ordered = order.Descending - ? query.OrderByDescending(order.KeySelector) - : query.OrderBy(order.KeySelector); - } - else - { - ordered = order.Descending - ? ordered.ThenByDescending(order.KeySelector) - : ordered.ThenBy(order.KeySelector); - } - } - - if (ordered is not null) - { - query = ordered; - } + ordered = ApplyOrderExpression(query, ordered, order); } - return query; + return ordered ?? query; } - public static IQueryable GetQuery( - IQueryable inputQuery, - ISpecification specification) + private static IOrderedQueryable ApplyOrderExpression( + IQueryable query, + IOrderedQueryable? ordered, + OrderExpression order) where T : class { - ArgumentNullException.ThrowIfNull(inputQuery); - ArgumentNullException.ThrowIfNull(specification); - - var query = GetQuery(inputQuery, (ISpecification)specification); + if (ordered is null) + { + return order.Descending + ? query.OrderByDescending(order.KeySelector) + : query.OrderBy(order.KeySelector); + } - // When a selector is configured, includes may be ignored at the EF level, - // but behavior is consistently applied by always projecting at the end. - return query.Select(specification.Selector); + return order.Descending + ? ordered.ThenByDescending(order.KeySelector) + : ordered.ThenBy(order.KeySelector); } } - diff --git a/src/BuildingBlocks/Web/RateLimiting/Extensions.cs b/src/BuildingBlocks/Web/RateLimiting/Extensions.cs index 630d7a3e59..94f05fc84e 100644 --- a/src/BuildingBlocks/Web/RateLimiting/Extensions.cs +++ b/src/BuildingBlocks/Web/RateLimiting/Extensions.cs @@ -30,67 +30,77 @@ public static IServiceCollection AddHeroRateLimiting(this IServiceCollection ser return; } - string GetPartitionKey(HttpContext context) + options.GlobalLimiter = CreateGlobalLimiter(settings); + AddGlobalPolicy(options, settings); + AddAuthPolicy(options, settings); + }); + + return services; + } + + private static PartitionedRateLimiter CreateGlobalLimiter(RateLimitingOptions settings) + { + return PartitionedRateLimiter.Create(context => + { + if (IsHealthPath(context.Request.Path)) { - var tenant = context.User?.FindFirst(ClaimConstants.Tenant)?.Value; - var userId = context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (!string.IsNullOrWhiteSpace(tenant)) return $"tenant:{tenant}"; - if (!string.IsNullOrWhiteSpace(userId)) return $"user:{userId}"; - var ip = context.Connection.RemoteIpAddress?.ToString(); - return string.IsNullOrWhiteSpace(ip) ? "ip:unknown" : $"ip:{ip}"; + return RateLimitPartition.GetNoLimiter("health"); } - bool IsHealthPath(PathString path) => - path.StartsWithSegments("/health", StringComparison.OrdinalIgnoreCase) || - path.StartsWithSegments("/healthz", StringComparison.OrdinalIgnoreCase) || - path.StartsWithSegments("/ready", StringComparison.OrdinalIgnoreCase) || - path.StartsWithSegments("/live", StringComparison.OrdinalIgnoreCase); + var key = GetPartitionKey(context); + return CreateFixedWindowPartition(key, settings.Global); + }); + } + + private static void AddGlobalPolicy(Microsoft.AspNetCore.RateLimiting.RateLimiterOptions options, RateLimitingOptions settings) + { + options.AddPolicy("global", context => + CreateFixedWindowPartition(GetPartitionKey(context), settings.Global)); + } + + private static void AddAuthPolicy(Microsoft.AspNetCore.RateLimiting.RateLimiterOptions options, RateLimitingOptions settings) + { + options.AddPolicy("auth", context => + CreateFixedWindowPartition(GetPartitionKey(context), settings.Auth)); + } - options.GlobalLimiter = PartitionedRateLimiter.Create(context => + private static RateLimitPartition CreateFixedWindowPartition(string partitionKey, FixedWindowPolicyOptions policy) + { + return RateLimitPartition.GetFixedWindowLimiter( + partitionKey: partitionKey, + factory: _ => new FixedWindowRateLimiterOptions { - if (IsHealthPath(context.Request.Path)) - { - return RateLimitPartition.GetNoLimiter("health"); - } - - var key = GetPartitionKey(context); - return RateLimitPartition.GetFixedWindowLimiter( - partitionKey: key, - factory: _ => new FixedWindowRateLimiterOptions - { - PermitLimit = settings.Global.PermitLimit, - Window = TimeSpan.FromSeconds(settings.Global.WindowSeconds), - QueueProcessingOrder = QueueProcessingOrder.OldestFirst, - QueueLimit = settings.Global.QueueLimit - }); + PermitLimit = policy.PermitLimit, + Window = TimeSpan.FromSeconds(policy.WindowSeconds), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = policy.QueueLimit }); + } - options.AddPolicy("global", context => - RateLimitPartition.GetFixedWindowLimiter( - partitionKey: GetPartitionKey(context), - factory: _ => new FixedWindowRateLimiterOptions - { - PermitLimit = settings.Global.PermitLimit, - Window = TimeSpan.FromSeconds(settings.Global.WindowSeconds), - QueueProcessingOrder = QueueProcessingOrder.OldestFirst, - QueueLimit = settings.Global.QueueLimit - })); - - options.AddPolicy("auth", context => - RateLimitPartition.GetFixedWindowLimiter( - partitionKey: GetPartitionKey(context), - factory: _ => new FixedWindowRateLimiterOptions - { - PermitLimit = settings.Auth.PermitLimit, - Window = TimeSpan.FromSeconds(settings.Auth.WindowSeconds), - QueueProcessingOrder = QueueProcessingOrder.OldestFirst, - QueueLimit = settings.Auth.QueueLimit - })); - }); + private static string GetPartitionKey(HttpContext context) + { + var tenant = context.User?.FindFirst(ClaimConstants.Tenant)?.Value; + if (!string.IsNullOrWhiteSpace(tenant)) + { + return $"tenant:{tenant}"; + } - return services; + var userId = context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!string.IsNullOrWhiteSpace(userId)) + { + return $"user:{userId}"; + } + + var ip = context.Connection.RemoteIpAddress?.ToString(); + return string.IsNullOrWhiteSpace(ip) ? "ip:unknown" : $"ip:{ip}"; } + private static bool IsHealthPath(PathString path) => + path.StartsWithSegments("/health", StringComparison.OrdinalIgnoreCase) || + path.StartsWithSegments("/healthz", StringComparison.OrdinalIgnoreCase) || + path.StartsWithSegments("/ready", StringComparison.OrdinalIgnoreCase) || + path.StartsWithSegments("/live", StringComparison.OrdinalIgnoreCase); + public static IApplicationBuilder UseHeroRateLimiting(this IApplicationBuilder app) { ArgumentNullException.ThrowIfNull(app); diff --git a/src/Modules/Auditing/Modules.Auditing/Core/AuditBackgroundWorker.cs b/src/Modules/Auditing/Modules.Auditing/Core/AuditBackgroundWorker.cs index 52a7d7a51c..4e70886f6f 100644 --- a/src/Modules/Auditing/Modules.Auditing/Core/AuditBackgroundWorker.cs +++ b/src/Modules/Auditing/Modules.Auditing/Core/AuditBackgroundWorker.cs @@ -1,3 +1,4 @@ +using System.Threading.Channels; using FSH.Modules.Auditing.Contracts; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -34,58 +35,81 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var reader = _publisher.Reader; var batch = new List(_batchSize); - - // Single delay task we reuse/reset to avoid concurrent waits. - Task delayTask = Task.Delay(_flushInterval, stoppingToken); + var delayTask = Task.Delay(_flushInterval, stoppingToken); try { while (!stoppingToken.IsCancellationRequested) { - // Greedily drain whatever is available, up to batch size. - while (batch.Count < _batchSize && reader.TryRead(out var item)) - batch.Add(item); + var (shouldContinue, newDelayTask) = await ProcessBatchCycleAsync(reader, batch, delayTask, stoppingToken); + delayTask = newDelayTask; - // If we've filled the batch, flush immediately. - if (batch.Count >= _batchSize) + if (!shouldContinue) { - await FlushAsync(batch, stoppingToken); - delayTask = Task.Delay(_flushInterval, stoppingToken); // reset window after flush - continue; + break; } + } + } + catch (OperationCanceledException) { /* shutting down */ } + catch (Exception ex) + { + _logger.LogError(ex, "Audit background worker crashed."); + } - // If we have nothing yet, wait for either data to arrive or the flush window to elapse. - var readTask = reader.WaitToReadAsync(stoppingToken).AsTask(); - var winner = await Task.WhenAny(readTask, delayTask); + await FinalFlushAsync(batch, stoppingToken); + } - if (winner == readTask) - { - // If channel is completed, exit the loop. - if (!await readTask.ConfigureAwait(false)) - break; + private async Task<(bool shouldContinue, Task delayTask)> ProcessBatchCycleAsync( + ChannelReader reader, + List batch, + Task delayTask, + CancellationToken stoppingToken) + { + DrainAvailableItems(reader, batch); - // Loop back to drain newly available items (no flush yet). - continue; - } + if (batch.Count >= _batchSize) + { + await FlushAsync(batch, stoppingToken); + return (true, Task.Delay(_flushInterval, stoppingToken)); + } - // Timer window elapsed: flush whatever we have (if any) and open a new window. - if (batch.Count > 0) - await FlushAsync(batch, stoppingToken); + var readTask = reader.WaitToReadAsync(stoppingToken).AsTask(); + var winner = await Task.WhenAny(readTask, delayTask); - delayTask = Task.Delay(_flushInterval, stoppingToken); // start a fresh window - } + if (winner == readTask) + { + var canRead = await readTask.ConfigureAwait(false); + return (canRead, delayTask); } - catch (OperationCanceledException) { /* shutting down */ } - catch (Exception ex) + + if (batch.Count > 0) { - _logger.LogError(ex, "Audit background worker crashed."); + await FlushAsync(batch, stoppingToken); + } + + return (true, Task.Delay(_flushInterval, stoppingToken)); + } + + private void DrainAvailableItems(ChannelReader reader, List batch) + { + while (batch.Count < _batchSize && reader.TryRead(out var item)) + { + batch.Add(item); } + } - // Best-effort final flush on shutdown. + private async Task FinalFlushAsync(List batch, CancellationToken stoppingToken) + { if (batch.Count > 0 && !stoppingToken.IsCancellationRequested) { - try { await _sink.WriteAsync(batch, stoppingToken); } - catch (Exception ex) { _logger.LogError(ex, "Final audit flush failed."); } + try + { + await _sink.WriteAsync(batch, stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Final audit flush failed."); + } } } @@ -97,7 +121,6 @@ private async Task FlushAsync(List batch, CancellationToken ct) } catch (Exception ex) { - // Don't crash the worker; log and keep going. _logger.LogError(ex, "Audit background flush failed."); await Task.Delay(250, ct); } diff --git a/src/Modules/Auditing/Modules.Auditing/Core/ChannelAuditPublisher.cs b/src/Modules/Auditing/Modules.Auditing/Core/ChannelAuditPublisher.cs index 3ab608d7f8..180bc0e02b 100644 --- a/src/Modules/Auditing/Modules.Auditing/Core/ChannelAuditPublisher.cs +++ b/src/Modules/Auditing/Modules.Auditing/Core/ChannelAuditPublisher.cs @@ -36,54 +36,65 @@ public ValueTask PublishAsync(IAuditEvent auditEvent, CancellationToken ct = def ArgumentNullException.ThrowIfNull(auditEvent); var scope = CurrentScope; + var envelope = CreateEnvelope(auditEvent); + envelope = BackfillScopeContext(envelope, scope); - if (auditEvent is not AuditEnvelope env) + return _channel.Writer.TryWrite(envelope) + ? ValueTask.CompletedTask + : ValueTask.FromCanceled(ct); + } + + private static AuditEnvelope CreateEnvelope(IAuditEvent auditEvent) + { + if (auditEvent is AuditEnvelope existing) { - // wrap into an envelope if a custom IAuditEvent was passed (rare) - env = new AuditEnvelope( - id: Guid.CreateVersion7(), - occurredAtUtc: auditEvent.OccurredAtUtc, - receivedAtUtc: DateTime.UtcNow, - eventType: auditEvent.EventType, - severity: auditEvent.Severity, - tenantId: auditEvent.TenantId, - userId: auditEvent.UserId, - userName: auditEvent.UserName, - traceId: auditEvent.TraceId, - spanId: auditEvent.SpanId, - correlationId: auditEvent.CorrelationId, - requestId: auditEvent.RequestId, - source: auditEvent.Source, - tags: auditEvent.Tags, - payload: auditEvent.Payload); + return existing; } - // Backfill tenant/user context from the current scope if missing. - if (string.IsNullOrWhiteSpace(env.TenantId) || (string.IsNullOrWhiteSpace(env.UserId) && scope.UserId is not null)) + return new AuditEnvelope( + id: Guid.CreateVersion7(), + occurredAtUtc: auditEvent.OccurredAtUtc, + receivedAtUtc: DateTime.UtcNow, + eventType: auditEvent.EventType, + severity: auditEvent.Severity, + tenantId: auditEvent.TenantId, + userId: auditEvent.UserId, + userName: auditEvent.UserName, + traceId: auditEvent.TraceId, + spanId: auditEvent.SpanId, + correlationId: auditEvent.CorrelationId, + requestId: auditEvent.RequestId, + source: auditEvent.Source, + tags: auditEvent.Tags, + payload: auditEvent.Payload); + } + + private static AuditEnvelope BackfillScopeContext(AuditEnvelope env, IAuditScope scope) + { + bool needsTenantBackfill = string.IsNullOrWhiteSpace(env.TenantId); + bool needsUserBackfill = string.IsNullOrWhiteSpace(env.UserId) && scope.UserId is not null; + + if (!needsTenantBackfill && !needsUserBackfill) { - env = new AuditEnvelope( - id: env.Id, - occurredAtUtc: env.OccurredAtUtc, - receivedAtUtc: env.ReceivedAtUtc, - eventType: env.EventType, - severity: env.Severity, - tenantId: string.IsNullOrWhiteSpace(env.TenantId) ? scope.TenantId : env.TenantId, - userId: string.IsNullOrWhiteSpace(env.UserId) ? scope.UserId : env.UserId, - userName: string.IsNullOrWhiteSpace(env.UserId) && scope.UserId is not null - ? scope.UserName ?? env.UserName - : env.UserName, - traceId: env.TraceId, - spanId: env.SpanId, - correlationId: env.CorrelationId, - requestId: env.RequestId, - source: env.Source, - tags: env.Tags, - payload: env.Payload); + return env; } - return _channel.Writer.TryWrite(env) - ? ValueTask.CompletedTask - : ValueTask.FromCanceled(ct); // optional: swallow based on config + return new AuditEnvelope( + id: env.Id, + occurredAtUtc: env.OccurredAtUtc, + receivedAtUtc: env.ReceivedAtUtc, + eventType: env.EventType, + severity: env.Severity, + tenantId: needsTenantBackfill ? scope.TenantId : env.TenantId, + userId: needsUserBackfill ? scope.UserId : env.UserId, + userName: needsUserBackfill ? scope.UserName ?? env.UserName : env.UserName, + traceId: env.TraceId, + spanId: env.SpanId, + correlationId: env.CorrelationId, + requestId: env.RequestId, + source: env.Source, + tags: env.Tags, + payload: env.Payload); } internal ChannelReader Reader => _channel.Reader; diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryHandler.cs index 9867923559..179b15a6c0 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryHandler.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryHandler.cs @@ -20,8 +20,14 @@ public async ValueTask Handle(GetAuditSummaryQuery que { ArgumentNullException.ThrowIfNull(query); - IQueryable audits = _dbContext.AuditRecords.AsNoTracking(); + var audits = ApplyFilters(_dbContext.AuditRecords.AsNoTracking(), query); + var list = await audits.ToListAsync(cancellationToken).ConfigureAwait(false); + + return AggregateRecords(list); + } + private static IQueryable ApplyFilters(IQueryable audits, GetAuditSummaryQuery query) + { if (query.FromUtc.HasValue) { audits = audits.Where(a => a.OccurredAtUtc >= query.FromUtc.Value); @@ -37,32 +43,50 @@ public async ValueTask Handle(GetAuditSummaryQuery que audits = audits.Where(a => a.TenantId == query.TenantId); } - var list = await audits.ToListAsync(cancellationToken).ConfigureAwait(false); + return audits; + } + private static AuditSummaryAggregateDto AggregateRecords(List records) + { var aggregate = new AuditSummaryAggregateDto(); - foreach (var record in list) + foreach (var record in records) { - var type = (AuditEventType)record.EventType; - aggregate.EventsByType[type] = aggregate.EventsByType.TryGetValue(type, out var c) ? c + 1 : 1; - - var severity = (AuditSeverity)record.Severity; - aggregate.EventsBySeverity[severity] = aggregate.EventsBySeverity.TryGetValue(severity, out var s) ? s + 1 : 1; - - if (!string.IsNullOrWhiteSpace(record.Source)) - { - var key = record.Source!; - aggregate.EventsBySource[key] = aggregate.EventsBySource.TryGetValue(key, out var cs) ? cs + 1 : 1; - } - - if (!string.IsNullOrWhiteSpace(record.TenantId)) - { - var tenantKey = record.TenantId!; - aggregate.EventsByTenant[tenantKey] = aggregate.EventsByTenant.TryGetValue(tenantKey, out var ct) ? ct + 1 : 1; - } + AggregateByType(aggregate, record); + AggregrateBySeverity(aggregate, record); + AggregateBySource(aggregate, record); + AggregateByTenant(aggregate, record); } return aggregate; } + + private static void AggregateByType(AuditSummaryAggregateDto aggregate, AuditRecord record) + { + var type = (AuditEventType)record.EventType; + aggregate.EventsByType[type] = aggregate.EventsByType.TryGetValue(type, out var c) ? c + 1 : 1; + } + + private static void AggregrateBySeverity(AuditSummaryAggregateDto aggregate, AuditRecord record) + { + var severity = (AuditSeverity)record.Severity; + aggregate.EventsBySeverity[severity] = aggregate.EventsBySeverity.TryGetValue(severity, out var s) ? s + 1 : 1; + } + + private static void AggregateBySource(AuditSummaryAggregateDto aggregate, AuditRecord record) + { + if (!string.IsNullOrWhiteSpace(record.Source)) + { + aggregate.EventsBySource[record.Source] = aggregate.EventsBySource.TryGetValue(record.Source, out var cs) ? cs + 1 : 1; + } + } + + private static void AggregateByTenant(AuditSummaryAggregateDto aggregate, AuditRecord record) + { + if (!string.IsNullOrWhiteSpace(record.TenantId)) + { + aggregate.EventsByTenant[record.TenantId] = aggregate.EventsByTenant.TryGetValue(record.TenantId, out var ct) ? ct + 1 : 1; + } + } } diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryHandler.cs index ddd2d9161f..6d09b8de94 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryHandler.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryHandler.cs @@ -20,10 +20,23 @@ public async ValueTask> Handle(GetExceptionAudits { ArgumentNullException.ThrowIfNull(query); - IQueryable audits = _dbContext.AuditRecords + var audits = GetBaseQuery(); + audits = ApplyDateFilters(audits, query); + audits = ApplySeverityFilter(audits, query); + audits = ApplyPayloadFilters(audits, query); + + return await ProjectToDto(audits, cancellationToken); + } + + private IQueryable GetBaseQuery() + { + return _dbContext.AuditRecords .AsNoTracking() .Where(a => a.EventType == (int)AuditEventType.Exception); + } + private static IQueryable ApplyDateFilters(IQueryable audits, GetExceptionAuditsQuery query) + { if (query.FromUtc.HasValue) { audits = audits.Where(a => a.OccurredAtUtc >= query.FromUtc.Value); @@ -34,11 +47,21 @@ public async ValueTask> Handle(GetExceptionAudits audits = audits.Where(a => a.OccurredAtUtc <= query.ToUtc.Value); } + return audits; + } + + private static IQueryable ApplySeverityFilter(IQueryable audits, GetExceptionAuditsQuery query) + { if (query.Severity.HasValue) { audits = audits.Where(a => a.Severity == (byte)query.Severity.Value); } + return audits; + } + + private static IQueryable ApplyPayloadFilters(IQueryable audits, GetExceptionAuditsQuery query) + { if (query.Area.HasValue && query.Area.Value != ExceptionArea.None) { string areaValue = query.Area.Value.ToString(); @@ -58,7 +81,12 @@ public async ValueTask> Handle(GetExceptionAudits EF.Functions.ILike(a.PayloadJson, $"%\"routeOrLocation\":\"{query.RouteOrLocation}%")); } - var list = await audits + return audits; + } + + private static async Task> ProjectToDto(IQueryable audits, CancellationToken cancellationToken) + { + return await audits .OrderByDescending(a => a.OccurredAtUtc) .Select(a => new AuditSummaryDto { @@ -77,8 +105,6 @@ public async ValueTask> Handle(GetExceptionAudits }) .ToListAsync(cancellationToken) .ConfigureAwait(false); - - return list; } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandHandler.cs index 319bc1b814..482d883688 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandHandler.cs @@ -24,65 +24,85 @@ public async ValueTask Handle(UpdateGroupCommand command, Cancellation { ArgumentNullException.ThrowIfNull(command); - var group = await _dbContext.Groups + var group = await GetGroupAsync(command.Id, cancellationToken); + await ValidateUniqueNameAsync(command.Id, command.Name, cancellationToken); + await ValidateRoleIdsAsync(command.RoleIds, cancellationToken); + + var userId = _currentUser.GetUserId().ToString(); + group.Update(command.Name, command.Description, userId); + group.SetAsDefault(command.IsDefault, userId); + + var newRoleIds = UpdateRoleAssignments(group, command.RoleIds); + await _dbContext.SaveChangesAsync(cancellationToken); + + return await BuildResponseAsync(group, newRoleIds, cancellationToken); + } + + private async Task GetGroupAsync(Guid id, CancellationToken cancellationToken) + { + return await _dbContext.Groups .Include(g => g.GroupRoles) - .FirstOrDefaultAsync(g => g.Id == command.Id, cancellationToken) - ?? throw new NotFoundException($"Group with ID '{command.Id}' not found."); + .FirstOrDefaultAsync(g => g.Id == id, cancellationToken) + ?? throw new NotFoundException($"Group with ID '{id}' not found."); + } - // Validate name is unique within tenant (excluding self) + private async Task ValidateUniqueNameAsync(Guid excludeId, string name, CancellationToken cancellationToken) + { var nameExists = await _dbContext.Groups - .AnyAsync(g => g.Name == command.Name && g.Id != command.Id, cancellationToken); + .AnyAsync(g => g.Name == name && g.Id != excludeId, cancellationToken); if (nameExists) { - throw new CustomException($"Group with name '{command.Name}' already exists.", (IEnumerable?)null, System.Net.HttpStatusCode.Conflict); + throw new CustomException($"Group with name '{name}' already exists.", (IEnumerable?)null, System.Net.HttpStatusCode.Conflict); } + } - // Validate role IDs exist - if (command.RoleIds is { Count: > 0 }) + private async Task ValidateRoleIdsAsync(IReadOnlyList? roleIds, CancellationToken cancellationToken) + { + if (roleIds is not { Count: > 0 }) { - var existingRoleIds = await _dbContext.Roles - .Where(r => command.RoleIds.Contains(r.Id)) - .Select(r => r.Id) - .ToListAsync(cancellationToken); - - var invalidRoleIds = command.RoleIds.Except(existingRoleIds).ToList(); - if (invalidRoleIds.Count > 0) - { - throw new NotFoundException($"Roles not found: {string.Join(", ", invalidRoleIds)}"); - } + return; } - // Update group properties - group.Update(command.Name, command.Description, _currentUser.GetUserId().ToString()); - group.SetAsDefault(command.IsDefault, _currentUser.GetUserId().ToString()); + var existingRoleIds = await _dbContext.Roles + .Where(r => roleIds.Contains(r.Id)) + .Select(r => r.Id) + .ToListAsync(cancellationToken); + + var invalidRoleIds = roleIds.Except(existingRoleIds).ToList(); + if (invalidRoleIds.Count > 0) + { + throw new NotFoundException($"Roles not found: {string.Join(", ", invalidRoleIds)}"); + } + } - // Update role assignments + private static HashSet UpdateRoleAssignments(Group group, IReadOnlyList? roleIds) + { var currentRoleIds = group.GroupRoles.Select(gr => gr.RoleId).ToHashSet(); - var newRoleIds = command.RoleIds?.ToHashSet() ?? []; + var newRoleIds = roleIds?.ToHashSet() ?? []; - // Remove roles no longer assigned var rolesToRemove = group.GroupRoles.Where(gr => !newRoleIds.Contains(gr.RoleId)).ToList(); foreach (var role in rolesToRemove) { group.GroupRoles.Remove(role); } - // Add new role assignments foreach (var roleId in newRoleIds.Where(id => !currentRoleIds.Contains(id))) { group.GroupRoles.Add(GroupRole.Create(group.Id, roleId)); } - await _dbContext.SaveChangesAsync(cancellationToken); + return newRoleIds; + } - // Get member count and role names for response + private async Task BuildResponseAsync(Group group, HashSet roleIds, CancellationToken cancellationToken) + { var memberCount = await _dbContext.UserGroups .CountAsync(ug => ug.GroupId == group.Id, cancellationToken); - var roleNames = newRoleIds.Count > 0 + var roleNames = roleIds.Count > 0 ? await _dbContext.Roles - .Where(r => newRoleIds.Contains(r.Id)) + .Where(r => roleIds.Contains(r.Id)) .Select(r => r.Name!) .ToListAsync(cancellationToken) : []; @@ -95,7 +115,7 @@ public async ValueTask Handle(UpdateGroupCommand command, Cancellation IsDefault = group.IsDefault, IsSystemGroup = group.IsSystemGroup, MemberCount = memberCount, - RoleIds = newRoleIds.ToList().AsReadOnly(), + RoleIds = roleIds.ToList().AsReadOnly(), RoleNames = roleNames.AsReadOnly(), CreatedAt = group.CreatedAt }; diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs index 8b77b60f30..299042e7fd 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs @@ -87,23 +87,40 @@ public async Task UpdatePermissionsAsync(string roleId, List per { ArgumentNullException.ThrowIfNull(permissions); - var role = await roleManager.FindByIdAsync(roleId); - _ = role ?? throw new NotFoundException("role not found"); + var role = await roleManager.FindByIdAsync(roleId) + ?? throw new NotFoundException("role not found"); + + ValidateRoleCanBeModified(role); + FilterRootPermissions(permissions); + + var currentClaims = await roleManager.GetClaimsAsync(role); + await RemoveRevokedPermissionsAsync(role, currentClaims, permissions); + await AddNewPermissionsAsync(role, currentClaims, permissions); + + return "permissions updated"; + } + + private static void ValidateRoleCanBeModified(FshRole role) + { if (role.Name == RoleConstants.Admin) { throw new CustomException("operation not permitted"); } + } + private void FilterRootPermissions(List permissions) + { if (multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id != MultitenancyConstants.Root.Id) { - // Remove Root Permissions if the Role is not created for Root Tenant. permissions.RemoveAll(u => u.StartsWith("Permissions.Root.", StringComparison.InvariantCultureIgnoreCase)); } + } - var currentClaims = await roleManager.GetClaimsAsync(role); + private async Task RemoveRevokedPermissionsAsync(FshRole role, IList currentClaims, List permissions) + { + var claimsToRemove = currentClaims.Where(c => !permissions.Exists(p => p == c.Value)); - // Remove permissions that were previously selected - foreach (var claim in currentClaims.Where(c => !permissions.Exists(p => p == c.Value))) + foreach (var claim in claimsToRemove) { var result = await roleManager.RemoveClaimAsync(role, claim); if (!result.Succeeded) @@ -112,23 +129,28 @@ public async Task UpdatePermissionsAsync(string roleId, List per throw new CustomException("operation failed", errors); } } + } + + private async Task AddNewPermissionsAsync(FshRole role, IList currentClaims, List permissions) + { + var newPermissions = permissions + .Where(p => !string.IsNullOrEmpty(p) && !currentClaims.Any(c => c.Value == p)) + .ToList(); - // Add all permissions that were not previously selected - foreach (string permission in permissions.Where(c => !currentClaims.Any(p => p.Value == c))) + foreach (string permission in newPermissions) { - if (!string.IsNullOrEmpty(permission)) + context.RoleClaims.Add(new FshRoleClaim { - context.RoleClaims.Add(new FshRoleClaim - { - RoleId = role.Id, - ClaimType = ClaimConstants.Permission, - ClaimValue = permission, - CreatedBy = currentUser.GetUserId().ToString() - }); - await context.SaveChangesAsync(); - } + RoleId = role.Id, + ClaimType = ClaimConstants.Permission, + ClaimValue = permission, + CreatedBy = currentUser.GetUserId().ToString() + }); } - return "permissions updated"; + if (newPermissions.Count > 0) + { + await context.SaveChangesAsync(); + } } } diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantAutoProvisioningHostedService.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantAutoProvisioningHostedService.cs index 5ef2c2a90e..e9abc16fbb 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantAutoProvisioningHostedService.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantAutoProvisioningHostedService.cs @@ -28,7 +28,7 @@ public TenantAutoProvisioningHostedService( public async Task StartAsync(CancellationToken cancellationToken) { - if (!_options.AutoProvisionOnStartup && !_options.RunTenantMigrationsOnStartup) + if (!ShouldRunProvisioning()) { return; } @@ -39,11 +39,20 @@ public async Task StartAsync(CancellationToken cancellationToken) return; } + await ProvisionTenantsAsync(cancellationToken); + } + + private bool ShouldRunProvisioning() => + _options.AutoProvisionOnStartup || _options.RunTenantMigrationsOnStartup; + + private async Task ProvisionTenantsAsync(CancellationToken cancellationToken) + { using var scope = _serviceProvider.CreateScope(); var tenantStore = scope.ServiceProvider.GetRequiredService>(); var provisioning = scope.ServiceProvider.GetRequiredService(); var tenants = await tenantStore.GetAllAsync().ConfigureAwait(false); + foreach (var tenant in tenants) { if (cancellationToken.IsCancellationRequested) @@ -51,31 +60,39 @@ public async Task StartAsync(CancellationToken cancellationToken) break; } - try - { - var latest = await provisioning.GetLatestAsync(tenant.Id, cancellationToken).ConfigureAwait(false); - - // When RunTenantMigrationsOnStartup is enabled, always re-provision to apply any new migrations - // Otherwise, only provision if not completed yet - bool shouldProvision = _options.RunTenantMigrationsOnStartup || - latest is null || - latest.Status != TenantProvisioningStatus.Completed; - - if (shouldProvision) - { - await provisioning.StartAsync(tenant.Id, cancellationToken).ConfigureAwait(false); - _logger.LogInformation("Enqueued provisioning for tenant {TenantId} on startup.", tenant.Id); - } - } - catch (CustomException ex) - { - _logger.LogInformation("Provisioning already in progress or recently queued for tenant {TenantId}: {Message}", tenant.Id, ex.Message); - } - catch (Exception ex) + await TryProvisionTenantAsync(provisioning, tenant, cancellationToken); + } + } + + private async Task TryProvisionTenantAsync(ITenantProvisioningService provisioning, AppTenantInfo tenant, CancellationToken cancellationToken) + { + try + { + if (await ShouldProvisionTenantAsync(provisioning, tenant.Id, cancellationToken)) { - _logger.LogError(ex, "Failed to enqueue provisioning for tenant {TenantId}", tenant.Id); + await provisioning.StartAsync(tenant.Id, cancellationToken).ConfigureAwait(false); + _logger.LogInformation("Enqueued provisioning for tenant {TenantId} on startup.", tenant.Id); } } + catch (CustomException ex) + { + _logger.LogInformation("Provisioning already in progress or recently queued for tenant {TenantId}: {Message}", tenant.Id, ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to enqueue provisioning for tenant {TenantId}", tenant.Id); + } + } + + private async Task ShouldProvisionTenantAsync(ITenantProvisioningService provisioning, string tenantId, CancellationToken cancellationToken) + { + if (_options.RunTenantMigrationsOnStartup) + { + return true; + } + + var latest = await provisioning.GetLatestAsync(tenantId, cancellationToken).ConfigureAwait(false); + return latest is null || latest.Status != TenantProvisioningStatus.Completed; } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;