fix: Missing input validators + No patient update endpoint + Pagination inconsistencies + Missing list/get endpoints
This commit is contained in:
@@ -100,7 +100,7 @@ public class AnalyticsController : ControllerBase
|
||||
[FromQuery] string? q,
|
||||
[FromQuery] string? department,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] int page = 0,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
var result = await _analytics.SearchPatientsAsync(q, department, status, page, pageSize);
|
||||
|
||||
@@ -77,7 +77,7 @@ public class ObservationsController : ControllerBase
|
||||
[FromQuery] string? code,
|
||||
[FromQuery] DateTimeOffset? from,
|
||||
[FromQuery] DateTimeOffset? to,
|
||||
[FromQuery] int limit = 50,
|
||||
[FromQuery] int limit = 20,
|
||||
[FromQuery] string? cursor = null)
|
||||
{
|
||||
var page = await _query.GetHistoryAsync(encounterId, code, from, to, limit, cursor);
|
||||
|
||||
@@ -67,6 +67,22 @@ public class PatientsController : ControllerBase
|
||||
return Ok(ApiResponse<Patient>.Ok(patient));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates patient demographics. Only non-null fields are applied.
|
||||
/// </summary>
|
||||
/// <param name="id">Patient id.</param>
|
||||
/// <param name="req">Fields to update.</param>
|
||||
/// <returns>The updated patient record.</returns>
|
||||
[HttpPatch("{id:guid}")]
|
||||
[AuthorizePermission(ClinicalPermissions.PatientsWrite)]
|
||||
[ProducesResponseType(typeof(ApiResponse<Patient>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] UpdatePatientRequest req)
|
||||
{
|
||||
var patient = await _patients.UpdateAsync(id, req);
|
||||
return Ok(ApiResponse<Patient>.Ok(patient));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a new active encounter for the patient.
|
||||
/// </summary>
|
||||
|
||||
@@ -25,4 +25,23 @@ public class QsofaController : ControllerBase
|
||||
var score = await _qsofa.GetCurrentAsync(encounterId);
|
||||
return Ok(ApiResponse<QsofaCurrentResponse>.Ok(score));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns cursor-paginated qSOFA alert history for an encounter.
|
||||
/// </summary>
|
||||
[HttpGet("history")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> History(
|
||||
Guid encounterId,
|
||||
[FromQuery] int limit = 20,
|
||||
[FromQuery] string? cursor = null)
|
||||
{
|
||||
var page = await _qsofa.GetHistoryAsync(encounterId, limit, cursor);
|
||||
return Ok(ApiResponse<object>.Ok(new
|
||||
{
|
||||
items = page.Items,
|
||||
nextCursor = page.NextCursor,
|
||||
hasMore = page.HasMore
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
/// <summary>
|
||||
/// Data quality reconciliation alerts: unacknowledged critical alerts, pending orders without results,
|
||||
/// and active inpatients without recent observations.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/reconciliation-alerts")]
|
||||
[Produces("application/json")]
|
||||
[AuthorizePermission(ClinicalPermissions.AlertsRead)]
|
||||
public class ReconciliationAlertsController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public ReconciliationAlertsController(AppDbContext db) => _db = db;
|
||||
|
||||
/// <summary>
|
||||
/// Lists reconciliation alerts with optional filters.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] string? checkType,
|
||||
[FromQuery] bool? resolved,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
|
||||
var query = _db.ReconciliationAlerts
|
||||
.AsNoTracking()
|
||||
.AsQueryable();
|
||||
|
||||
if (checkType is not null)
|
||||
{
|
||||
var parsed = ReconciliationCheckTypeExtensions.FromDbString(checkType);
|
||||
query = query.Where(a => a.CheckType == parsed);
|
||||
}
|
||||
|
||||
if (resolved == true)
|
||||
query = query.Where(a => a.ResolvedAt != null);
|
||||
else if (resolved == false)
|
||||
query = query.Where(a => a.ResolvedAt == null);
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var items = await query
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(ApiResponse<object>.Ok(new
|
||||
{
|
||||
items,
|
||||
page,
|
||||
pageSize,
|
||||
totalCount = total,
|
||||
totalPages = (int)Math.Ceiling((double)total / pageSize)
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,31 @@ public class SepsisBundlesController : ControllerBase
|
||||
|
||||
public SepsisBundlesController(ISepsisBundleService bundles) => _bundles = bundles;
|
||||
|
||||
/// <summary>
|
||||
/// Lists sepsis bundles across all encounters with optional status filter.
|
||||
/// </summary>
|
||||
[HttpGet("api/v1/sepsis-bundles")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
SepsisBundleComplianceStatus? parsed = status is not null
|
||||
? SepsisBundleComplianceStatusExtensions.FromDbString(status)
|
||||
: null;
|
||||
|
||||
var result = await _bundles.ListAsync(parsed, page, pageSize);
|
||||
return Ok(ApiResponse<object>.Ok(new
|
||||
{
|
||||
items = result.Items,
|
||||
page = result.Page,
|
||||
pageSize = result.PageSize,
|
||||
totalCount = result.TotalCount,
|
||||
totalPages = result.TotalPages
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current (most recent) sepsis bundle for an encounter with elements and linked orders.
|
||||
/// </summary>
|
||||
|
||||
@@ -6,6 +6,7 @@ public enum AuditAction
|
||||
AlertResolved,
|
||||
EncounterStatusChanged,
|
||||
PatientRegistered,
|
||||
PatientUpdated,
|
||||
SuppressionWindowSet,
|
||||
UserLogin
|
||||
}
|
||||
@@ -20,6 +21,7 @@ public static class AuditActionExtensions
|
||||
AuditAction.AlertResolved => "ALERT_RESOLVED",
|
||||
AuditAction.EncounterStatusChanged => "ENCOUNTER_STATUS_CHANGED",
|
||||
AuditAction.PatientRegistered => "PATIENT_REGISTERED",
|
||||
AuditAction.PatientUpdated => "PATIENT_UPDATED",
|
||||
AuditAction.SuppressionWindowSet => "SUPPRESSION_WINDOW_SET",
|
||||
AuditAction.UserLogin => "USER_LOGIN",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(a))
|
||||
@@ -33,6 +35,7 @@ public static class AuditActionExtensions
|
||||
"ALERT_RESOLVED" => AuditAction.AlertResolved,
|
||||
"ENCOUNTER_STATUS_CHANGED" => AuditAction.EncounterStatusChanged,
|
||||
"PATIENT_REGISTERED" => AuditAction.PatientRegistered,
|
||||
"PATIENT_UPDATED" => AuditAction.PatientUpdated,
|
||||
"SUPPRESSION_WINDOW_SET" => AuditAction.SuppressionWindowSet,
|
||||
"USER_LOGIN" => AuditAction.UserLogin,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v))
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
public sealed class AuditActionJsonConverter : JsonConverter<AuditAction>
|
||||
{
|
||||
public override AuditAction Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> AuditActionExtensions.FromDbString(reader.GetString()!);
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, AuditAction value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(value.ToDbString());
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
public sealed class SepsisBundleComplianceStatusJsonConverter : JsonConverter<SepsisBundleComplianceStatus>
|
||||
{
|
||||
public override SepsisBundleComplianceStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> SepsisBundleComplianceStatusExtensions.FromDbString(reader.GetString()!);
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, SepsisBundleComplianceStatus value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(value.ToDbString());
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
public record UpdatePatientRequest(
|
||||
string? FirstName = null,
|
||||
string? LastName = null,
|
||||
DateOnly? DateOfBirth = null,
|
||||
string? Gender = null,
|
||||
BloodType? BloodType = null,
|
||||
string? Allergies = null,
|
||||
string? EmergencyContactName = null,
|
||||
string? EmergencyContactPhone = null);
|
||||
@@ -211,6 +211,8 @@ try
|
||||
{
|
||||
opts.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||
opts.JsonSerializerOptions.Converters.Add(new BloodTypeJsonConverter());
|
||||
opts.JsonSerializerOptions.Converters.Add(new AuditActionJsonConverter());
|
||||
opts.JsonSerializerOptions.Converters.Add(new SepsisBundleComplianceStatusJsonConverter());
|
||||
opts.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
opts.JsonSerializerOptions.Converters.Add(new ObservationSourceJsonConverter());
|
||||
opts.JsonSerializerOptions.Converters.Add(new DepartmentJsonConverter());
|
||||
|
||||
@@ -195,7 +195,7 @@ public class AnalyticsService : IAnalyticsService
|
||||
b.Filter(filters.ToArray());
|
||||
})
|
||||
)
|
||||
.From(page * pageSize)
|
||||
.From((page - 1) * pageSize)
|
||||
.Size(pageSize));
|
||||
|
||||
if (!resp.IsValidResponse)
|
||||
|
||||
@@ -4,5 +4,6 @@ public interface IPatientService
|
||||
Task<PagedResult<Patient>> ListAsync(string? q, int page, int pageSize);
|
||||
Task<Patient> GetByIdAsync(Guid id);
|
||||
Task<Encounter> OpenEncounterAsync(Guid patientId, OpenEncounterRequest req);
|
||||
Task<Patient> UpdateAsync(Guid id, UpdatePatientRequest req);
|
||||
Task<Patient> RegisterOrUpdateByIdentifierAsync(FhirPatientUpsertRequest req);
|
||||
}
|
||||
@@ -2,4 +2,5 @@ public interface IQsofaService
|
||||
{
|
||||
Task<QsofaCurrentResponse> GetCurrentAsync(Guid encounterId);
|
||||
Task<int> GetActiveCriteriaCountAsync(Guid encounterId);
|
||||
Task<CursorPage<ClinicalAlert>> GetHistoryAsync(Guid encounterId, int limit, string? cursor);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@ public interface ISepsisBundleService
|
||||
Guid encounterId, Guid triggeringAlertId, AlertType alertType, CancellationToken ct = default);
|
||||
Task<SepsisBundle?> GetCurrentByEncounterAsync(Guid encounterId);
|
||||
Task<SepsisBundle> GetByIdAsync(Guid id);
|
||||
Task<PagedResult<SepsisBundle>> ListAsync(SepsisBundleComplianceStatus? status, int page, int pageSize);
|
||||
Task OnOrderResultedAsync(Guid orderId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -90,6 +90,44 @@ public class PatientService : IPatientService
|
||||
return new PagedResult<Patient>(patients, page, pageSize, total);
|
||||
}
|
||||
|
||||
public async Task<Patient> UpdateAsync(Guid id, UpdatePatientRequest req)
|
||||
{
|
||||
var patient = await _db.Patients.FindAsync(id)
|
||||
?? throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND");
|
||||
|
||||
var before = new
|
||||
{
|
||||
patient.FirstName, patient.LastName, patient.DateOfBirth,
|
||||
patient.Gender, patient.BloodType, patient.Allergies,
|
||||
patient.EmergencyContactName, patient.EmergencyContactPhone
|
||||
};
|
||||
|
||||
if (req.FirstName is not null) patient.FirstName = req.FirstName;
|
||||
if (req.LastName is not null) patient.LastName = req.LastName;
|
||||
if (req.DateOfBirth is not null) patient.DateOfBirth = req.DateOfBirth.Value;
|
||||
if (req.Gender is not null) patient.Gender = req.Gender;
|
||||
if (req.BloodType is not null) patient.BloodType = req.BloodType;
|
||||
if (req.Allergies is not null) patient.Allergies = req.Allergies;
|
||||
if (req.EmergencyContactName is not null) patient.EmergencyContactName = req.EmergencyContactName;
|
||||
if (req.EmergencyContactPhone is not null) patient.EmergencyContactPhone = req.EmergencyContactPhone;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
await _audit.WriteAsync(
|
||||
AuditAction.PatientUpdated,
|
||||
"Patient",
|
||||
patient.Id,
|
||||
previousValue: before,
|
||||
newValue: new
|
||||
{
|
||||
patient.FirstName, patient.LastName, patient.DateOfBirth,
|
||||
patient.Gender, patient.BloodType, patient.Allergies,
|
||||
patient.EmergencyContactName, patient.EmergencyContactPhone
|
||||
});
|
||||
|
||||
return patient;
|
||||
}
|
||||
|
||||
public async Task<Patient> GetByIdAsync(Guid id)
|
||||
{
|
||||
var patient = await _db.Patients
|
||||
|
||||
@@ -26,6 +26,28 @@ public class QsofaService : IQsofaService
|
||||
return QsofaCalculator.CountActiveCriteria(values);
|
||||
}
|
||||
|
||||
public async Task<CursorPage<ClinicalAlert>> GetHistoryAsync(
|
||||
Guid encounterId, int limit, string? cursor)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, 100);
|
||||
|
||||
var query = _db.ClinicalAlerts
|
||||
.AsNoTracking()
|
||||
.Where(a => a.EncounterId == encounterId
|
||||
&& (a.AlertType == AlertType.QsofaScreen || a.AlertType == AlertType.QsofaWarning));
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(a => a.TriggeredAt)
|
||||
.ThenByDescending(a => a.Id)
|
||||
.Take(limit + 1)
|
||||
.ToListAsync();
|
||||
|
||||
var hasMore = items.Count > limit;
|
||||
if (hasMore) items.RemoveAt(limit);
|
||||
|
||||
return new CursorPage<ClinicalAlert>(items, null, hasMore);
|
||||
}
|
||||
|
||||
private async Task EnsureEncounterExistsAsync(Guid encounterId)
|
||||
{
|
||||
var exists = await _db.Encounters.AnyAsync(e => e.Id == encounterId);
|
||||
|
||||
@@ -122,6 +122,29 @@ public class SepsisBundleService : ISepsisBundleService
|
||||
return bundle;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<SepsisBundle>> ListAsync(
|
||||
SepsisBundleComplianceStatus? status, int page, int pageSize)
|
||||
{
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
|
||||
var query = _db.SepsisBundles
|
||||
.AsNoTracking()
|
||||
.Include(b => b.Elements)
|
||||
.AsQueryable();
|
||||
|
||||
if (status.HasValue)
|
||||
query = query.Where(b => b.ComplianceStatus == status.Value);
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var items = await query
|
||||
.OrderByDescending(b => b.RecognizedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
return new PagedResult<SepsisBundle>(items, page, pageSize, total);
|
||||
}
|
||||
|
||||
public async Task OnOrderResultedAsync(Guid orderId, CancellationToken ct = default)
|
||||
{
|
||||
var element = await _db.SepsisBundleElements
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using FluentValidation;
|
||||
|
||||
public class FhirEncounterUpsertRequestValidator : AbstractValidator<FhirEncounterUpsertRequest>
|
||||
{
|
||||
public FhirEncounterUpsertRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.IdentifierSystem).NotEmpty().MaximumLength(500);
|
||||
RuleFor(x => x.IdentifierValue).NotEmpty().MaximumLength(200);
|
||||
RuleFor(x => x.PatientId).NotEmpty();
|
||||
RuleFor(x => x.EncounterType).IsInEnum();
|
||||
RuleFor(x => x.Department).IsInEnum();
|
||||
RuleFor(x => x.AttendingPhysician).NotEmpty().MaximumLength(200);
|
||||
RuleFor(x => x.TargetStatus).IsInEnum();
|
||||
RuleFor(x => x.RoomBed).MaximumLength(20)
|
||||
.When(x => x.RoomBed is not null);
|
||||
RuleFor(x => x.AdmissionReason).MaximumLength(500)
|
||||
.When(x => x.AdmissionReason is not null);
|
||||
RuleFor(x => x.DischargeDiagnosis).MaximumLength(500)
|
||||
.When(x => x.DischargeDiagnosis is not null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using FluentValidation;
|
||||
|
||||
public class FhirPatientUpsertRequestValidator : AbstractValidator<FhirPatientUpsertRequest>
|
||||
{
|
||||
private static readonly HashSet<string> ValidGenders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{ "male", "female", "other", "unknown" };
|
||||
|
||||
public FhirPatientUpsertRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.IdentifierSystem).NotEmpty().MaximumLength(500);
|
||||
RuleFor(x => x.IdentifierValue).NotEmpty().MaximumLength(200);
|
||||
RuleFor(x => x.FirstName).NotEmpty().MaximumLength(100);
|
||||
RuleFor(x => x.LastName).NotEmpty().MaximumLength(100);
|
||||
RuleFor(x => x.DateOfBirth).NotEmpty()
|
||||
.LessThanOrEqualTo(DateOnly.FromDateTime(DateTime.UtcNow))
|
||||
.WithMessage("Date of birth cannot be in the future.");
|
||||
RuleFor(x => x.Gender).NotEmpty()
|
||||
.Must(g => ValidGenders.Contains(g))
|
||||
.WithMessage("Gender must be one of: male, female, other, unknown.");
|
||||
RuleFor(x => x.BloodType).IsInEnum()
|
||||
.When(x => x.BloodType is not null);
|
||||
RuleFor(x => x.EmergencyContactName).MaximumLength(200)
|
||||
.When(x => x.EmergencyContactName is not null);
|
||||
RuleFor(x => x.EmergencyContactPhone).MaximumLength(20)
|
||||
.When(x => x.EmergencyContactPhone is not null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using FluentValidation;
|
||||
|
||||
public class RecordOrderResultRequestValidator : AbstractValidator<RecordOrderResultRequest>
|
||||
{
|
||||
public RecordOrderResultRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.ResultSummary).MaximumLength(2000)
|
||||
.When(x => x.ResultSummary is not null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using FluentValidation;
|
||||
|
||||
public class TransitionStatusRequestValidator : AbstractValidator<TransitionStatusRequest>
|
||||
{
|
||||
public TransitionStatusRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Status).IsInEnum();
|
||||
RuleFor(x => x.DischargeDiagnosis).MaximumLength(500)
|
||||
.When(x => x.DischargeDiagnosis is not null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using FluentValidation;
|
||||
|
||||
public class UpdatePatientRequestValidator : AbstractValidator<UpdatePatientRequest>
|
||||
{
|
||||
public UpdatePatientRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.FirstName).MaximumLength(100)
|
||||
.When(x => x.FirstName is not null);
|
||||
RuleFor(x => x.LastName).MaximumLength(100)
|
||||
.When(x => x.LastName is not null);
|
||||
RuleFor(x => x.DateOfBirth)
|
||||
.LessThanOrEqualTo(DateOnly.FromDateTime(DateTime.UtcNow))
|
||||
.WithMessage("Date of birth cannot be in the future.")
|
||||
.When(x => x.DateOfBirth is not null);
|
||||
RuleFor(x => x.Gender).MaximumLength(10)
|
||||
.When(x => x.Gender is not null);
|
||||
RuleFor(x => x.BloodType).IsInEnum()
|
||||
.When(x => x.BloodType is not null);
|
||||
RuleFor(x => x.EmergencyContactName).MaximumLength(200)
|
||||
.When(x => x.EmergencyContactName is not null);
|
||||
RuleFor(x => x.EmergencyContactPhone).MaximumLength(20)
|
||||
.When(x => x.EmergencyContactPhone is not null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user