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.Services.Configure<FhirOptions>(
|
||||||
builder.Configuration.GetSection(FhirOptions.Section));
|
builder.Configuration.GetSection(FhirOptions.Section));
|
||||||
|
|
||||||
|
builder.Services.Configure<PatientOptions>(
|
||||||
|
builder.Configuration.GetSection(PatientOptions.Section));
|
||||||
|
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
options.AddPolicy("Dashboard", policy =>
|
options.AddPolicy("Dashboard", policy =>
|
||||||
|
|||||||
@@ -1,29 +1,32 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
public class PatientService : IPatientService
|
public class PatientService : IPatientService
|
||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly IExternalIdentifierService _identifiers;
|
private readonly IExternalIdentifierService _identifiers;
|
||||||
private readonly IAuditService _audit;
|
private readonly IAuditService _audit;
|
||||||
|
private readonly PatientOptions _options;
|
||||||
|
|
||||||
public PatientService(
|
public PatientService(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
IExternalIdentifierService identifiers,
|
IExternalIdentifierService identifiers,
|
||||||
IAuditService audit)
|
IAuditService audit,
|
||||||
|
IOptions<PatientOptions> options)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_identifiers = identifiers;
|
_identifiers = identifiers;
|
||||||
_audit = audit;
|
_audit = audit;
|
||||||
|
_options = options.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Patient> RegisterAsync(RegisterPatientRequest req)
|
public async Task<Patient> RegisterAsync(RegisterPatientRequest req)
|
||||||
{
|
{
|
||||||
var mrn = await GenerateMrnAsync();
|
|
||||||
var patient = new Patient
|
var patient = new Patient
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Mrn = mrn,
|
Mrn = await GenerateMrnAsync(),
|
||||||
FirstName = req.FirstName,
|
FirstName = req.FirstName,
|
||||||
LastName = req.LastName,
|
LastName = req.LastName,
|
||||||
DateOfBirth = req.DateOfBirth,
|
DateOfBirth = req.DateOfBirth,
|
||||||
@@ -35,7 +38,18 @@ public class PatientService : IPatientService
|
|||||||
CreatedAt = DateTimeOffset.UtcNow
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
};
|
};
|
||||||
_db.Patients.Add(patient);
|
_db.Patients.Add(patient);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
await _db.SaveChangesAsync();
|
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(
|
await _audit.WriteAsync(
|
||||||
AuditAction.PatientRegistered,
|
AuditAction.PatientRegistered,
|
||||||
@@ -46,6 +60,13 @@ public class PatientService : IPatientService
|
|||||||
return patient;
|
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(
|
public async Task<PagedResult<Patient>> ListAsync(
|
||||||
string? q, int page, int pageSize)
|
string? q, int page, int pageSize)
|
||||||
{
|
{
|
||||||
@@ -196,7 +217,9 @@ public class PatientService : IPatientService
|
|||||||
|
|
||||||
private async Task<string> GenerateMrnAsync()
|
private async Task<string> GenerateMrnAsync()
|
||||||
{
|
{
|
||||||
var count = await _db.Patients.CountAsync();
|
var seq = await _db.Database
|
||||||
return $"MRN-{(count + 1):D6}";
|
.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
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
await _db.SaveChangesAsync(ct);
|
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;
|
return bundle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user