using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using StellaOps.Auth.ServerIntegration.Tenancy; using StellaOps.Notifier.WebService.Constants; using StellaOps.Notifier.WebService.Extensions; using StellaOps.Notifier.Worker.Fallback; using StellaOps.Notify.Models; namespace StellaOps.Notifier.WebService.Endpoints; /// /// REST API endpoints for fallback handler operations. /// public static class FallbackEndpoints { /// /// Maps fallback API endpoints. /// public static RouteGroupBuilder MapFallbackEndpoints(this IEndpointRouteBuilder endpoints) { var group = endpoints.MapGroup("/api/v2/fallback") .WithTags("Fallback") .WithOpenApi() .RequireAuthorization(NotifierPolicies.NotifyViewer) .RequireTenant(); // Get fallback statistics group.MapGet("/statistics", async ( int? windowHours, HttpContext context, IFallbackHandler fallbackHandler, CancellationToken cancellationToken) => { var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default"; var window = windowHours.HasValue ? TimeSpan.FromHours(windowHours.Value) : (TimeSpan?)null; var stats = await fallbackHandler.GetStatisticsAsync(tenantId, window, cancellationToken); return Results.Ok(new { stats.TenantId, window = stats.Window.ToString(), stats.TotalDeliveries, stats.PrimarySuccesses, stats.FallbackAttempts, stats.FallbackSuccesses, stats.ExhaustedDeliveries, successRate = $"{stats.SuccessRate:P1}", fallbackUtilizationRate = $"{stats.FallbackUtilizationRate:P1}", failuresByChannel = stats.FailuresByChannel.ToDictionary( kvp => kvp.Key.ToString(), kvp => kvp.Value) }); }) .WithName("GetFallbackStatistics") .WithSummary("Gets fallback handling statistics for a tenant") .WithDescription("Returns aggregate delivery statistics for the tenant including primary success rate, fallback attempt count, fallback success rate, and per-channel failure breakdown over the specified window."); // Get fallback chain for a channel group.MapGet("/chains/{channelType}", async ( NotifyChannelType channelType, HttpContext context, IFallbackHandler fallbackHandler, CancellationToken cancellationToken) => { var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default"; var chain = await fallbackHandler.GetFallbackChainAsync(tenantId, channelType, cancellationToken); return Results.Ok(new { tenantId, primaryChannel = channelType.ToString(), fallbackChain = chain.Select(c => c.ToString()).ToList(), chainLength = chain.Count }); }) .WithName("GetFallbackChain") .WithSummary("Gets the fallback chain for a channel type") .WithDescription("Returns the ordered list of fallback channel types that will be tried when the primary channel fails. If no custom chain is configured, the system default is returned."); // Set fallback chain for a channel group.MapPut("/chains/{channelType}", async ( NotifyChannelType channelType, SetFallbackChainRequest request, HttpContext context, IFallbackHandler fallbackHandler, CancellationToken cancellationToken) => { var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default"; var actor = context.Request.Headers["X-Actor"].FirstOrDefault() ?? "system"; var chain = request.FallbackChain .Select(s => Enum.TryParse(s, out var t) ? t : (NotifyChannelType?)null) .Where(t => t.HasValue) .Select(t => t!.Value) .ToList(); await fallbackHandler.SetFallbackChainAsync(tenantId, channelType, chain, actor, cancellationToken); return Results.Ok(new { message = "Fallback chain updated successfully", primaryChannel = channelType.ToString(), fallbackChain = chain.Select(c => c.ToString()).ToList() }); }) .WithName("SetFallbackChain") .WithSummary("Sets a custom fallback chain for a channel type") .WithDescription("Creates or replaces the fallback chain for a primary channel type. The chain must reference valid channel types; invalid entries are silently filtered out.") .RequireAuthorization(NotifierPolicies.NotifyOperator); // Test fallback resolution group.MapPost("/test", async ( TestFallbackRequest request, HttpContext context, IFallbackHandler fallbackHandler, CancellationToken cancellationToken) => { var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default"; if (!Enum.TryParse(request.FailedChannelType, out var channelType)) { return Results.BadRequest(new { error = $"Invalid channel type: {request.FailedChannelType}" }); } var deliveryId = $"test-{Guid.NewGuid():N}"[..20]; // Simulate failure recording await fallbackHandler.RecordFailureAsync( tenantId, deliveryId, channelType, "Test failure", cancellationToken); // Get fallback result var result = await fallbackHandler.GetFallbackAsync( tenantId, channelType, deliveryId, cancellationToken); // Clean up test state await fallbackHandler.ClearDeliveryStateAsync(tenantId, deliveryId, cancellationToken); return Results.Ok(new { testDeliveryId = deliveryId, result.HasFallback, nextChannelType = result.NextChannelType?.ToString(), result.AttemptNumber, result.TotalChannels, result.IsExhausted, result.ExhaustionReason, failedChannels = result.FailedChannels.Select(f => new { channelType = f.ChannelType.ToString(), f.Reason, f.FailedAt, f.AttemptNumber }).ToList() }); }) .WithName("TestFallback") .WithSummary("Tests fallback resolution without affecting real deliveries") .WithDescription("Simulates a channel failure for the specified channel type and returns which fallback channel would be selected next. The simulated delivery state is cleaned up after the test.") .RequireAuthorization(NotifierPolicies.NotifyOperator); // Clear delivery state group.MapDelete("/deliveries/{deliveryId}", async ( string deliveryId, HttpContext context, IFallbackHandler fallbackHandler, CancellationToken cancellationToken) => { var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default"; await fallbackHandler.ClearDeliveryStateAsync(tenantId, deliveryId, cancellationToken); return Results.Ok(new { message = $"Delivery state for '{deliveryId}' cleared" }); }) .WithName("ClearDeliveryFallbackState") .WithSummary("Clears fallback state for a specific delivery") .WithDescription("Removes all in-memory fallback tracking state for a delivery ID. Use this to reset a stuck delivery that has exhausted its fallback chain without entering a terminal status.") .RequireAuthorization(NotifierPolicies.NotifyOperator); return group; } } /// /// Request to set a custom fallback chain. /// public sealed record SetFallbackChainRequest { /// /// Ordered list of fallback channel types. /// public required List FallbackChain { get; init; } } /// /// Request to test fallback resolution. /// public sealed record TestFallbackRequest { /// /// The channel type that "failed". /// public required string FailedChannelType { get; init; } }