From 9201912859700ca66c67f47ec0465b32d1b6c7c6 Mon Sep 17 00:00:00 2001 From: Reed Date: Sat, 21 Feb 2026 16:51:53 -0800 Subject: [PATCH] feat: Add migration gate middleware and demo data seeding Fix startup race condition where dashboard queries hit prior_auth_requests before migration completes (503 errors). Add MigrationGateMiddleware that returns 503 with Retry-After until all migrations complete, allowing health checks through. Add IDataSeeder interface and PARequestDataSeeder that seeds 4 demo PA requests in development. Port patterns from aegis-api. Co-Authored-By: Claude Opus 4.6 --- .../Data/MigrationGateMiddlewareTests.cs | 143 +++++++++++++ .../Data/MigrationHealthCheckTests.cs | 109 ++++++++++ .../Data/PARequestDataSeederTests.cs | 128 ++++++++++++ .../DependencyExtensionsTests.cs | 21 ++ .../EncounterProcessingAlbaBootstrap.cs | 14 ++ .../Integration/GatewayAlbaBootstrap.cs | 14 ++ .../PARequestLifecycleAlbaBootstrap.cs | 14 ++ apps/gateway/Gateway.API/Data/IDataSeeder.cs | 26 +++ .../Data/MigrationGateMiddleware.cs | 61 ++++++ .../Gateway.API/Data/MigrationHealthCheck.cs | 12 ++ .../Gateway.API/Data/MigrationService.cs | 17 ++ .../Data/MigrationServiceOptions.cs | 6 + .../Gateway.API/Data/PARequestDataSeeder.cs | 196 ++++++++++++++++++ .../Gateway.API/DependencyExtensions.cs | 3 + apps/gateway/Gateway.API/Program.cs | 3 + 15 files changed, 767 insertions(+) create mode 100644 apps/gateway/Gateway.API.Tests/Data/MigrationGateMiddlewareTests.cs create mode 100644 apps/gateway/Gateway.API.Tests/Data/MigrationHealthCheckTests.cs create mode 100644 apps/gateway/Gateway.API.Tests/Data/PARequestDataSeederTests.cs create mode 100644 apps/gateway/Gateway.API/Data/IDataSeeder.cs create mode 100644 apps/gateway/Gateway.API/Data/MigrationGateMiddleware.cs create mode 100644 apps/gateway/Gateway.API/Data/PARequestDataSeeder.cs diff --git a/apps/gateway/Gateway.API.Tests/Data/MigrationGateMiddlewareTests.cs b/apps/gateway/Gateway.API.Tests/Data/MigrationGateMiddlewareTests.cs new file mode 100644 index 0000000..c8ae475 --- /dev/null +++ b/apps/gateway/Gateway.API.Tests/Data/MigrationGateMiddlewareTests.cs @@ -0,0 +1,143 @@ +// ============================================================================= +// +// Copyright (c) Levelup Software. All rights reserved. +// +// ============================================================================= + +namespace Gateway.API.Tests.Data; + +using Gateway.API.Data; +using Microsoft.AspNetCore.Http; + +/// +/// Tests for . +/// +[NotInParallel("MigrationHealthCheck")] +public class MigrationGateMiddlewareTests +{ + [Before(Test)] + public void Setup() + { + MigrationHealthCheck.Reset(); + } + + [After(Test)] + public void Cleanup() + { + MigrationHealthCheck.Reset(); + } + + [Test] + public async Task InvokeAsync_NotReady_Returns503() + { + // Arrange — register but don't complete + MigrationHealthCheck.RegisterExpected("TestContext"); + var nextCalled = false; + var middleware = new MigrationGateMiddleware(_ => + { + nextCalled = true; + return Task.CompletedTask; + }); + var context = CreateHttpContext("/api/graphql"); + + // Act + await middleware.InvokeAsync(context); + + // Assert + await Assert.That(context.Response.StatusCode).IsEqualTo(503); + await Assert.That(nextCalled).IsFalse(); + } + + [Test] + public async Task InvokeAsync_NotReady_SetsRetryAfterHeader() + { + MigrationHealthCheck.RegisterExpected("TestContext"); + var middleware = new MigrationGateMiddleware(_ => Task.CompletedTask); + var context = CreateHttpContext("/api/graphql"); + + await middleware.InvokeAsync(context); + + await Assert.That(context.Response.Headers["Retry-After"].ToString()).IsEqualTo("5"); + } + + [Test] + public async Task InvokeAsync_Ready_CallsNext() + { + // Arrange — complete migration + MigrationHealthCheck.RegisterExpected("TestContext"); + MigrationHealthCheck.MarkComplete("TestContext"); + var nextCalled = false; + var middleware = new MigrationGateMiddleware(_ => + { + nextCalled = true; + return Task.CompletedTask; + }); + var context = CreateHttpContext("/api/graphql"); + + // Act + await middleware.InvokeAsync(context); + + // Assert + await Assert.That(nextCalled).IsTrue(); + } + + [Test] + public async Task InvokeAsync_NotReady_HealthEndpoint_CallsNext() + { + MigrationHealthCheck.RegisterExpected("TestContext"); + var nextCalled = false; + var middleware = new MigrationGateMiddleware(_ => + { + nextCalled = true; + return Task.CompletedTask; + }); + var context = CreateHttpContext("/health"); + + await middleware.InvokeAsync(context); + + await Assert.That(nextCalled).IsTrue(); + } + + [Test] + public async Task InvokeAsync_NotReady_AliveEndpoint_CallsNext() + { + MigrationHealthCheck.RegisterExpected("TestContext"); + var nextCalled = false; + var middleware = new MigrationGateMiddleware(_ => + { + nextCalled = true; + return Task.CompletedTask; + }); + var context = CreateHttpContext("/alive"); + + await middleware.InvokeAsync(context); + + await Assert.That(nextCalled).IsTrue(); + } + + [Test] + public async Task InvokeAsync_NoRegistrations_Returns503() + { + // No registrations means IsReady is false (Count == 0) + var nextCalled = false; + var middleware = new MigrationGateMiddleware(_ => + { + nextCalled = true; + return Task.CompletedTask; + }); + var context = CreateHttpContext("/api/data"); + + await middleware.InvokeAsync(context); + + await Assert.That(context.Response.StatusCode).IsEqualTo(503); + await Assert.That(nextCalled).IsFalse(); + } + + private static DefaultHttpContext CreateHttpContext(string path) + { + var context = new DefaultHttpContext(); + context.Request.Path = path; + context.Response.Body = new MemoryStream(); + return context; + } +} diff --git a/apps/gateway/Gateway.API.Tests/Data/MigrationHealthCheckTests.cs b/apps/gateway/Gateway.API.Tests/Data/MigrationHealthCheckTests.cs new file mode 100644 index 0000000..cd6a66a --- /dev/null +++ b/apps/gateway/Gateway.API.Tests/Data/MigrationHealthCheckTests.cs @@ -0,0 +1,109 @@ +// ============================================================================= +// +// Copyright (c) Levelup Software. All rights reserved. +// +// ============================================================================= + +namespace Gateway.API.Tests.Data; + +using Gateway.API.Data; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +/// +/// Tests for . +/// +[NotInParallel("MigrationHealthCheck")] +public class MigrationHealthCheckTests +{ + [Before(Test)] + public void Setup() + { + MigrationHealthCheck.Reset(); + } + + [After(Test)] + public void Cleanup() + { + MigrationHealthCheck.Reset(); + } + + [Test] + public async Task IsReady_NoRegistrations_ReturnsFalse() + { + await Assert.That(MigrationHealthCheck.IsReady).IsFalse(); + } + + [Test] + public async Task IsReady_RegisteredButNotComplete_ReturnsFalse() + { + MigrationHealthCheck.RegisterExpected("TestContext"); + + await Assert.That(MigrationHealthCheck.IsReady).IsFalse(); + } + + [Test] + public async Task IsReady_AllComplete_ReturnsTrue() + { + MigrationHealthCheck.RegisterExpected("TestContext"); + MigrationHealthCheck.MarkComplete("TestContext"); + + await Assert.That(MigrationHealthCheck.IsReady).IsTrue(); + } + + [Test] + public async Task IsReady_MultipleContexts_OneIncomplete_ReturnsFalse() + { + MigrationHealthCheck.RegisterExpected("Context1"); + MigrationHealthCheck.RegisterExpected("Context2"); + MigrationHealthCheck.MarkComplete("Context1"); + + await Assert.That(MigrationHealthCheck.IsReady).IsFalse(); + } + + [Test] + public async Task IsReady_MultipleContexts_AllComplete_ReturnsTrue() + { + MigrationHealthCheck.RegisterExpected("Context1"); + MigrationHealthCheck.RegisterExpected("Context2"); + MigrationHealthCheck.MarkComplete("Context1"); + MigrationHealthCheck.MarkComplete("Context2"); + + await Assert.That(MigrationHealthCheck.IsReady).IsTrue(); + } + + [Test] + public async Task CheckHealthAsync_Pending_ReturnsUnhealthy() + { + MigrationHealthCheck.RegisterExpected("TestContext"); + var check = new MigrationHealthCheck(); + + var result = await check.CheckHealthAsync(new HealthCheckContext()); + + await Assert.That(result.Status).IsEqualTo(HealthStatus.Unhealthy); + } + + [Test] + public async Task CheckHealthAsync_AllComplete_ReturnsHealthy() + { + MigrationHealthCheck.RegisterExpected("TestContext"); + MigrationHealthCheck.MarkComplete("TestContext"); + var check = new MigrationHealthCheck(); + + var result = await check.CheckHealthAsync(new HealthCheckContext()); + + await Assert.That(result.Status).IsEqualTo(HealthStatus.Healthy); + } + + [Test] + public async Task Reset_ClearsAllState() + { + MigrationHealthCheck.RegisterExpected("TestContext"); + MigrationHealthCheck.MarkComplete("TestContext"); + + await Assert.That(MigrationHealthCheck.IsReady).IsTrue(); + + MigrationHealthCheck.Reset(); + + await Assert.That(MigrationHealthCheck.IsReady).IsFalse(); + } +} diff --git a/apps/gateway/Gateway.API.Tests/Data/PARequestDataSeederTests.cs b/apps/gateway/Gateway.API.Tests/Data/PARequestDataSeederTests.cs new file mode 100644 index 0000000..8f52097 --- /dev/null +++ b/apps/gateway/Gateway.API.Tests/Data/PARequestDataSeederTests.cs @@ -0,0 +1,128 @@ +// ============================================================================= +// +// Copyright (c) Levelup Software. All rights reserved. +// +// ============================================================================= + +namespace Gateway.API.Tests.Data; + +using Gateway.API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; + +/// +/// Tests for . +/// +public class PARequestDataSeederTests +{ + private GatewayDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new GatewayDbContext(options); + } + + [Test] + public async Task SeedAsync_EmptyDatabase_SeedsData() + { + // Arrange + using var context = CreateDbContext(); + var logger = Substitute.For>(); + var seeder = new PARequestDataSeeder(logger); + + // Act + await seeder.SeedAsync(context, CancellationToken.None); + + // Assert + var count = await context.PriorAuthRequests.CountAsync(); + await Assert.That(count).IsEqualTo(4); + } + + [Test] + public async Task SeedAsync_ExistingData_SkipsSeeding() + { + // Arrange + using var context = CreateDbContext(); + var logger = Substitute.For>(); + var seeder = new PARequestDataSeeder(logger); + + // Seed once + await seeder.SeedAsync(context, CancellationToken.None); + + // Act — seed again + await seeder.SeedAsync(context, CancellationToken.None); + + // Assert — still 4, not 8 + var count = await context.PriorAuthRequests.CountAsync(); + await Assert.That(count).IsEqualTo(4); + } + + [Test] + public async Task SeedAsync_CreatesVariousStatuses() + { + // Arrange + using var context = CreateDbContext(); + var logger = Substitute.For>(); + var seeder = new PARequestDataSeeder(logger); + + // Act + await seeder.SeedAsync(context, CancellationToken.None); + + // Assert — should have draft, ready, and submitted statuses + var statuses = await context.PriorAuthRequests + .Select(e => e.Status) + .Distinct() + .ToListAsync(); + + await Assert.That(statuses).Contains("draft"); + await Assert.That(statuses).Contains("ready"); + await Assert.That(statuses).Contains("submitted"); + } + + [Test] + public async Task SeedAsync_SetsRequiredFields() + { + // Arrange + using var context = CreateDbContext(); + var logger = Substitute.For>(); + var seeder = new PARequestDataSeeder(logger); + + // Act + await seeder.SeedAsync(context, CancellationToken.None); + + // Assert — all entities have required fields + var entities = await context.PriorAuthRequests.ToListAsync(); + foreach (var entity in entities) + { + await Assert.That(entity.Id).IsNotNull().And.IsNotEqualTo(string.Empty); + await Assert.That(entity.PatientId).IsNotNull().And.IsNotEqualTo(string.Empty); + await Assert.That(entity.PatientName).IsNotNull().And.IsNotEqualTo(string.Empty); + await Assert.That(entity.ProcedureCode).IsNotNull().And.IsNotEqualTo(string.Empty); + await Assert.That(entity.ProcedureName).IsNotNull().And.IsNotEqualTo(string.Empty); + } + } + + [Test] + public async Task CreateDemoEntities_ReturnsExpectedCount() + { + var entities = PARequestDataSeeder.CreateDemoEntities(DateTimeOffset.UtcNow); + + await Assert.That(entities.Length).IsEqualTo(4); + } + + [Test] + public async Task CreateDemoEntities_ReadyEntities_HaveCriteria() + { + var entities = PARequestDataSeeder.CreateDemoEntities(DateTimeOffset.UtcNow); + var readyEntities = entities.Where(e => e.Status == "ready").ToList(); + + foreach (var entity in readyEntities) + { + await Assert.That(entity.CriteriaJson).IsNotNull().And.IsNotEqualTo(string.Empty); + await Assert.That(entity.Confidence).IsGreaterThan(0); + await Assert.That(entity.ReadyAt).IsNotNull(); + } + } +} diff --git a/apps/gateway/Gateway.API.Tests/DependencyExtensionsTests.cs b/apps/gateway/Gateway.API.Tests/DependencyExtensionsTests.cs index defa4d6..734b84b 100644 --- a/apps/gateway/Gateway.API.Tests/DependencyExtensionsTests.cs +++ b/apps/gateway/Gateway.API.Tests/DependencyExtensionsTests.cs @@ -135,6 +135,27 @@ public async Task AddGatewayServices_DoesNotRegisterMockDataService() await Assert.That(mockRegistrations.Count()).IsEqualTo(0); } + [Test] + public async Task AddGatewayPersistence_RegistersDataSeeder() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContext(options => + options.UseInMemoryDatabase(Guid.NewGuid().ToString())); + + services.AddGatewayPersistence(); + var provider = services.BuildServiceProvider(); + + // Act + using var scope = provider.CreateScope(); + var seeder = scope.ServiceProvider.GetService>(); + + // Assert + await Assert.That(seeder).IsNotNull(); + await Assert.That(seeder).IsTypeOf(); + } + private static IConfiguration CreateTestConfiguration() { var configValues = new Dictionary diff --git a/apps/gateway/Gateway.API.Tests/Integration/EncounterProcessingAlbaBootstrap.cs b/apps/gateway/Gateway.API.Tests/Integration/EncounterProcessingAlbaBootstrap.cs index 44300c3..3c2fbea 100644 --- a/apps/gateway/Gateway.API.Tests/Integration/EncounterProcessingAlbaBootstrap.cs +++ b/apps/gateway/Gateway.API.Tests/Integration/EncounterProcessingAlbaBootstrap.cs @@ -91,6 +91,20 @@ public async Task InitializeAsync() services.AddDbContext(options => options.UseInMemoryDatabase(databaseName)); + // Remove MigrationService (in-memory DB doesn't need migration) + var migrationServiceDescriptor = services.FirstOrDefault(d => + d.ServiceType == typeof(IHostedService) && + d.ImplementationType?.IsGenericType == true && + d.ImplementationType.GetGenericTypeDefinition() == typeof(MigrationService<>)); + if (migrationServiceDescriptor != null) + { + services.Remove(migrationServiceDescriptor); + } + + // Mark migrations as complete so MigrationGateMiddleware passes requests through + MigrationHealthCheck.RegisterExpected(nameof(GatewayDbContext)); + MigrationHealthCheck.MarkComplete(nameof(GatewayDbContext)); + // Remove AthenaPollingService to avoid scoped dependency validation issue // The singleton background service cannot consume scoped IPatientRegistry services.RemoveAll(); diff --git a/apps/gateway/Gateway.API.Tests/Integration/GatewayAlbaBootstrap.cs b/apps/gateway/Gateway.API.Tests/Integration/GatewayAlbaBootstrap.cs index a441532..d1c37a7 100644 --- a/apps/gateway/Gateway.API.Tests/Integration/GatewayAlbaBootstrap.cs +++ b/apps/gateway/Gateway.API.Tests/Integration/GatewayAlbaBootstrap.cs @@ -90,6 +90,20 @@ public async Task InitializeAsync() services.AddDbContext(options => options.UseInMemoryDatabase(databaseName)); + // Remove MigrationService (in-memory DB doesn't need migration) + var migrationServiceDescriptor = services.FirstOrDefault(d => + d.ServiceType == typeof(IHostedService) && + d.ImplementationType?.IsGenericType == true && + d.ImplementationType.GetGenericTypeDefinition() == typeof(MigrationService<>)); + if (migrationServiceDescriptor != null) + { + services.Remove(migrationServiceDescriptor); + } + + // Mark migrations as complete so MigrationGateMiddleware passes requests through + MigrationHealthCheck.RegisterExpected(nameof(GatewayDbContext)); + MigrationHealthCheck.MarkComplete(nameof(GatewayDbContext)); + // Remove AthenaPollingService to avoid scoped dependency validation issue // The singleton background service cannot consume scoped IPatientRegistry services.RemoveAll(); diff --git a/apps/gateway/Gateway.API.Tests/Integration/PARequestLifecycleAlbaBootstrap.cs b/apps/gateway/Gateway.API.Tests/Integration/PARequestLifecycleAlbaBootstrap.cs index 8b2db53..f896375 100644 --- a/apps/gateway/Gateway.API.Tests/Integration/PARequestLifecycleAlbaBootstrap.cs +++ b/apps/gateway/Gateway.API.Tests/Integration/PARequestLifecycleAlbaBootstrap.cs @@ -79,6 +79,20 @@ public async Task InitializeAsync() services.AddDbContext(options => options.UseInMemoryDatabase(databaseName)); + // Remove MigrationService (in-memory DB doesn't need migration) + var migrationServiceDescriptor = services.FirstOrDefault(d => + d.ServiceType == typeof(IHostedService) && + d.ImplementationType?.IsGenericType == true && + d.ImplementationType.GetGenericTypeDefinition() == typeof(MigrationService<>)); + if (migrationServiceDescriptor != null) + { + services.Remove(migrationServiceDescriptor); + } + + // Mark migrations as complete so MigrationGateMiddleware passes requests through + MigrationHealthCheck.RegisterExpected(nameof(GatewayDbContext)); + MigrationHealthCheck.MarkComplete(nameof(GatewayDbContext)); + // Remove AthenaPollingService to avoid scoped dependency validation issue services.RemoveAll(); var hostedServiceDescriptor = services.FirstOrDefault(d => diff --git a/apps/gateway/Gateway.API/Data/IDataSeeder.cs b/apps/gateway/Gateway.API/Data/IDataSeeder.cs new file mode 100644 index 0000000..7245823 --- /dev/null +++ b/apps/gateway/Gateway.API/Data/IDataSeeder.cs @@ -0,0 +1,26 @@ +// ============================================================================= +// +// Copyright (c) Levelup Software. All rights reserved. +// +// ============================================================================= + +using Microsoft.EntityFrameworkCore; + +namespace Gateway.API.Data; + +/// +/// Interface for seeding initial data into a database context after migration. +/// Implementations should be idempotent (safe to run multiple times). +/// +/// The DbContext type this seeder targets. +public interface IDataSeeder + where TContext : DbContext +{ + /// + /// Seeds data into the database. Must be idempotent. + /// + /// The database context to seed data into. + /// The cancellation token. + /// A task representing the asynchronous operation. + Task SeedAsync(TContext context, CancellationToken cancellationToken); +} diff --git a/apps/gateway/Gateway.API/Data/MigrationGateMiddleware.cs b/apps/gateway/Gateway.API/Data/MigrationGateMiddleware.cs new file mode 100644 index 0000000..4055955 --- /dev/null +++ b/apps/gateway/Gateway.API/Data/MigrationGateMiddleware.cs @@ -0,0 +1,61 @@ +// ============================================================================= +// +// Copyright (c) Levelup Software. All rights reserved. +// +// ============================================================================= + +using System.Net; + +namespace Gateway.API.Data; + +/// +/// Middleware that returns 503 Service Unavailable while database migrations are in progress. +/// Always allows health check endpoints through so orchestrators can monitor readiness. +/// +public sealed class MigrationGateMiddleware +{ + private static readonly string[] AllowedPaths = ["/health", "/alive"]; + private readonly RequestDelegate _next; + + /// + /// Initializes a new instance of the class. + /// + /// The next middleware in the pipeline. + public MigrationGateMiddleware(RequestDelegate next) + { + _next = next; + } + + /// + /// Invokes the middleware. Returns 503 if migrations are not yet complete, + /// unless the request is to an allowed path (health checks). + /// + /// The HTTP context. + /// A task representing the asynchronous operation. + public async Task InvokeAsync(HttpContext context) + { + if (!MigrationHealthCheck.IsReady && !IsAllowedPath(context.Request.Path)) + { + context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; + context.Response.Headers["Retry-After"] = "5"; + context.Response.ContentType = "text/plain"; + await context.Response.WriteAsync("Service is starting up. Database migrations in progress.", context.RequestAborted); + return; + } + + await _next(context); + } + + private static bool IsAllowedPath(PathString path) + { + foreach (var allowed in AllowedPaths) + { + if (path.StartsWithSegments(allowed, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } +} diff --git a/apps/gateway/Gateway.API/Data/MigrationHealthCheck.cs b/apps/gateway/Gateway.API/Data/MigrationHealthCheck.cs index 61aaa47..21b2244 100644 --- a/apps/gateway/Gateway.API/Data/MigrationHealthCheck.cs +++ b/apps/gateway/Gateway.API/Data/MigrationHealthCheck.cs @@ -30,6 +30,13 @@ public static void MarkComplete(string contextName) => public static void RegisterExpected(string contextName) => s_completedMigrations[contextName] = false; + /// + /// Gets a value indicating whether all registered migrations have completed. + /// Returns false if no migrations have been registered. + /// + public static bool IsReady => + s_completedMigrations.Count > 0 && s_completedMigrations.Values.All(v => v); + /// /// Checks if migration is complete for the specified context. /// @@ -57,6 +64,11 @@ public static async Task WaitForMigrationAsync( } } + /// + /// Resets all migration tracking state. For testing only. + /// + internal static void Reset() => s_completedMigrations.Clear(); + /// /// Checks the health of database migrations. /// diff --git a/apps/gateway/Gateway.API/Data/MigrationService.cs b/apps/gateway/Gateway.API/Data/MigrationService.cs index 5585d7f..8060fd5 100644 --- a/apps/gateway/Gateway.API/Data/MigrationService.cs +++ b/apps/gateway/Gateway.API/Data/MigrationService.cs @@ -88,6 +88,11 @@ protected override async Task ExecuteAsync(CancellationToken cancellationToken) await RunMigrationAsync(context, cancellationToken).ConfigureAwait(false); } + if (_options.Value.SeedData && _environment.IsDevelopment()) + { + await RunSeedersAsync(scope.ServiceProvider, context, cancellationToken).ConfigureAwait(false); + } + MigrationHealthCheck.MarkComplete(contextName); _logger.LogInformation("Migration process completed successfully for {ContextName}.", contextName); } @@ -98,6 +103,18 @@ protected override async Task ExecuteAsync(CancellationToken cancellationToken) } } + private async Task RunSeedersAsync(IServiceProvider scopedProvider, TContext context, CancellationToken cancellationToken) + { + var seeders = scopedProvider.GetServices>(); + foreach (var seeder in seeders) + { + var seederName = seeder.GetType().Name; + _logger.LogInformation("Running data seeder {SeederName} for {ContextName}...", seederName, typeof(TContext).Name); + await seeder.SeedAsync(context, cancellationToken).ConfigureAwait(false); + _logger.LogInformation("Data seeder {SeederName} completed for {ContextName}.", seederName, typeof(TContext).Name); + } + } + private async Task ValidateDatabaseExistsAsync(TContext context, CancellationToken cancellationToken) { var strategy = context.Database.CreateExecutionStrategy(); diff --git a/apps/gateway/Gateway.API/Data/MigrationServiceOptions.cs b/apps/gateway/Gateway.API/Data/MigrationServiceOptions.cs index 3d6b9ca..cba6a05 100644 --- a/apps/gateway/Gateway.API/Data/MigrationServiceOptions.cs +++ b/apps/gateway/Gateway.API/Data/MigrationServiceOptions.cs @@ -22,4 +22,10 @@ public class MigrationServiceOptions /// WARNING: This will delete all existing data. Only use in development environments. /// public bool RecreateDatabase { get; set; } + + /// + /// Gets or sets a value indicating whether to run data seeders after migration completes. + /// Defaults to true. Seeders are only invoked in Development environment. + /// + public bool SeedData { get; set; } = true; } diff --git a/apps/gateway/Gateway.API/Data/PARequestDataSeeder.cs b/apps/gateway/Gateway.API/Data/PARequestDataSeeder.cs new file mode 100644 index 0000000..058ff82 --- /dev/null +++ b/apps/gateway/Gateway.API/Data/PARequestDataSeeder.cs @@ -0,0 +1,196 @@ +// ============================================================================= +// +// Copyright (c) Levelup Software. All rights reserved. +// +// ============================================================================= + +using System.Text.Json; +using Gateway.API.Data.Entities; +using Gateway.API.GraphQL.Models; +using Microsoft.EntityFrameworkCore; + +namespace Gateway.API.Data; + +/// +/// Seeds demo prior authorization requests for development environments. +/// Idempotent: skips seeding if any PA requests already exist. +/// +public sealed class PARequestDataSeeder : IDataSeeder +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + public PARequestDataSeeder(ILogger logger) + { + _logger = logger; + } + + /// + public async Task SeedAsync(GatewayDbContext context, CancellationToken cancellationToken) + { + if (await context.PriorAuthRequests.AnyAsync(cancellationToken)) + { + _logger.LogInformation("PA requests already exist, skipping seed."); + return; + } + + var now = DateTimeOffset.UtcNow; + var entities = CreateDemoEntities(now); + + context.PriorAuthRequests.AddRange(entities); + await context.SaveChangesAsync(cancellationToken); + + _logger.LogInformation("Seeded {Count} demo PA requests.", entities.Length); + } + + internal static PriorAuthRequestEntity[] CreateDemoEntities(DateTimeOffset now) + { + return + [ + new PriorAuthRequestEntity + { + Id = "PA-DEMO-001", + PatientId = "60178", + FhirPatientId = "a-195900.E-60178", + PatientName = "Sarah Johnson", + PatientMrn = "MRN-10001", + PatientDob = "March 15, 1985", + PatientMemberId = "BCBS-100234", + PatientPayer = "Blue Cross Blue Shield", + PatientAddress = "123 Main St, Springfield, IL 62701", + PatientPhone = "(555) 123-4567", + ProcedureCode = "72148", + ProcedureName = "MRI Lumbar Spine w/o Contrast", + DiagnosisCode = "M54.5", + DiagnosisName = "Low Back Pain", + ProviderId = "DR001", + ProviderName = "Dr. Amanda Martinez", + ProviderNpi = "1234567890", + ServiceDate = now.ToString("MMMM d, yyyy"), + PlaceOfService = "Outpatient", + ClinicalSummary = "Patient presents with chronic low back pain for 6 months, unresponsive to conservative treatment including physical therapy and NSAIDs. MRI recommended to evaluate for disc herniation or spinal stenosis.", + Status = "ready", + Confidence = 87, + CriteriaJson = SerializeCriteria( + [ + new CriterionModel { Met = true, Label = "Conservative treatment failed", Reason = "6 months of PT and NSAIDs without improvement" }, + new CriterionModel { Met = true, Label = "Clinical indication present", Reason = "Chronic low back pain with radiculopathy symptoms" }, + new CriterionModel { Met = true, Label = "Prior imaging reviewed", Reason = "X-ray completed showing degenerative changes" }, + new CriterionModel { Met = false, Label = "Specialist referral", Reason = "No specialist referral documented" }, + ]), + CreatedAt = now.AddHours(-2), + UpdatedAt = now.AddMinutes(-30), + ReadyAt = now.AddMinutes(-30), + }, + new PriorAuthRequestEntity + { + Id = "PA-DEMO-002", + PatientId = "60179", + FhirPatientId = "a-195900.E-60179", + PatientName = "James Wilson", + PatientMrn = "MRN-10002", + PatientDob = "July 22, 1958", + PatientMemberId = "AET-200567", + PatientPayer = "Aetna", + PatientAddress = "456 Oak Ave, Chicago, IL 60601", + PatientPhone = "(555) 234-5678", + ProcedureCode = "27447", + ProcedureName = "Total Knee Replacement", + DiagnosisCode = "M17.11", + DiagnosisName = "Primary Osteoarthritis, Right Knee", + ProviderId = "DR002", + ProviderName = "Dr. Robert Kim", + ProviderNpi = "0987654321", + ServiceDate = now.AddDays(14).ToString("MMMM d, yyyy"), + PlaceOfService = "Inpatient", + ClinicalSummary = "67-year-old male with severe right knee osteoarthritis. BMI 28. Failed 12 months of conservative management including corticosteroid injections, physical therapy, and bracing. Kellgren-Lawrence Grade IV on imaging.", + Status = "ready", + Confidence = 94, + CriteriaJson = SerializeCriteria( + [ + new CriterionModel { Met = true, Label = "Conservative treatment exhausted", Reason = "12 months of PT, injections, and bracing" }, + new CriterionModel { Met = true, Label = "Imaging confirms severity", Reason = "KL Grade IV osteoarthritis on X-ray" }, + new CriterionModel { Met = true, Label = "Functional limitation", Reason = "Unable to walk more than 1 block, difficulty with stairs" }, + new CriterionModel { Met = true, Label = "BMI within range", Reason = "BMI 28, within surgical criteria" }, + ]), + CreatedAt = now.AddHours(-4), + UpdatedAt = now.AddHours(-1), + ReadyAt = now.AddHours(-1), + }, + new PriorAuthRequestEntity + { + Id = "PA-DEMO-003", + PatientId = "60180", + FhirPatientId = "a-195900.E-60180", + PatientName = "Maria Garcia", + PatientMrn = "MRN-10003", + PatientDob = "November 8, 1972", + PatientMemberId = "UHC-300891", + PatientPayer = "United Healthcare", + PatientAddress = "789 Pine Rd, Austin, TX 78701", + PatientPhone = "(555) 345-6789", + ProcedureCode = "J1745", + ProcedureName = "Infliximab (Remicade)", + DiagnosisCode = "M06.9", + DiagnosisName = "Rheumatoid Arthritis, Unspecified", + ProviderId = "DR001", + ProviderName = "Dr. Amanda Martinez", + ProviderNpi = "1234567890", + ServiceDate = now.AddDays(7).ToString("MMMM d, yyyy"), + PlaceOfService = "Outpatient", + ClinicalSummary = "Patient with moderate-to-severe rheumatoid arthritis, failed methotrexate and hydroxychloroquine. DAS28 score 5.1 indicating high disease activity. Requesting biologic therapy initiation.", + Status = "draft", + Confidence = 0, + CreatedAt = now.AddMinutes(-45), + UpdatedAt = now.AddMinutes(-45), + }, + new PriorAuthRequestEntity + { + Id = "PA-DEMO-004", + PatientId = "60181", + FhirPatientId = "a-195900.E-60181", + PatientName = "Robert Chen", + PatientMrn = "MRN-10004", + PatientDob = "February 3, 1990", + PatientMemberId = "CIG-400123", + PatientPayer = "Cigna", + PatientAddress = "321 Elm St, Seattle, WA 98101", + PatientPhone = "(555) 456-7890", + ProcedureCode = "70553", + ProcedureName = "MRI Brain w/ & w/o Contrast", + DiagnosisCode = "G43.909", + DiagnosisName = "Migraine, Unspecified", + ProviderId = "DR003", + ProviderName = "Dr. Lisa Thompson", + ProviderNpi = "1122334455", + ServiceDate = now.AddDays(3).ToString("MMMM d, yyyy"), + PlaceOfService = "Outpatient", + ClinicalSummary = "35-year-old male with new-onset severe migraines with aura, increasing in frequency over 3 months. Neurological exam notable for mild papilledema. MRI brain indicated to rule out intracranial pathology.", + Status = "submitted", + Confidence = 91, + CriteriaJson = SerializeCriteria( + [ + new CriterionModel { Met = true, Label = "New neurological symptoms", Reason = "New-onset migraines with aura and papilledema" }, + new CriterionModel { Met = true, Label = "Red flag symptoms", Reason = "Papilledema on exam suggests elevated intracranial pressure" }, + new CriterionModel { Met = true, Label = "Progressive symptoms", Reason = "Increasing frequency over 3 months" }, + ]), + CreatedAt = now.AddDays(-1), + UpdatedAt = now.AddHours(-6), + ReadyAt = now.AddHours(-12), + SubmittedAt = now.AddHours(-6), + ReviewTimeSeconds = 142, + }, + ]; + } + + private static string SerializeCriteria(List criteria) => + JsonSerializer.Serialize(criteria, JsonOptions); +} diff --git a/apps/gateway/Gateway.API/DependencyExtensions.cs b/apps/gateway/Gateway.API/DependencyExtensions.cs index ff1e002..61dcabf 100644 --- a/apps/gateway/Gateway.API/DependencyExtensions.cs +++ b/apps/gateway/Gateway.API/DependencyExtensions.cs @@ -152,6 +152,9 @@ public static IServiceCollection AddGatewayPersistence(this IServiceCollection s services.AddScoped(); services.AddScoped(); + // Data seeder for demo data in development + services.AddScoped, PARequestDataSeeder>(); + return services; } diff --git a/apps/gateway/Gateway.API/Program.cs b/apps/gateway/Gateway.API/Program.cs index 459a930..827ca7d 100644 --- a/apps/gateway/Gateway.API/Program.cs +++ b/apps/gateway/Gateway.API/Program.cs @@ -45,6 +45,9 @@ // --------------------------------------------------------------------------- // Middleware Pipeline // --------------------------------------------------------------------------- +// Gate all requests (except health checks) until migrations complete +app.UseMiddleware(); + app.MapOpenApi(); app.MapScalarApiReference(options => {