feature: RBAC + Clinical Audit Logging

This commit is contained in:
voltsrage
2026-06-21 15:46:55 +08:00
parent a43db52813
commit 5af6ab490e
83 changed files with 4281 additions and 70 deletions
@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Authorization;
public class AuthorizePermissionAttribute : AuthorizeAttribute
{
public AuthorizePermissionAttribute(string permission)
{
Policy = $"perm:{permission}";
}
}
@@ -0,0 +1,19 @@
public static class ClinicalPermissions
{
public const string PatientsRead = "patients:read";
public const string PatientsWrite = "patients:write";
public const string EncountersRead = "encounters:read";
public const string EncountersWrite = "encounters:write";
public const string ObservationsIngest = "observations:ingest";
public const string AlertsRead = "alerts:read";
public const string AlertsAcknowledge = "alerts:acknowledge";
public const string AlertsResolve = "alerts:resolve";
public const string ThresholdsRead = "thresholds:read";
public const string ThresholdsWrite = "thresholds:write";
public const string AnalyticsRead = "analytics:read";
public const string OrdersWrite = "orders:write";
public const string MedicationsWrite = "medications:write";
public const string FhirIngest = "fhir:ingest";
public const string AuditRead = "audit:read";
public const string UsersAdmin = "users:admin";
}
@@ -0,0 +1,63 @@
public static class ClinicalRolePermissionMap
{
private static readonly Dictionary<ClinicalRole, HashSet<string>> _map = new()
{
[ClinicalRole.Nurse] = new(StringComparer.Ordinal)
{
ClinicalPermissions.PatientsRead,
ClinicalPermissions.EncountersRead,
ClinicalPermissions.ObservationsIngest,
ClinicalPermissions.AlertsRead,
ClinicalPermissions.AlertsAcknowledge,
ClinicalPermissions.AlertsResolve,
ClinicalPermissions.ThresholdsRead,
ClinicalPermissions.AnalyticsRead,
ClinicalPermissions.OrdersWrite,
ClinicalPermissions.MedicationsWrite,
},
[ClinicalRole.Physician] = new(StringComparer.Ordinal)
{
ClinicalPermissions.PatientsRead,
ClinicalPermissions.EncountersRead,
ClinicalPermissions.EncountersWrite,
ClinicalPermissions.ObservationsIngest,
ClinicalPermissions.AlertsRead,
ClinicalPermissions.AlertsAcknowledge,
ClinicalPermissions.AlertsResolve,
ClinicalPermissions.ThresholdsRead,
ClinicalPermissions.AnalyticsRead,
ClinicalPermissions.OrdersWrite,
ClinicalPermissions.MedicationsWrite,
},
[ClinicalRole.Admin] = new(StringComparer.Ordinal)
{
ClinicalPermissions.PatientsRead,
ClinicalPermissions.PatientsWrite,
ClinicalPermissions.EncountersRead,
ClinicalPermissions.EncountersWrite,
ClinicalPermissions.ObservationsIngest,
ClinicalPermissions.AlertsRead,
ClinicalPermissions.AlertsAcknowledge,
ClinicalPermissions.AlertsResolve,
ClinicalPermissions.ThresholdsRead,
ClinicalPermissions.ThresholdsWrite,
ClinicalPermissions.AnalyticsRead,
ClinicalPermissions.OrdersWrite,
ClinicalPermissions.MedicationsWrite,
ClinicalPermissions.FhirIngest,
ClinicalPermissions.AuditRead,
ClinicalPermissions.UsersAdmin,
},
[ClinicalRole.Integration] = new(StringComparer.Ordinal)
{
ClinicalPermissions.PatientsWrite,
ClinicalPermissions.EncountersWrite,
ClinicalPermissions.ObservationsIngest,
ClinicalPermissions.MedicationsWrite,
ClinicalPermissions.FhirIngest,
},
};
public static bool HasPermission(ClinicalRole role, string permission) =>
_map.TryGetValue(role, out var perms) && perms.Contains(permission);
}
@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Authorization;
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
PermissionRequirement requirement)
{
var roleClaim = context.User.FindFirst("clinical_role")?.Value;
if (roleClaim is null)
return Task.CompletedTask;
var role = ClinicalRoleExtensions.FromDbString(roleClaim);
if (ClinicalRolePermissionMap.HasPermission(role, requirement.Permission))
context.Succeed(requirement);
return Task.CompletedTask;
}
}
@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
public class PermissionPolicyProvider : IAuthorizationPolicyProvider
{
private readonly DefaultAuthorizationPolicyProvider _fallback;
public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
{
_fallback = new DefaultAuthorizationPolicyProvider(options);
}
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
if (policyName.StartsWith("perm:", StringComparison.Ordinal))
{
var permission = policyName["perm:".Length..];
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.AddRequirements(new PermissionRequirement(permission))
.Build();
return Task.FromResult<AuthorizationPolicy?>(policy);
}
return _fallback.GetPolicyAsync(policyName);
}
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() =>
_fallback.GetDefaultPolicyAsync();
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() =>
_fallback.GetFallbackPolicyAsync();
}
@@ -0,0 +1,7 @@
using Microsoft.AspNetCore.Authorization;
public class PermissionRequirement : IAuthorizationRequirement
{
public string Permission { get; }
public PermissionRequirement(string permission) => Permission = permission;
}
@@ -0,0 +1,9 @@
public class JwtOptions
{
public const string Section = "Jwt";
public string Issuer { get; set; } = "VigilCareClinical";
public string Audience { get; set; } = "VigilCareClinical.Dashboard";
public string SigningKey { get; set; } = null!;
public int ExpirationMinutes { get; set; } = 480;
}
@@ -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(
@@ -19,6 +19,8 @@ public class AppDbContext : DbContext
public DbSet<GcsScore> GcsScores => Set<GcsScore>();
public DbSet<SofaScore> SofaScores => Set<SofaScore>();
public DbSet<ExternalResourceIdentifier> ExternalResourceIdentifiers => Set<ExternalResourceIdentifier>();
public DbSet<ClinicalUser> ClinicalUsers => Set<ClinicalUser>();
public DbSet<ClinicalAuditLog> ClinicalAuditLogs => Set<ClinicalAuditLog>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class ClinicalAuditLogConfiguration : IEntityTypeConfiguration<ClinicalAuditLog>
{
public void Configure(EntityTypeBuilder<ClinicalAuditLog> builder)
{
builder.ToTable("clinical_audit_logs");
builder.HasKey(a => a.Id);
builder.Property(a => a.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(a => a.Action).HasColumnName("action").HasMaxLength(50).IsRequired()
.HasConversion(v => v.ToDbString(), v => AuditActionExtensions.FromDbString(v));
builder.Property(a => a.EntityType).HasColumnName("entity_type").HasMaxLength(100).IsRequired();
builder.Property(a => a.EntityId).HasColumnName("entity_id").IsRequired();
builder.Property(a => a.UserId).HasColumnName("user_id");
builder.Property(a => a.UserDisplayName).HasColumnName("user_display_name").HasMaxLength(200);
builder.Property(a => a.PreviousValueJson).HasColumnName("previous_value_json").HasColumnType("jsonb");
builder.Property(a => a.NewValueJson).HasColumnName("new_value_json").HasColumnType("jsonb");
builder.Property(a => a.Reason).HasColumnName("reason");
builder.Property(a => a.IpAddress).HasColumnName("ip_address").HasMaxLength(45);
builder.Property(a => a.CorrelationId).HasColumnName("correlation_id").HasMaxLength(100);
builder.Property(a => a.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
// Append-only — no UPDATE/DELETE from application code
builder.HasIndex(a => a.EntityType);
builder.HasIndex(a => a.EntityId);
builder.HasIndex(a => a.UserId);
builder.HasIndex(a => a.CreatedAt);
}
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class ClinicalUserConfiguration : IEntityTypeConfiguration<ClinicalUser>
{
public void Configure(EntityTypeBuilder<ClinicalUser> builder)
{
builder.ToTable("clinical_users");
builder.HasKey(u => u.Id);
builder.Property(u => u.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(u => u.Username).HasColumnName("username").HasMaxLength(100).IsRequired();
builder.Property(u => u.PasswordHash).HasColumnName("password_hash").HasMaxLength(500).IsRequired();
builder.Property(u => u.DisplayName).HasColumnName("display_name").HasMaxLength(200).IsRequired();
builder.Property(u => u.Role).HasColumnName("role").HasMaxLength(20).IsRequired()
.HasConversion(v => v.ToDbString(), v => ClinicalRoleExtensions.FromDbString(v));
builder.Property(u => u.IsActive).HasColumnName("is_active").HasDefaultValue(true);
builder.Property(u => u.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.Property(u => u.LastLoginAt).HasColumnName("last_login_at");
builder.HasIndex(u => u.Username).IsUnique();
}
}
@@ -97,6 +97,7 @@ public static class DataSeeder
public static async Task SeedThresholdsOnlyAsync(AppDbContext db, IConnectionMultiplexer redis)
{
await db.Database.ExecuteSqlRawAsync("DELETE FROM alert_thresholds");
var thresholds = BuildDefaultThresholds();
db.AlertThresholds.AddRange(thresholds);
await db.SaveChangesAsync();
@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore;
public static class UserSeeder
{
public static async Task SeedAsync(AppDbContext db)
{
if (await db.ClinicalUsers.AnyAsync())
return;
db.ClinicalUsers.AddRange(
new ClinicalUser
{
Id = Guid.Parse("11111111-1111-1111-1111-111111111111"),
Username = "nurse.demo",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoNurse1!"),
DisplayName = "Demo Nurse",
Role = ClinicalRole.Nurse,
CreatedAt = DateTimeOffset.UtcNow
},
new ClinicalUser
{
Id = Guid.Parse("22222222-2222-2222-2222-222222222222"),
Username = "physician.demo",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoPhysician1!"),
DisplayName = "Dr. Demo Physician",
Role = ClinicalRole.Physician,
CreatedAt = DateTimeOffset.UtcNow
},
new ClinicalUser
{
Id = Guid.Parse("33333333-3333-3333-3333-333333333333"),
Username = "admin.demo",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoAdmin1!"),
DisplayName = "Demo Admin",
Role = ClinicalRole.Admin,
CreatedAt = DateTimeOffset.UtcNow
},
new ClinicalUser
{
Id = Guid.Parse("44444444-4444-4444-4444-444444444444"),
Username = "integration.mirth",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("MirthIntegration1!"),
DisplayName = "Mirth Connect",
Role = ClinicalRole.Integration,
CreatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync();
}
}
@@ -0,0 +1,15 @@
public class ClinicalAuditLog
{
public Guid Id { get; set; }
public AuditAction Action { get; set; }
public string EntityType { get; set; } = null!;
public Guid EntityId { get; set; }
public Guid? UserId { get; set; }
public string? UserDisplayName { get; set; }
public string? PreviousValueJson { get; set; }
public string? NewValueJson { get; set; }
public string? Reason { get; set; }
public string? IpAddress { get; set; }
public string? CorrelationId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
@@ -0,0 +1,11 @@
public class ClinicalUser
{
public Guid Id { get; set; }
public string Username { get; set; } = null!;
public string PasswordHash { get; set; } = null!;
public string DisplayName { get; set; } = null!;
public ClinicalRole Role { get; set; }
public bool IsActive { get; set; } = true;
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? LastLoginAt { get; set; }
}
@@ -0,0 +1,40 @@
public enum AuditAction
{
ThresholdCreated,
ThresholdUpdated,
AlertAcknowledged,
AlertResolved,
EncounterStatusChanged,
PatientRegistered,
SuppressionWindowSet,
UserLogin
}
public static class AuditActionExtensions
{
public static string ToDbString(this AuditAction a) => a switch
{
AuditAction.ThresholdCreated => "THRESHOLD_CREATED",
AuditAction.ThresholdUpdated => "THRESHOLD_UPDATED",
AuditAction.AlertAcknowledged => "ALERT_ACKNOWLEDGED",
AuditAction.AlertResolved => "ALERT_RESOLVED",
AuditAction.EncounterStatusChanged => "ENCOUNTER_STATUS_CHANGED",
AuditAction.PatientRegistered => "PATIENT_REGISTERED",
AuditAction.SuppressionWindowSet => "SUPPRESSION_WINDOW_SET",
AuditAction.UserLogin => "USER_LOGIN",
_ => throw new ArgumentOutOfRangeException(nameof(a))
};
public static AuditAction FromDbString(string v) => v switch
{
"THRESHOLD_CREATED" => AuditAction.ThresholdCreated,
"THRESHOLD_UPDATED" => AuditAction.ThresholdUpdated,
"ALERT_ACKNOWLEDGED" => AuditAction.AlertAcknowledged,
"ALERT_RESOLVED" => AuditAction.AlertResolved,
"ENCOUNTER_STATUS_CHANGED" => AuditAction.EncounterStatusChanged,
"PATIENT_REGISTERED" => AuditAction.PatientRegistered,
"SUPPRESSION_WINDOW_SET" => AuditAction.SuppressionWindowSet,
"USER_LOGIN" => AuditAction.UserLogin,
_ => throw new ArgumentOutOfRangeException(nameof(v))
};
}
@@ -0,0 +1,28 @@
public enum ClinicalRole
{
Nurse,
Physician,
Admin,
Integration
}
public static class ClinicalRoleExtensions
{
public static string ToDbString(this ClinicalRole r) => r switch
{
ClinicalRole.Nurse => "NURSE",
ClinicalRole.Physician => "PHYSICIAN",
ClinicalRole.Admin => "ADMIN",
ClinicalRole.Integration => "INTEGRATION",
_ => throw new ArgumentOutOfRangeException(nameof(r))
};
public static ClinicalRole FromDbString(string v) => v switch
{
"NURSE" => ClinicalRole.Nurse,
"PHYSICIAN" => ClinicalRole.Physician,
"ADMIN" => ClinicalRole.Admin,
"INTEGRATION" => ClinicalRole.Integration,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown role: '{v}'")
};
}
@@ -0,0 +1,71 @@
using System.Security.Claims;
using Hl7.Fhir.Serialization;
using Microsoft.Extensions.Options;
using Task = System.Threading.Tasks.Task;
public class FhirApiKeyOrJwtMiddleware
{
public const string SchemeName = "FhirApiKey";
private readonly RequestDelegate _next;
private readonly FhirOptions _options;
private static readonly FhirJsonSerializer Serializer = new();
public FhirApiKeyOrJwtMiddleware(RequestDelegate next, IOptions<FhirOptions> options)
{
_next = next;
_options = options.Value;
}
public async Task InvokeAsync(HttpContext context)
{
if (!context.Request.Path.StartsWithSegments("/fhir"))
{
await _next(context);
return;
}
if (context.Request.Path.StartsWithSegments("/fhir/R4/metadata"))
{
await _next(context);
return;
}
if (context.User.Identity?.IsAuthenticated == true)
{
await _next(context);
return;
}
if (string.IsNullOrWhiteSpace(_options.ApiKey))
{
await _next(context);
return;
}
if (context.Request.Headers.TryGetValue("X-Api-Key", out var key) && key == _options.ApiKey)
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, "44444444-4444-4444-4444-444444444444"),
new Claim(ClaimTypes.Name, "integration.mirth"),
new Claim("display_name", "Mirth Connect"),
new Claim("clinical_role", ClinicalRole.Integration.ToDbString()),
};
context.User = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName));
await _next(context);
return;
}
if (context.Request.Headers.ContainsKey("X-Api-Key"))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.ContentType = "application/fhir+json";
var outcome = FhirOperationOutcomeBuilder.Create(401, "login", "Invalid or missing API key.");
await context.Response.WriteAsync(Serializer.SerializeToString(outcome));
return;
}
await _next(context);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,46 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddClinicalUsers : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "clinical_users",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
username = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
password_hash = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
display_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
role = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
is_active = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
last_login_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_clinical_users", x => x.id);
});
migrationBuilder.CreateIndex(
name: "IX_clinical_users_username",
table: "clinical_users",
column: "username",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "clinical_users");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,64 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddClinicalAuditLogs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "clinical_audit_logs",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
action = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
entity_type = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
entity_id = table.Column<Guid>(type: "uuid", nullable: false),
user_id = table.Column<Guid>(type: "uuid", nullable: true),
user_display_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
previous_value_json = table.Column<string>(type: "jsonb", nullable: true),
new_value_json = table.Column<string>(type: "jsonb", nullable: true),
reason = table.Column<string>(type: "text", nullable: true),
ip_address = table.Column<string>(type: "character varying(45)", maxLength: 45, nullable: true),
correlation_id = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
},
constraints: table =>
{
table.PrimaryKey("PK_clinical_audit_logs", x => x.id);
});
migrationBuilder.CreateIndex(
name: "IX_clinical_audit_logs_created_at",
table: "clinical_audit_logs",
column: "created_at");
migrationBuilder.CreateIndex(
name: "IX_clinical_audit_logs_entity_id",
table: "clinical_audit_logs",
column: "entity_id");
migrationBuilder.CreateIndex(
name: "IX_clinical_audit_logs_entity_type",
table: "clinical_audit_logs",
column: "entity_type");
migrationBuilder.CreateIndex(
name: "IX_clinical_audit_logs_user_id",
table: "clinical_audit_logs",
column: "user_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "clinical_audit_logs");
}
}
}
@@ -164,6 +164,136 @@ namespace VigilCareClinicalAPI.Migrations
});
});
modelBuilder.Entity("ClinicalAuditLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("action");
b.Property<string>("CorrelationId")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("correlation_id");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<Guid>("EntityId")
.HasColumnType("uuid")
.HasColumnName("entity_id");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entity_type");
b.Property<string>("IpAddress")
.HasMaxLength(45)
.HasColumnType("character varying(45)")
.HasColumnName("ip_address");
b.Property<string>("NewValueJson")
.HasColumnType("jsonb")
.HasColumnName("new_value_json");
b.Property<string>("PreviousValueJson")
.HasColumnType("jsonb")
.HasColumnName("previous_value_json");
b.Property<string>("Reason")
.HasColumnType("text")
.HasColumnName("reason");
b.Property<string>("UserDisplayName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("user_display_name");
b.Property<Guid?>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("EntityId");
b.HasIndex("EntityType");
b.HasIndex("UserId");
b.ToTable("clinical_audit_logs", (string)null);
});
modelBuilder.Entity("ClinicalUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display_name");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true)
.HasColumnName("is_active");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_login_at");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("password_hash");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("role");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("username");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("clinical_users", (string)null);
});
modelBuilder.Entity("Encounter", b =>
{
b.Property<Guid>("Id")
@@ -1 +1 @@
public record AcknowledgeAlertRequest(string ClinicianId, string? Note);
public record AcknowledgeAlertRequest(string? Note);
@@ -0,0 +1 @@
public record LoginRequest(string Username, string Password);
@@ -0,0 +1,7 @@
public record LoginResponse(
string AccessToken,
DateTimeOffset ExpiresAt,
Guid UserId,
string Username,
string DisplayName,
string Role);
+42 -1
View File
@@ -8,6 +8,10 @@ using FluentValidation;
using FluentValidation.AspNetCore;
using Microsoft.AspNetCore.Mvc;
using System.Reflection;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Microsoft.AspNetCore.Authorization;
using System.Text;
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
@@ -17,6 +21,35 @@ try
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection(JwtOptions.Section));
var jwtOptions = builder.Configuration.GetSection(JwtOptions.Section).Get<JwtOptions>()!;
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtOptions.Issuer,
ValidAudience = jwtOptions.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey))
};
});
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
builder.Services.AddSingleton<IAuthorizationHandler, PermissionAuthorizationHandler>();
builder.Services.AddAuthorization(options =>
{
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
});
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
@@ -133,6 +166,10 @@ try
builder.Services.AddScoped<MedicationAdministrationFhirMapper>();
builder.Services.AddScoped<FhirBundleProcessor>();
builder.Services.AddScoped<FhirExceptionFilter>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IAuditService, AuditService>();
builder.Services.AddHostedService<ThresholdCacheLoader>();
builder.Services.AddHostedService<KafkaTopicProvisioner>();
@@ -208,6 +245,7 @@ try
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
await DataSeeder.SeedAsync(db, redis);
await UserSeeder.SeedAsync(db);
}
if (!app.Environment.IsEnvironment("Testing"))
@@ -220,11 +258,14 @@ try
}
app.UseMiddleware<CorrelationIdMiddleware>();
app.UseMiddleware<FhirApiKeyMiddleware>();
app.UseMiddleware<FhirApiKeyOrJwtMiddleware>();
app.UseMiddleware<ExceptionHandlerMiddleware>();
app.UseCors("Dashboard");
app.UseAuthentication();
app.UseAuthorization();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
+44 -3
View File
@@ -6,11 +6,19 @@ public class AlertService : IAlertService
{
private readonly AppDbContext _db;
private readonly IServiceProvider _services;
private readonly ICurrentUserService _currentUser;
private readonly IAuditService _audit;
public AlertService(AppDbContext db, IServiceProvider services)
public AlertService(
AppDbContext db,
IServiceProvider services,
ICurrentUserService currentUser,
IAuditService audit)
{
_db = db;
_services = services;
_currentUser = currentUser;
_audit = audit;
}
public async Task<PagedResult<ClinicalAlert>> ListByEncounterAsync(
@@ -75,6 +83,12 @@ public class AlertService : IAlertService
public async Task<ClinicalAlert> AcknowledgeAsync(Guid id, AcknowledgeAlertRequest req)
{
if (!_currentUser.IsAuthenticated)
throw new ValidationException("Authentication required.", "AUTH_REQUIRED");
var displayName = _currentUser.DisplayName ?? _currentUser.Username
?? throw new ValidationException("Authenticated user identity missing.", "AUTH_REQUIRED");
var alert = await _db.ClinicalAlerts.FindAsync(id);
if (alert is null)
throw new NotFoundException("Alert not found.", "ALERT_NOT_FOUND");
@@ -84,9 +98,10 @@ public class AlertService : IAlertService
$"Alert cannot be acknowledged from status '{alert.Status}'.",
"ALERT_NOT_ACKNOWLEDGEABLE");
var previousStatus = alert.Status;
alert.Status = AlertStatus.Acknowledged;
alert.AcknowledgedAt = DateTimeOffset.UtcNow;
alert.AcknowledgedBy = req.ClinicianId;
alert.AcknowledgedBy = displayName;
// Write an outbox event so the Kafka consumer (Phase 6) can cancel the
// pending RabbitMQ escalation timer when it sees this acknowledgment.
@@ -98,7 +113,7 @@ public class AlertService : IAlertService
{
alertId = alert.Id,
encounterId = alert.EncounterId,
acknowledgedBy = req.ClinicianId,
acknowledgedBy = displayName,
acknowledgedAt = alert.AcknowledgedAt,
note = req.Note
}),
@@ -120,6 +135,25 @@ public class AlertService : IAlertService
}
await _db.SaveChangesAsync();
await _audit.WriteAsync(
AuditAction.AlertAcknowledged,
"ClinicalAlert",
alert.Id,
previousValue: new { status = previousStatus.ToDbString() },
newValue: new { status = alert.Status.ToDbString(), alert.AcknowledgedBy },
reason: req.Note);
if (alert.AlertType.IsSuppressible())
{
await _audit.WriteAsync(
AuditAction.SuppressionWindowSet,
"ClinicalAlert",
alert.Id,
newValue: new { alert.AlertType, alert.EncounterId },
reason: req.Note);
}
return alert;
}
@@ -157,6 +191,13 @@ public class AlertService : IAlertService
alert.ResolvedAt = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync();
await _audit.WriteAsync(
AuditAction.AlertResolved,
"ClinicalAlert",
alert.Id,
previousValue: new { status = AlertStatus.Acknowledged.ToDbString() },
newValue: new { status = alert.Status.ToDbString() });
return alert;
}
}
@@ -5,11 +5,16 @@ public class AlertThresholdService : IAlertThresholdService
{
private readonly AppDbContext _db;
private readonly IConnectionMultiplexer _redis;
private readonly IAuditService _audit;
public AlertThresholdService(AppDbContext db, IConnectionMultiplexer redis)
public AlertThresholdService(
AppDbContext db,
IConnectionMultiplexer redis,
IAuditService audit)
{
_db = db;
_redis = redis;
_audit = audit;
}
public async Task<AlertThreshold> CreateAsync(AlertThresholdRequest req)
@@ -35,6 +40,19 @@ public class AlertThresholdService : IAlertThresholdService
_db.AlertThresholds.Add(threshold);
await _db.SaveChangesAsync();
await _audit.WriteAsync(
AuditAction.ThresholdCreated,
"AlertThreshold",
threshold.Id,
newValue: new
{
threshold.ObservationCode,
threshold.CriticalLow,
threshold.WarningLow,
threshold.WarningHigh,
threshold.CriticalHigh
});
await InvalidateCacheAsync(threshold.ObservationCode);
return threshold;
}
@@ -56,6 +74,16 @@ public class AlertThresholdService : IAlertThresholdService
if (threshold is null)
throw new NotFoundException("Threshold not found.", "THRESHOLD_NOT_FOUND");
var previous = new
{
threshold.ObservationCode,
threshold.CriticalLow,
threshold.WarningLow,
threshold.WarningHigh,
threshold.CriticalHigh,
threshold.SuppressionWindowMinutes
};
threshold.DisplayName = req.DisplayName;
threshold.Unit = req.Unit;
threshold.CriticalLow = req.CriticalLow;
@@ -64,6 +92,22 @@ public class AlertThresholdService : IAlertThresholdService
threshold.CriticalHigh = req.CriticalHigh;
await _db.SaveChangesAsync();
await _audit.WriteAsync(
AuditAction.ThresholdUpdated,
"AlertThreshold",
threshold.Id,
previousValue: previous,
newValue: new
{
threshold.ObservationCode,
threshold.CriticalLow,
threshold.WarningLow,
threshold.WarningHigh,
threshold.CriticalHigh,
threshold.SuppressionWindowMinutes
});
await InvalidateCacheAsync(threshold.ObservationCode);
return threshold;
}
@@ -0,0 +1,47 @@
using System.Text.Json;
public class AuditService : IAuditService
{
private readonly AppDbContext _db;
private readonly ICurrentUserService _currentUser;
private readonly IHttpContextAccessor _http;
public AuditService(
AppDbContext db,
ICurrentUserService currentUser,
IHttpContextAccessor http)
{
_db = db;
_currentUser = currentUser;
_http = http;
}
public async Task WriteAsync(
AuditAction action,
string entityType,
Guid entityId,
object? previousValue = null,
object? newValue = null,
string? reason = null)
{
var correlationId = _http.HttpContext?.Items["CorrelationId"]?.ToString();
_db.ClinicalAuditLogs.Add(new ClinicalAuditLog
{
Id = Guid.NewGuid(),
Action = action,
EntityType = entityType,
EntityId = entityId,
UserId = _currentUser.UserId,
UserDisplayName = _currentUser.DisplayName ?? _currentUser.Username,
PreviousValueJson = previousValue is null ? null : JsonSerializer.Serialize(previousValue),
NewValueJson = newValue is null ? null : JsonSerializer.Serialize(newValue),
Reason = reason,
IpAddress = _currentUser.IpAddress,
CorrelationId = correlationId,
CreatedAt = DateTimeOffset.UtcNow
});
await _db.SaveChangesAsync();
}
}
@@ -0,0 +1,76 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
public class AuthService : IAuthService
{
private readonly AppDbContext _db;
private readonly JwtOptions _jwt;
public AuthService(AppDbContext db, IOptions<JwtOptions> jwt)
{
_db = db;
_jwt = jwt.Value;
}
public async Task<LoginResponse> LoginAsync(LoginRequest req)
{
var user = await _db.ClinicalUsers
.FirstOrDefaultAsync(u => u.Username == req.Username && u.IsActive);
if (user is null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
throw new ValidationException("Invalid username or password.", "INVALID_CREDENTIALS");
user.LastLoginAt = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync();
_db.ClinicalAuditLogs.Add(new ClinicalAuditLog
{
Id = Guid.NewGuid(),
Action = AuditAction.UserLogin,
EntityType = "ClinicalUser",
EntityId = user.Id,
UserId = user.Id,
UserDisplayName = user.DisplayName,
CreatedAt = DateTimeOffset.UtcNow
});
await _db.SaveChangesAsync();
var expires = DateTimeOffset.UtcNow.AddMinutes(_jwt.ExpirationMinutes);
var token = GenerateToken(user, expires);
return new LoginResponse(
token,
expires,
user.Id,
user.Username,
user.DisplayName,
user.Role.ToDbString());
}
private string GenerateToken(ClinicalUser user, DateTimeOffset expires)
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim("display_name", user.DisplayName),
new Claim("clinical_role", user.Role.ToDbString()),
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwt.SigningKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _jwt.Issuer,
audience: _jwt.Audience,
claims: claims,
expires: expires.UtcDateTime,
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
@@ -0,0 +1,29 @@
using System.Security.Claims;
public class CurrentUserService : ICurrentUserService
{
private readonly IHttpContextAccessor _http;
public CurrentUserService(IHttpContextAccessor http) => _http = http;
public Guid? UserId =>
Guid.TryParse(_http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier), out var id)
? id : null;
public string? Username => _http.HttpContext?.User.FindFirstValue(ClaimTypes.Name);
public string? DisplayName => _http.HttpContext?.User.FindFirstValue("display_name");
public ClinicalRole? Role
{
get
{
var role = _http.HttpContext?.User.FindFirstValue("clinical_role");
return role is null ? null : ClinicalRoleExtensions.FromDbString(role);
}
}
public bool IsAuthenticated => _http.HttpContext?.User.Identity?.IsAuthenticated == true;
public string? IpAddress => _http.HttpContext?.Connection.RemoteIpAddress?.ToString();
}
@@ -16,15 +16,18 @@ public class EncounterService : IEncounterService
private readonly AppDbContext _db;
private readonly IQsofaService _qsofa;
private readonly IExternalIdentifierService _identifiers;
private readonly IAuditService _audit;
public EncounterService(
AppDbContext db,
IQsofaService qsofa,
IExternalIdentifierService identifiers)
IExternalIdentifierService identifiers,
IAuditService audit)
{
_db = db;
_qsofa = qsofa;
_identifiers = identifiers;
_audit = audit;
}
public async Task<Encounter> GetByIdAsync(Guid id)
@@ -171,6 +174,14 @@ public class EncounterService : IEncounterService
});
await _db.SaveChangesAsync();
await _audit.WriteAsync(
AuditAction.EncounterStatusChanged,
"Encounter",
encounterId,
previousValue: new { status = previousStatus.ToDbString() },
newValue: new { status = targetStatus.ToDbString(), dischargeDiagnosis });
return new EncounterStatusTransitionResult(encounterId, targetStatus);
}
@@ -0,0 +1,10 @@
public interface IAuditService
{
Task WriteAsync(
AuditAction action,
string entityType,
Guid entityId,
object? previousValue = null,
object? newValue = null,
string? reason = null);
}
@@ -0,0 +1,4 @@
public interface IAuthService
{
Task<LoginResponse> LoginAsync(LoginRequest req);
}
@@ -0,0 +1,9 @@
public interface ICurrentUserService
{
Guid? UserId { get; }
string? Username { get; }
string? DisplayName { get; }
ClinicalRole? Role { get; }
bool IsAuthenticated { get; }
string? IpAddress { get; }
}
@@ -5,11 +5,16 @@ public class PatientService : IPatientService
{
private readonly AppDbContext _db;
private readonly IExternalIdentifierService _identifiers;
private readonly IAuditService _audit;
public PatientService(AppDbContext db, IExternalIdentifierService identifiers)
public PatientService(
AppDbContext db,
IExternalIdentifierService identifiers,
IAuditService audit)
{
_db = db;
_identifiers = identifiers;
_audit = audit;
}
public async Task<Patient> RegisterAsync(RegisterPatientRequest req)
@@ -31,6 +36,13 @@ public class PatientService : IPatientService
};
_db.Patients.Add(patient);
await _db.SaveChangesAsync();
await _audit.WriteAsync(
AuditAction.PatientRegistered,
"Patient",
patient.Id,
newValue: new { patient.Mrn, patient.FirstName, patient.LastName });
return patient;
}
@@ -4,6 +4,6 @@ public class AcknowledgeAlertRequestValidator : AbstractValidator<AcknowledgeAle
{
public AcknowledgeAlertRequestValidator()
{
RuleFor(x => x.ClinicianId).NotEmpty().MaximumLength(200);
RuleFor(x => x.Note).MaximumLength(1000).When(x => x.Note is not null);
}
}
@@ -0,0 +1,10 @@
using FluentValidation;
public class LoginRequestValidator : AbstractValidator<LoginRequest>
{
public LoginRequestValidator()
{
RuleFor(x => x.Username).NotEmpty().MaximumLength(100);
RuleFor(x => x.Password).NotEmpty().MinimumLength(8).MaximumLength(200);
}
}
@@ -13,10 +13,12 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Confluent.Kafka" Version="2.14.0" />
<PackageReference Include="Elastic.Clients.Elasticsearch" Version="8.13.12" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Hl7.Fhir.R4" Version="5.11.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.27" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+6
View File
@@ -190,5 +190,11 @@
"AMB": "OUTPATIENT",
"EMER": "EMERGENCY"
}
},
"Jwt": {
"Issuer": "VigilCareClinical",
"Audience": "VigilCareClinical.Dashboard",
"SigningKey": "DEV-ONLY-REPLACE-WITH-256-BIT-SECRET-IN-PRODUCTION-abc123xyz",
"ExpirationMinutes": 480
}
}