fixes: MRN generation race condition and Sepsis bundle creation race condition (TOCTOU)
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
public class PatientOptions
|
||||
{
|
||||
public const string Section = "Patient";
|
||||
|
||||
public string MrnPrefix { get; set; } = "MRN";
|
||||
public int MrnDigits { get; set; } = 6;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareClinicalAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMrnSequence : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"
|
||||
DO $$
|
||||
DECLARE
|
||||
next_val bigint;
|
||||
BEGIN
|
||||
SELECT COALESCE(MAX(
|
||||
CASE WHEN mrn ~ '^MRN-[0-9]+$'
|
||||
THEN CAST(SUBSTRING(mrn FROM 5) AS bigint)
|
||||
ELSE 0
|
||||
END
|
||||
), 0) + 1 INTO next_val FROM patients;
|
||||
|
||||
EXECUTE format('CREATE SEQUENCE mrn_seq START WITH %s INCREMENT BY 1', next_val);
|
||||
END $$;
|
||||
");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("DROP SEQUENCE IF EXISTS mrn_seq;");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1261
File diff suppressed because it is too large
Load Diff
+26
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareClinicalAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSepsisBundleUniqueInProgressIndex : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE UNIQUE INDEX ix_sepsis_bundles_encounter_in_progress
|
||||
ON sepsis_bundles (encounter_id)
|
||||
WHERE compliance_status = 'IN_PROGRESS';
|
||||
");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("DROP INDEX IF EXISTS ix_sepsis_bundles_encounter_in_progress;");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,9 @@ try
|
||||
builder.Services.Configure<FhirOptions>(
|
||||
builder.Configuration.GetSection(FhirOptions.Section));
|
||||
|
||||
builder.Services.Configure<PatientOptions>(
|
||||
builder.Configuration.GetSection(PatientOptions.Section));
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("Dashboard", policy =>
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public class PatientService : IPatientService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IExternalIdentifierService _identifiers;
|
||||
private readonly IAuditService _audit;
|
||||
private readonly PatientOptions _options;
|
||||
|
||||
public PatientService(
|
||||
AppDbContext db,
|
||||
IExternalIdentifierService identifiers,
|
||||
IAuditService audit)
|
||||
IAuditService audit,
|
||||
IOptions<PatientOptions> options)
|
||||
{
|
||||
_db = db;
|
||||
_identifiers = identifiers;
|
||||
_audit = audit;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<Patient> RegisterAsync(RegisterPatientRequest req)
|
||||
{
|
||||
var mrn = await GenerateMrnAsync();
|
||||
var patient = new Patient
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Mrn = mrn,
|
||||
Mrn = await GenerateMrnAsync(),
|
||||
FirstName = req.FirstName,
|
||||
LastName = req.LastName,
|
||||
DateOfBirth = req.DateOfBirth,
|
||||
@@ -35,7 +38,18 @@ public class PatientService : IPatientService
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
_db.Patients.Add(patient);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsMrnUniqueViolation(ex))
|
||||
{
|
||||
_db.Entry(patient).State = EntityState.Detached;
|
||||
patient.Mrn = await GenerateMrnAsync();
|
||||
_db.Patients.Add(patient);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await _audit.WriteAsync(
|
||||
AuditAction.PatientRegistered,
|
||||
@@ -46,6 +60,13 @@ public class PatientService : IPatientService
|
||||
return patient;
|
||||
}
|
||||
|
||||
private static bool IsMrnUniqueViolation(DbUpdateException ex)
|
||||
{
|
||||
return ex.InnerException is Npgsql.PostgresException pg
|
||||
&& pg.SqlState == "23505"
|
||||
&& pg.ConstraintName?.Contains("mrn") == true;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<Patient>> ListAsync(
|
||||
string? q, int page, int pageSize)
|
||||
{
|
||||
@@ -196,7 +217,9 @@ public class PatientService : IPatientService
|
||||
|
||||
private async Task<string> GenerateMrnAsync()
|
||||
{
|
||||
var count = await _db.Patients.CountAsync();
|
||||
return $"MRN-{(count + 1):D6}";
|
||||
var seq = await _db.Database
|
||||
.SqlQueryRaw<long>("SELECT nextval('mrn_seq') AS \"Value\"")
|
||||
.SingleAsync();
|
||||
return $"{_options.MrnPrefix}-{seq.ToString($"D{_options.MrnDigits}")}";
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,18 @@ public class SepsisBundleService : ISepsisBundleService
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
try
|
||||
{
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateException ex)
|
||||
when (ex.InnerException is Npgsql.PostgresException { SqlState: "23505" } pg
|
||||
&& pg.ConstraintName == "ix_sepsis_bundles_encounter_in_progress")
|
||||
{
|
||||
_db.ChangeTracker.Clear();
|
||||
return null;
|
||||
}
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user