feature: RBAC + Clinical Audit Logging
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
||||
@@ -7,6 +8,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
[ApiController]
|
||||
[Route("api/v1/alert-thresholds")]
|
||||
[Produces("application/json")]
|
||||
[Authorize]
|
||||
public class AlertThresholdsController : ControllerBase
|
||||
{
|
||||
private readonly IAlertThresholdService _thresholds;
|
||||
@@ -19,6 +21,7 @@ public class AlertThresholdsController : ControllerBase
|
||||
/// <param name="req">Threshold bounds and display metadata.</param>
|
||||
/// <returns>The created threshold.</returns>
|
||||
[HttpPost]
|
||||
[AuthorizePermission(ClinicalPermissions.ThresholdsWrite)]
|
||||
[ProducesResponseType(typeof(ApiResponse<AlertThreshold>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Create([FromBody] AlertThresholdRequest req)
|
||||
@@ -32,6 +35,7 @@ public class AlertThresholdsController : ControllerBase
|
||||
/// </summary>
|
||||
/// <returns>All configured thresholds.</returns>
|
||||
[HttpGet]
|
||||
[AuthorizePermission(ClinicalPermissions.ThresholdsRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<List<AlertThreshold>>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
@@ -45,6 +49,7 @@ public class AlertThresholdsController : ControllerBase
|
||||
/// <param name="id">Threshold id.</param>
|
||||
/// <returns>The threshold record.</returns>
|
||||
[HttpGet("{id:guid}")]
|
||||
[AuthorizePermission(ClinicalPermissions.ThresholdsRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<AlertThreshold>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
@@ -60,6 +65,7 @@ public class AlertThresholdsController : ControllerBase
|
||||
/// <param name="req">Updated threshold bounds and display metadata.</param>
|
||||
/// <returns>The updated threshold.</returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[AuthorizePermission(ClinicalPermissions.ThresholdsWrite)]
|
||||
[ProducesResponseType(typeof(ApiResponse<AlertThreshold>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] AlertThresholdRequest req)
|
||||
@@ -67,4 +73,4 @@ public class AlertThresholdsController : ControllerBase
|
||||
var threshold = await _thresholds.UpdateAsync(id, req);
|
||||
return Ok(ApiResponse<AlertThreshold>.Ok(threshold));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
||||
@@ -6,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Produces("application/json")]
|
||||
[Authorize]
|
||||
public class AlertsController : ControllerBase
|
||||
{
|
||||
private readonly IAlertService _alerts;
|
||||
@@ -21,6 +23,7 @@ public class AlertsController : ControllerBase
|
||||
/// <param name="pageSize">Results per page.</param>
|
||||
/// <returns>A paginated list of alerts for the encounter.</returns>
|
||||
[HttpGet("api/v1/encounters/{encounterId:guid}/alerts")]
|
||||
[AuthorizePermission(ClinicalPermissions.AlertsRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> ListByEncounter(
|
||||
@@ -63,6 +66,7 @@ public class AlertsController : ControllerBase
|
||||
/// <param name="pageSize">Results per page.</param>
|
||||
/// <returns>A paginated list of alerts.</returns>
|
||||
[HttpGet("api/v1/alerts")]
|
||||
[AuthorizePermission(ClinicalPermissions.AlertsRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> ListGlobal(
|
||||
@@ -128,6 +132,7 @@ public class AlertsController : ControllerBase
|
||||
/// <param name="id">Alert id.</param>
|
||||
/// <returns>The alert record.</returns>
|
||||
[HttpGet("api/v1/alerts/{id:guid}")]
|
||||
[AuthorizePermission(ClinicalPermissions.AlertsRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<ClinicalAlert>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
@@ -140,9 +145,10 @@ public class AlertsController : ControllerBase
|
||||
/// Acknowledges an open or escalated alert and emits an outbox event for downstream consumers.
|
||||
/// </summary>
|
||||
/// <param name="id">Alert id.</param>
|
||||
/// <param name="req">Clinician id and optional note.</param>
|
||||
/// <param name="req">Optional acknowledgment note.</param>
|
||||
/// <returns>The updated alert.</returns>
|
||||
[HttpPost("api/v1/alerts/{id:guid}/acknowledge")]
|
||||
[AuthorizePermission(ClinicalPermissions.AlertsAcknowledge)]
|
||||
[ProducesResponseType(typeof(ApiResponse<ClinicalAlert>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
@@ -158,6 +164,7 @@ public class AlertsController : ControllerBase
|
||||
/// <param name="id">Alert id.</param>
|
||||
/// <returns>The updated alert.</returns>
|
||||
[HttpPost("api/v1/alerts/{id:guid}/resolve")]
|
||||
[AuthorizePermission(ClinicalPermissions.AlertsResolve)]
|
||||
[ProducesResponseType(typeof(ApiResponse<ClinicalAlert>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
@@ -166,4 +173,4 @@ public class AlertsController : ControllerBase
|
||||
var alert = await _alerts.ResolveAsync(id);
|
||||
return Ok(ApiResponse<ClinicalAlert>.Ok(alert));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
||||
@@ -7,6 +8,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
[ApiController]
|
||||
[Route("api/v1/analytics")]
|
||||
[Produces("application/json")]
|
||||
[AuthorizePermission(ClinicalPermissions.AnalyticsRead)]
|
||||
public class AnalyticsController : ControllerBase
|
||||
{
|
||||
private readonly IAnalyticsService _analytics;
|
||||
@@ -104,4 +106,4 @@ public class AnalyticsController : ControllerBase
|
||||
var result = await _analytics.SearchPatientsAsync(q, department, status, page, pageSize);
|
||||
return Ok(ApiResponse<object>.Ok(result));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
/// <summary>
|
||||
/// Clinical audit log query (Admin only).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/audit-logs")]
|
||||
[Produces("application/json")]
|
||||
[AuthorizePermission(ClinicalPermissions.AuditRead)]
|
||||
public class AuditLogsController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public AuditLogsController(AppDbContext db) => _db = db;
|
||||
|
||||
/// <summary>Query clinical audit logs with optional filters.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] string? entityType,
|
||||
[FromQuery] Guid? entityId,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] string? action,
|
||||
[FromQuery] DateTimeOffset? from,
|
||||
[FromQuery] DateTimeOffset? to,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50)
|
||||
{
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
var query = _db.ClinicalAuditLogs.AsNoTracking().AsQueryable();
|
||||
|
||||
if (!string.IsNullOrEmpty(entityType))
|
||||
query = query.Where(a => a.EntityType == entityType);
|
||||
if (entityId.HasValue)
|
||||
query = query.Where(a => a.EntityId == entityId);
|
||||
if (userId.HasValue)
|
||||
query = query.Where(a => a.UserId == userId);
|
||||
if (!string.IsNullOrEmpty(action))
|
||||
query = query.Where(a => a.Action.ToDbString() == action);
|
||||
if (from.HasValue)
|
||||
query = query.Where(a => a.CreatedAt >= from);
|
||||
if (to.HasValue)
|
||||
query = query.Where(a => a.CreatedAt <= to);
|
||||
|
||||
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(total / (double)pageSize)
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
/// <summary>
|
||||
/// JWT authentication: login and current-user profile.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/auth")]
|
||||
[Produces("application/json")]
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IAuthService _auth;
|
||||
|
||||
public AuthController(IAuthService auth) => _auth = auth;
|
||||
|
||||
/// <summary>Authenticate and receive a JWT bearer token.</summary>
|
||||
[HttpPost("login")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(ApiResponse<LoginResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest req)
|
||||
{
|
||||
var result = await _auth.LoginAsync(req);
|
||||
return Ok(ApiResponse<LoginResponse>.Ok(result));
|
||||
}
|
||||
|
||||
/// <summary>Returns the authenticated user's profile.</summary>
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public IActionResult Me([FromServices] ICurrentUserService currentUser)
|
||||
{
|
||||
return Ok(ApiResponse<object>.Ok(new
|
||||
{
|
||||
userId = currentUser.UserId,
|
||||
username = currentUser.Username,
|
||||
displayName = currentUser.DisplayName,
|
||||
role = currentUser.Role?.ToDbString()
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
||||
@@ -7,6 +8,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
[ApiController]
|
||||
[Route("api/v1/encounters")]
|
||||
[Produces("application/json")]
|
||||
[Authorize]
|
||||
public class EncountersController : ControllerBase
|
||||
{
|
||||
private readonly IEncounterService _encounters;
|
||||
@@ -21,6 +23,7 @@ public class EncountersController : ControllerBase
|
||||
/// <param name="page">Page number (1-based).</param>
|
||||
/// <param name="pageSize">Results per page.</param>
|
||||
[HttpGet]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> List(
|
||||
@@ -72,6 +75,7 @@ public class EncountersController : ControllerBase
|
||||
/// <param name="id">Encounter id.</param>
|
||||
/// <returns>The encounter with related data.</returns>
|
||||
[HttpGet("{id:guid}")]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<Encounter>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
@@ -87,6 +91,7 @@ public class EncountersController : ControllerBase
|
||||
/// <param name="req">Target status.</param>
|
||||
/// <returns>The encounter id and new status.</returns>
|
||||
[HttpPatch("{id:guid}/status")]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersWrite)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
@@ -102,6 +107,7 @@ public class EncountersController : ControllerBase
|
||||
/// <param name="id">Encounter id.</param>
|
||||
/// <returns>Ordered timeline events.</returns>
|
||||
[HttpGet("{id:guid}/timeline")]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Timeline(Guid id)
|
||||
@@ -109,4 +115,4 @@ public class EncountersController : ControllerBase
|
||||
var timeline = await _encounters.GetTimelineAsync(id);
|
||||
return Ok(ApiResponse<object>.Ok(timeline));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
using Hl7.Fhir.Model;
|
||||
using Hl7.Fhir.Serialization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
/// <summary>
|
||||
/// FHIR R4 inbound facade: single-resource create and transaction Bundle processing.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("fhir/R4")]
|
||||
[AuthorizePermission(ClinicalPermissions.FhirIngest)]
|
||||
[ServiceFilter(typeof(FhirExceptionFilter))]
|
||||
public class FhirIngestController : ControllerBase
|
||||
{
|
||||
@@ -52,9 +57,18 @@ public class FhirIngestController : ControllerBase
|
||||
_metrics = metrics;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates a Patient from a FHIR R4 Patient resource (idempotent by identifier).
|
||||
/// </summary>
|
||||
/// <returns>The persisted Patient resource with Location header.</returns>
|
||||
[HttpPost("Patient")]
|
||||
[Consumes("application/fhir+json")]
|
||||
[Produces("application/fhir+json")]
|
||||
[ProducesResponseType(typeof(Hl7.Fhir.Model.Patient), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> CreatePatient()
|
||||
{
|
||||
var fhir = await ParseBodyAsync<Hl7.Fhir.Model.Patient>();
|
||||
@@ -64,12 +78,22 @@ public class FhirIngestController : ControllerBase
|
||||
ExternalResourceType.Patient, patient.Id, _options.PatientIdentifierSystems);
|
||||
var response = _patientMapper.ToFhirResponse(patient, hospitalId);
|
||||
_metrics.FhirIngestTotal.WithLabels("Patient", "success").Inc();
|
||||
return Created($"{Request.Path}/{patient.Id}", Serialize(response));
|
||||
Response.Headers.Location = $"{Request.Path}/{patient.Id}";
|
||||
return Serialize(response, StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates an Encounter from a FHIR R4 Encounter resource (idempotent by identifier).
|
||||
/// </summary>
|
||||
/// <returns>The persisted Encounter resource with Location header.</returns>
|
||||
[HttpPost("Encounter")]
|
||||
[Consumes("application/fhir+json")]
|
||||
[Produces("application/fhir+json")]
|
||||
[ProducesResponseType(typeof(Hl7.Fhir.Model.Encounter), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> CreateEncounter()
|
||||
{
|
||||
var fhir = await ParseBodyAsync<Hl7.Fhir.Model.Encounter>();
|
||||
@@ -79,12 +103,22 @@ public class FhirIngestController : ControllerBase
|
||||
ExternalResourceType.Encounter, encounter.Id, _options.EncounterIdentifierSystems);
|
||||
var response = _encounterMapper.ToFhirResponse(encounter, hospitalId);
|
||||
_metrics.FhirIngestTotal.WithLabels("Encounter", "success").Inc();
|
||||
return Created($"{Request.Path}/{encounter.Id}", Serialize(response));
|
||||
Response.Headers.Location = $"{Request.Path}/{encounter.Id}";
|
||||
return Serialize(response, StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ingests one or more observations from a FHIR R4 Observation resource.
|
||||
/// </summary>
|
||||
/// <returns>The last persisted Observation resource with Location header.</returns>
|
||||
[HttpPost("Observation")]
|
||||
[Consumes("application/fhir+json")]
|
||||
[Produces("application/fhir+json")]
|
||||
[ProducesResponseType(typeof(Hl7.Fhir.Model.Observation), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> CreateObservation()
|
||||
{
|
||||
var fhir = await ParseBodyAsync<Hl7.Fhir.Model.Observation>();
|
||||
@@ -104,12 +138,22 @@ public class FhirIngestController : ControllerBase
|
||||
}
|
||||
|
||||
_metrics.FhirIngestTotal.WithLabels("Observation", "success").Inc();
|
||||
return Created(Request.Path.Value!, Serialize(lastResponse!));
|
||||
Response.Headers.Location = Request.Path.Value!;
|
||||
return Serialize(lastResponse!, StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a medication administration from a FHIR R4 MedicationAdministration resource.
|
||||
/// </summary>
|
||||
/// <returns>The persisted MedicationAdministration resource with Location header.</returns>
|
||||
[HttpPost("MedicationAdministration")]
|
||||
[Consumes("application/fhir+json")]
|
||||
[Produces("application/fhir+json")]
|
||||
[ProducesResponseType(typeof(Hl7.Fhir.Model.MedicationAdministration), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> CreateMedicationAdministration()
|
||||
{
|
||||
var fhir = await ParseBodyAsync<Hl7.Fhir.Model.MedicationAdministration>();
|
||||
@@ -117,13 +161,22 @@ public class FhirIngestController : ControllerBase
|
||||
var med = await _medications.CreateAsync(encounterId, req);
|
||||
var response = new Hl7.Fhir.Model.MedicationAdministration { Id = med.Id.ToString() };
|
||||
_metrics.FhirIngestTotal.WithLabels("MedicationAdministration", "success").Inc();
|
||||
return Created($"{Request.Path}/{med.Id}", Serialize(response));
|
||||
Response.Headers.Location = $"{Request.Path}/{med.Id}";
|
||||
return Serialize(response, StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
/// <summary>Accepts Bundle.type=transaction (ADT admit) or batch.</summary>
|
||||
/// <summary>
|
||||
/// Processes a FHIR R4 transaction Bundle (e.g. ADT admit with Patient + Encounter).
|
||||
/// </summary>
|
||||
/// <returns>A transaction-response Bundle with per-entry outcomes.</returns>
|
||||
[HttpPost]
|
||||
[Consumes("application/fhir+json")]
|
||||
[Produces("application/fhir+json")]
|
||||
[ProducesResponseType(typeof(Bundle), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> ProcessBundle()
|
||||
{
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
@@ -135,7 +188,7 @@ public class FhirIngestController : ControllerBase
|
||||
|
||||
var responseBundle = await _bundleProcessor.ProcessTransactionAsync(bundle);
|
||||
_metrics.FhirIngestTotal.WithLabels("Bundle", "success").Inc();
|
||||
return Ok(Serialize(responseBundle));
|
||||
return Serialize(responseBundle);
|
||||
}
|
||||
|
||||
private async Task<T> ParseBodyAsync<T>() where T : Resource
|
||||
@@ -145,6 +198,11 @@ public class FhirIngestController : ControllerBase
|
||||
return Parser.Parse<T>(json);
|
||||
}
|
||||
|
||||
private ContentResult Serialize(Resource resource) =>
|
||||
Content(Serializer.SerializeToString(resource), "application/fhir+json");
|
||||
}
|
||||
private ContentResult Serialize(Resource resource, int statusCode = StatusCodes.Status200OK) =>
|
||||
new()
|
||||
{
|
||||
Content = Serializer.SerializeToString(resource),
|
||||
ContentType = "application/fhir+json",
|
||||
StatusCode = statusCode
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
using Hl7.Fhir.Model;
|
||||
using Hl7.Fhir.Serialization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using static Hl7.Fhir.Model.CapabilityStatement;
|
||||
|
||||
/// <summary>
|
||||
/// FHIR R4 CapabilityStatement metadata for the inbound facade.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("fhir/R4")]
|
||||
[ServiceFilter(typeof(FhirExceptionFilter))]
|
||||
public class FhirMetadataController : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the FHIR R4 CapabilityStatement describing supported interactions.
|
||||
/// </summary>
|
||||
/// <returns>CapabilityStatement in application/fhir+json.</returns>
|
||||
[HttpGet("metadata")]
|
||||
[AllowAnonymous]
|
||||
[Produces("application/fhir+json")]
|
||||
[ProducesResponseType(typeof(CapabilityStatement), StatusCodes.Status200OK)]
|
||||
public IActionResult Metadata()
|
||||
{
|
||||
var capability = new CapabilityStatement
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
/// <summary>
|
||||
/// Glasgow Coma Scale (GCS) scoring: latest computed score per encounter.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/encounters/{encounterId:guid}/gcs")]
|
||||
[Produces("application/json")]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
public class GcsController : ControllerBase
|
||||
{
|
||||
private readonly IGcsService _gcs;
|
||||
|
||||
public GcsController(IGcsService gcs) => _gcs = gcs;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the latest GCS score for an encounter, or null data when no score has been computed.
|
||||
/// </summary>
|
||||
/// <param name="encounterId">Encounter id.</param>
|
||||
/// <returns>The GCS component scores and total, or null.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<GcsScoreResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Current(Guid encounterId)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
||||
@@ -6,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Produces("application/json")]
|
||||
[Authorize]
|
||||
public class MedicationsController : ControllerBase
|
||||
{
|
||||
private readonly IMedicationService _medications;
|
||||
@@ -19,6 +21,7 @@ public class MedicationsController : ControllerBase
|
||||
/// <param name="req">Medication administration details.</param>
|
||||
/// <returns>The created medication administration record.</returns>
|
||||
[HttpPost("api/v1/encounters/{encounterId:guid}/medications")]
|
||||
[AuthorizePermission(ClinicalPermissions.MedicationsWrite)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MedicationAdministration>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
@@ -37,6 +40,7 @@ public class MedicationsController : ControllerBase
|
||||
/// <param name="pageSize">Results per page.</param>
|
||||
/// <returns>A paginated list of medication administrations.</returns>
|
||||
[HttpGet("api/v1/encounters/{encounterId:guid}/medications")]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> ListByEncounter(
|
||||
Guid encounterId,
|
||||
@@ -61,6 +65,7 @@ public class MedicationsController : ControllerBase
|
||||
/// <param name="id">Medication administration id.</param>
|
||||
/// <returns>The medication administration record.</returns>
|
||||
[HttpGet("api/v1/medications/{id:guid}")]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<MedicationAdministration>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
||||
@@ -7,6 +8,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
[ApiController]
|
||||
[Route("api/v1/encounters/{encounterId:guid}/news2")]
|
||||
[Produces("application/json")]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
public class News2Controller : ControllerBase
|
||||
{
|
||||
private readonly INews2Service _news2;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
||||
@@ -7,6 +8,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
[ApiController]
|
||||
[Route("api/v1/encounters/{encounterId:guid}/observations")]
|
||||
[Produces("application/json")]
|
||||
[Authorize]
|
||||
public class ObservationsController : ControllerBase
|
||||
{
|
||||
private readonly IObservationService _ingest;
|
||||
@@ -25,6 +27,7 @@ public class ObservationsController : ControllerBase
|
||||
/// <param name="req">Batch of observations to record.</param>
|
||||
/// <returns>Per-observation ingest results, including any generated alerts.</returns>
|
||||
[HttpPost]
|
||||
[AuthorizePermission(ClinicalPermissions.ObservationsIngest)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
@@ -67,6 +70,7 @@ public class ObservationsController : ControllerBase
|
||||
/// <param name="cursor">Opaque cursor from a previous page.</param>
|
||||
/// <returns>A page of observations with an optional next cursor.</returns>
|
||||
[HttpGet]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> History(
|
||||
Guid encounterId,
|
||||
@@ -84,4 +88,4 @@ public class ObservationsController : ControllerBase
|
||||
hasMore = page.HasMore
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
||||
@@ -6,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Produces("application/json")]
|
||||
[Authorize]
|
||||
public class OrdersController : ControllerBase
|
||||
{
|
||||
private readonly IOrderService _orders;
|
||||
@@ -16,6 +18,7 @@ public class OrdersController : ControllerBase
|
||||
/// Creates a new clinical order for an encounter.
|
||||
/// </summary>
|
||||
[HttpPost("api/v1/encounters/{encounterId:guid}/orders")]
|
||||
[AuthorizePermission(ClinicalPermissions.OrdersWrite)]
|
||||
[ProducesResponseType(typeof(ApiResponse<Order>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
@@ -29,6 +32,7 @@ public class OrdersController : ControllerBase
|
||||
/// Lists orders for an encounter with optional status filter.
|
||||
/// </summary>
|
||||
[HttpGet("api/v1/encounters/{encounterId:guid}/orders")]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> ListByEncounter(
|
||||
@@ -65,6 +69,7 @@ public class OrdersController : ControllerBase
|
||||
/// Gets a single order by id with its encounter.
|
||||
/// </summary>
|
||||
[HttpGet("api/v1/orders/{id:guid}")]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<Order>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
@@ -77,6 +82,7 @@ public class OrdersController : ControllerBase
|
||||
/// Transitions an order to a new status.
|
||||
/// </summary>
|
||||
[HttpPatch("api/v1/orders/{id:guid}/status")]
|
||||
[AuthorizePermission(ClinicalPermissions.OrdersWrite)]
|
||||
[ProducesResponseType(typeof(ApiResponse<Order>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
@@ -90,6 +96,7 @@ public class OrdersController : ControllerBase
|
||||
/// Records a result for an order, transitioning it to Resulted status.
|
||||
/// </summary>
|
||||
[HttpPatch("api/v1/orders/{id:guid}/result")]
|
||||
[AuthorizePermission(ClinicalPermissions.OrdersWrite)]
|
||||
[ProducesResponseType(typeof(ApiResponse<Order>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
@@ -98,4 +105,4 @@ public class OrdersController : ControllerBase
|
||||
var order = await _orders.RecordResultAsync(id, req);
|
||||
return Ok(ApiResponse<Order>.Ok(order));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
||||
@@ -7,6 +8,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
[ApiController]
|
||||
[Route("api/v1/patients")]
|
||||
[Produces("application/json")]
|
||||
[Authorize]
|
||||
public class PatientsController : ControllerBase
|
||||
{
|
||||
private readonly IPatientService _patients;
|
||||
@@ -19,6 +21,7 @@ public class PatientsController : ControllerBase
|
||||
/// <param name="req">Patient demographics.</param>
|
||||
/// <returns>The created patient record.</returns>
|
||||
[HttpPost]
|
||||
[AuthorizePermission(ClinicalPermissions.PatientsWrite)]
|
||||
[ProducesResponseType(typeof(ApiResponse<Patient>), StatusCodes.Status201Created)]
|
||||
public async Task<IActionResult> Register([FromBody] RegisterPatientRequest req)
|
||||
{
|
||||
@@ -34,6 +37,7 @@ public class PatientsController : ControllerBase
|
||||
/// <param name="pageSize">Results per page.</param>
|
||||
/// <returns>A paginated list of patients.</returns>
|
||||
[HttpGet]
|
||||
[AuthorizePermission(ClinicalPermissions.PatientsRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> List([FromQuery] string? q, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||
{
|
||||
@@ -54,6 +58,7 @@ public class PatientsController : ControllerBase
|
||||
/// <param name="id">Patient id.</param>
|
||||
/// <returns>The patient record.</returns>
|
||||
[HttpGet("{id:guid}")]
|
||||
[AuthorizePermission(ClinicalPermissions.PatientsRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<Patient>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
@@ -69,6 +74,7 @@ public class PatientsController : ControllerBase
|
||||
/// <param name="req">Encounter type, department, and attending physician.</param>
|
||||
/// <returns>The created encounter.</returns>
|
||||
[HttpPost("{id:guid}/encounters")]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersWrite)]
|
||||
[ProducesResponseType(typeof(ApiResponse<Encounter>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
@@ -77,4 +83,4 @@ public class PatientsController : ControllerBase
|
||||
var encounter = await _patients.OpenEncounterAsync(id, req);
|
||||
return StatusCode(201, ApiResponse<Encounter>.Created(encounter));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
/// <summary>
|
||||
@@ -6,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
[ApiController]
|
||||
[Route("api/v1/encounters/{encounterId:guid}/qsofa")]
|
||||
[Produces("application/json")]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
public class QsofaController : ControllerBase
|
||||
{
|
||||
private readonly IQsofaService _qsofa;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
/// <summary>
|
||||
@@ -5,6 +6,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Produces("application/json")]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
public class SepsisBundlesController : ControllerBase
|
||||
{
|
||||
private readonly ISepsisBundleService _bundles;
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
/// <summary>
|
||||
/// SOFA composite scoring: current score and paginated history per encounter.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/encounters/{encounterId:guid}/sofa")]
|
||||
[Produces("application/json")]
|
||||
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
|
||||
public class SofaController : ControllerBase
|
||||
{
|
||||
private readonly ISofaService _sofa;
|
||||
|
||||
public SofaController(ISofaService sofa) => _sofa = sofa;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the latest SOFA score for an encounter, or null data when no score has been computed.
|
||||
/// </summary>
|
||||
/// <param name="encounterId">Encounter id.</param>
|
||||
/// <returns>The SOFA component scores and total, or null.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<SofaScoreResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Current(Guid encounterId)
|
||||
@@ -21,6 +31,13 @@ public class SofaController : ControllerBase
|
||||
return Ok(ApiResponse<SofaScoreResponse>.Ok(MapResponse(score)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns cursor-paginated SOFA score history for an encounter.
|
||||
/// </summary>
|
||||
/// <param name="encounterId">Encounter id.</param>
|
||||
/// <param name="limit">Maximum items per page.</param>
|
||||
/// <param name="cursor">Opaque cursor from a previous page.</param>
|
||||
/// <returns>A page of SOFA scores with an optional next cursor.</returns>
|
||||
[HttpGet("history")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> History(
|
||||
|
||||
Reference in New Issue
Block a user