feature: RBAC + Clinical Audit Logging
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
+1187
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1261
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);
|
||||
@@ -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())
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user