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 =>
{