Update to allow for seeding of database
CI / backend (push) Successful in 9m43s
CI / frontend (push) Successful in 1m52s

This commit is contained in:
2026-08-11 05:29:51 +08:00
parent 1ade75b635
commit 8b1eddb587
9 changed files with 287 additions and 7 deletions
@@ -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);
}
}
+26 -2
View File
@@ -48,8 +48,17 @@ public static class DataSeeder
};
db.Encounters.AddRange(encounter1, encounter2);
var thresholds = BuildDefaultThresholds();
db.AlertThresholds.AddRange(thresholds);
// Thresholds may already exist from BootstrapSeeder — unique on observation_code.
List<AlertThreshold> thresholds;
if (!await db.AlertThresholds.AnyAsync())
{
thresholds = BuildDefaultThresholds();
db.AlertThresholds.AddRange(thresholds);
}
else
{
thresholds = await db.AlertThresholds.ToListAsync();
}
// Observations spanning normal, warning, and critical ranges for encounter1
var now = DateTimeOffset.UtcNow;
@@ -108,6 +117,21 @@ public static class DataSeeder
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()
{
new() {
@@ -1,5 +1,9 @@
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 readonly Guid DemoSiteId =
@@ -1,5 +1,10 @@
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 readonly Guid SimulationRunnerUserId =