feature: Live Capture (Track B)

This commit is contained in:
voltsrage
2026-06-27 04:23:51 +08:00
parent 01000a2489
commit 88e70b3dbe
30 changed files with 4547 additions and 23 deletions
@@ -0,0 +1,88 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
/// <summary>
/// Track B live capture endpoints for credentialed clinicians.
/// Observations entered via these endpoints skip the verification queue
/// and are promoted synchronously to VigilCareClinical with full
/// critical value alerting.
/// </summary>
[ApiController]
[Route("api/v1/live-capture")]
[Produces("application/json")]
[Authorize(Roles = "CLINICIAN")]
public class LiveCaptureController : ControllerBase
{
private readonly ILiveCaptureService _liveCapture;
public LiveCaptureController(ILiveCaptureService liveCapture) =>
_liveCapture = liveCapture;
/// <summary>
/// Records observations against an existing VigilCareClinical encounter.
/// Requires clinician attestation and password re-confirmation.
/// Returns promoted observation IDs and any synchronous critical alerts.
/// </summary>
/// <remarks>
/// Track B workflow: no verification queue. Clinician attestation replaces
/// the dual-human gate used in Track A (backfill). Each observation is
/// promoted immediately and evaluated against critical alert thresholds
/// before this response returns.
///
/// Critical alerts are generated synchronously — a critical potassium
/// value will have an open ClinicalAlert row in the database before
/// the 201 response reaches the clinician's tablet.
/// </remarks>
[HttpPost("encounters/{encounterId:guid}/observations")]
[ProducesResponseType(typeof(ApiResponse<LiveCaptureResponse>), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> RecordObservations(
Guid encounterId,
[FromBody] RecordObservationsRequest request)
{
var clinicianUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var result = await _liveCapture.RecordObservationsAsync(
encounterId, request, clinicianUserId);
return StatusCode(201, ApiResponse<LiveCaptureResponse>.Created(result));
}
/// <summary>
/// Opens a new encounter and records initial vitals in a single request.
/// Designed for outpatient workflows where the encounter does not yet
/// exist in VigilCareClinical.
/// </summary>
/// <remarks>
/// Creates the encounter with status "active", records all observations,
/// promotes immediately, and evaluates critical thresholds — all within
/// a single database transaction.
///
/// If the patient already has an active encounter, returns 409 with
/// ACTIVE_ENCOUNTER_EXISTS. Use the observation-only endpoint instead.
/// </remarks>
[HttpPost("encounters")]
[ProducesResponseType(typeof(ApiResponse<LiveCaptureResponse>), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> OpenEncounterWithVitals(
[FromBody] OpenEncounterWithVitalsRequest request)
{
var clinicianUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var result = await _liveCapture.OpenEncounterWithVitalsAsync(
request, clinicianUserId);
return StatusCode(201, ApiResponse<LiveCaptureResponse>.Created(result));
}
}
+2
View File
@@ -18,6 +18,8 @@ public class AppDbContext : DbContext
public DbSet<Encounter> Encounters => Set<Encounter>();
public DbSet<Observation> Observations => Set<Observation>();
public DbSet<OutboxEvent> OutboxEvents => Set<OutboxEvent>();
public DbSet<AlertThreshold> AlertThresholds => Set<AlertThreshold>();
public DbSet<ClinicalAlert> ClinicalAlerts => Set<ClinicalAlert>();
public DbSet<LiveEncounter> LiveEncounters => Set<LiveEncounter>();
public DbSet<LiveObservation> LiveObservations => Set<LiveObservation>();
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class AlertThresholdConfiguration : IEntityTypeConfiguration<AlertThreshold>
{
public void Configure(EntityTypeBuilder<AlertThreshold> builder)
{
builder.ToTable("alert_thresholds", "clinical");
builder.HasKey(t => t.Id);
builder.Property(t => t.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(t => t.ObservationCode).HasColumnName("observation_code").HasMaxLength(50).IsRequired();
builder.Property(t => t.DisplayName).HasColumnName("display_name").HasMaxLength(200).IsRequired();
builder.Property(t => t.Unit).HasColumnName("unit").HasMaxLength(20).IsRequired();
builder.Property(t => t.CriticalLow).HasColumnName("critical_low").HasColumnType("decimal(10,3)");
builder.Property(t => t.WarningLow).HasColumnName("warning_low").HasColumnType("decimal(10,3)");
builder.Property(t => t.WarningHigh).HasColumnName("warning_high").HasColumnType("decimal(10,3)");
builder.Property(t => t.CriticalHigh).HasColumnName("critical_high").HasColumnType("decimal(10,3)");
builder.Property(t => t.SuppressionWindowMinutes).HasColumnName("suppression_window_minutes");
builder.Property(t => t.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.HasIndex(t => t.ObservationCode).IsUnique();
}
}
@@ -0,0 +1,81 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class ClinicalAlertConfiguration : IEntityTypeConfiguration<ClinicalAlert>
{
public void Configure(EntityTypeBuilder<ClinicalAlert> builder)
{
builder.ToTable("clinical_alerts", "clinical", t =>
{
t.HasCheckConstraint("chk_clinical_alerts_severity",
"severity IN ('WARNING', 'CRITICAL')");
t.HasCheckConstraint("chk_clinical_alerts_status",
"status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
t.HasCheckConstraint("chk_clinical_alerts_alert_type",
"alert_type IN (" +
"'SEPSIS_WARNING', " +
"'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', " +
"'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', " +
"'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', " +
"'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', " +
"'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', " +
"'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', " +
"'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', " +
"'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', " +
"'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', " +
"'WARNING_GLUCOSE_MG_DL', " +
"'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', " +
"'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', " +
"'NEWS2_WARNING', 'NEWS2_EMERGENCY', " +
"'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', " +
"'GCS_CRITICAL', 'GCS_WARNING', " +
"'SOFA_SEPSIS', 'SOFA_WARNING')");
});
builder.HasKey(a => a.Id);
builder.Property(a => a.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(a => a.EncounterId).HasColumnName("encounter_id");
builder.Property(a => a.PatientId).HasColumnName("patient_id");
builder.Property(a => a.ObservationId).HasColumnName("observation_id");
builder.Property(a => a.AlertType)
.HasColumnName("alert_type")
.HasMaxLength(50)
.HasConversion(
v => v.ToDbString(),
v => AlertTypeExtensions.FromDbString(v))
.IsRequired();
builder.Property(a => a.Severity)
.HasColumnName("severity")
.HasMaxLength(20)
.HasConversion(
v => v.ToDbString(),
v => AlertSeverityExtensions.FromDbString(v))
.IsRequired();
builder.Property(a => a.Details).HasColumnName("details").IsRequired();
builder.Property(a => a.ObservationCode).HasColumnName("observation_code").HasMaxLength(50);
builder.Property(a => a.Status)
.HasColumnName("status")
.HasMaxLength(20)
.HasConversion(
v => v.ToDbString(),
v => AlertStatusExtensions.FromDbString(v))
.HasDefaultValueSql("'OPEN'")
.HasSentinel((AlertStatus)(-1));
builder.Property(a => a.AcknowledgedAt).HasColumnName("acknowledged_at");
builder.Property(a => a.AcknowledgedBy).HasColumnName("acknowledged_by").HasMaxLength(200);
builder.Property(a => a.ResolvedAt).HasColumnName("resolved_at");
builder.Property(a => a.ClientAlertId).HasColumnName("client_alert_id");
builder.Property(a => a.SyncedFromGateway).HasColumnName("synced_from_gateway").HasDefaultValue(false);
builder.Property(a => a.TriggeredAt).HasColumnName("triggered_at").HasDefaultValueSql("NOW()");
builder.Property(a => a.FeedbackReceived).HasColumnName("feedback_received").HasDefaultValue(false);
builder.HasIndex(a => new { a.EncounterId, a.TriggeredAt });
builder.HasIndex(a => new { a.PatientId, a.TriggeredAt });
builder.HasIndex(a => new { a.Severity, a.TriggeredAt })
.HasFilter("status = 'OPEN'");
builder.HasIndex(a => new { a.EncounterId, a.AlertType, a.ObservationCode })
.HasFilter("status IN ('OPEN', 'ESCALATED')");
builder.HasIndex(a => a.ClientAlertId)
.IsUnique()
.HasFilter("client_alert_id IS NOT NULL");
}
}
@@ -0,0 +1,119 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareRecordsAPI.Data.Migrations
{
/// <inheritdoc />
public partial class AddAlertThresholdsAndClinicalAlerts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "alert_thresholds",
schema: "clinical",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
observation_code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
display_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
unit = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
critical_low = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
warning_low = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
warning_high = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
critical_high = table.Column<decimal>(type: "numeric(10,3)", nullable: true),
suppression_window_minutes = table.Column<int>(type: "integer", nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
},
constraints: table =>
{
table.PrimaryKey("PK_alert_thresholds", x => x.id);
});
migrationBuilder.CreateTable(
name: "clinical_alerts",
schema: "clinical",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
encounter_id = table.Column<Guid>(type: "uuid", nullable: false),
patient_id = table.Column<Guid>(type: "uuid", nullable: false),
observation_id = table.Column<Guid>(type: "uuid", nullable: true),
alert_type = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
severity = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
details = table.Column<string>(type: "text", nullable: false),
observation_code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValueSql: "'OPEN'"),
acknowledged_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
acknowledged_by = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
resolved_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
triggered_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
client_alert_id = table.Column<Guid>(type: "uuid", nullable: true),
synced_from_gateway = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
feedback_received = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_clinical_alerts", x => x.id);
table.CheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', 'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', 'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', 'GCS_CRITICAL', 'GCS_WARNING', 'SOFA_SEPSIS', 'SOFA_WARNING')");
table.CheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
table.CheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
});
migrationBuilder.CreateIndex(
name: "IX_alert_thresholds_observation_code",
schema: "clinical",
table: "alert_thresholds",
column: "observation_code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_clinical_alerts_client_alert_id",
schema: "clinical",
table: "clinical_alerts",
column: "client_alert_id",
unique: true,
filter: "client_alert_id IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_clinical_alerts_encounter_id_alert_type_observation_code",
schema: "clinical",
table: "clinical_alerts",
columns: new[] { "encounter_id", "alert_type", "observation_code" },
filter: "status IN ('OPEN', 'ESCALATED')");
migrationBuilder.CreateIndex(
name: "IX_clinical_alerts_encounter_id_triggered_at",
schema: "clinical",
table: "clinical_alerts",
columns: new[] { "encounter_id", "triggered_at" });
migrationBuilder.CreateIndex(
name: "IX_clinical_alerts_patient_id_triggered_at",
schema: "clinical",
table: "clinical_alerts",
columns: new[] { "patient_id", "triggered_at" });
migrationBuilder.CreateIndex(
name: "IX_clinical_alerts_severity_triggered_at",
schema: "clinical",
table: "clinical_alerts",
columns: new[] { "severity", "triggered_at" },
filter: "status = 'OPEN'");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "alert_thresholds",
schema: "clinical");
migrationBuilder.DropTable(
name: "clinical_alerts",
schema: "clinical");
}
}
}
@@ -21,6 +21,66 @@ namespace VigilCareRecordsAPI.Data.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlertThreshold", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<decimal?>("CriticalHigh")
.HasColumnType("decimal(10,3)")
.HasColumnName("critical_high");
b.Property<decimal?>("CriticalLow")
.HasColumnType("decimal(10,3)")
.HasColumnName("critical_low");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display_name");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<int?>("SuppressionWindowMinutes")
.HasColumnType("integer")
.HasColumnName("suppression_window_minutes");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal?>("WarningHigh")
.HasColumnType("decimal(10,3)")
.HasColumnName("warning_high");
b.Property<decimal?>("WarningLow")
.HasColumnType("decimal(10,3)")
.HasColumnName("warning_low");
b.HasKey("Id");
b.HasIndex("ObservationCode")
.IsUnique();
b.ToTable("alert_thresholds", "clinical");
});
modelBuilder.Entity("AuthAuditEvent", b =>
{
b.Property<Guid>("Id")
@@ -59,6 +119,117 @@ namespace VigilCareRecordsAPI.Data.Migrations
});
});
modelBuilder.Entity("ClinicalAlert", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("AcknowledgedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acknowledged_at");
b.Property<string>("AcknowledgedBy")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("acknowledged_by");
b.Property<string>("AlertType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("alert_type");
b.Property<Guid?>("ClientAlertId")
.HasColumnType("uuid")
.HasColumnName("client_alert_id");
b.Property<string>("Details")
.IsRequired()
.HasColumnType("text")
.HasColumnName("details");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<bool>("FeedbackReceived")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("feedback_received");
b.Property<string>("ObservationCode")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<Guid?>("ObservationId")
.HasColumnType("uuid")
.HasColumnName("observation_id");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("severity");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'OPEN'");
b.Property<bool>("SyncedFromGateway")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("synced_from_gateway");
b.Property<DateTimeOffset>("TriggeredAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("triggered_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("ClientAlertId")
.IsUnique()
.HasFilter("client_alert_id IS NOT NULL");
b.HasIndex("EncounterId", "TriggeredAt");
b.HasIndex("PatientId", "TriggeredAt");
b.HasIndex("Severity", "TriggeredAt")
.HasFilter("status = 'OPEN'");
b.HasIndex("EncounterId", "AlertType", "ObservationCode")
.HasFilter("status IN ('OPEN', 'ESCALATED')");
b.ToTable("clinical_alerts", "clinical", t =>
{
t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', 'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', 'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', 'GCS_CRITICAL', 'GCS_WARNING', 'SOFA_SEPSIS', 'SOFA_WARNING')");
t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
});
});
modelBuilder.Entity("DigitizationBatch", b =>
{
b.Property<Guid>("Id")
@@ -0,0 +1,13 @@
public class AlertThreshold
{
public Guid Id { get; set; }
public string ObservationCode { get; set; } = null!;
public string DisplayName { get; set; } = null!;
public string Unit { get; set; } = null!;
public decimal? CriticalLow { get; set; }
public decimal? WarningLow { get; set; }
public decimal? WarningHigh { get; set; }
public decimal? CriticalHigh { get; set; }
public int? SuppressionWindowMinutes { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
@@ -0,0 +1,19 @@
public class ClinicalAlert
{
public Guid Id { get; set; }
public Guid EncounterId { get; set; }
public Guid PatientId { get; set; }
public Guid? ObservationId { get; set; }
public AlertType AlertType { get; set; }
public AlertSeverity Severity { get; set; }
public string Details { get; set; } = null!;
public string? ObservationCode { get; set; }
public AlertStatus Status { get; set; } = AlertStatus.Open;
public DateTimeOffset? AcknowledgedAt { get; set; }
public string? AcknowledgedBy { get; set; }
public DateTimeOffset? ResolvedAt { get; set; }
public DateTimeOffset TriggeredAt { get; set; }
public Guid? ClientAlertId { get; set; }
public bool SyncedFromGateway { get; set; }
public bool FeedbackReceived { get; set; }
}
@@ -0,0 +1,18 @@
public enum AlertSeverity { Warning, Critical }
public static class AlertSeverityExtensions
{
public static string ToDbString(this AlertSeverity s) => s switch
{
AlertSeverity.Warning => "WARNING",
AlertSeverity.Critical => "CRITICAL",
_ => throw new ArgumentOutOfRangeException(nameof(s))
};
public static AlertSeverity FromDbString(string v) => v switch
{
"WARNING" => AlertSeverity.Warning,
"CRITICAL" => AlertSeverity.Critical,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert severity: '{v}'")
};
}
@@ -0,0 +1,22 @@
public enum AlertStatus { Open, Acknowledged, Resolved, Escalated }
public static class AlertStatusExtensions
{
public static string ToDbString(this AlertStatus s) => s switch
{
AlertStatus.Open => "OPEN",
AlertStatus.Acknowledged => "ACKNOWLEDGED",
AlertStatus.Resolved => "RESOLVED",
AlertStatus.Escalated => "ESCALATED",
_ => throw new ArgumentOutOfRangeException(nameof(s))
};
public static AlertStatus FromDbString(string v) => v switch
{
"OPEN" => AlertStatus.Open,
"ACKNOWLEDGED" => AlertStatus.Acknowledged,
"RESOLVED" => AlertStatus.Resolved,
"ESCALATED" => AlertStatus.Escalated,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert status: '{v}'")
};
}
@@ -0,0 +1,225 @@
public enum AlertType
{
[Obsolete("Legacy — replaced by SOFA_SEPSIS in Phase 27. Retained for historical alert queries.")]
SepsisWarning,
CriticalHeartRate,
CriticalTempC,
CriticalPotassiumMeqL,
CriticalSpo2,
CriticalRespRate,
CriticalWbcKUl,
CriticalSystolicBp,
CriticalDiastolicBp,
CriticalLactateMmolL,
CriticalAvpu,
CriticalGlucoseMgDl,
// New — warning-level threshold alerts
WarningHeartRate,
WarningTempC,
WarningPotassiumMeqL,
WarningSpo2,
WarningRespRate,
WarningWbcKUl,
WarningSystolicBp,
WarningDiastolicBp,
WarningLactateMmolL,
WarningGlucoseMgDl,
News2Warning,
News2Emergency,
RapidDeterioration,
[Obsolete("Legacy — replaced by QSOFA_SCREEN in Phase 27. Retained for historical alert queries.")]
QsofaWarning,
QsofaScreen,
GcsCritical,
GcsWarning,
CriticalPao2MmHg,
WarningPao2MmHg,
CriticalPlateletKUl,
WarningPlateletKUl,
CriticalBilirubinMgDl,
WarningBilirubinMgDl,
CriticalCreatinineMgDl,
WarningCreatinineMgDl,
SofaSepsis,
SofaWarning,
}
public static class AlertTypeExtensions
{
#pragma warning disable CS0618 // Legacy alert types retained for historical DB strings
public static string ToDbString(this AlertType t) => t switch
{
AlertType.SepsisWarning => "SEPSIS_WARNING",
AlertType.CriticalHeartRate => "CRITICAL_HEART_RATE",
AlertType.CriticalTempC => "CRITICAL_TEMP_C",
AlertType.CriticalPotassiumMeqL => "CRITICAL_POTASSIUM_MEQ_L",
AlertType.CriticalSpo2 => "CRITICAL_SPO2",
AlertType.CriticalRespRate => "CRITICAL_RESP_RATE",
AlertType.CriticalWbcKUl => "CRITICAL_WBC_K_UL",
AlertType.CriticalSystolicBp => "CRITICAL_SYSTOLIC_BP",
AlertType.CriticalDiastolicBp => "CRITICAL_DIASTOLIC_BP",
AlertType.CriticalLactateMmolL => "CRITICAL_LACTATE_MMOL_L",
AlertType.CriticalAvpu => "CRITICAL_AVPU",
AlertType.CriticalGlucoseMgDl => "CRITICAL_GLUCOSE_MG_DL",
AlertType.WarningHeartRate => "WARNING_HEART_RATE",
AlertType.WarningTempC => "WARNING_TEMP_C",
AlertType.WarningPotassiumMeqL => "WARNING_POTASSIUM_MEQ_L",
AlertType.WarningSpo2 => "WARNING_SPO2",
AlertType.WarningRespRate => "WARNING_RESP_RATE",
AlertType.WarningWbcKUl => "WARNING_WBC_K_UL",
AlertType.WarningSystolicBp => "WARNING_SYSTOLIC_BP",
AlertType.WarningDiastolicBp => "WARNING_DIASTOLIC_BP",
AlertType.WarningLactateMmolL => "WARNING_LACTATE_MMOL_L",
AlertType.WarningGlucoseMgDl => "WARNING_GLUCOSE_MG_DL",
AlertType.News2Warning => "NEWS2_WARNING",
AlertType.News2Emergency => "NEWS2_EMERGENCY",
AlertType.RapidDeterioration => "RAPID_DETERIORATION",
AlertType.QsofaWarning => "QSOFA_WARNING",
AlertType.GcsCritical => "GCS_CRITICAL",
AlertType.GcsWarning => "GCS_WARNING",
AlertType.CriticalPao2MmHg => "CRITICAL_PAO2_MMHG",
AlertType.WarningPao2MmHg => "WARNING_PAO2_MMHG",
AlertType.CriticalPlateletKUl => "CRITICAL_PLATELET_K_UL",
AlertType.WarningPlateletKUl => "WARNING_PLATELET_K_UL",
AlertType.CriticalBilirubinMgDl => "CRITICAL_BILIRUBIN_MG_DL",
AlertType.WarningBilirubinMgDl => "WARNING_BILIRUBIN_MG_DL",
AlertType.CriticalCreatinineMgDl => "CRITICAL_CREATININE_MG_DL",
AlertType.WarningCreatinineMgDl => "WARNING_CREATININE_MG_DL",
AlertType.SofaSepsis => "SOFA_SEPSIS",
AlertType.SofaWarning => "SOFA_WARNING",
AlertType.QsofaScreen => "QSOFA_SCREEN",
_ => throw new ArgumentOutOfRangeException(nameof(t))
};
#pragma warning restore CS0618
#pragma warning disable CS0618 // Legacy alert types retained for historical DB strings
public static AlertType FromDbString(string v) => v switch
{
"SEPSIS_WARNING" => AlertType.SepsisWarning,
"CRITICAL_HEART_RATE" => AlertType.CriticalHeartRate,
"CRITICAL_TEMP_C" => AlertType.CriticalTempC,
"CRITICAL_POTASSIUM_MEQ_L"=> AlertType.CriticalPotassiumMeqL,
"CRITICAL_SPO2" => AlertType.CriticalSpo2,
"CRITICAL_RESP_RATE" => AlertType.CriticalRespRate,
"CRITICAL_WBC_K_UL" => AlertType.CriticalWbcKUl,
"CRITICAL_SYSTOLIC_BP" => AlertType.CriticalSystolicBp,
"CRITICAL_DIASTOLIC_BP" => AlertType.CriticalDiastolicBp,
"CRITICAL_LACTATE_MMOL_L" => AlertType.CriticalLactateMmolL,
"CRITICAL_AVPU" => AlertType.CriticalAvpu,
"CRITICAL_GLUCOSE_MG_DL" => AlertType.CriticalGlucoseMgDl,
"WARNING_HEART_RATE" => AlertType.WarningHeartRate,
"WARNING_TEMP_C" => AlertType.WarningTempC,
"WARNING_POTASSIUM_MEQ_L" => AlertType.WarningPotassiumMeqL,
"WARNING_SPO2" => AlertType.WarningSpo2,
"WARNING_RESP_RATE" => AlertType.WarningRespRate,
"WARNING_WBC_K_UL" => AlertType.WarningWbcKUl,
"WARNING_SYSTOLIC_BP" => AlertType.WarningSystolicBp,
"WARNING_DIASTOLIC_BP" => AlertType.WarningDiastolicBp,
"WARNING_LACTATE_MMOL_L" => AlertType.WarningLactateMmolL,
"WARNING_GLUCOSE_MG_DL" => AlertType.WarningGlucoseMgDl,
"NEWS2_WARNING" => AlertType.News2Warning,
"NEWS2_EMERGENCY" => AlertType.News2Emergency,
"RAPID_DETERIORATION" => AlertType.RapidDeterioration,
"QSOFA_WARNING" => AlertType.QsofaWarning,
"QSOFA_SCREEN" => AlertType.QsofaScreen,
"GCS_CRITICAL" => AlertType.GcsCritical,
"GCS_WARNING" => AlertType.GcsWarning,
"CRITICAL_PAO2_MMHG" => AlertType.CriticalPao2MmHg,
"WARNING_PAO2_MMHG" => AlertType.WarningPao2MmHg,
"CRITICAL_PLATELET_K_UL" => AlertType.CriticalPlateletKUl,
"WARNING_PLATELET_K_UL" => AlertType.WarningPlateletKUl,
"CRITICAL_BILIRUBIN_MG_DL" => AlertType.CriticalBilirubinMgDl,
"WARNING_BILIRUBIN_MG_DL" => AlertType.WarningBilirubinMgDl,
"CRITICAL_CREATININE_MG_DL" => AlertType.CriticalCreatinineMgDl,
"WARNING_CREATININE_MG_DL" => AlertType.WarningCreatinineMgDl,
"SOFA_SEPSIS" => AlertType.SofaSepsis,
"SOFA_WARNING" => AlertType.SofaWarning,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown alert type: '{v}'")
};
#pragma warning restore CS0618
// Threshold alerts are derived from observation codes in alert_thresholds — not free-form strings.
public static AlertType CriticalFor(string observationCode) => observationCode switch
{
"HEART_RATE" => AlertType.CriticalHeartRate,
"TEMP_C" => AlertType.CriticalTempC,
"POTASSIUM_MEQ_L" => AlertType.CriticalPotassiumMeqL,
"SPO2" => AlertType.CriticalSpo2,
"RESP_RATE" => AlertType.CriticalRespRate,
"WBC_K_UL" => AlertType.CriticalWbcKUl,
"SYSTOLIC_BP" => AlertType.CriticalSystolicBp,
"DIASTOLIC_BP" => AlertType.CriticalDiastolicBp,
"LACTATE_MMOL_L" => AlertType.CriticalLactateMmolL,
"AVPU" => AlertType.CriticalAvpu,
"GLUCOSE_MG_DL" => AlertType.CriticalGlucoseMgDl,
"PAO2_MMHG" => AlertType.CriticalPao2MmHg,
"PLATELET_K_UL" => AlertType.CriticalPlateletKUl,
"BILIRUBIN_MG_DL" => AlertType.CriticalBilirubinMgDl,
"CREATININE_MG_DL" => AlertType.CriticalCreatinineMgDl,
_ => throw new ArgumentOutOfRangeException(
nameof(observationCode), $"No critical alert type for observation code '{observationCode}'")
};
public static AlertType WarningFor(string observationCode) => observationCode switch
{
"HEART_RATE" => AlertType.WarningHeartRate,
"TEMP_C" => AlertType.WarningTempC,
"POTASSIUM_MEQ_L" => AlertType.WarningPotassiumMeqL,
"SPO2" => AlertType.WarningSpo2,
"RESP_RATE" => AlertType.WarningRespRate,
"WBC_K_UL" => AlertType.WarningWbcKUl,
"SYSTOLIC_BP" => AlertType.WarningSystolicBp,
"DIASTOLIC_BP" => AlertType.WarningDiastolicBp,
"LACTATE_MMOL_L" => AlertType.WarningLactateMmolL,
"GLUCOSE_MG_DL" => AlertType.WarningGlucoseMgDl,
"PAO2_MMHG" => AlertType.WarningPao2MmHg,
"PLATELET_K_UL" => AlertType.WarningPlateletKUl,
"BILIRUBIN_MG_DL" => AlertType.WarningBilirubinMgDl,
"CREATININE_MG_DL" => AlertType.WarningCreatinineMgDl,
_ => throw new ArgumentOutOfRangeException(
nameof(observationCode), $"No warning alert type for observation code '{observationCode}'")
};
#pragma warning disable CS0618 // Legacy alert types retained for historical DB strings
public static bool IsSuppressible(this AlertType t) => t switch
{
AlertType.SepsisWarning or AlertType.News2Emergency => false,
AlertType.CriticalHeartRate or AlertType.CriticalTempC or AlertType.CriticalPotassiumMeqL
or AlertType.CriticalSpo2 or AlertType.CriticalRespRate or AlertType.CriticalWbcKUl
or AlertType.CriticalSystolicBp or AlertType.CriticalDiastolicBp
or AlertType.CriticalLactateMmolL or AlertType.CriticalAvpu
or AlertType.CriticalGlucoseMgDl => false,
AlertType.RapidDeterioration => false,
AlertType.GcsCritical => false, // trajectory alerts are never suppressed
AlertType.SofaSepsis => false,
_ => true // all Warning* types, News2Warning, QsofaScreen, SofaWarning, GcsWarning
};
#pragma warning restore CS0618
public static string? ObservationCodeForWarning(this AlertType t) => t switch
{
AlertType.WarningHeartRate => "HEART_RATE",
AlertType.WarningTempC => "TEMP_C",
AlertType.WarningPotassiumMeqL => "POTASSIUM_MEQ_L",
AlertType.WarningSpo2 => "SPO2",
AlertType.WarningRespRate => "RESP_RATE",
AlertType.WarningWbcKUl => "WBC_K_UL",
AlertType.WarningSystolicBp => "SYSTOLIC_BP",
AlertType.WarningDiastolicBp => "DIASTOLIC_BP",
AlertType.WarningLactateMmolL => "LACTATE_MMOL_L",
AlertType.WarningGlucoseMgDl => "GLUCOSE_MG_DL",
AlertType.WarningPao2MmHg => "PAO2_MMHG",
AlertType.WarningPlateletKUl => "PLATELET_K_UL",
AlertType.WarningBilirubinMgDl => "BILIRUBIN_MG_DL",
AlertType.WarningCreatinineMgDl => "CREATININE_MG_DL",
_ => null
};
}
@@ -0,0 +1,11 @@
/// <summary>
/// Internal result type for critical alert evaluation. Not exposed in API responses;
/// mapped to LiveCaptureCriticalAlert in the response builder.
/// </summary>
internal record CriticalAlertResult(
Guid Id,
string Severity,
string Message,
decimal ThresholdValue,
string ThresholdBound
);
@@ -0,0 +1,11 @@
/// <summary>
/// A critical alert that was generated synchronously during live capture promotion.
/// Returned inline so the clinician sees the alert before the HTTP response completes.
/// </summary>
public record LiveCaptureCriticalAlert(
Guid AlertId,
string Severity,
string Message,
decimal ThresholdValue,
string ThresholdBound
);
@@ -0,0 +1,10 @@
/// <summary>
/// A single observation entered at bedside by a credentialed clinician.
/// </summary>
public record LiveCaptureObservationRequest(
string ObservationCode,
decimal Value,
string Unit,
DateTimeOffset RecordedAt,
string? Note
);
@@ -0,0 +1,13 @@
/// <summary>
/// Response for a single promoted observation, including any synchronous
/// critical alert generated during promotion.
/// </summary>
public record LiveCaptureObservationResponse(
Guid DraftObservationId,
Guid LiveObservationId,
string ObservationCode,
decimal Value,
string Unit,
DateTimeOffset RecordedAt,
LiveCaptureCriticalAlert? CriticalAlert
);
@@ -0,0 +1,12 @@
/// <summary>
/// Full response for a live capture operation. Contains the batch ID,
/// the VigilCareClinical encounter ID, all promoted observations with
/// their live IDs, and any critical alerts generated synchronously.
/// </summary>
public record LiveCaptureResponse(
Guid BatchId,
Guid EncounterId,
IReadOnlyList<LiveCaptureObservationResponse> Observations,
int CriticalAlertCount,
DateTimeOffset PromotedAt
);
@@ -0,0 +1,13 @@
/// <summary>
/// Opens a new encounter and records initial vitals in a single request.
/// Used for outpatient workflows where the encounter does not yet exist.
/// </summary>
public record OpenEncounterWithVitalsRequest(
Guid PatientId,
string Department,
string? RoomBed,
string AdmissionReason,
List<LiveCaptureObservationRequest> Observations,
bool ClinicianAttestation,
string PasswordConfirm
);
@@ -0,0 +1,9 @@
/// <summary>
/// Records one or more observations against an existing VigilCareClinical encounter.
/// Requires clinician attestation and password re-confirmation.
/// </summary>
public record RecordObservationsRequest(
List<LiveCaptureObservationRequest> Observations,
bool ClinicianAttestation,
string PasswordConfirm
);
@@ -0,0 +1,6 @@
public record ThresholdCacheEntry(
string ObservationCode,
decimal? CriticalLow,
decimal? WarningLow,
decimal? WarningHigh,
decimal? CriticalHigh);
+2
View File
@@ -66,6 +66,8 @@ try
builder.Services.AddScoped<IIdempotencyService, IdempotencyService>();
builder.Services.AddScoped<IMrnGenerator, MrnGenerator>();
builder.Services.AddScoped<IDigitizationHistoryService, DigitizationHistoryService>();
builder.Services.AddScoped<IAttestationService, AttestationService>();
builder.Services.AddScoped<ILiveCaptureService, LiveCaptureService>();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
@@ -0,0 +1,55 @@
public class AttestationService : IAttestationService
{
private readonly AppDbContext _db;
private readonly ILogger<AttestationService> _logger;
public AttestationService(AppDbContext db, ILogger<AttestationService> logger)
{
_db = db;
_logger = logger;
}
public async Task<User> ValidateAttestationAsync(
Guid userId, bool clinicianAttestation, string passwordConfirm)
{
// 1. Attestation flag must be explicitly true
if (!clinicianAttestation)
throw new ValidationException(
"Clinician attestation is required for live capture.",
"ATTESTATION_REQUIRED");
// 2. Load user
var user = await _db.Users.FindAsync(userId);
if (user is null)
throw new NotFoundException("User not found.", "USER_NOT_FOUND");
// 3. Role check — only Clinician role can use live capture
if (user.Role != UserRole.Clinician)
throw new ValidationException(
"Only users with the Clinician role can perform live capture.",
"CLINICIAN_ROLE_REQUIRED");
// 4. Account must be active
if (!user.IsActive)
throw new ConflictException(
"Account is disabled.", "ACCOUNT_DISABLED");
// 5. Password re-confirmation — prevents unattended sessions from
// submitting clinical data without the clinician present
if (string.IsNullOrWhiteSpace(passwordConfirm))
throw new ValidationException(
"Password re-confirmation is required.",
"PASSWORD_CONFIRM_REQUIRED");
if (!BCrypt.Net.BCrypt.Verify(passwordConfirm, user.PasswordHash))
throw new ValidationException(
"Password re-confirmation failed.",
"PASSWORD_CONFIRM_INVALID");
_logger.LogInformation(
"Clinician attestation validated for user {UserId} ({FullName})",
user.Id, user.FullName);
return user;
}
}
@@ -0,0 +1,12 @@
public interface IAttestationService
{
/// <summary>
/// Validates that the user is a credentialed clinician and that the
/// password re-confirm matches their stored hash. Throws on failure.
/// </summary>
/// <param name="userId">The authenticated user's ID from JWT claims.</param>
/// <param name="clinicianAttestation">Must be true; false throws ValidationException.</param>
/// <param name="passwordConfirm">Raw password for re-confirmation.</param>
/// <returns>The validated User entity.</returns>
Task<User> ValidateAttestationAsync(Guid userId, bool clinicianAttestation, string passwordConfirm);
}
@@ -0,0 +1,20 @@
public interface ILiveCaptureService
{
/// <summary>
/// Records observations against an existing VigilCareClinical encounter.
/// Validates clinician attestation, creates a live_capture batch, promotes
/// synchronously, and returns live observation IDs with any critical alerts.
/// </summary>
Task<LiveCaptureResponse> RecordObservationsAsync(
Guid encounterId,
RecordObservationsRequest request,
Guid clinicianUserId);
/// <summary>
/// Opens a new encounter in VigilCareClinical and records initial vitals
/// in a single atomic operation. Used for outpatient workflows.
/// </summary>
Task<LiveCaptureResponse> OpenEncounterWithVitalsAsync(
OpenEncounterWithVitalsRequest request,
Guid clinicianUserId);
}
@@ -0,0 +1,481 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;
public class LiveCaptureService : ILiveCaptureService
{
private readonly AppDbContext _db;
private readonly IAttestationService _attestation;
private readonly IConnectionMultiplexer _redis;
private readonly ILogger<LiveCaptureService> _logger;
// Redis key prefix for cached alert thresholds (same as VigilCareClinical)
private const string ThresholdCachePrefix = "threshold:";
public LiveCaptureService(
AppDbContext db,
IAttestationService attestation,
IConnectionMultiplexer redis,
ILogger<LiveCaptureService> logger)
{
_db = db;
_attestation = attestation;
_redis = redis;
_logger = logger;
}
public async Task<LiveCaptureResponse> RecordObservationsAsync(
Guid encounterId, RecordObservationsRequest request, Guid clinicianUserId)
{
// 1. Validate attestation (role + password re-confirm)
var clinician = await _attestation.ValidateAttestationAsync(
clinicianUserId, request.ClinicianAttestation, request.PasswordConfirm);
// 2. Validate encounter exists and is active in VigilCareClinical
var encounter = await _db.Encounters.FindAsync(encounterId);
if (encounter is null)
throw new NotFoundException(
"Encounter not found in VigilCareClinical.", "ENCOUNTER_NOT_FOUND");
if (encounter.Status != "active")
throw new ConflictException(
"Observations can only be recorded against active encounters.",
"ENCOUNTER_NOT_ACTIVE");
// 3. Validate observations
if (request.Observations is null || request.Observations.Count == 0)
throw new ValidationException(
"At least one observation is required.", "EMPTY_OBSERVATIONS");
if (request.Observations.Count > 10)
throw new ValidationException(
"Maximum 10 observations per live capture request.",
"TOO_MANY_OBSERVATIONS");
foreach (var obs in request.Observations)
{
if (!PlausibilityValidator.IsPlausible(obs.ObservationCode, obs.Value, out var reason))
throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE");
}
// 4. Execute synchronous promotion within a single transaction
return await PromoteSynchronouslyAsync(
encounter.Id, encounter.PatientId, clinician, request.Observations);
}
public async Task<LiveCaptureResponse> OpenEncounterWithVitalsAsync(
OpenEncounterWithVitalsRequest request, Guid clinicianUserId)
{
// 1. Validate attestation
var clinician = await _attestation.ValidateAttestationAsync(
clinicianUserId, request.ClinicianAttestation, request.PasswordConfirm);
// 2. Validate patient exists
var patient = await _db.Patients.FindAsync(request.PatientId);
if (patient is null)
throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND");
// 3. Validate observations
if (request.Observations is null || request.Observations.Count == 0)
throw new ValidationException(
"At least one observation is required.", "EMPTY_OBSERVATIONS");
if (request.Observations.Count > 10)
throw new ValidationException(
"Maximum 10 observations per live capture request.",
"TOO_MANY_OBSERVATIONS");
foreach (var obs in request.Observations)
{
if (!PlausibilityValidator.IsPlausible(obs.ObservationCode, obs.Value, out var reason))
throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE");
}
// 4. Check no duplicate active encounter for this patient
var existingActive = await _db.Encounters.AnyAsync(e =>
e.PatientId == request.PatientId &&
e.Status == "active");
if (existingActive)
throw new ConflictException(
"Patient already has an active encounter. Record observations against the existing encounter.",
"ACTIVE_ENCOUNTER_EXISTS");
if (!DepartmentExtensions.TryFromDbString(request.Department, out var department))
throw new ValidationException(
$"Invalid department '{request.Department}'. Must be a recognized hospital department.",
"INVALID_DEPARTMENT");
// 5. Create the encounter in VigilCareClinical
var encounter = new Encounter
{
Id = Guid.NewGuid(),
PatientId = request.PatientId,
Department = department,
RoomBed = request.RoomBed,
AdmissionReason = request.AdmissionReason,
Status = "active",
AdmissionDate = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
_db.Encounters.Add(encounter);
// 6. Promote observations synchronously
return await PromoteSynchronouslyAsync(
encounter.Id, request.PatientId, clinician, request.Observations);
}
/// <summary>
/// Core promotion logic shared by both endpoints. Creates the batch, draft
/// observations, live observations, evaluates critical thresholds, and writes
/// all audit and outbox events within a single database transaction.
/// </summary>
private async Task<LiveCaptureResponse> PromoteSynchronouslyAsync(
Guid encounterId, Guid patientId, User clinician,
List<LiveCaptureObservationRequest> observations)
{
var now = DateTimeOffset.UtcNow;
var batchId = Guid.NewGuid();
var observationResponses = new List<LiveCaptureObservationResponse>();
var criticalAlertCount = 0;
await using var transaction = await _db.Database.BeginTransactionAsync();
try
{
// --- Create the live_capture batch (already in terminal Promoted state) ---
var batch = new DigitizationBatch
{
Id = batchId,
Status = BatchStatus.Promoted,
BatchType = BatchType.VitalsSheet,
Track = BatchTrack.LiveCapture,
PatientId = patientId,
DocumentRef = "live-capture", // No scanned document for Track B
DocumentSha256 = ComputeLiveCaptureHash(clinician.Id, encounterId, now),
EnableRetroactiveAlerts = false, // Not applicable — live capture always alerts
EnteredByUserId = clinician.Id,
VerifiedByUserId = clinician.Id, // Clinician attestation replaces verifier
ApprovedByUserId = clinician.Id,
ClinicianAttestation = true,
PromotedAt = now,
PromotionEncounterId = encounterId,
CreatedAt = now,
UpdatedAt = now
};
_db.DigitizationBatches.Add(batch);
// --- Write attestation and promotion events ---
_db.DigitizationEvents.Add(new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = batchId,
EventType = DigitizationEventType.LiveCaptureAttested,
ActorUserId = clinician.Id,
OccurredAt = now,
MetadataJson = JsonSerializer.Serialize(new
{
clinicianId = clinician.Id,
clinicianName = clinician.FullName,
encounterId,
observationCount = observations.Count,
track = "LIVE_CAPTURE"
})
});
_db.DigitizationEvents.Add(new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = batchId,
EventType = DigitizationEventType.Promoted,
ActorUserId = clinician.Id,
OccurredAt = now.AddMilliseconds(1),
MetadataJson = JsonSerializer.Serialize(new
{
promotionType = "synchronous_live_capture",
encounterId
})
});
// --- Process each observation ---
var cache = _redis.GetDatabase();
foreach (var obs in observations)
{
var draftObsId = Guid.NewGuid();
var liveObsId = Guid.NewGuid();
// Create draft observation (audit trail)
var draftObservation = new DraftObservation
{
Id = draftObsId,
BatchId = batchId,
ObservationCode = obs.ObservationCode,
Value = obs.Value,
Unit = obs.Unit,
RecordedAt = obs.RecordedAt,
Note = obs.Note,
CreatedAt = now
};
_db.DraftObservations.Add(draftObservation);
// Create live observation in VigilCareClinical tables
var liveObservation = new Observation
{
Id = liveObsId,
EncounterId = encounterId,
PatientId = patientId,
ObservationCode = obs.ObservationCode,
Value = obs.Value,
Unit = obs.Unit,
RecordedAt = obs.RecordedAt,
Note = obs.Note,
Source = "live_capture",
SourceDraftObservationId = draftObsId,
SourceBatchId = batchId,
CreatedAt = now
};
_db.Observations.Add(liveObservation);
_db.OutboxEvents.Add(new OutboxEvent
{
Id = Guid.NewGuid(),
EventType = "observation.recorded",
AggregateType = "Observation",
AggregateId = liveObsId,
PayloadJson = JsonSerializer.Serialize(new
{
observationId = liveObsId,
encounterId,
patientId,
observationCode = obs.ObservationCode,
value = obs.Value,
unit = obs.Unit,
recordedAt = obs.RecordedAt,
source = "live_capture",
batchId
}),
CreatedAt = now,
ProcessedAt = null,
RetryCount = 0
});
// --- Synchronous critical value detection ---
var alert = await EvaluateCriticalThresholdAsync(
cache, liveObsId, encounterId, patientId,
obs.ObservationCode, obs.Value, obs.Unit, now);
if (alert is not null)
{
criticalAlertCount++;
observationResponses.Add(new LiveCaptureObservationResponse(
draftObsId, liveObsId,
obs.ObservationCode, obs.Value, obs.Unit, obs.RecordedAt,
new LiveCaptureCriticalAlert(
alert.Id,
alert.Severity,
alert.Message,
alert.ThresholdValue,
alert.ThresholdBound)));
}
else
{
observationResponses.Add(new LiveCaptureObservationResponse(
draftObsId, liveObsId,
obs.ObservationCode, obs.Value, obs.Unit, obs.RecordedAt,
null));
}
}
await _db.SaveChangesAsync();
await transaction.CommitAsync();
_logger.LogInformation(
"Live capture batch {BatchId} promoted synchronously: " +
"{ObsCount} observations, {AlertCount} critical alerts, " +
"encounter {EncounterId}, clinician {ClinicianId}",
batchId, observations.Count, criticalAlertCount,
encounterId, clinician.Id);
return new LiveCaptureResponse(
batchId, encounterId, observationResponses,
criticalAlertCount, now);
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
/// <summary>
/// Evaluates a single observation against Redis-cached alert thresholds.
/// If the value breaches a CRITICAL bound, creates a ClinicalAlert row and
/// an outbox event within the current transaction (before SaveChanges).
///
/// Returns null if no critical threshold is breached.
/// Warning thresholds are handled asynchronously by the Kafka consumer —
/// the same split as VigilCareClinical Phase 2.
/// </summary>
private async Task<CriticalAlertResult?> EvaluateCriticalThresholdAsync(
StackExchange.Redis.IDatabase cache, Guid observationId, Guid encounterId, Guid patientId,
string observationCode, decimal value, string unit, DateTimeOffset now)
{
var threshold = await LoadThresholdAsync(cache, observationCode);
if (threshold is null)
return null;
var breach = GetCriticalBreach(value, threshold);
if (breach is null)
return null;
var (thresholdValue, thresholdBound) = breach.Value;
var details = BuildCriticalDetails(observationCode, value, unit, threshold, thresholdBound);
AlertType alertType;
try
{
alertType = AlertTypeExtensions.CriticalFor(observationCode);
}
catch (ArgumentOutOfRangeException)
{
_logger.LogWarning(
"No critical alert type configured for observation code {ObservationCode}",
observationCode);
return null;
}
var alertId = Guid.NewGuid();
var clinicalAlert = new ClinicalAlert
{
Id = alertId,
EncounterId = encounterId,
PatientId = patientId,
ObservationId = observationId,
ObservationCode = observationCode,
AlertType = alertType,
Severity = AlertSeverity.Critical,
Details = details,
Status = AlertStatus.Open,
TriggeredAt = now
};
_db.ClinicalAlerts.Add(clinicalAlert);
_db.OutboxEvents.Add(new OutboxEvent
{
Id = Guid.NewGuid(),
EventType = "alert.generated",
AggregateType = "ClinicalAlert",
AggregateId = alertId,
PayloadJson = JsonSerializer.Serialize(new
{
alertId,
encounterId,
patientId,
observationId,
observationCode,
alertType = alertType.ToDbString(),
severity = AlertSeverity.Critical.ToDbString(),
details,
triggeredValue = value,
thresholdValue,
thresholdBound,
source = "live_capture",
triggeredAt = now
}),
CreatedAt = now,
ProcessedAt = null,
RetryCount = 0
});
_logger.LogWarning(
"CRITICAL alert {AlertId} generated via live capture: " +
"{ObservationCode} = {Value} {Unit} ({ThresholdBound} = {ThresholdValue}), " +
"encounter {EncounterId}, patient {PatientId}",
alertId, observationCode, value, unit,
thresholdBound, thresholdValue, encounterId, patientId);
return new CriticalAlertResult(
alertId,
AlertSeverity.Critical.ToDbString(),
details,
thresholdValue,
thresholdBound);
}
private async Task<ThresholdCacheEntry?> LoadThresholdAsync(
StackExchange.Redis.IDatabase cache, string observationCode)
{
var cacheKey = $"{ThresholdCachePrefix}{observationCode}";
var cached = await cache.StringGetAsync(cacheKey);
if (cached.HasValue)
return JsonSerializer.Deserialize<ThresholdCacheEntry>(cached!);
var threshold = await _db.AlertThresholds
.AsNoTracking()
.FirstOrDefaultAsync(t => t.ObservationCode == observationCode);
if (threshold is null)
return null;
var entry = new ThresholdCacheEntry(
threshold.ObservationCode,
threshold.CriticalLow,
threshold.WarningLow,
threshold.WarningHigh,
threshold.CriticalHigh);
await cache.StringSetAsync(
cacheKey,
JsonSerializer.Serialize(entry),
TimeSpan.FromMinutes(30));
return entry;
}
private static (decimal ThresholdValue, string ThresholdBound)? GetCriticalBreach(
decimal value, ThresholdCacheEntry threshold)
{
if (threshold.CriticalLow.HasValue && value < threshold.CriticalLow.Value)
return (threshold.CriticalLow.Value, "CRITICAL_LOW");
if (threshold.CriticalHigh.HasValue && value > threshold.CriticalHigh.Value)
return (threshold.CriticalHigh.Value, "CRITICAL_HIGH");
return null;
}
private static string BuildCriticalDetails(
string observationCode, decimal value, string unit,
ThresholdCacheEntry threshold, string thresholdBound)
{
if (thresholdBound == "CRITICAL_LOW")
{
return $"{observationCode} value {value} {unit} is below critical low " +
$"threshold of {threshold.CriticalLow} {unit}";
}
return $"{observationCode} value {value} {unit} is above critical high " +
$"threshold of {threshold.CriticalHigh} {unit}";
}
/// <summary>
/// Computes a deterministic hash for live capture batches (no scanned document).
/// Uses clinician ID, encounter ID, and timestamp to generate uniqueness.
/// </summary>
private static string ComputeLiveCaptureHash(Guid clinicianId, Guid encounterId, DateTimeOffset timestamp)
{
var input = $"{clinicianId}:{encounterId}:{timestamp:O}";
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}