diff --git a/.env.example b/.env.example
index 8a53804..0340cab 100644
--- a/.env.example
+++ b/.env.example
@@ -72,4 +72,19 @@ GATEWAY_JWT_SIGNING_KEY=CHANGE_ME_MINIMUM_32_BYTES
# ---- gateway identity ----
GATEWAY_ID=00000000-0000-0000-0000-000000000000
GATEWAY_SITE_ID=00000000-0000-0000-0000-000000000000
-GATEWAY_DEPARTMENT=ICU
\ No newline at end of file
+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
diff --git a/VigilCareClinicalAPI/Configuration/SeedingOptions.cs b/VigilCareClinicalAPI/Configuration/SeedingOptions.cs
new file mode 100644
index 0000000..8f19180
--- /dev/null
+++ b/VigilCareClinicalAPI/Configuration/SeedingOptions.cs
@@ -0,0 +1,33 @@
+public class SeedingOptions
+{
+ public const string Section = "Seeding";
+
+ ///
+ /// Demo patients, observations, well-known *.demo users, and demo site/gateway.
+ /// Must stay false in production.
+ ///
+ public bool EnableDemoData { get; set; } = true;
+
+ public BootstrapUserOptions? Admin { get; set; }
+ public BootstrapUserOptions? Nurse { get; set; }
+ public BootstrapUserOptions? Physician { get; set; }
+
+ ///
+ /// 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).
+ ///
+ 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; }
+}
diff --git a/VigilCareClinicalAPI/Data/Seed/BootstrapSeeder.cs b/VigilCareClinicalAPI/Data/Seed/BootstrapSeeder.cs
new file mode 100644
index 0000000..00fadf3
--- /dev/null
+++ b/VigilCareClinicalAPI/Data/Seed/BootstrapSeeder.cs
@@ -0,0 +1,160 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using StackExchange.Redis;
+
+///
+/// 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.
+///
+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);
+ }
+}
diff --git a/VigilCareClinicalAPI/Data/Seed/DataSeeder.cs b/VigilCareClinicalAPI/Data/Seed/DataSeeder.cs
index b700a23..e291be8 100644
--- a/VigilCareClinicalAPI/Data/Seed/DataSeeder.cs
+++ b/VigilCareClinicalAPI/Data/Seed/DataSeeder.cs
@@ -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 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);
}
+ ///
+ /// Idempotent: inserts the default threshold catalogue only when the table is empty,
+ /// then warms Redis. Safe for production bootstrap.
+ ///
+ 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 BuildDefaultThresholds() => new()
{
new() {
diff --git a/VigilCareClinicalAPI/Data/Seed/GatewayRegistrySeeder.cs b/VigilCareClinicalAPI/Data/Seed/GatewayRegistrySeeder.cs
index dd98b76..9b7e862 100644
--- a/VigilCareClinicalAPI/Data/Seed/GatewayRegistrySeeder.cs
+++ b/VigilCareClinicalAPI/Data/Seed/GatewayRegistrySeeder.cs
@@ -1,5 +1,9 @@
using Microsoft.EntityFrameworkCore;
+///
+/// Demo site + gateway with fixed GUIDs. Production uses BootstrapSeeder /
+/// register-gateway with GATEWAY_ID / GATEWAY_SITE_ID from the environment.
+///
public static class GatewayRegistrySeeder
{
public static readonly Guid DemoSiteId =
diff --git a/VigilCareClinicalAPI/Data/Seed/UserSeeder.cs b/VigilCareClinicalAPI/Data/Seed/UserSeeder.cs
index a551b71..5a41187 100644
--- a/VigilCareClinicalAPI/Data/Seed/UserSeeder.cs
+++ b/VigilCareClinicalAPI/Data/Seed/UserSeeder.cs
@@ -1,5 +1,10 @@
using Microsoft.EntityFrameworkCore;
+///
+/// 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.
+///
public static class UserSeeder
{
public static readonly Guid SimulationRunnerUserId =
diff --git a/VigilCareClinicalAPI/Program.cs b/VigilCareClinicalAPI/Program.cs
index 0d6b91b..e35c589 100644
--- a/VigilCareClinicalAPI/Program.cs
+++ b/VigilCareClinicalAPI/Program.cs
@@ -170,9 +170,15 @@ try
builder.Services.Configure(
builder.Configuration.GetSection(SimulationOptions.Section));
+ builder.Services.Configure(
+ builder.Configuration.GetSection(SeedingOptions.Section));
+
var simulationOptions = builder.Configuration
.GetSection(SimulationOptions.Section).Get() ?? new();
+ var seedingOptions = builder.Configuration.GetSection(SeedingOptions.Section).Get()
+ ?? new SeedingOptions();
+
if (simulationOptions.Enabled)
{
builder.Services.AddHttpClient("simulation-loopback", c =>
@@ -358,8 +364,8 @@ try
var app = builder.Build();
- // One-shot CLI verbs exit before hosting. Skip demo seeding for them so
- // create-admin / register-gateway never race the demo UserSeeder.
+ // One-shot CLI verbs exit before hosting. Skip seeding for them so
+ // create-admin / register-gateway never race auto-bootstrap.
var isCliCommand = args.Contains("encrypt-phi")
|| args.Contains("create-admin")
|| args.Contains("register-gateway");
@@ -375,10 +381,24 @@ try
// must never be created in production. Seeding:EnableDemoData defaults to
// true so local development and the existing verification scripts are
// unaffected; appsettings.Production.json sets it to false.
- var enableDemoData = builder.Configuration.GetValue("Seeding:EnableDemoData", true)
+ var enableDemoData = seedingOptions.EnableDemoData
&& !app.Environment.IsEnvironment("Testing")
&& !isCliCommand;
+ var runBootstrap = !app.Environment.IsEnvironment("Testing") && !isCliCommand;
+
+ if (runBootstrap)
+ {
+ using var scope = app.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var redis = scope.ServiceProvider.GetRequiredService();
+ var bootstrapLogger = scope.ServiceProvider
+ .GetRequiredService()
+ .CreateLogger("BootstrapSeeder");
+ // Always: default thresholds (+ optional bootstrap users / site+gateway from config).
+ await BootstrapSeeder.SeedAsync(db, redis, seedingOptions, bootstrapLogger);
+ }
+
if (enableDemoData)
{
using var scope = app.Services.CreateScope();
diff --git a/VigilCareClinicalAPI/appsettings.json b/VigilCareClinicalAPI/appsettings.json
index 35f40e1..3221545 100644
--- a/VigilCareClinicalAPI/appsettings.json
+++ b/VigilCareClinicalAPI/appsettings.json
@@ -227,7 +227,10 @@
"Enabled": true
},
"Seeding": {
- "EnableDemoData": true
+ "EnableDemoData": true,
+ "Admin": null,
+ "Nurse": null,
+ "Physician": null
},
"Simulation": {
"Enabled": false,
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 745eca0..083d9c7 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -51,6 +51,22 @@ services:
Fhir__ApiKey: "${FHIR_API_KEY}"
Dashboard__CorsOrigins__0: "${DASHBOARD_ORIGIN}"
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"
volumes:
# CRITICAL: the Data Protection keyring encrypts patient PHI. If this