fix: Missing input validators + No patient update endpoint + Pagination inconsistencies + Missing list/get endpoints

This commit is contained in:
voltsrage
2026-06-21 19:13:13 +08:00
parent 33895122d1
commit a37fad0e57
26 changed files with 932 additions and 22 deletions
@@ -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);
+2
View File
@@ -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);
}
}