Update to allow for seeding of database
This commit is contained in:
@@ -73,3 +73,18 @@ GATEWAY_JWT_SIGNING_KEY=CHANGE_ME_MINIMUM_32_BYTES
|
|||||||
GATEWAY_ID=00000000-0000-0000-0000-000000000000
|
GATEWAY_ID=00000000-0000-0000-0000-000000000000
|
||||||
GATEWAY_SITE_ID=00000000-0000-0000-0000-000000000000
|
GATEWAY_SITE_ID=00000000-0000-0000-0000-000000000000
|
||||||
GATEWAY_DEPARTMENT=ICU
|
GATEWAY_DEPARTMENT=ICU
|
||||||
|
GATEWAY_CODE=GW-ICU-1
|
||||||
|
GATEWAY_SITE_CODE=SITE-01
|
||||||
|
GATEWAY_SITE_NAME=Primary Site
|
||||||
|
# GATEWAY_SITE_ADDRESS=
|
||||||
|
|
||||||
|
# ---- production bootstrap users (API seeds when missing; change before first deploy) ----
|
||||||
|
SEED_ADMIN_USERNAME=admin
|
||||||
|
SEED_ADMIN_PASSWORD=CHANGE_ME
|
||||||
|
SEED_ADMIN_DISPLAY_NAME=System Admin
|
||||||
|
SEED_NURSE_USERNAME=nurse
|
||||||
|
SEED_NURSE_PASSWORD=CHANGE_ME
|
||||||
|
SEED_NURSE_DISPLAY_NAME=Charge Nurse
|
||||||
|
SEED_PHYSICIAN_USERNAME=physician
|
||||||
|
SEED_PHYSICIAN_PASSWORD=CHANGE_ME
|
||||||
|
SEED_PHYSICIAN_DISPLAY_NAME=Attending Physician
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
public class SeedingOptions
|
||||||
|
{
|
||||||
|
public const string Section = "Seeding";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Demo patients, observations, well-known *.demo users, and demo site/gateway.
|
||||||
|
/// Must stay false in production.
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableDemoData { get; set; } = true;
|
||||||
|
|
||||||
|
public BootstrapUserOptions? Admin { get; set; }
|
||||||
|
public BootstrapUserOptions? Nurse { get; set; }
|
||||||
|
public BootstrapUserOptions? Physician { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When SiteId + GatewayId are set, ensure matching clinical_sites / ward_gateways rows exist
|
||||||
|
/// so the edge gateway can heartbeat (same IDs as Gateway__* on the ward-gateway container).
|
||||||
|
/// </summary>
|
||||||
|
public Guid? SiteId { get; set; }
|
||||||
|
public string? SiteCode { get; set; }
|
||||||
|
public string? SiteName { get; set; }
|
||||||
|
public string? SiteAddress { get; set; }
|
||||||
|
public Guid? GatewayId { get; set; }
|
||||||
|
public string? GatewayCode { get; set; }
|
||||||
|
public string? GatewayDepartment { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BootstrapUserOptions
|
||||||
|
{
|
||||||
|
public string? Username { get; set; }
|
||||||
|
public string? Password { get; set; }
|
||||||
|
public string? DisplayName { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Production-safe, idempotent bootstrap: default alert thresholds, optional role
|
||||||
|
/// accounts (passwords from config), and optional site/gateway matching env GUIDs.
|
||||||
|
/// Never creates patient PHI or demo credentials.
|
||||||
|
/// </summary>
|
||||||
|
public static class BootstrapSeeder
|
||||||
|
{
|
||||||
|
public static async Task SeedAsync(
|
||||||
|
AppDbContext db,
|
||||||
|
IConnectionMultiplexer redis,
|
||||||
|
SeedingOptions options,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
await DataSeeder.EnsureDefaultThresholdsAsync(db, redis);
|
||||||
|
|
||||||
|
await EnsureBootstrapUserAsync(db, options.Admin, ClinicalRole.Admin, logger);
|
||||||
|
await EnsureBootstrapUserAsync(db, options.Nurse, ClinicalRole.Nurse, logger);
|
||||||
|
await EnsureBootstrapUserAsync(db, options.Physician, ClinicalRole.Physician, logger);
|
||||||
|
|
||||||
|
await EnsureSiteAndGatewayAsync(db, options, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task EnsureBootstrapUserAsync(
|
||||||
|
AppDbContext db,
|
||||||
|
BootstrapUserOptions? user,
|
||||||
|
ClinicalRole role,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
if (user is null
|
||||||
|
|| string.IsNullOrWhiteSpace(user.Username)
|
||||||
|
|| string.IsNullOrWhiteSpace(user.Password))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var username = user.Username.Trim();
|
||||||
|
if (await db.ClinicalUsers.AnyAsync(u => u.Username == username))
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Bootstrap user '{Username}' ({Role}) already exists — skipping.",
|
||||||
|
username, role);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.Password.Length < 8)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Skipping bootstrap {Role} '{Username}': password must be at least 8 characters.",
|
||||||
|
role, username);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayName = string.IsNullOrWhiteSpace(user.DisplayName)
|
||||||
|
? username
|
||||||
|
: user.DisplayName.Trim();
|
||||||
|
|
||||||
|
db.ClinicalUsers.Add(new ClinicalUser
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Username = username,
|
||||||
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword(user.Password),
|
||||||
|
DisplayName = displayName,
|
||||||
|
Role = role,
|
||||||
|
IsActive = true,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Bootstrap user '{Username}' ({Role}) created.",
|
||||||
|
username, role);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task EnsureSiteAndGatewayAsync(
|
||||||
|
AppDbContext db,
|
||||||
|
SeedingOptions options,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
if (options.SiteId is null || options.SiteId == Guid.Empty
|
||||||
|
|| options.GatewayId is null || options.GatewayId == Guid.Empty)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var siteId = options.SiteId.Value;
|
||||||
|
var gatewayId = options.GatewayId.Value;
|
||||||
|
var siteCode = string.IsNullOrWhiteSpace(options.SiteCode) ? "SITE-01" : options.SiteCode.Trim();
|
||||||
|
var siteName = string.IsNullOrWhiteSpace(options.SiteName) ? "Primary Site" : options.SiteName.Trim();
|
||||||
|
var gatewayCode = string.IsNullOrWhiteSpace(options.GatewayCode) ? "GW-ICU-1" : options.GatewayCode.Trim();
|
||||||
|
var department = string.IsNullOrWhiteSpace(options.GatewayDepartment) ? "ICU" : options.GatewayDepartment.Trim();
|
||||||
|
var address = string.IsNullOrWhiteSpace(options.SiteAddress) ? null : options.SiteAddress.Trim();
|
||||||
|
|
||||||
|
var existingGateway = await db.WardGateways
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(g => g.Id == gatewayId);
|
||||||
|
|
||||||
|
if (existingGateway is not null)
|
||||||
|
{
|
||||||
|
if (existingGateway.SiteId != siteId
|
||||||
|
|| !string.Equals(existingGateway.GatewayCode, gatewayCode, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(existingGateway.Department, department, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Gateway id {GatewayId} already exists with different site/code/department — not modifying.",
|
||||||
|
gatewayId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Gateway '{GatewayCode}' (id={GatewayId}) already registered — skipping.",
|
||||||
|
existingGateway.GatewayCode, gatewayId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingSite = await db.ClinicalSites.FirstOrDefaultAsync(s => s.Id == siteId);
|
||||||
|
if (existingSite is null)
|
||||||
|
{
|
||||||
|
if (await db.ClinicalSites.AnyAsync(s => s.SiteCode == siteCode))
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Site code '{SiteCode}' exists under a different id — cannot bootstrap site {SiteId}.",
|
||||||
|
siteCode, siteId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var site = new ClinicalSite(siteCode, siteName, address);
|
||||||
|
db.Entry(site).Property(nameof(ClinicalSite.Id)).CurrentValue = siteId;
|
||||||
|
db.ClinicalSites.Add(site);
|
||||||
|
logger.LogInformation("Bootstrap created site '{SiteCode}' (id={SiteId}).", siteCode, siteId);
|
||||||
|
}
|
||||||
|
else if (!string.Equals(existingSite.SiteCode, siteCode, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Site id {SiteId} already exists as code '{Existing}', not '{Requested}' — aborting gateway bootstrap.",
|
||||||
|
siteId, existingSite.SiteCode, siteCode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await db.WardGateways.AnyAsync(g => g.SiteId == siteId && g.GatewayCode == gatewayCode))
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Gateway code '{GatewayCode}' already exists for site {SiteId} under a different id.",
|
||||||
|
gatewayCode, siteId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var gateway = new WardGateway(siteId, gatewayCode, department);
|
||||||
|
db.Entry(gateway).Property(nameof(WardGateway.Id)).CurrentValue = gatewayId;
|
||||||
|
db.WardGateways.Add(gateway);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Bootstrap registered gateway '{GatewayCode}' (id={GatewayId}) on site {SiteId}.",
|
||||||
|
gatewayCode, gatewayId, siteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,8 +48,17 @@ public static class DataSeeder
|
|||||||
};
|
};
|
||||||
db.Encounters.AddRange(encounter1, encounter2);
|
db.Encounters.AddRange(encounter1, encounter2);
|
||||||
|
|
||||||
var thresholds = BuildDefaultThresholds();
|
// Thresholds may already exist from BootstrapSeeder — unique on observation_code.
|
||||||
|
List<AlertThreshold> thresholds;
|
||||||
|
if (!await db.AlertThresholds.AnyAsync())
|
||||||
|
{
|
||||||
|
thresholds = BuildDefaultThresholds();
|
||||||
db.AlertThresholds.AddRange(thresholds);
|
db.AlertThresholds.AddRange(thresholds);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
thresholds = await db.AlertThresholds.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
// Observations spanning normal, warning, and critical ranges for encounter1
|
// Observations spanning normal, warning, and critical ranges for encounter1
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
@@ -108,6 +117,21 @@ public static class DataSeeder
|
|||||||
await CacheThresholdsAsync(redis, thresholds);
|
await CacheThresholdsAsync(redis, thresholds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Idempotent: inserts the default threshold catalogue only when the table is empty,
|
||||||
|
/// then warms Redis. Safe for production bootstrap.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task EnsureDefaultThresholdsAsync(AppDbContext db, IConnectionMultiplexer redis)
|
||||||
|
{
|
||||||
|
if (await db.AlertThresholds.AnyAsync())
|
||||||
|
return;
|
||||||
|
|
||||||
|
var thresholds = BuildDefaultThresholds();
|
||||||
|
db.AlertThresholds.AddRange(thresholds);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
await CacheThresholdsAsync(redis, thresholds);
|
||||||
|
}
|
||||||
|
|
||||||
public static List<AlertThreshold> BuildDefaultThresholds() => new()
|
public static List<AlertThreshold> BuildDefaultThresholds() => new()
|
||||||
{
|
{
|
||||||
new() {
|
new() {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Demo site + gateway with fixed GUIDs. Production uses BootstrapSeeder /
|
||||||
|
/// register-gateway with GATEWAY_ID / GATEWAY_SITE_ID from the environment.
|
||||||
|
/// </summary>
|
||||||
public static class GatewayRegistrySeeder
|
public static class GatewayRegistrySeeder
|
||||||
{
|
{
|
||||||
public static readonly Guid DemoSiteId =
|
public static readonly Guid DemoSiteId =
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Demo-only users with well-known passwords. Invoked only when
|
||||||
|
/// Seeding:EnableDemoData is true (never in production). Production bootstrap
|
||||||
|
/// accounts come from BootstrapSeeder + Seeding:Admin/Nurse/Physician config.
|
||||||
|
/// </summary>
|
||||||
public static class UserSeeder
|
public static class UserSeeder
|
||||||
{
|
{
|
||||||
public static readonly Guid SimulationRunnerUserId =
|
public static readonly Guid SimulationRunnerUserId =
|
||||||
|
|||||||
@@ -170,9 +170,15 @@ try
|
|||||||
builder.Services.Configure<SimulationOptions>(
|
builder.Services.Configure<SimulationOptions>(
|
||||||
builder.Configuration.GetSection(SimulationOptions.Section));
|
builder.Configuration.GetSection(SimulationOptions.Section));
|
||||||
|
|
||||||
|
builder.Services.Configure<SeedingOptions>(
|
||||||
|
builder.Configuration.GetSection(SeedingOptions.Section));
|
||||||
|
|
||||||
var simulationOptions = builder.Configuration
|
var simulationOptions = builder.Configuration
|
||||||
.GetSection(SimulationOptions.Section).Get<SimulationOptions>() ?? new();
|
.GetSection(SimulationOptions.Section).Get<SimulationOptions>() ?? new();
|
||||||
|
|
||||||
|
var seedingOptions = builder.Configuration.GetSection(SeedingOptions.Section).Get<SeedingOptions>()
|
||||||
|
?? new SeedingOptions();
|
||||||
|
|
||||||
if (simulationOptions.Enabled)
|
if (simulationOptions.Enabled)
|
||||||
{
|
{
|
||||||
builder.Services.AddHttpClient("simulation-loopback", c =>
|
builder.Services.AddHttpClient("simulation-loopback", c =>
|
||||||
@@ -358,8 +364,8 @@ try
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// One-shot CLI verbs exit before hosting. Skip demo seeding for them so
|
// One-shot CLI verbs exit before hosting. Skip seeding for them so
|
||||||
// create-admin / register-gateway never race the demo UserSeeder.
|
// create-admin / register-gateway never race auto-bootstrap.
|
||||||
var isCliCommand = args.Contains("encrypt-phi")
|
var isCliCommand = args.Contains("encrypt-phi")
|
||||||
|| args.Contains("create-admin")
|
|| args.Contains("create-admin")
|
||||||
|| args.Contains("register-gateway");
|
|| args.Contains("register-gateway");
|
||||||
@@ -375,10 +381,24 @@ try
|
|||||||
// must never be created in production. Seeding:EnableDemoData defaults to
|
// must never be created in production. Seeding:EnableDemoData defaults to
|
||||||
// true so local development and the existing verification scripts are
|
// true so local development and the existing verification scripts are
|
||||||
// unaffected; appsettings.Production.json sets it to false.
|
// unaffected; appsettings.Production.json sets it to false.
|
||||||
var enableDemoData = builder.Configuration.GetValue("Seeding:EnableDemoData", true)
|
var enableDemoData = seedingOptions.EnableDemoData
|
||||||
&& !app.Environment.IsEnvironment("Testing")
|
&& !app.Environment.IsEnvironment("Testing")
|
||||||
&& !isCliCommand;
|
&& !isCliCommand;
|
||||||
|
|
||||||
|
var runBootstrap = !app.Environment.IsEnvironment("Testing") && !isCliCommand;
|
||||||
|
|
||||||
|
if (runBootstrap)
|
||||||
|
{
|
||||||
|
using var scope = app.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||||
|
var bootstrapLogger = scope.ServiceProvider
|
||||||
|
.GetRequiredService<ILoggerFactory>()
|
||||||
|
.CreateLogger("BootstrapSeeder");
|
||||||
|
// Always: default thresholds (+ optional bootstrap users / site+gateway from config).
|
||||||
|
await BootstrapSeeder.SeedAsync(db, redis, seedingOptions, bootstrapLogger);
|
||||||
|
}
|
||||||
|
|
||||||
if (enableDemoData)
|
if (enableDemoData)
|
||||||
{
|
{
|
||||||
using var scope = app.Services.CreateScope();
|
using var scope = app.Services.CreateScope();
|
||||||
|
|||||||
@@ -227,7 +227,10 @@
|
|||||||
"Enabled": true
|
"Enabled": true
|
||||||
},
|
},
|
||||||
"Seeding": {
|
"Seeding": {
|
||||||
"EnableDemoData": true
|
"EnableDemoData": true,
|
||||||
|
"Admin": null,
|
||||||
|
"Nurse": null,
|
||||||
|
"Physician": null
|
||||||
},
|
},
|
||||||
"Simulation": {
|
"Simulation": {
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
|
|||||||
@@ -51,6 +51,22 @@ services:
|
|||||||
Fhir__ApiKey: "${FHIR_API_KEY}"
|
Fhir__ApiKey: "${FHIR_API_KEY}"
|
||||||
Dashboard__CorsOrigins__0: "${DASHBOARD_ORIGIN}"
|
Dashboard__CorsOrigins__0: "${DASHBOARD_ORIGIN}"
|
||||||
Seeding__EnableDemoData: "false"
|
Seeding__EnableDemoData: "false"
|
||||||
|
Seeding__Admin__Username: "${SEED_ADMIN_USERNAME:-admin}"
|
||||||
|
Seeding__Admin__Password: "${SEED_ADMIN_PASSWORD}"
|
||||||
|
Seeding__Admin__DisplayName: "${SEED_ADMIN_DISPLAY_NAME:-System Admin}"
|
||||||
|
Seeding__Nurse__Username: "${SEED_NURSE_USERNAME:-nurse}"
|
||||||
|
Seeding__Nurse__Password: "${SEED_NURSE_PASSWORD}"
|
||||||
|
Seeding__Nurse__DisplayName: "${SEED_NURSE_DISPLAY_NAME:-Charge Nurse}"
|
||||||
|
Seeding__Physician__Username: "${SEED_PHYSICIAN_USERNAME:-physician}"
|
||||||
|
Seeding__Physician__Password: "${SEED_PHYSICIAN_PASSWORD}"
|
||||||
|
Seeding__Physician__DisplayName: "${SEED_PHYSICIAN_DISPLAY_NAME:-Attending Physician}"
|
||||||
|
Seeding__SiteId: "${GATEWAY_SITE_ID}"
|
||||||
|
Seeding__SiteCode: "${GATEWAY_SITE_CODE:-SITE-01}"
|
||||||
|
Seeding__SiteName: "${GATEWAY_SITE_NAME:-Primary Site}"
|
||||||
|
Seeding__SiteAddress: "${GATEWAY_SITE_ADDRESS:-}"
|
||||||
|
Seeding__GatewayId: "${GATEWAY_ID}"
|
||||||
|
Seeding__GatewayCode: "${GATEWAY_CODE:-GW-ICU-1}"
|
||||||
|
Seeding__GatewayDepartment: "${GATEWAY_DEPARTMENT:-ICU}"
|
||||||
Swagger__Enabled: "false"
|
Swagger__Enabled: "false"
|
||||||
volumes:
|
volumes:
|
||||||
# CRITICAL: the Data Protection keyring encrypts patient PHI. If this
|
# CRITICAL: the Data Protection keyring encrypts patient PHI. If this
|
||||||
|
|||||||
Reference in New Issue
Block a user