Files
adas-core/adas-core.Application/Services/ObservationService.cs
T
2026-06-26 10:29:23 +02:00

1873 lines
88 KiB
C#

using adas_core.Application.Exceptions;
using adas_core.Application.Repositories.Interfaces;
using adas_core.Application.Services.Interfaces;
using adas_core.Application.Subscriptions;
using adas_core.Domain.Enums;
using adas_core.Domain.Models;
using adas_core.Domain.Models.AppSettings;
using adas_core.Domain.Models.Filter;
using adas_core.Domain.Models.GroupedObservations;
using adas_core.Domain.Models.MongoModels;
using adas_core.Domain.Models.Responses;
using adas_core.Domain.Utils;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using MongoDB.Driver;
using Serilog;
using Patient = adas_core.Domain.Models.MongoModels.Patient;
namespace adas_core.Application.Services;
/// <summary>
/// Provides the concrete implementation of the <see cref="IObservationService"/> contract,
/// encapsulating the business logic required to manage and expose observation-related operations.
/// </summary>
public class ObservationService : IObservationService
{
private readonly ICacheService _cacheService;
private readonly CacheSettings? _cacheSettings;
private readonly IAlarmService _alarmService;
private readonly List<string> _allergies;
private readonly ILocalAuditService _auditService;
private readonly Lazy<ICalculatedObservationsService> _calculatedObservationsService;
private readonly IClientMessageService _clientMessageService;
private readonly IConfigObservationService _configObservationService;
private readonly IConfigUnitsService _configUnitsService;
private readonly List<string> _diagnosisCode;
private readonly IDiagnosisService _diagnosisService;
private readonly List<string> _drainageCode;
private readonly IGroupedObservationService _groupedObservationService;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly List<string> _intravenousLines;
private readonly List<string> _isolationCode;
private readonly ILightBeaconService _lightBeaconService;
private readonly ILogger<ObservationService> _logger;
private readonly IObservationArchiveRepository _observationArchiveRepository;
private readonly IObservationRepository _observationRepository;
private readonly IPatientService _patientService;
//Suprimo el warning porque detecta que no se usa en el código, pero sí se usa en los test
//#pragma warning disable IDE0051 // Quitar miembros privados no utilizados
//#pragma warning disable CS0169 // El campo '_observationService.createPatientWithORU' nunca se usa
/*private readonly bool createPatientWithORU;
private readonly bool createPatientWithoutLocation;
private readonly bool createPatientWithLocation;
//private readonly bool updatePatientLocationWithORU;
private readonly bool updatePatientDataWithORU;
private readonly bool ArchivePatientWithOru;*/
//#pragma warning restore CS0169 // El campo '_observationService.createPatientWithORU' nunca se usa
//#pragma warning restore IDE0051 // Quitar miembros privados no utilizados
private readonly bool _persistObservationCodes;
private readonly IPointOfCareService _pointOfCareService;
private readonly List<string> _positionCode;
private readonly IRecordingService _recordingService;
private readonly IRelayService _relayService;
private readonly ISubscriberGroupedService _subscriberGroupedService;
private readonly ISubscribersService _subscribersService;
public ObservationService(
IPatientService patientService,
IConfigObservationService configObservationService,
IObservationRepository observationRepository,
IObservationArchiveRepository observationArchiveRepository,
IConfigUnitsService configUnitsService,
IDiagnosisService diagnosisService,
IOptions<ApiSettings> apiSettings,
IOptions<CacheSettings> cacheSettings,
ILightBeaconService lightBeaconService,
IRelayService relayService,
IRecordingService recordingService,
ILogger<ObservationService> logger,
IGroupedObservationService groupedObservationService,
IAlarmService alarmService,
IClientMessageService clientMessageService,
ISubscribersService subscribersService,
ISubscriberGroupedService subscriberGroupedService,
Lazy<ICalculatedObservationsService> calculatedObservationsService,
IHttpContextAccessor httpContextAccessor,
ILocalAuditService auditService,
IPointOfCareService pointOfCareService,
ICacheService cacheService
)
{
_patientService = patientService;
_configObservationService = configObservationService;
_observationRepository = observationRepository;
_observationArchiveRepository = observationArchiveRepository;
_configUnitsService = configUnitsService;
_diagnosisService = diagnosisService;
_lightBeaconService = lightBeaconService;
_relayService = relayService;
_recordingService = recordingService;
_httpContextAccessor = httpContextAccessor;
_auditService = auditService;
_groupedObservationService = groupedObservationService;
_cacheService = cacheService;
_logger = logger;
if (apiSettings == null) throw new Exception("ApiSettings must be defined");
_persistObservationCodes = apiSettings.Value.PersistObservationCodes;
_intravenousLines = apiSettings.Value.IntravenousLinesCode ?? [];
_allergies = apiSettings.Value.AllergiesCode;
_drainageCode = apiSettings.Value.DrainageCode;
_isolationCode = apiSettings.Value.IsolationCode;
_positionCode = apiSettings.Value.PositionCode;
_diagnosisCode = apiSettings.Value.DiagnosisCode;
_alarmService = alarmService;
_clientMessageService = clientMessageService;
_subscribersService = subscribersService;
_subscriberGroupedService = subscriberGroupedService;
_calculatedObservationsService = calculatedObservationsService;
_pointOfCareService = pointOfCareService;
_cacheSettings = cacheSettings.Value;
}
/// <summary>
/// Retrieves the most recent observations for a specified patient by delegating to the observation repository's aggregation pipeline.
/// </summary>
/// <param name="patientId">The unique identifier of the patient whose observations are being queried.</param>
/// <param name="num">The maximum number of recent observations to return. Defaults to 2.</param>
/// <param name="filterObservations">An optional list of observation codes/names used to narrow down which observations are considered.</param>
/// <returns>A task that represents the asynchronous operation, containing a list of the patient's most recent <see cref="PatientObservation"/> entries.</returns>
public async Task<List<PatientObservation>> FindLastObservations(ObjectId patientId, int num = 2,
List<string>? filterObservations = null)
{
var result =
await _observationRepository.AggregatedPatientLastObservations(patientId, num, filterObservations);
return result;
}
/// <summary>
/// Retrieves the most recent aggregated patient observations, using a cache-aside pattern to avoid recomputing results within the configured TTL. Field names in <paramref name="filterObservations"/> are normalized (null or whitespace names are dropped) before being used to compute the cache key.
/// </summary>
/// <param name="patientId">Identifier of the patient whose latest observations are being requested.</param>
/// <param name="filterObservations">Optional list of fields to filter the aggregated observations by; entries with null or whitespace names are ignored when building the cache key. When null, an empty field set is used.</param>
/// <param name="ct">Cancellation token forwarded to the cache and repository operations.</param>
/// <returns>A task containing the list of <see cref="PatientObservation"/> values, either served from cache or freshly aggregated from the repository on a cache miss.</returns>
private async Task<List<PatientObservation>> AggregatedLastObsCached(
ObjectId patientId,
List<Field>? filterObservations,
CancellationToken ct = default)
{
// Normalizar campos
var fieldNames = (filterObservations ?? new())
.Select(f => f.Name ?? string.Empty)
.Where(n => !string.IsNullOrWhiteSpace(n))
.ToList();
// Clave + TTL según PatientObservation / backend configurado
var (key, ttl) = CacheKeys.LatestObservationsKeyWithTtl(
_cacheSettings,
patientId,
fieldNames
);
// GET → MISS → LOCK → AGGREGATE → SET
var result = await _cacheService.GetOrSetObjectAsync(
key,
async () =>
{
var raw = await _observationRepository
.AggregatedPatientLastObservationsByField(patientId, filterObservations);
return raw;
},
ttl, ct);
return result;
}
/// <summary>
/// Retrieves the most recent observations for a patient from the aggregated cache, optionally filtered by specific fields, and optionally enriched through a name-based mapping.
/// When <paramref name="mapped"/> is <c>false</c>, the raw cached observations are returned directly; otherwise each observation is individually mapped and those that yield no result are excluded from the output.
/// </summary>
/// <param name="patientId">The identifier of the patient whose latest observations are being queried.</param>
/// <param name="filterObservations">Optional list of fields used to restrict which observations are retrieved from the cache.</param>
/// <param name="mapped">When <c>true</c> (default), applies a name-based mapping to each observation; when <c>false</c>, returns the raw results as they come from the cache.</param>
/// <param name="ct">Cancellation token to cancel the asynchronous operation.</param>
/// <returns>A task containing the list of patient observations, either as raw cached entries or as mapped values depending on <paramref name="mapped"/>.</returns>
public async Task<List<PatientObservation>> FindLastObservationsByField(
ObjectId patientId,
List<Field>? filterObservations = null,
bool mapped = true,
CancellationToken ct = default)
{
//lista RAW desde la caché
var raw = await AggregatedLastObsCached(patientId, filterObservations, ct);
if (!mapped)
return raw;
// Mapeo
var mappedList = new List<PatientObservation>();
foreach (var o in raw)
{
var mo = await MapObservationsByName(o);
if (mo != null)
mappedList.Add(mo);
}
return mappedList;
}
/// <summary>
/// Maps the specified patient observation by name by delegating to the configuration observation service using the by-name mapping mode.
/// </summary>
/// <param name="obs">The patient observation to be mapped.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the mapped <see cref="PatientObservation"/>, or null if no matching mapping is found.</returns>
public async Task<PatientObservation?> MapObservationsByName(PatientObservation obs)
{
return await _configObservationService.Map(obs, true);
}
/// <summary>
/// Maps a <see cref="PatientObservation"/> through a sequence of configuration, units, and calculated observations services to produce a fully mapped observation, returning <c>null</c> if any mapping step yields no result or if an error occurs.
/// </summary>
/// <param name="obs">The patient observation to be mapped.</param>
/// <param name="onlyByName">When <c>true</c>, restricts the mapping to name-based lookups only.</param>
/// <returns>A task containing the mapped <see cref="PatientObservation"/>, or <c>null</c> if the observation is ignored, not found, or an exception is raised during processing.</returns>
public async Task<PatientObservation?> MapObservation(PatientObservation obs, bool onlyByName = false)
{
try
{
_logger.LogTrace("Mapping config Observation obs: {obs} onlyByName: {onlyByName}", obs, onlyByName);
var obs2 = await _configObservationService.Map(obs, onlyByName);
if (obs2 == null)
{
_logger.LogTrace("Mapping obs2 {obs}: Ignored", obs);
return null;
}
var obs3 = await _configUnitsService.Map(obs2);
var obs4 = await _calculatedObservationsService.Value.Map(obs3, onlyByName);
_logger.LogTrace("Mapping calculatedObservations.Map obs4: {obs4}", obs4);
if (obs4 == null)
{
_logger.LogTrace("Mapping obs4 {obs3}: Ignored", obs3);
return null;
}
if (obs4.CheckObservations)
_ = _alarmService.CheckObservationAlarm(obs4);
return obs4;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error Mapping Observation, Ignoring Observation: {obs} Exception:{ex}", obs,
ex.Message);
return null;
}
}
/// <summary>
/// Inserts a new observation if needed
/// </summary>
/// <param name="obs">Observation</param>
/// <param name="persistObs"></param>
/// <param name="mapObs">True if it needs to be inserted</param>
public async Task InsertObservation(PatientObservation obs, bool persistObs = true, bool mapObs = true)
{
try
{
var obs2 = obs;
//only will be false if the obs comes from the inner refactor job
if (mapObs) obs2 = await MapObservation(obs2);
if (obs2 == null)
{
_logger.LogDebug("Mapped observation returns null. Ignored {obs}", obs);
}
else
{
_logger.LogDebug("Mapped {obs2}", obs2);
if (!_persistObservationCodes)
{
obs2.Code = null;
obs2.CodingSystem = null;
obs2.ParentData = null;
}
_ = SendObsBroadcast(obs2);
if (obs2.Persist.HasValue)
persistObs = obs2.Persist.Value;
_logger.LogDebug("Insert {obs}, persist is {persistObs}", obs2, persistObs);
if (persistObs)
{
await _observationRepository.InsertOneAsync(obs2);
await _auditService.CreateAuditLogAsync(_httpContextAccessor.HttpContext?.User!, null, obs2);
_logger.LogDebug("Inserted {obs2}", obs2);
_ = Task.Run(() =>
CheckForGroupedObs(obs2)
);
}
_ = DoRetentionActions(obs2);
}
}
catch (Exception ex)
{
_logger.LogError("Error Inserting observation {obs}. Excepcion; {ex} ", obs, ex);
}
}
/// <summary>
/// Inserts a nurse observation by mapping it, persisting it via the observation repository,
/// updating the latest-observations cache, broadcasting the change, and creating an audit log entry.
/// If mapping returns a null result or an observation with a null name, the method logs the error and returns without inserting.
/// Any exception thrown during the operation is caught and logged.
/// </summary>
/// <param name="obs">The patient observation provided by the nurse to be mapped, persisted, cached, broadcast, and audited.</param>
public async Task InsertNurseObservation(PatientObservation obs)
{
try
{
var obs2 = obs;
obs2 = await MapObservation(obs2, true);
if (obs2 == null || obs2.Name == null)
{
_logger.LogError("Error mapping observation {obs}", obs.ToString());
return;
}
await _observationRepository.InsertOneAsync(obs2);
var (key, ttl) = CacheKeys.LatestObservationsKeyWithTtl(
_cacheSettings,
obs2.PatientId,
[obs2.Name]
);
// GET → MISS → LOCK → AGGREGATE → SET
var result = _cacheService.GetOrSetObjectAsync(
key,
async () =>
{
return obs2;
},
ttl, default);
_ = SendObsBroadcast(obs2);
await _auditService.CreateAuditLogAsync(_httpContextAccessor.HttpContext?.User!, null, obs2);
}
catch (Exception ex)
{
_logger.LogError("Error Inserting nurse observation {obs}. Excepcion; {ex} ", obs, ex);
}
}
/// <summary>
/// Inserts a new patient observation only when its value differs from the most recent observation recorded for the same observation name; otherwise the existing record is kept and no insertion is performed.
/// </summary>
/// <param name="name">The name of the observation used to look up the latest existing value for the patient.</param>
/// <param name="observation">The patient observation to compare against the most recent value and to insert when a change is detected.</param>
/// <param name="persistObs">Indicates whether the new observation should be persisted when inserted.</param>
/// <param name="mapObs">Indicates whether the new observation should be mapped when inserted.</param>
/// <returns>A task that resolves to <c>true</c> if the observation was inserted because the value changed, or <c>false</c> if the most recent observation already has the same value and no insertion was made.</returns>
public async Task<bool> InsertIfChanged(string name, PatientObservation observation, bool persistObs = true,
bool mapObs = true)
{
var changedList = await FindLastObservations(observation.PatientId, 1, [name]);
var changed = changedList.All(o => observation.Value != o.Value);
if (!changed) return false;
await InsertObservation(observation, persistObs, mapObs);
return true;
}
/// <summary>
/// Broadcasts a list of patient observations to all display subscribers whose registered locations match the specified patient location by unit name, bed, and room. Subscribers without any registered locations are excluded from the broadcast.
/// </summary>
/// <param name="obsList">The list of patient observations to send to matching subscribers.</param>
/// <param name="location">The patient location used to identify subscribers to notify.</param>
public Task SendObsBroadcast(List<PatientObservation> obsList, PatientLocation location)
{
// Display Subscription
var displaySubscribers = _subscribersService.GetSubscribers().Where(s =>
!s.Locations.IsNullOrEmpty() && s.Locations.Any(c =>
c.UnitName == location.UnitName &&
c.Bed == location.Bed &&
c.Room == location.Room
)).ToList();
foreach (var subscriber in displaySubscribers)
foreach (var obs in obsList)
_ = _clientMessageService.SendAsync(subscriber.Id, OperationType.Observation, obs);
return Task.CompletedTask;
}
/// <summary>
/// Broadcasts a list of patient observations to all display subscribers whose location identifiers match the specified point of care identifier.
/// Subscribers with null or empty location identifiers are excluded, and each observation is dispatched asynchronously to every matching subscriber.
/// </summary>
/// <param name="obsList">The collection of patient observations to be sent to the matched subscribers.</param>
/// <param name="pocId">The point of care identifier used to filter the subscribers by their configured location identifiers.</param>
public Task SendObsBroadcast(List<PatientObservation> obsList, ObjectId pocId)
{
// Display Subscription
var displaySubscribers = _subscribersService.GetSubscribers().Where(s =>
!s.LocationIds.IsNullOrEmpty() && s.LocationIds.Any(c =>
c == pocId
)).ToList();
foreach (var subscriber in displaySubscribers)
foreach (var obs in obsList)
_ = _clientMessageService.SendAsync(subscriber.Id, OperationType.Observation, obs);
return Task.CompletedTask;
}
/// <summary>
/// Broadcasts a patient observation to all display subscribers whose location matches the patient's point of care.
/// The method skips the broadcast if the observation has no name, and falls back to looking up the patient by id when it is not included in the observation.
/// </summary>
/// <param name="obs">The patient observation to broadcast. May include the patient or require a lookup via <see cref="BasePatientObservation.PatientId"/>.</param>
public async Task SendObsBroadcast(BasePatientObservation obs)
{
if (obs.Name == null) return;
var patient = obs.Patient ?? await _patientService.FindById(obs.PatientId);
if (patient == null)
{
_logger.LogDebug("Not patient on bd to sendOnBroadcastObs: {obspatientid}", obs.PatientId);
return;
}
_logger.LogTrace(
"sending obs name: {obsname} to patient id: {patientid}, PointOfCare: {patientpointOfCare} {patientbed}",
obs.Name, patient.Id, patient.UnitString, patient.Bed);
var displaySubscribers = _subscribersService.GetSubscribers().Where(s =>
!s.LocationIds.IsNullOrEmpty() && s.LocationIds.Any(c =>
c == patient.PointOfCareId
)).ToList();
foreach (var subscriber in displaySubscribers)
_ = _clientMessageService.SendAsync(subscriber.Id, OperationType.Observation, obs);
}
/// <summary>
/// Processes a batch of patient observations by attaching patient, parent data, and message metadata,
/// then persists them asynchronously through the calculated observations mapping service. Missing
/// observation or message timestamps default to <see cref="DateTime.UtcNow"/>, and any errors during
/// processing are logged without being rethrown.
/// </summary>
/// <param name="observations">The list of patient observations to be processed and inserted.</param>
/// <param name="patient">The patient to whom the observations belong.</param>
/// <param name="messageTime">The timestamp associated with the source message.</param>
/// <param name="observationData">Optional parent observation metadata used to populate the parent data of each observation.</param>
public async void ProcessObservations(List<PatientObservation> observations, Patient patient,
DateTime messageTime, ObservationData? observationData = null)
{
try
{
_logger.LogDebug(
"Patient: {patientid} PoC {patientpointOfCare} {patientbed} msg {messageTime} INSERTING {observationsCount} OBSERVATIONS",
patient.Id, patient.PointOfCare, patient.Bed, messageTime, observations.Count);
ParentDataClass? parentData = null;
if (observationData != null)
parentData = new ParentDataClass
{
Code = observationData.Code,
CodingSystem = observationData.CodingSystem,
Name = observationData.Text
};
var obsToInsert = new List<PatientObservation>();
// Alarm to insert
// var alrmToInsert = new List<PatientObservationAlarm>();
foreach (var obs in observations)
{
obs.ParentData = parentData;
obs.MessageTime = messageTime;
obs.PatientId = patient.Id;
obs.Patient = patient;
obs.Id = ObjectId.GenerateNewId();
if (obs.Time == DateTime.MinValue)
obs.Time = DateTime.UtcNow;
if (obs.MessageTime == DateTime.MinValue)
obs.MessageTime = DateTime.UtcNow;
_logger.LogDebug(
"Patient: {patientId} PoC {patientpointOfCare} {patientbed} msg {messageTime} INSERTING observation {obs}",
patient.Id, patient.PointOfCare, patient.Bed, messageTime, obs);
obsToInsert.Add(obs);
}
obsToInsert = await _calculatedObservationsService.Value.MapList(obsToInsert);
_ = Task.WhenAll(obsToInsert.Select(obs =>
Task.Run(() => InsertObservation(obs)))); //.GetAwaiter().GetResult();
}
catch (Exception ex)
{
_logger.LogError(
"Patient: {patientid} PoC {patientpointOfCare} {patientbed} msg {messageTime} ERROR PROCESSING observations. Exception: {ex}",
patient.Id, patient.PointOfCare, patient.Bed, messageTime, ex);
}
}
/// <summary>
/// Asynchronously saves nurse observations from the provided API request by executing the save operation on a background thread.
/// </summary>
/// <param name="request">The API request containing the nurse observation data to be persisted.</param>
/// <returns>A task that represents the asynchronous nurse observation save operation.</returns>
public Task SaveRequestNurseObsAsync(ApiRequest request)
{
return Task.Run(() => SaveRequestNurseObs(request));
}
/// <summary>
/// Processes and persists an inbound medical API request (HL7), routing <c>ORU_R40</c> alerts to the alarm service
/// and <c>ORU_R01</c> observations to the appropriate handler (intravenous lines, allergies, drainage, isolation,
/// position, diagnosis, or generic observations) based on the observation code. Throws when both patient and
/// location are missing or when the request type is not supported, and silently returns when no matching patient
/// is found.
/// </summary>
/// <param name="apiRequest">The incoming API request containing patient/location identifiers, message type, and observation data.</param>
/// <exception cref="ApiRequestException">Thrown when both the patient number and location are null or empty, or when the request type is not valid for observations.</exception>
public async Task SaveRequest(ApiRequest apiRequest)
{
if (
string.IsNullOrEmpty(apiRequest.PatientNumber) &&
apiRequest.Location != null && apiRequest.Location.IsEmpty()
)
{
_logger.LogDebug("Patient and PointOfCare are nulls");
throw new ApiRequestException("Patient and PointOfCare are nulls");
}
_logger.LogDebug("patientNumber: {PatientNumber} location: {Location}", apiRequest.PatientNumber,
apiRequest.Location);
_logger.LogDebug("RequestType: {Type}", apiRequest.Type);
var patient = await _patientService.FindPatientByApiRequest(apiRequest);
if (patient == null)
{
// NO PATIENTS OR LOCATIONS WERE FOUND
_logger.LogWarning(
"Observation for patient: {apiRequestpatientNumber} with location: {apiRequestlocation} not found, ignoring",
apiRequest.PatientNumber, apiRequest.Location);
return;
}
try
{
switch (apiRequest.Type)
{
/*
* ORU_R01 - Unsolicited transmission of an observation message
* ORU_R40 - Unsolicited transmission of an alert observation message
*/
case "ORU_R40": // UNSOLICITED ALERT OBSERVATION
await _alarmService.SaveRequestAsync(apiRequest);
return;
case "ORU_R01": // UNSOLICITED OBSERVATON
if (!apiRequest.Alarms.IsNullOrEmpty())
{
await _alarmService.SaveRequestAsync(apiRequest);
return;
}
// OBSERVATIONS
if (apiRequest.Observation != null &&
(apiRequest.Observations == null || !apiRequest.Observations.Any()))
apiRequest.Observations = [apiRequest.Observation];
var obrcode = apiRequest.ObservationData?.Code ?? "";
if (apiRequest.Observations != null)
{
if (_intravenousLines.Contains(obrcode))
await ProcessIntravenousLinesObservation(apiRequest, patient);
else if (_allergies.Contains(obrcode))
await ProcessAllergiesObservation(apiRequest, patient);
else if (_drainageCode.Contains(obrcode))
await ProcessDrainageObservation(apiRequest, patient);
//TODO PARA SALIR EN EL RYC REFACTOR
else if (_isolationCode.Contains(obrcode))
await ProcessIsolationObservation(apiRequest, patient);
//TODO REFACTOR, CAMBIO RAPIDO PARA SALIR RYC, PENSAR SI NO SOLO HAY QUE FIARSE POR EL CÓDIGO SI NO POR TODA LA ESTRUCTURA
//DE CONFIG OBSERVATIONS, A LO MEJOR LA BD TIENE QUE INDICAR SI ES UN PROCESAMIENTO ESPECIAL? REVISAR.
else if (_positionCode.Contains(obrcode) &&
!"Temperatura(ºC)".Equals(apiRequest.ObservationData?.Text))
await ProcessPositionObservation(apiRequest, patient);
else if (_diagnosisCode.Contains(obrcode))
await _diagnosisService.SaveRequest(apiRequest, patient);
else
ProcessObservations(apiRequest.Observations, patient, apiRequest.MessageTime,
apiRequest.ObservationData);
}
break;
default:
_logger.LogWarning("ApiRequest type {apiRequesttype} sis not valid for Observations",
apiRequest.Type);
throw new ApiRequestException("ApiRequest type " + apiRequest.Type +
" is not valid for Observations");
}
}
catch (Exception ex)
{
_logger.LogError(
"ERROR SAVING REQUEST: exception: {exMessage} trace: {exStackTrace}",
ex.Message, ex.StackTrace); //add traceability
throw new Exception(ex.Message);
}
}
/// <summary>
/// Asynchronously saves the provided API request by executing the save operation on a background thread.
/// </summary>
/// <param name="apiRequest">The API request to be saved.</param>
/// <returns>A task that represents the asynchronous save operation.</returns>
public Task SaveRequestAsync(ApiRequest apiRequest)
{
//return Task.FromResult(Task.Run(async () => { await SaveRequest(apiRequest); }));
return Task.Run(() => SaveRequest(apiRequest));
}
/// <summary>
/// Retrieves all <see cref="PatientObservation"/> records associated with the specified patient by delegating to the observation repository.
/// </summary>
/// <param name="patientId">The unique identifier of the patient whose observations are being queried.</param>
/// <returns>An <see cref="IAsyncCursor{PatientObservation}"/> that iterates over the matching patient observations.</returns>
public async Task<IAsyncCursor<PatientObservation>> FindByPatientIdAsync(ObjectId patientId)
{
return await _observationRepository.FindByPatientIdAsync(patientId);
}
/// <summary>
/// Asynchronously retrieves patient observations filtered by the specified patient identifier, coding system, and name.
/// </summary>
/// <param name="patientId">The unique identifier of the patient whose observations are being queried.</param>
/// <param name="codingSystem">The coding system used to classify the observations.</param>
/// <param name="name">The name associated with the observations to filter by.</param>
/// <returns>An asynchronous cursor over the matching <see cref="PatientObservation"/> documents.</returns>
public async Task<IAsyncCursor<PatientObservation>> FindByPatientIdAndCodingSystemAsync(ObjectId patientId,
string codingSystem, string name)
{
return await _observationRepository.FindByPatientIdAndCodingSystemAsync(patientId, codingSystem, name);
}
/// <summary>
/// Deletes all observations associated with the specified patient identifier, clears the related cache entries, and records an audit log.
/// If no observations exist for the given patient, the method returns without performing any deletion, cache invalidation, or audit logging.
/// Any exception encountered during the process is logged and swallowed without being rethrown.
/// </summary>
/// <param name="id">The unique identifier of the patient whose observations should be deleted.</param>
public async Task DeleteByPatientId(ObjectId id)
{
try
{
var deletedObservationList = await _observationRepository.FindByPatientId(id);
if (deletedObservationList == null || deletedObservationList.Count == 0)
return;
_logger.LogDebug("Delete Observations by Patient Id {id}", id);
await _observationRepository.DeleteByPatientId(id);
await _cacheService.DeleteByPatternAsync(CacheKeyPatterns.ByPatient("patients:latestObs", id.ToString()));
await _auditService.CreateAuditLogAsync(_httpContextAccessor.HttpContext?.User!, deletedObservationList,
null);
}
catch (Exception ex)
{
_logger.LogError("Error Deleting by PatientId. Exception: {ex}", ex);
}
}
/// <summary>
/// Archives a patient observation by persisting it to the archive repository, removing it from the active observations, and invalidating the related cache entries for the patient's latest observations.
/// </summary>
/// <param name="observation">The patient observation to be archived.</param>
public async Task Archive(PatientObservation observation)
{
await _observationArchiveRepository.InsertOneAsync(observation);
await _observationRepository.DeleteAsync(observation.Id);
await _cacheService.DeleteByPatternAsync(CacheKeyPatterns.ByPatient("patients:latestObs", observation.PatientId.ToString()));
}
/// <summary>
/// Archives the specified patient by delegating to the archive operation keyed by the patient's identifier.
/// </summary>
/// <param name="patient">The patient to be archived. Its identifier is used to locate and archive the corresponding record.</param>
public async Task Archive(Patient patient)
{
await ArchiveByPatientId(patient.Id);
}
/// <summary>
/// Archives all observations associated with the specified patient by copying them into the archive repository with newly generated identifiers, then removes the originals and invalidates the related cache entries.
/// </summary>
/// <param name="id">The unique identifier of the patient whose observations should be archived.</param>
public async Task ArchiveByPatientId(ObjectId id)
{
_logger.LogDebug("Archive Observations by Patient Id {id}", id);
var cursor = await FindByPatientIdAsync(id);
while (await cursor.MoveNextAsync())
foreach (var current in cursor.Current)
{
current.Id = ObjectId.GenerateNewId();
await _observationArchiveRepository.InsertOneAsync(current);
}
await DeleteByPatientId(id);
await _cacheService.DeleteByPatternAsync(CacheKeyPatterns.ByPatient("patients:latestObs", id.ToString()));
}
/// <summary>
/// Retrieves the most recent active intravenous lines observations for a patient, aggregated by location.
/// </summary>
/// <param name="patientId">The unique identifier of the patient whose intravenous lines observations are being queried.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains a list of <see cref="PatientObservation"/> objects, which may include null entries, representing the latest active intravenous lines observations grouped by location.</returns>
public async Task<List<PatientObservation?>> FindLastIntravenousLinesObservationByLocation(ObjectId patientId)
{
return await _observationRepository.AggregatedPatientActiveIntravenousLinesObservations(patientId);
}
/// <summary>
/// Retrieves the most recent observation time for all patients by delegating to the observation repository.
/// </summary>
/// <returns>A task that represents the asynchronous operation, containing a dictionary mapping patient <see cref="ObjectId"/> values to their last observation <see cref="DateTime"/>.</returns>
public async Task<Dictionary<ObjectId, DateTime>> FindAllLastPatientObservationTime()
{
return await _observationRepository.FindAllLastPatientObservationTime();
}
/// <summary>
/// Updates an existing patient observation if it exists in the repository, invalidates the related cache entries, and records an audit log of the change.
/// If no observation with the specified identifier is found, the method performs no action.
/// </summary>
/// <param name="observation">The patient observation containing the updated data to be persisted.</param>
public async Task UpdateObservation(PatientObservation observation)
{
var obs = await _observationRepository.FindById(observation.Id);
if (obs != null)
{
await _observationRepository.Update(observation);
await _cacheService.DeleteByPatternAsync(CacheKeyPatterns.ByPatient("patients:latestObs", observation.PatientId.ToString()));
await _auditService.CreateAuditLogAsync(_httpContextAccessor.HttpContext?.User!, obs, observation);
}
}
/// <summary>
/// Retrieves the most recent patient observation recorded before the specified date, optionally filtered by observation name.
/// Returns <c>null</c> when no matching observation exists.
/// </summary>
/// <param name="patientId">The unique identifier of the patient whose observation history is being queried.</param>
/// <param name="date">The upper bound date; only observations recorded prior to this date are considered.</param>
/// <param name="obsName">The optional name of the observation to filter by. When <c>null</c>, observations of any name are considered.</param>
/// <returns>The latest <see cref="PatientObservation"/> recorded before the specified date, or <c>null</c> if none was found.</returns>
public async Task<PatientObservation?> FindLastBeforeDate(ObjectId patientId, DateTime date, string? obsName)
{
return await _observationRepository.FindLastObservationBeforeDate(patientId, obsName, date);
}
/// <summary>
/// Asynchronously retrieves any patient observations matching the specified patient, date, and optional observation name by delegating to the underlying observation repository.
/// Returns null when no matching observations are found for the given criteria.
/// </summary>
/// <param name="patientId">The unique identifier of the patient whose observations are being queried.</param>
/// <param name="date">The date used to find observations recorded on the same day.</param>
/// <param name="obsName">The optional name of the observation to filter by; when null, observations of any name on the given date are considered.</param>
/// <returns>A task that resolves to a list of matching <see cref="PatientObservation"/> instances, or null if no observations match the specified criteria.</returns>
public async Task<List<PatientObservation>?> FindAnyWithSameDate(ObjectId patientId, DateTime date,
string? obsName)
{
return await _observationRepository.FindAnyWithSameDate(patientId, obsName, date);
}
//TODO To implement
/// <summary>
/// Retrieves all patient observations recorded before the specified date by delegating to the observation repository.
/// </summary>
/// <param name="patientId">The unique identifier of the patient whose observations are being queried.</param>
/// <param name="date">The cutoff date; observations recorded before this date will be returned.</param>
/// <param name="filterObservations">An optional list of observation identifiers to filter the results.</param>
/// <returns>A task representing the asynchronous operation, containing a list of <see cref="PatientObservation"/> entries found before the specified date.</returns>
public async Task<List<PatientObservation>> FindAllBeforeDate(ObjectId patientId, DateTime date,
List<string>? filterObservations = null)
{
return await _observationRepository.FindAnyBeforeDate(patientId, date);
}
/// <summary>
/// Retrieves the latest unique observation values for a specified patient and observation name, delegating the lookup to the underlying observation repository.
/// </summary>
/// <param name="patientId">The unique identifier of the patient whose observations are being queried.</param>
/// <param name="name">The name of the observation to search for.</param>
/// <param name="expires">An optional expiration value (in seconds) applied to the query.</param>
/// <returns>A task that represents the asynchronous operation, containing a list of the latest unique <see cref="PatientObservation"/> values.</returns>
public async Task<List<PatientObservation>> FindLatestUniqueValuesByName(ObjectId patientId, string name,
int? expires)
{
return await _observationRepository.FindLatestUniqueValuesByName(patientId, name, expires);
}
//TODO To implement
/// <summary>
/// Retrieves all patient observations recorded after the specified date, optionally filtered by a list of observation types.
/// </summary>
/// <param name="patientId">The unique identifier of the patient whose observations are being retrieved.</param>
/// <param name="date">The cutoff date; only observations recorded after this date will be returned.</param>
/// <param name="filterObservations">An optional list of observation identifiers used to narrow the returned results.</param>
/// <returns>A task that represents the asynchronous operation, containing a list of patient observations matching the criteria.</returns>
/// <exception cref="NotImplementedException">Thrown when the method is invoked, as the implementation has not yet been provided.</exception>
public Task<List<PatientObservation>> FindAllAfterDate(ObjectId patientId, DateTime date,
List<string>? filterObservations = null)
{
throw new NotImplementedException();
}
/// <summary>
/// Retrieves the most recent non-expired patient observations matching the specified name, optionally filtered by an end-after threshold and limited in count.
/// </summary>
/// <param name="patientId">The identifier of the patient whose observations are being queried.</param>
/// <param name="name">The name of the observation to filter by.</param>
/// <param name="endAfter">Optional threshold used to restrict which observations are considered; if null, no end-after filter is applied.</param>
/// <param name="num">Optional maximum number of observations to return; if null, all matching observations are returned.</param>
/// <returns>A task that represents the asynchronous operation, containing an enumerable collection of matching <see cref="PatientObservation"/> instances.</returns>
public async Task<IEnumerable<PatientObservation>> FindLastNotExpiredObservatonsByPatient(ObjectId patientId,
string name, int? endAfter = null, int? num = null)
{
return await _observationRepository.FindLastNotExpiredObservatonsByPatient(patientId, name, endAfter, num);
}
/// <summary>
/// Retrieve all observations with expire time from configObservation service
/// Check all of this observations in patient_observations and expire them if (obs.time + config.expires) smaller than
/// current time
/// mark as expired in bd.
/// </summary>
public async Task CheckAndExpireObservations()
{
var count = 0;
_logger.LogDebug("start checking expired observations");
await using var enumerator = FindNotExpiredObservationsShouldBeExpired().GetAsyncEnumerator();
while (await enumerator.MoveNextAsync())
{
count++;
var current = enumerator.Current;
current.Expired = true;
await _observationRepository.Update(current);
}
_logger.LogDebug("found observations {count} ", count);
}
/// <summary>
/// Updates the repository to mark a list of patient observations as expired, invalidates the corresponding cache entries, and creates audit log entries capturing the pre-update state of each observation.
/// </summary>
/// <param name="patientObservations">The list of patient observations to be marked as expired.</param>
public async Task UpdateExpiredObservations(List<PatientObservation> patientObservations)
{
await _observationRepository.UpdateExpiredObservations(patientObservations);
var id = patientObservations.First().Id;
await _cacheService.DeleteByPatternAsync(CacheKeyPatterns.ByPatient("patients:latestObs", id.ToString()));
foreach (var observation in patientObservations)
{
var auxObs = await _auditService.DeepCopyAsync(observation);
observation.Expired = true;
await _auditService.CreateAuditLogAsync(_httpContextAccessor.HttpContext?.User!, auxObs, observation);
}
}
/// <summary>
/// Retrieves observations that are currently marked as not expired but should be expired based on their configured expiration thresholds.
/// For each candidate observation, the patient is validated; missing patients trigger cleanup of their observations and cached entries. Observations with missing names or unparsable expiration values are skipped, and only those whose expected expiration time (observation time plus configured minutes) has passed are yielded.
/// </summary>
/// <returns>An asynchronous stream of <see cref="PatientObservation"/> instances that are not expired in storage but whose effective expiration time has elapsed.</returns>
public async IAsyncEnumerable<PatientObservation> FindNotExpiredObservationsShouldBeExpired()
{
var expiringObservations = await _configObservationService.GetAllConfigs();
var expiringList = expiringObservations.ToList();
expiringList.RemoveAll(z => z.Expires == null);
var count = 0;
var uniqueNames = expiringList
.Select(item => item.Name)
.Distinct()
.ToList();
using var enumerator = (await _observationRepository.FindNotExpired(uniqueNames)).GetEnumerator();
while (enumerator.MoveNext())
{
count++;
var current = enumerator.Current;
var patientid = current.PatientId;
var patient = await _patientService.FindById(patientid);
if (patient == null)
{
await _observationRepository.FindByPatientId(patientid);
await _observationRepository.DeleteByPatientId(patientid);
await _cacheService.DeleteByPatternAsync(CacheKeyPatterns.ByPatient("patients:latestObs", patientid.ToString()));
continue;
}
if (current.Name == null) continue;
await _configObservationService.GetConfigObservationItemsByName(current.Name);
var configObs = expiringObservations?
.FirstOrDefault();
if (configObs == null) continue;
if (!double.TryParse(configObs.Expires.ToString(), out var expires))
continue;
var expectedExpireTime = current.Time.AddMinutes(expires);
if (DateTime.Now > expectedExpireTime) yield return current;
}
_logger.LogDebug("expired observations retrieved {count} observations", count);
}
/// <summary>
/// Retrieves all observation configurations and expires those whose <c>Expires</c> value is set to a positive number, ignoring configurations with a null or non-positive expiry.
/// </summary>
public async Task ExpireObservations()
{
//TODO expire each section
var expiringObservations = await _configObservationService.GetAllConfigs();
var expiringList = expiringObservations.ToList();
expiringList.RemoveAll(z => z.Expires is null or <= 0);
await _observationRepository.ExpireExpiredObservations(expiringList.Distinct()
.ToList());
}
/// <summary>
/// Expires patient observations that should no longer be active and recalculates the latest observation values per configured field. Sets a global flag while running to signal that expiration is in progress, processes expirations in batches of 1000 to limit memory usage, and clears the patient observations cache once finished; any error is logged and swallowed without rethrowing.
/// </summary>
public async Task ExpireObservationsAndRecalculateAsync()
{
try
{
GlobalData.AddData("isCheckingExpiration", true);
var configObservations =
await _configObservationService.GetAllConfigs();
var names = configObservations.Select(o => o.Name).ToList();
var patients = await _patientService.FindAll();
foreach (var patient in patients)
{
var listField = names?.Select(n => new Field
{
Name = n,
Last = 1,
OnlyExpired = true
}).ToList();
var lastPatientObservationsByName =
await _observationRepository.AggregatedPatientLastObservationsByField(patient.Id, listField);
foreach (var obs in
lastPatientObservationsByName)
await InsertObservation(obs, false, false); //Not really insert, only makes calcs
}
var obsToExpireList = new List<PatientObservation>();
var i = 0;
var count = 0;
await using var enumerator = FindNotExpiredObservationsShouldBeExpired().GetAsyncEnumerator();
while (await enumerator.MoveNextAsync())
{
i++;
//every 1000 cut list to avoid memory leaks when there are tons of data.
if (i > 1000)
{
await UpdateExpiredObservations(obsToExpireList);
i = 0;
obsToExpireList.Clear();
}
var current = enumerator.Current;
current.Expired = true;
obsToExpireList.Add(current);
count++;
}
await _cacheService.DeleteByPatternAsync(CacheKeyPatterns.ForEntity(CacheEnum.EntityType.PatientObservations));
_logger.LogDebug("number of expired observations should be expired:{count}", count);
}
catch (Exception ex)
{
_logger.LogError("ERROR expire observations And recalculate {exMessage} trace: {exStackTrace}",
ex.Message, ex.StackTrace);
}
GlobalData.AddData("isCheckingExpiration", false);
}
/// <summary>
/// Updates multiple observation records by replacing the specified old object identifier with a new one for the given name identifier.
/// </summary>
/// <param name="nameId">The name identifier of the field whose value should be updated across matching records.</param>
/// <param name="id">The new <see cref="ObjectId"/> value to assign to the matching records.</param>
/// <param name="oldId">The existing <see cref="ObjectId"/> value to be replaced.</param>
public async Task UpdateManyObjectId(string nameId, ObjectId id, ObjectId oldId)
{
await _observationRepository.UpdateManyObjectId(nameId, id, oldId);
}
/// <summary>
/// Inserts a simple patient observation into the repository and records an audit log entry for the operation using the current HTTP context user.
/// </summary>
/// <param name="observation">The patient observation to insert.</param>
public async Task InsertSimpleObservation(PatientObservation observation)
{
await _observationRepository.InsertOneAsync(observation);
await _auditService.CreateAuditLogAsync(_httpContextAccessor.HttpContext?.User!, null, observation);
}
/// <summary>
/// Retrieves a paginated list of patient observations for a specific patient within an optional date range, optionally filtering by observation names and supporting both active and archived collections.
/// </summary>
/// <param name="patientId">The unique identifier of the patient whose observations are being queried.</param>
/// <param name="startDate">The inclusive lower bound of the observation time range. Falls back to <see cref="DateTime.MinValue"/> when null.</param>
/// <param name="endDate">The exclusive upper bound of the observation time range. Falls back to <see cref="DateTime.MaxValue"/> when null.</param>
/// <param name="filterObservations">Optional list of observation names to restrict the results to. When null or empty, no name-based filter is applied.</param>
/// <param name="fromArchived">When true, queries the archived observation collection; otherwise, queries the active observation collection.</param>
/// <param name="filter">Optional pagination settings controlling the page number and page size of the returned results.</param>
/// <returns>A task that resolves to the list of <see cref="PatientObservation"/> records matching the specified criteria.</returns>
public async Task<List<PatientObservation>> FindAllBetweenDates(
ObjectId patientId,
DateTime? startDate = null,
DateTime? endDate = null,
List<string>? filterObservations = null,
bool fromArchived = false,
PaginationFilter? filter = null
)
{
try
{
var collection = fromArchived
? _observationArchiveRepository.Collection
: _observationRepository.Collection;
var filterBuilder = Builders<PatientObservation>.Filter;
var conditions = new List<FilterDefinition<PatientObservation>>
{
filterBuilder.Eq(o => o.PatientId, patientId),
filterBuilder.Ne(o => o.Name, null),
//filterBuilder.In(o => o.Name, filterObservations ?? new List<string?>()),
filterBuilder.Gt(o => o.Time, startDate ?? DateTime.MinValue),
filterBuilder.Lt(o => o.Time, endDate ?? DateTime.MaxValue)
};
if (filterObservations != null && filterObservations.Any())
conditions.Add(filterBuilder.In(o => o.Name, filterObservations));
var combinedFilter = filterBuilder.And(conditions);
var findOptions = new FindOptions<PatientObservation>
{
Skip = (filter?.PageNumber - 1) * filter?.PageSize,
Limit = filter?.PageSize
};
var cursor = await collection.FindAsync(combinedFilter, findOptions);
var observationsList = await cursor.ToListAsync();
return observationsList;
}
catch (Exception ex)
{
Log.Error("Error while getting PatientObservation between dates. Exception: {ex}", ex);
throw;
}
}
/// <summary>
/// Asynchronously processes alert expiration and powers off beacon LEDs for points of care that are not currently in use.
/// Skips patients located in virtual/moved/deleted/pushed/unknown locations and ignores emulated beacons and configurations with disabled alarms.
/// </summary>
public async Task ExpireAlertsAndPowerOffAsync()
{
try
{
GlobalData.AddData("isCheckingAlertsExpiration", true);
var pointOfCares = await _pointOfCareService.GetAllConfigs();
pointOfCares = pointOfCares
.Where(box =>
box.Configuration is { BeaconList: not null } &&
box.Configuration!.BeaconList.Any() &&
!box.Configuration!.BeaconList.First().Options.Emulate)
.ToList();
var configObs = await _configObservationService.GetAllConfigs();
var configList = configObs.ToList();
configList.RemoveAll(z => z.Alarm is not { Enabled: true });
if (configList.Count == 0)
return;
var patients = await _patientService.FindAll();
if (!patients.Any())
return;
//apagamos balizas de los boxes que no tienen pacientes por si se ha quedado alguna encendida
var emptyBoxes = pointOfCares
.Where(box => box.Status != StatusEnum.PointOfCare.InUse)
.ToList();
emptyBoxes.ForEach(async void (b) =>
{
try
{
await _lightBeaconService.PowerOffLed(b.Id);
}
catch (Exception ex)
{
_logger.LogError("Exception powerOffLed {exMessage} trace: {exStackTrace}",
ex.Message, ex.StackTrace);
}
});
var configObservationItems = configObs.ToList();
var endAfterDefaultValue = configObservationItems
.Where(c => c.Alarm != null)
.Select(c =>
c.Alarm?.EndAfter ?? 0) // comprobación de null aquí por seguridad adicional.
.DefaultIfEmpty(0)
.Max();
foreach (var patient in patients)
{
//Nos quedamos con los pacientes que tienen cama
if (patient.Location.UnitName == nameof(VirtualPointOfCare.Moved) ||
patient.Location.UnitName == nameof(VirtualPointOfCare.Deleted) ||
patient.Location.UnitName == nameof(VirtualPointOfCare.Pushed) ||
patient.Location.UnitName == nameof(VirtualPointOfCare.Unknown))
continue;
_ = CheckBeacon(patient, configObs, endAfterDefaultValue);
_ = CheckRelay(patient, configObs, endAfterDefaultValue);
_ = CheckRecordings(patient, pointOfCares, configObs, endAfterDefaultValue);
}
}
catch (Exception ex)
{
_logger.LogError("ERROR expire observations And recalculate {exMessage} trace: {exStackTrace}",
ex.Message, ex.StackTrace);
}
GlobalData.AddData("isCheckingAlertsExpiration", false);
}
/// <summary>
/// Retrieves a paginated collection of patient observations from the repository, returning both the requested page of data and the total document count to support client-side pagination.
/// </summary>
/// <param name="filter">The pagination filter that specifies the page number and page size used to compute the skip/limit range.</param>
/// <returns>A <see cref="PaginationResponse{PatientObservation}"/> containing the page of patient observations along with pagination metadata.</returns>
public async Task<PaginationResponse<PatientObservation>> GetPaginatedObservations(PaginationFilter filter)
{
var result = _observationRepository.GetPaginatedObservations(filter);
var count = await result.CountDocumentsAsync();
var data = await result.Skip((filter.PageNumber - 1) * filter.PageSize)
.Limit(filter.PageSize)
.ToCursorAsync();
var dataList = await data.ToListAsync();
return new PaginationResponse<PatientObservation>(dataList, filter.PageNumber, filter.PageSize, count);
}
/// <summary>
/// Executes the configured retention policy actions for a given patient observation, deleting older entries
/// based on the resolved policy (days, seconds, count, or none) and auditing the deleted observations. The
/// method performs an early return when the retention configuration is unavailable, the policy value is missing,
/// or the observation name is empty, and logs any errors that occur during processing.
/// </summary>
/// <param name="obs">The patient observation used to resolve the retention policy and identify which records to delete.</param>
private async Task DoRetentionActions(PatientObservation obs)
{
try
{
var result = await _configObservationService.RetentionActions(obs);
if (result == null || !result.RetentionPolicyValue.HasValue || string.IsNullOrEmpty(obs.Name)) return;
var deletedObs = new List<PatientObservation>();
switch (result.RetentionPolicy)
{
case RetentionPolicy.DeleteOlderDays:
deletedObs =
await _observationRepository.DeleteOlderDaysAsync(obs.Name, result.RetentionPolicyValue.Value);
break;
case RetentionPolicy.DeleteOlderSeconds:
deletedObs = await _observationRepository.DeleteOlderSecondsAsync(obs.Name,
result.RetentionPolicyValue.Value);
break;
case RetentionPolicy.DeleteOlderNumber:
deletedObs = await _observationRepository.DeleteOlderNumberAsync(obs.Name,
result.RetentionPolicyValue.Value);
break;
case RetentionPolicy.NoDelete:
default:
break;
}
foreach (var observation in deletedObs)
await _auditService.CreateAuditLogAsync(_httpContextAccessor.HttpContext?.User!, observation, null);
await _cacheService.DeleteByPatternAsync(CacheKeyPatterns.ByPatient("patients:latestObs", obs.PatientId.ToString()));
}
catch (Exception ex)
{
_logger.LogError("Error doing retention actions. Exception: {ex}", ex);
}
}
/// <summary>
/// Processes a patient observation by identifying matching subscriber groups, regenerating the grouped
/// observation, updating the group's last grouped observation, notifying all group members, and
/// clearing the related cache entries. Skips groups where the observation name is not contained
/// in the group's names or where the group does not consider the observation relevant.
/// </summary>
/// <param name="obs">The incoming patient observation used to find and update matching groups.</param>
private async void CheckForGroupedObs(PatientObservation obs)
{
try
{
//Removed ignore obs to future, we accept them and recalculate and front decide what to do
foreach (var wsg in _subscriberGroupedService.GetGrouped().Where(g =>
g.PatientId == obs.PatientId && obs.Name != null && g.Names.Contains(obs.Name) &&
g.IsNewObservationRelevantForGroup(obs)))
{
wsg.Timer.Stop();
var groupedField = new GroupedField
{
Names = wsg.Names,
Name = obs.Name ?? string.Empty,
Max = wsg.Max,
Group = wsg.Group.FirstOrDefault().Value,
Regularity = wsg.Regularity,
Result = wsg.Result,
Since = wsg.Since,
StartTimeShift = wsg.StartTimeShift
};
var newGroupedObservation = await _groupedObservationService
.GenerateGroupedObservation(obs.PatientId, groupedField, wsg.LastGroupedObservationObs, obs,
wsg.TimeZoneId);
_subscriberGroupedService.UpdateLastGroupedObsInGroup(wsg.HashCode, newGroupedObservation);
foreach (var gr in wsg.Group)
{
var grName = wsg.Group.GetValue(gr.Key);
if (grName == null) continue;
newGroupedObservation.Group = grName;
_ = Task.Run(() => _clientMessageService.SendAsync(gr.Key, OperationType.GroupedObservation,
newGroupedObservation));
}
//wsg.TimerReestart();
await _cacheService.DeleteByPatternAsync(CacheKeys.GroupedObs(obs.PatientId, obs.Name ?? string.Empty));
}
}
catch (Exception e)
{
_logger.LogError("error checking grouped observations: exception: {eMessage} trace:{eStackTrace}",
e.Message, e.StackTrace);
}
}
/// <summary>
/// Processes an intravenous line observation for a patient from the provided API request, extracting catheter type and location from the observation text and mapping additional observations to insertion, removal, and duration details. The observation is only persisted when the line status is recognized as <c>Insertado</c> (Inserted) or <c>Retirado</c> (Removed); otherwise the method logs an error or exits without inserting.
/// </summary>
/// <param name="apiRequest">The API request containing the observation text, parent data, message time, and additional observations used to build the intravenous line record.</param>
/// <param name="patient">The patient to associate the resulting observation with.</param>
private async Task ProcessIntravenousLinesObservation(ApiRequest apiRequest, Patient patient)
{
var isInsertable = false;
_logger.LogDebug("INSERT IntraVenousLineObservation");
if (apiRequest.ObsertationData == null || !apiRequest.ObsertationData.Time.HasValue)
{
_logger.LogError("Error INSERTING IntraVenousLineObservation. ");
return;
}
var obs = new PatientObservation
{
Time = apiRequest.ObsertationData.Time.Value,
Code = apiRequest.ObsertationData.Code,
CodingSystem = apiRequest.ObsertationData.CodingSystem,
PatientId = patient.Id,
ParentData = new ParentDataClass
{
Code = apiRequest.ObservationData?.Code,
CodingSystem = apiRequest.ObservationData?.CodingSystem
},
MessageTime = apiRequest.MessageTime
};
//Todos los formatos de texto de vías son Catéter X: + localizacion
var typeLocation = apiRequest.ObservationData?.Text?.Split(':');
obs.Value = new PatientIntravenousLinesValue
{
Type = typeLocation != null ? typeLocation[0] : string.Empty
};
if (typeLocation is { Length: > 1 })
if (obs.Value is PatientIntravenousLinesValue obsValue)
obsValue.Location = typeLocation[1];
if (apiRequest.Observations != null)
{
if (obs.Value is not PatientIntravenousLinesValue obsValue)
return;
foreach (var observation in apiRequest.Observations)
{
var obsValueStr = observation.Value.ToString();
if (obsValueStr == null) continue;
switch (observation.Code)
{
case "273248003":
//In ICCA an intravenouse Line OBS should come with Insertado and Retirado but can come with more, ignore them.
if (obsValueStr.Equals("Insertado") || obsValueStr.Equals("Retirado"))
{
obsValue.Action = observation.Value.ToString() ?? "NULL";
isInsertable = true;
}
else
{
return;
}
break;
case "397898000":
obsValue.RemoveTime = (DateTime?)observation.Value;
break;
case "439272007":
obsValue.InsertTime = (DateTime?)observation.Value;
break;
case "228864003":
obsValue.Duration = observation.Value.ToString() ?? "NULL";
break;
}
}
}
if (!isInsertable) return;
await InsertObservation(obs);
}
/// <summary>
/// Processes allergies observation data from the API request and persists it as a patient observation.
/// Validates that observation data and timestamps are present, maps SNOMED-coded allergy entries to allergy types, values, and notes,
/// and short-circuits when the patient reports "no known allergies" (Sin alergias conocidas).
/// </summary>
/// <param name="apiRequest">The API request containing the observation data and coded allergy entries to process.</param>
/// <param name="patient">The patient associated with the allergies observation being recorded.</param>
private async Task ProcessAllergiesObservation(ApiRequest apiRequest, Patient patient)
{
_logger.LogDebug("INSERT AllergiesObservation");
if (apiRequest.ObservationData == null)
{
_logger.LogError("Error Processing Allergies Observation. ObservationData is null");
return;
}
if (!apiRequest.ObservationData.Time.HasValue)
{
_logger.LogError("Error Processing Allergies Observation. ObservationData time null");
return;
}
var obs = new PatientObservation
{
Time = apiRequest.ObservationData.Time.Value,
Name = apiRequest.ObsertationData?.Text ?? string.Empty,
Code = apiRequest.ObservationData.Code,
CodingSystem = apiRequest.ObservationData.CodingSystem,
PatientId = patient.Id,
ParentData = new ParentDataClass
{
Code = apiRequest.ObservationData.Code,
CodingSystem = apiRequest.ObservationData.CodingSystem,
Name = apiRequest.ObservationData.Text
},
MessageTime = apiRequest.MessageTime
};
List<PatientAllergiesValue> patientAllergiesValues = [];
PatientAllergiesValue allergiesValues = new();
if (apiRequest.Observations == null)
{
_logger.LogError("Error Processing Allergies Observation. Observations null");
return;
}
for (var i = 0; i <= apiRequest.Observations.Count - 1; i++)
{
switch (apiRequest.Observations[i].Code)
{
case "263490005":
if (apiRequest.Observations[i].Value.ToString() == "Sin alergias conocidas") return;
continue;
case "300916003": //Latex
if (apiRequest.Observations[i].Value.ToString() == "Si")
{
allergiesValues.Type = "Latex";
allergiesValues.Value = "Si";
//patientAllergiesValues.Add(allergiesValues);
//allergiesValues = new PatientAllergiesValue { };
}
break;
case "419199007":
allergiesValues.Type = apiRequest.Observations[i].Value.ToString();
break;
case "277054007":
case "416098002":
allergiesValues.Value = apiRequest.Observations[i].Value.ToString();
break;
case "281296001":
allergiesValues.Notes = apiRequest.Observations[i].Value.ToString();
break;
}
if (i == apiRequest.Observations.Count - 1)
{
if (allergiesValues is { Type: not null })
patientAllergiesValues.Add(allergiesValues);
//allergiesValues = new PatientAllergiesValue { value = new List<string>(), notes = new List<string>() };
allergiesValues = new PatientAllergiesValue();
}
else
{
i++;
if (apiRequest.Observations[i].Code == "300916003" ||
apiRequest.Observations[i].Code == "419199007")
{
if (allergiesValues is { Type: not null })
patientAllergiesValues.Add(allergiesValues);
//allergiesValues = new PatientAllergiesValue { value = new List<string>(), notes = new List<string>() };
allergiesValues = new PatientAllergiesValue();
}
i--;
}
}
obs.Value = patientAllergiesValues;
await InsertObservation(obs);
}
/// <summary>
/// Processes an isolation observation when the incoming request contains an observation with the text "Aislamiento" and a non-null timestamp, normalizing the value by replacing semicolons with commas and persisting it as a <c>PatientObservation</c> under the "Isolation" name and "ADAS" coding system. If the observation value is null, an error is logged and the method returns without inserting.
/// </summary>
/// <param name="apiRequest">The incoming API request whose <c>ObservationData</c> is inspected for the isolation marker text and timestamp.</param>
/// <param name="patient">The patient associated with the observation, used to assign the patient identifier to the new record.</param>
private async Task ProcessIsolationObservation(ApiRequest apiRequest, Patient patient)
{
if (apiRequest.ObservationData is { Text: "Aislamiento", Time: not null })
{
_logger.LogDebug("INSERT Isolation OBS");
var value = apiRequest.ObservationData.Value?.ToString()?.Replace(";", ",");
if (value == null)
{
_logger.LogError("Error Processing Isolation Observation. Value is null");
return;
}
var obs = new PatientObservation
{
Time = apiRequest.ObservationData.Time.Value,
Name = "Isolation",
CodingSystem = "ADAS",
PatientId = patient.Id,
Value = value,
MessageTime = apiRequest.MessageTime
};
await InsertObservation(obs);
}
}
/// <summary>
/// Processes a postural changes observation (<c>CAMBIOS POSTURALES</c>) from the API request, mapping it to a <see cref="PatientObservation"/> entry and persisting it.
/// Falls back to the single <c>Observation</c> or the first item of <c>Observations</c> when the observation data value is empty, and skips processing when the value or time is missing.
/// Replaces semicolons with commas in the value before insertion to ensure proper formatting.
/// </summary>
/// <param name="apiRequest">The incoming API request containing the observation data, observations collection, and message timestamp to be processed.</param>
/// <param name="patient">The patient associated with the observation; its identifier is stored in the resulting <see cref="PatientObservation"/>.</param>
private async Task ProcessPositionObservation(ApiRequest apiRequest, Patient patient)
{
if (apiRequest.ObservationData != null &&
apiRequest.ObservationData?.Text?.ToUpper() == "CAMBIOS POSTURALES")
{
_logger.LogDebug("INSERT Position OBS");
var strValue = apiRequest.ObservationData.Value?.ToString();
if (string.IsNullOrEmpty(strValue))
{
var ob = apiRequest.Observation ?? apiRequest.Observations?.FirstOrDefault();
if (ob != null)
strValue = ob.Value.ToString();
}
if (string.IsNullOrEmpty(strValue))
{
_logger.LogWarning("Position Observation value is null or empty.");
return;
}
if (!apiRequest.ObservationData.Time.HasValue)
{
_logger.LogWarning("Position Observation time is null. ");
return;
}
var obs = new PatientObservation
{
Time = apiRequest.ObservationData.Time.Value,
Name = "Patient_Position",
CodingSystem = "ADAS",
PatientId = patient.Id,
Value = strValue.Replace(";", ","),
MessageTime = apiRequest.MessageTime
};
await InsertObservation(obs);
}
}
/// <summary>
/// Processes and persists a drainage observation for the given patient, mapping incoming observation codes to drainage-specific properties such as type, height, location, and volume. Validates that required observation data and the value object are present before building and inserting the observation; logs a warning and exits early if validation fails.
/// </summary>
/// <param name="apiRequest">The API request containing the observation data, time, and the list of observations to be processed.</param>
/// <param name="patient">The patient associated with the drainage observation being recorded.</param>
private async Task ProcessDrainageObservation(ApiRequest apiRequest, Patient patient)
{
_logger.LogDebug("INSERT DrainageObservation");
if (apiRequest.ObservationData == null || !apiRequest.ObservationData.Time.HasValue ||
apiRequest.Observations == null)
{
_logger.LogWarning("Error Processing Drainage Observation.");
return;
}
var obs = new PatientObservation
{
Time = apiRequest.ObservationData.Time.Value,
Code = apiRequest.ObservationData.Code,
CodingSystem = apiRequest.ObservationData.CodingSystem,
PatientId = patient.Id,
ParentData = new ParentDataClass
{
Code = apiRequest.ObservationData.Code,
CodingSystem = apiRequest.ObservationData.CodingSystem,
Name = apiRequest.ObservationData.Text
},
MessageTime = apiRequest.MessageTime
};
if (apiRequest.Observation != null) obs.Time = apiRequest.Observation.Time;
obs.Value = new PatientDrainagesValue();
if (obs.Value is not PatientDrainagesValue obsVal)
{
_logger.LogDebug("Error Processing Drainage Observation. Value is null. ");
return;
}
foreach (var observation in apiRequest.Observations)
switch (observation.Code)
{
case "138875005":
switch (observation.Name ?? "")
{
case "Tipo de drenaje":
obsVal.Type = observation.Value.ToString();
break;
case "Altura columna(cmH2O)":
if (int.TryParse(observation.Value.ToString(), out var valueParsed))
obsVal.Height = valueParsed;
break;
}
break;
case "10546003":
obsVal.Location = observation.Value.ToString();
break;
case "56868008":
if (int.TryParse(observation.Value.ToString(), out var obsValue)) obsVal.Volume = obsValue;
break;
}
await InsertObservation(obs);
}
/**
* Las obs que se insertan de forma manual desde nurse deben seguir la logica contraria a las obs
* recibidas desde el censo
*/
/// <summary>
/// Saves manual nurse observations following the logic opposite to that of observations received from the census.
/// </summary>
/// <param name="apiRequest">The API request containing the data required to locate the patient and the observations to be saved.</param>
/// <returns>A task that represents the asynchronous save operation.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="apiRequest"/> is null.</exception>
public async Task SaveRequestNurseObs(ApiRequest apiRequest)
{
var patient = await _patientService.FindPatientByApiRequest(apiRequest);
if (patient == null)
{
// NO PATIENTS OR LOCATIONS WERE FOUND
_logger.LogWarning(
"Observation for patient: {apiRequestpatientNumber} with location: {apiRequestlocation} not found, ignoring",
apiRequest.PatientNumber, apiRequest.Location);
return;
}
if (apiRequest.Observations != null)
ProcessObservations(apiRequest.Observations, patient, apiRequest.MessageTime,
apiRequest.ObservationData);
}
/// <summary>
/// Stops manual recordings for a patient when there are no active recording alarms, based on the patient's point of care configuration and the observation configuration list.
/// </summary>
/// <param name="patient">The patient whose recordings are being evaluated.</param>
/// <param name="configs">The list of point of care configurations used to locate the configuration associated with the patient.</param>
/// <param name="obsConfigList">The observation configurations from which the recording end-after time is derived, falling back to <paramref name="defaultValue"/> when no recording alarms are configured.</param>
/// <param name="defaultValue">The default end-after value applied when no observation configuration specifies a recording alarm.</param>
private async Task CheckRecordings(Patient patient, List<PointOfCare> configs,
IEnumerable<ConfigObservation> obsConfigList, int defaultValue)
{
//paramos grabación si no hay alarmas activas y hay una grabación
var recordingEndAfter = obsConfigList
.Where(c => c.Alarm is { Recording: not null })
.Select(c => c.Alarm is { Recording: not null } ? c.Alarm.Recording.EndAfter : defaultValue)
.DefaultIfEmpty(defaultValue)
.Max();
var obsListWithRecordingAlarms =
await _observationRepository.FindLastNotExpiredObservatonsByPatient(patient.Id, "ADAS_ALARM",
recordingEndAfter);
if (obsListWithRecordingAlarms.Any())
return;
var boxCfg = configs.FirstOrDefault(box => box.Id == patient.PointOfCareId);
var roomId = boxCfg?.Configuration?.Id;
if (roomId == null || !int.TryParse(roomId.ToString(), out var roomIdParsed))
return;
var currentRecordings = await _recordingService.GetRecordings(roomIdParsed);
if (currentRecordings == null || !currentRecordings.Any())
return;
var manualRecordings =
currentRecordings.Where(r => r.AlarmType == AlarmEnum.Type.Manual);
foreach (var rec in manualRecordings)
{
_logger.LogDebug("PatientId: {obsid}. Send Stop Manual Recording Power Off", patient.Id);
var manualRecording = new ManualRecording
{
Recording = new MRecording
{
StartRecordingTime = rec.StartRecordingTime,
StopRecordingTime = rec.StopRecordingTime ?? DateTime.Now
}
};
if (boxCfg != null)
await _recordingService.SendRecordingData(patient, boxCfg, manualRecording, false);
}
}
/// <summary>
/// Checks the relay state for a patient based on observation configurations and active alarms, and powers off
/// the configured relays when no active "ADAS_ALARM" observations remain within the configured end-after window.
/// Falls back to the provided default value when no observation configurations define an <c>OpenDoor</c> alarm,
/// and skips relay control when the patient has no associated point of care.
/// </summary>
/// <param name="patient">The patient whose relay state is being evaluated; must have a valid <c>PointOfCareId</c>.</param>
/// <param name="obsConfigList">Collection of observation configurations used to determine the relay end-after threshold via the <c>OpenDoor</c> alarm.</param>
/// <param name="defaultValue">Fallback value used for the relay end-after threshold when no <c>OpenDoor</c> alarm configuration is present.</param>
private async Task CheckRelay(Patient patient,
IEnumerable<ConfigObservation> obsConfigList, int defaultValue)
{
var relayEndAfter = obsConfigList
.Where(c => c.Alarm is { OpenDoor: not null })
.Select(c => c.Alarm is { OpenDoor: not null } ? c.Alarm.OpenDoor.EndAfter : defaultValue)
.DefaultIfEmpty(defaultValue)
.Max();
var obsListWithRelayAlarms =
await _observationRepository.FindLastNotExpiredObservatonsByPatient(patient.Id, "ADAS_ALARM",
relayEndAfter);
if (!patient.PointOfCareId.HasValue)
{
_logger.LogError("PatientId: {PatId} has no points of Care on check relay", patient.Id);
return;
}
//Si no hay activas apagamos Relay
var relayConfig = await _pointOfCareService.FindById(patient.PointOfCareId.Value);
if (relayConfig is { Configuration.RelayIdList: not null } && !obsListWithRelayAlarms.Any())
{
_logger.LogDebug("PatientId: {PatId}. Send relay Power Off", patient.Id);
var relays = _relayService.GetRelayInList(relayConfig.Configuration.RelayIdList);
relays.ForEach(async void (r) =>
{
try
{
await _relayService.PowerOff(r);
}
catch (Exception e)
{
_logger.LogError("PatientId: {PatId}. Exception Power Off Relay {exMessage} trace: {exStackTrace}",
patient.Id, e.Message, e.StackTrace);
}
});
}
}
/// <summary>
/// Checks whether the patient has any active beacon alarms and, if none are found and the patient is assigned to a point of care, powers off the beacon LED.
/// </summary>
/// <param name="patient">The patient whose beacon state is being evaluated; its identifier and point of care assignment are used to locate recent observations and target the beacon.</param>
/// <param name="obsConfigList">The list of observation configurations used to determine the maximum beacon end-after value from the alarms that are both enabled and have their beacon enabled, falling back to <paramref name="defaultValue"/> when no configuration matches or the beacon is null.</param>
/// <param name="defaultValue">The fallback value used for the beacon end-after period when no configuration provides a value or when the matching configuration's beacon is null.</param>
private async Task CheckBeacon(Patient patient,
IEnumerable<ConfigObservation> obsConfigList, int defaultValue)
{
var beaconEndAfter = obsConfigList
.Where(c => c.Alarm is { Enabled: true, Beacon.Enabled: true })
.Select(c => c.Alarm is { Beacon: not null } ? c.Alarm.Beacon.EndAfter : defaultValue)
.DefaultIfEmpty(defaultValue)
.Max();
var obsListWithBeaconAlarms =
await _observationRepository.FindLastNotExpiredObservatonsByPatient(patient.Id, "ADAS_ALARM",
beaconEndAfter);
//TODO: Heredar de la observación el endAfter si el de la baliza está 0
//Si no tiene alarmas activas y no está ya apagada lo hacemos
if (!obsListWithBeaconAlarms.Any() && patient.PointOfCareId.HasValue)
{
_logger.LogDebug("PatientId: {patientId}. Send Beacon code Power Off", patient.Id);
await _lightBeaconService.PowerOffLed(patient.PointOfCareId.Value);
}
}
}