Files
adas-core/adas-core.Application/Services/ConfigObservationService.cs
T

867 lines
45 KiB
C#

using System.Collections.Concurrent;
using adas_core.Application.Exceptions;
using adas_core.Application.Repositories.Interfaces;
using adas_core.Application.Services.Interfaces;
using adas_core.Domain.Enums;
using adas_core.Domain.Models;
using adas_core.Domain.Models.AppSettings;
using adas_core.Domain.Models.DTO;
using adas_core.Domain.Models.Filter;
using adas_core.Domain.Models.GroupedObservations;
using adas_core.Domain.Models.MongoModels;
using adas_core.Domain.Models.Pumps;
using adas_core.Domain.Models.Responses;
using adas_core.Domain.Utils;
using Microsoft.AspNetCore.Http;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
namespace adas_core.Application.Services;
/// <summary>
/// Provides a concrete implementation of the <see cref="IConfigObservationService"/> contract for observing configuration state.
/// </summary>
/// <remarks>
/// Acts as the default service type that fulfills the configuration observation interface.
/// </remarks>
/// <!-- aidoc:v1 sig=b6374e4 -->
public class ConfigObservationService : IConfigObservationService
{
private static readonly ConcurrentDictionary<ObjectId, ConfigObservationCached> CachedConfigObservations = new();
private static readonly ConcurrentDictionary<string, ConfigObservationKeyCached>
CachedConfigObservationKeys = new();
private readonly IOptions<ApiSettings> _apiSettings;
private readonly ILocalAuditService _auditService;
private readonly IConfigObservationRepository _configObservationRepository;
private readonly RetentionPolicy _defaultRetentionPolicy;
private readonly int _defaultRetentionPolicyValue;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly bool _ignoreUnknownTreatment;
private readonly ILogger<ConfigObservationService> _logger;
private readonly int? _refreshTimeout;
private readonly IUnitService _unitService;
private readonly ICacheService _cacheService;
private readonly CacheSettings? _cacheSettings;
private bool IgnoreUnknownObservation =>
_apiSettings.Value.ConfigObservation?.IgnoreUnknownObservation ?? false;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigObservationService"/> class, storing its required dependencies and reading configuration values from the supplied <see cref="IOptions{TOptions}"/> instances to establish internal operational defaults such as the refresh timeout, unknown treatment handling, and retention policy.
/// </summary>
/// <param name="configObservationRepository">The <see cref="IConfigObservationRepository"/> used to access configuration observation data.</param>
/// <param name="apiSettings">The <see cref="IOptions{ApiSettings}"/> providing API configuration values, including the refresh interval and retention policy defaults.</param>
/// <param name="cacheSettings">The <see cref="IOptions{CacheSettings}"/> providing cache configuration values.</param>
/// <param name="logger">The <see cref="ILogger{ConfigObservationService}"/> used for diagnostic logging.</param>
/// <param name="unitService">The <see cref="IUnitService"/> used to perform unit-related operations.</param>
/// <param name="httpContextAccessor">The <see cref="IHttpContextAccessor"/> used to access the current HTTP context.</param>
/// <param name="auditService">The <see cref="ILocalAuditService"/> used to record local audit entries.</param>
/// <param name="cacheService">The <see cref="ICacheService"/> used for caching operations.</param>
/// <!-- aidoc:v1 sig=85a9f25 body=f5101e8 -->
public ConfigObservationService(
IConfigObservationRepository configObservationRepository,
IOptions<ApiSettings> apiSettings,
IOptions<CacheSettings> cacheSettings,
ILogger<ConfigObservationService> logger,
IUnitService unitService,
IHttpContextAccessor httpContextAccessor,
ILocalAuditService auditService,
ICacheService cacheService
)
{
_cacheService = cacheService;
_cacheSettings = cacheSettings.Value;
_configObservationRepository = configObservationRepository;
_apiSettings = apiSettings;
_logger = logger;
_unitService = unitService;
_refreshTimeout = _apiSettings.Value.ConfigObservation?.Refresh;
_ignoreUnknownTreatment = _apiSettings.Value.ConfigObservation?.IgnoreUnknownTreatment ?? false;
_defaultRetentionPolicyValue = _apiSettings.Value.RetentionPolicyValue;
_defaultRetentionPolicy = RetentionPolicy.NoDelete;
_httpContextAccessor = httpContextAccessor;
_auditService = auditService;
}
/// <summary>
/// Retrieves all configuration observations using a cache-aside strategy. If no cached value exists, the data is fetched from the repository and cached using the key and TTL determined by the cache settings.
/// </summary>
/// <param name="ct">A token to monitor for cancellation requests.</param>
/// <returns>A collection of all <see cref="ConfigObservation"/> entries, sourced from cache when available or from the repository otherwise.</returns>
/// <!-- aidoc:v1 sig=c20c545 body=6a29098 -->
public async Task<ICollection<ConfigObservation>> GetAllConfigs(CancellationToken ct = default)
{
var (key, ttl) = CacheKeys.ConfigObservationsAllKeyWithTtl(_cacheSettings);
var result = await _cacheService.GetOrSetObjectAsync(
key,
async () => await _configObservationRepository.FindAll(),
ttl,
ct);
return result;
}
/// <summary>
/// Retrieves a compact representation of all configuration observations by returning the total item count.
/// </summary>
/// <returns>A <see cref="ConfigObservationDto"/> containing the total number of configuration observations.</returns>
/// <!-- aidoc:v1 sig=42b2f1f body=5519b7f -->
public async Task<ConfigObservationDto> GetAllCompact()
{
var count = await _configObservationRepository.Count();
return new ConfigObservationDto { ItemCount = count };
}
/// <summary>
/// Retrieves a paginated list of <see cref="ConfigObservation"/> items from the repository along with the total count, used to build pagination metadata for the response.
/// </summary>
/// <param name="filter">The pagination filter containing the requested page number and page size used to retrieve the items and populate the response metadata.</param>
/// <returns>A <see cref="PaginationResponse{ConfigObservation}"/> containing the items for the requested page and the total count of all available items.</returns>
/// <!-- aidoc:v1 sig=6f51347 body=9b21a36 -->
public async Task<PaginationResponse<ConfigObservation>> GetPaginatedItems(PaginationFilter filter)
{
var result = await _configObservationRepository.GetPaginatedItems(filter);
var count = await _configObservationRepository.Count();
return new PaginationResponse<ConfigObservation>(result.ToList(), filter.PageNumber, filter.PageSize,
count);
}
/// <summary>
/// Retrieves a configuration observation by its unique identifier from the repository.
/// Returns null if no matching configuration observation is found.
/// </summary>
/// <param name="id">The unique identifier of the configuration observation to retrieve.</param>
/// <returns>The configuration observation matching the specified identifier, or null if not found.</returns>
/// <!-- aidoc:v1 sig=1821829 body=5e45c12 -->
public async Task<ConfigObservation?> GetConfigById(ObjectId id)
{
return await _configObservationRepository.FindById(id) ?? null;
}
/// <summary>
/// Retrieves the list of configuration names associated with the specified identifier by delegating to the configuration observation repository.
/// </summary>
/// <param name="id">The identifier used to look up the related configuration names.</param>
/// <returns>A task that represents the asynchronous operation, containing the list of configuration names matching the given identifier.</returns>
/// <!-- aidoc:v1 sig=cff8c0e body=4d4999a -->
public async Task<List<string>> GetConfigNames(string id)
{
return await _configObservationRepository.GetConfigNames(id);
}
/// <summary>
/// Retrieves the list of configuration names from the configuration observation repository.
/// </summary>
/// <returns>A task that represents the asynchronous operation. The task result contains a list of configuration names.</returns>
/// <!-- aidoc:v1 sig=fee3cef body=35d2c7e -->
public async Task<List<string>> GetConfigNames()
{
return await _configObservationRepository.GetConfigNames();
}
/// <summary>
/// Determines the retention action to apply for a patient observation by resolving its configured retention policy. Falls back to a "NoDelete" retention policy with no value when the observation has no associated configuration or its retention policy is null.
/// </summary>
/// <typeparam name="T">The patient observation type, constrained to <see cref="BasePatientObservation"/>.</typeparam>
/// <param name="obs">The patient observation for which the retention action is being evaluated.</param>
/// <returns>A task containing the resolved <see cref="ObservatitonRetentionResult"/>, or null when no configuration is available.</returns>
/// <!-- aidoc-review:v1 severity=high kind=wrong_returns
/// "The method's return type is nullable, but the code always returns a non-null ObservatitonRetentionResult (falling back to NoDelete/null-policy-value), never null. The doc claims it returns null when no configuration is available, which is incorrect." -->
public async Task<ObservatitonRetentionResult?> RetentionActions<T>(T obs) where T : BasePatientObservation
{
var conf = await Get(obs);
return conf is { RetentionPolicy: not null } ?
new ObservatitonRetentionResult(conf.RetentionPolicy.Value, conf.RetentionPolicyValue) :
new ObservatitonRetentionResult(RetentionPolicy.NoDelete, null);
}
/// <summary>
/// Resolves the status of a grouped patient observation by retrieving the configuration for the given name and mapping the observation through group-specific, result-specific, or default configuration. Returns <see cref="StatusEnum.Type.Ok"/> when no configuration exists for the name or when the mapping does not produce a status.
/// </summary>
/// <param name="groupedField">The grouped field whose <c>Group</c> key is used to look up group-specific configuration.</param>
/// <param name="result">The grouped observation result whose name is used to look up result-specific configuration.</param>
/// <param name="name">The observation name used to retrieve the configuration.</param>
/// <param name="value">The observation value included in the mapping.</param>
/// <param name="min">The optional minimum reference value included in the mapping.</param>
/// <param name="max">The optional maximum reference value included in the mapping.</param>
/// <returns>A task that resolves to the <see cref="StatusEnum.Type"/> computed from the mapped observation, or <see cref="StatusEnum.Type.Ok"/> when no applicable configuration or mapping status is found.</returns>
/// <!-- aidoc:v1 sig=2998c17 body=c347213 -->
public async Task<StatusEnum.Type> GroupedObservationStatus(GroupedField groupedField,
GroupedObservationEnum.Result result,
string name, object value, double? min, double? max)
{
var conf = await Get(name);
if (conf == null) return StatusEnum.Type.Ok;
PatientObservation? mapObs;
if (conf.Grouped != null && groupedField.Group != null &&
conf.Grouped.TryGetValue(groupedField.Group, out var grp))
{
mapObs = await MapConf(new PatientObservation { Name = name, Value = value, Min = min, Max = max }, grp);
if (mapObs != null) return mapObs.Status;
}
if (conf.Grouped != null && conf.Grouped.ContainsKey(result.ToString()))
{
mapObs = await MapConf(new PatientObservation { Name = name, Value = value, Min = min, Max = max },
conf.Grouped[result.ToString()]);
if (mapObs != null) return mapObs.Status;
}
mapObs = await MapConf(new PatientObservation { Name = name, Value = value, Min = min, Max = max }, conf);
if (mapObs != null) return mapObs.Status;
return StatusEnum.Type.Ok;
}
/// <summary>
/// Maps a patient observation to a configured representation by looking up its corresponding configuration
/// and applying the mapping. When no matching configuration is found, returns <c>null</c> if unknown
/// observations should be ignored, or the original observation otherwise. If the resolved configuration
/// has an empty name, the mapping is aborted and <c>null</c> is returned.
/// </summary>
/// <param name="obs">The patient observation to be mapped.</param>
/// <param name="onlyByName">If <c>true</c>, the configuration lookup is performed by name only; otherwise the full lookup is used.</param>
/// <returns>The mapped observation, the original observation when unknown observations are allowed, or <c>null</c> when mapping is ignored or the configuration is invalid.</returns>
/// <!-- aidoc:v1 sig=adab269 body=bb82b7b -->
public async Task<T?> Map<T>(T obs, bool onlyByName = false) where T : BasePatientObservation
{
var conf = onlyByName ? await Get(obs, onlyByName) : await Get(obs);
if (conf == null)
{
_logger.LogDebug("Ignore Unknown Observation. {Name} {Code} {CodingSystem}", obs.Name, obs.Code,
obs.CodingSystem);
return IgnoreUnknownObservation ? null : obs;
}
if (string.IsNullOrEmpty(conf.Name))
{
_logger.LogError("Config Name is null or empty. {conf}", conf);
return null;
}
return await MapConf(obs, conf);
}
/// <summary>
/// Removes a configuration observation item by its identifier. Returns <c>null</c> when the item does not exist,
/// otherwise deletes it from the repository and invalidates the cached collection of configuration observations.
/// </summary>
/// <param name="id">The unique identifier of the configuration observation to remove.</param>
/// <returns>The removed <see cref="ConfigObservation"/> if it was found and deleted; otherwise, <c>null</c>.</returns>
/// <!-- aidoc:v1 sig=93970f8 body=d775460 -->
public async Task<ConfigObservation?> RemoveConfigItem(ObjectId id)
{
var item = await _configObservationRepository.FindById(id);
if (item == null) return null;
var deleted = await _configObservationRepository.Delete(id);
// Invalidar CACHE (colección completa)
await _cacheService.DeleteByPatternAsync(CacheKeyPatterns.ForEntity(CacheEnum.EntityType.ConfigObservations));
return deleted;
}
/// <summary>
/// Maps a <see cref="PatientTreatment"/> by resolving the configuration observation for each of its requested give codes. Looks up the configuration by text when both the coding system and identifier are empty, otherwise by coding system and identifier. Returns <c>null</c> when the configuration is unknown and unknown treatments are ignored, or when the resolved configuration has no name; otherwise returns the original treatment.
/// </summary>
/// <param name="treatment">The patient treatment whose requested give codes are resolved against the configuration store.</param>
/// <returns>The original <see cref="PatientTreatment"/> if a valid configuration is found, or <c>null</c> when the treatment should be discarded.</returns>
/// <!-- aidoc:v1 sig=b468f2f body=f7cc310 -->
public async Task<PatientTreatment?> Map(PatientTreatment treatment)
{
ConfigObservation? conf = null;
foreach (var requestGiveCode in treatment.RequestedGiveCodes)
conf = string.IsNullOrEmpty(requestGiveCode.CodingSystem) &&
string.IsNullOrEmpty(requestGiveCode.Identifier)
? await Get(requestGiveCode.Text)
: await GetByCodeSysAndCode(requestGiveCode.CodingSystem, requestGiveCode.Identifier);
if (conf == null) return _ignoreUnknownTreatment ? null : treatment;
return conf.Name == null ? null : treatment;
}
/// <summary>
/// Retrieves a <see cref="ConfigObservation"/> that matches the supplied patient observation, either by name only or by a combination of code, coding system, and parent data fields.
/// </summary>
/// <param name="obs">The patient observation whose matching configuration should be resolved.</param>
/// <param name="onlyByName">When <c>true</c>, the lookup is restricted to matching by <see cref="BasePatientObservation.Name"/> only; otherwise matching also considers code, coding system, and parent observation data.</param>
/// <returns>A <see cref="Task{TResult}"/> containing the matched <see cref="ConfigObservation"/> processed via <c>Process</c>, or <c>null</c> if no configuration items are available or no match is found.</returns>
/// <!-- aidoc:v1 sig=887109c body=724c8ab -->
public async Task<ConfigObservation?> Get<T>(T obs, bool onlyByName = false)
where T : BasePatientObservation
{
var items = await GetAllConfigs();
if (items.Count == 0) return null;
if (onlyByName)
{
var configItem = items.FirstOrDefault(i => i.Name == obs.Name);
if (configItem != null) return await Process(configItem);
_logger.LogError("Error getting Config observation Item. Observation: {obs}", obs);
return null;
}
ConfigObservation? item = null;
if ((!string.IsNullOrEmpty(obs.Code) && !string.IsNullOrEmpty(obs.CodingSystem)) ||
obs.ParentData is { Code: not null, CodingSystem: not null })
foreach (var obsConfig in items)
{
if (obs.Code != null && obs.Code != obsConfig.Code)
continue;
if (obsConfig.CodingSystem != null && obs.CodingSystem != obsConfig.CodingSystem)
continue;
if (obsConfig.OriginalName != null && obs.Name != obsConfig.OriginalName)
continue;
if (obsConfig.ParentCode != null && obs.ParentData?.Code != obsConfig.ParentCode)
continue;
if (obsConfig.ParentCodingSystem != null &&
obs.ParentData?.CodingSystem != obsConfig.ParentCodingSystem)
continue;
if (obsConfig.ParentName != null && obs.ParentData?.Name != obsConfig.ParentName)
continue;
if (obsConfig.OriginalName == null && obsConfig.Name != null && obs.Name != null &&
obs.Name.Contains("Alarm") && obs.Name != obsConfig.Name)
continue;
if (obsConfig.Code == null && obsConfig.CodingSystem == null && obsConfig.ParentCode == null &&
obsConfig.ParentCodingSystem == null && obs.Name != null && !obs.Name.Contains("Alarm"))
continue;
if (obsConfig.Code == null &&
obsConfig is { CodingSystem: not null, ParentCode: null, ParentCodingSystem: null } &&
obs.Name != null && !obs.Name.Contains("Alarm"))
continue;
if (obsConfig.Code == null && obsConfig.CodingSystem == null && obsConfig.ParentCode == null &&
obsConfig.ParentCodingSystem != null && obs.Name != null && !obs.Name.Contains("Alarm"))
continue;
if (obsConfig.Code == null &&
obsConfig is { CodingSystem: not null, ParentCode: null, ParentCodingSystem: not null } &&
obs.Name != null && !obs.Name.Contains("Alarm"))
continue;
item = obsConfig;
break;
}
else item = items.FirstOrDefault(i => i.Name == obs.Name);
return item != null ? await Process(item) : null;
}
// public async Task<List<ConfigObservation>> GetAlarmWithRecordingConfig(ObjectId patientId)
// {
// var configObservationId = await GetConfigObservationKeyFromPatientId(patientId);
// var configObservation = await GetConfig(configObservationId);
// return configObservation?.Items
// .Where(i => i is { CodingSystem: "ADAS_ALARM", Alarm.Recording.Enabled: true })
// .ToList() ?? [];
// }
/// <summary>
/// Retrieves a <see cref="ConfigObservation"/> that matches the specified coding system and code, processing it before returning.
/// If no matching observation is found, a warning is logged and <see langword="null"/> is returned.
/// </summary>
/// <param name="codingSystem">The coding system identifier used to filter the observation. May be <see langword="null"/>.</param>
/// <param name="code">The code value used to filter the observation. May be <see langword="null"/>.</param>
/// <returns>A processed <see cref="ConfigObservation"/> if a match is found; otherwise, <see langword="null"/>.</returns>
/// <!-- aidoc:v1 sig=35cb21e body=8c37456 -->
public async Task<ConfigObservation?> GetByCodeSysAndCode(string? codingSystem, string? code)
{
var filteredResult = await _configObservationRepository.GetByCodeSysAndCode(codingSystem, code);
if (filteredResult != null) return await Process(filteredResult);
_logger.LogWarning("Config observation item not found. CodingSystem: {codingSystem} code: {code} ",
codingSystem ?? "null", code ?? "null");
return null;
}
/// <summary>
/// Retrieves a <see cref="ConfigObservation"/> by its name, returning <c>null</c> when the name is not provided or the configuration is not found.
/// </summary>
/// <param name="name">The name of the configuration to look up; if null or empty, the method returns <c>null</c>.</param>
/// <returns>A <see cref="ConfigObservation"/> when a matching configuration is found and successfully processed; otherwise, <c>null</c>.</returns>
/// <!-- aidoc:v1 sig=2eba3bf body=dd0b407 -->
public async Task<ConfigObservation?> Get(string? name)
{
if (string.IsNullOrEmpty(name)) return null;
var result = await GetConfigByName(name);
if (result == null)
{
// _logger.LogWarning(
// "Config observation item not found. name: {name} configObservationId: {configObservationId} ",
// name, name);
return null;
}
return await Process(result);
}
/// <summary>
/// Updates an existing <see cref="ConfigObservation"/> identified by its id and returns the updated entity.
/// Throws a not found exception when the configuration observation does not exist, invalidates the related cache entries, and records an audit log of the change.
/// </summary>
/// <param name="configObservationItem">The configuration observation payload containing the identifier of the record to update.</param>
/// <returns>The updated <see cref="ConfigObservation"/>.</returns>
/// <exception cref="NotFoundException">Thrown when no configuration observation exists for the supplied id.</exception>
/// <!-- aidoc:v1 sig=9196090 body=719174b -->
public async Task<ConfigObservation?> UpdateConfig(ConfigObservation configObservationItem)
{
// if (!configObservationItem.Id.HasValue)
// return await _configObservationRepository.InsertOneAsyncAndReturn(configObservationItem);
var configObservation = await _configObservationRepository.FindById(configObservationItem.Id) ??
throw new NotFoundException(HttpEnum.ErrorMessage.NotFoundResourceMissing);
var updatedConfig = await _configObservationRepository.Update(configObservation);
// Invalidar CACHE (colección completa)
await _cacheService.DeleteByPatternAsync(CacheKeyPatterns.ForEntity(CacheEnum.EntityType.ConfigObservations));
await _auditService.CreateAuditLogAsync(_httpContextAccessor.HttpContext?.User!, configObservation,
updatedConfig!);
return updatedConfig;
}
/// <summary>
/// Retrieves configuration observation items matching the specified name. Returns the matching items if any are found; otherwise logs a warning and throws a <see cref="NotFoundException"/>.
/// </summary>
/// <param name="name">The name used to look up the configuration observation items.</param>
/// <returns>A collection of <see cref="ConfigObservation"/> items matching the specified name.</returns>
/// <exception cref="NotFoundException">Thrown when no configuration observation items are found for the given name.</exception>
/// <!-- aidoc:v1 sig=9b9901d body=5a60f64 -->
public async Task<IEnumerable<ConfigObservation>?> GetConfigObservationItem(string name)
{
var configObservationItems = await _configObservationRepository.FindAllByName(name);
if (configObservationItems.Count != 0)
return configObservationItems;
_logger.LogWarning("Cant get config item, config not found, name: {name} ", name);
throw new NotFoundException(HttpEnum.ErrorMessage.NotFoundResourceMissing);
}
/// <summary>
/// Retrieves a single <see cref="ConfigObservation"/> item that matches the specified code, coding system, name, and original name.
/// Throws a not-found exception when no matching item exists in the repository.
/// </summary>
/// <param name="code">The code used to identify the configuration observation item.</param>
/// <param name="codingSystem">The coding system associated with the item.</param>
/// <param name="name">The name of the configuration observation item.</param>
/// <param name="originalName">The original name of the configuration observation item.</param>
/// <returns>The matching <see cref="ConfigObservation"/> item.</returns>
/// <exception cref="NotFoundException">Thrown when no matching configuration observation item is found.</exception>
/// <!-- aidoc:v1 sig=3639116 body=123e8de -->
public async Task<ConfigObservation?> GetSingleConfigObservationItem(string? code, string? codingSystem,
string? name, string? originalName)
{
var matchingItem =
await _configObservationRepository.GetSingleConfigObservationItem(code, codingSystem, name, originalName);
if (matchingItem == null) throw new NotFoundException(HttpEnum.ErrorMessage.NotFoundNoMatches);
return matchingItem;
}
/// <summary>
/// Deletes a single configuration observation item, invalidating the related cache entries and recording an audit log of the operation.
/// Throws a conflict exception if the item does not exist or the delete operation cannot be completed.
/// </summary>
/// <param name="configObservationItem">The configuration observation item to delete; its identifier is used to locate the existing record.</param>
/// <returns>A task that resolves to <c>true</c> when the item is successfully deleted.</returns>
/// <exception cref="ConflictException">Thrown when no configuration observation is found with the specified identifier, or when the underlying delete operation fails.</exception>
/// <!-- aidoc:v1 sig=ec35183 body=8a9a0b4 -->
public async Task<bool> DeleteSingleConfigObservationItem(ConfigObservation configObservationItem)
{
var configObservation = await _configObservationRepository.FindById(configObservationItem.Id) ??
throw new ConflictException(HttpEnum.ErrorMessage.ConflictDeleteFailed);
var auxConfigObservation = await _auditService.DeepCopyAsync(configObservation);
_ = await _configObservationRepository.DeleteAsync(configObservation.Id) ??
throw new ConflictException(HttpEnum.ErrorMessage.ConflictDeleteFailed);
// Invalidar CACHE (colección completa)
await _cacheService.DeleteByPatternAsync(CacheKeyPatterns.ForEntity(CacheEnum.EntityType.ConfigObservations));
await _auditService.CreateAuditLogAsync(_httpContextAccessor.HttpContext?.User!, auxConfigObservation,
configObservation);
return true;
}
/// <summary>
/// Retrieves configuration observation items that match the specified name.
/// </summary>
/// <param name="name">The name used to filter configuration observation items.</param>
/// <returns>A collection of <see cref="ConfigObservation"/> items matching the specified name.</returns>
/// <!-- aidoc:v1 sig=6809b70 body=c80c2e5 -->
public async Task<IEnumerable<ConfigObservation>> GetConfigObservationItemsByName(string name)
{
return await _configObservationRepository.FindAllByName(name);
}
/// <summary>
/// Creates a new configuration observation after verifying that no existing record shares the same identifier.
/// </summary>
/// <param name="configObservation">The configuration observation entity to persist.</param>
/// <returns>The created <see cref="ConfigObservation"/> if the operation succeeds.</returns>
/// <exception cref="BadRequestException">Thrown when a configuration observation with the same identifier already exists.</exception>
/// <!-- aidoc:v1 sig=d966188 body=7d8c724 -->
public async Task<ConfigObservation?> CreateConfig(ConfigObservation configObservation)
{
var existing = await _configObservationRepository.FindById(configObservation.Id);
if (existing != null)
throw new BadRequestException(HttpEnum.ErrorMessage.ConflictCreationFailed);
await _configObservationRepository.InsertOneAsyncAndReturn(configObservation);
await _auditService.CreateAuditLogAsync(_httpContextAccessor.HttpContext?.User!, null, configObservation);
return configObservation;
}
/// <summary>
/// Removes a configuration item identified by its name, creating an audit log entry prior to deletion and invalidating the related cache.
/// Throws a conflict exception when no configuration item with the specified name is found.
/// </summary>
/// <param name="itemName">The name of the configuration item to remove.</param>
/// <returns>The removed <see cref="ConfigObservation"/>, or <c>null</c> if the repository did not return a result.</returns>
/// <exception cref="ConflictException">Thrown when no configuration item is found with the specified name.</exception>
/// <!-- aidoc:v1 sig=f100c88 body=ec848a3 -->
public async Task<ConfigObservation?> RemoveConfigItem(string itemName)
{
var configObservation = await GetConfigByName(itemName) ??
throw new ConflictException(HttpEnum.ErrorMessage.ConflictDeleteFailed);
var auxConfigObservation = await GetConfigByName(itemName) ??
throw new ConflictException(HttpEnum.ErrorMessage.ConflictDeleteFailed);
await _auditService.CreateAuditLogAsync(_httpContextAccessor.HttpContext?.User!, auxConfigObservation,
configObservation);
var result = await _configObservationRepository.Delete(configObservation.Id!);
// Invalidar CACHE (colección completa)
await _cacheService.DeleteByPatternAsync(CacheKeyPatterns.ForEntity(CacheEnum.EntityType.ConfigObservations));
return result;
}
/// <summary>
/// Retrieves a configuration observation by its identifier, using an in-memory cache with a configurable refresh timeout to reduce repository calls.
/// Returns the cached value when available and not yet expired; otherwise fetches from the repository and caches the result, falling back to a new empty <see cref="ConfigObservation"/> when the repository does not find a matching record.
/// </summary>
/// <param name="configObservationId">The unique identifier of the configuration observation to retrieve.</param>
/// <returns>The configuration observation obtained from cache or repository, or a new empty instance when no matching record exists.</returns>
/// <!-- aidoc:v1 sig=f3c60d4 body=645b284 -->
public async Task<ConfigObservation?> GetConfig(ObjectId configObservationId)
{
RefreshCachedConfigObservations();
if (CachedConfigObservations.TryGetValue(configObservationId, out var cached)
&& DateTime.Now <= cached.NextRefresh)
return cached.ConfigObservation;
var config = await _configObservationRepository.FindById(configObservationId);
cached = new ConfigObservationCached
{
ConfigObservation = config ?? new ConfigObservation(),
NextRefresh = _refreshTimeout.HasValue ? DateTime.Now.AddSeconds(_refreshTimeout.Value) : DateTime.MinValue
};
CachedConfigObservations[configObservationId] = cached;
return cached.ConfigObservation;
}
/// <summary>
/// Retrieves a configuration observation by its name, using a time-limited in-memory cache before falling back to the repository.
/// Returns <c>null</c> if <paramref name="name"/> is null, empty, or whitespace, or if no matching configuration exists in the cache or repository.
/// Cache hits require a non-expired <c>NextRefresh</c> and use a case-insensitive name comparison.
/// </summary>
/// <param name="name">The case-insensitive name of the configuration observation to look up.</param>
/// <returns>The matching <see cref="ConfigObservation"/>, or <c>null</c> if not found or the name is invalid.</returns>
/// <!-- aidoc:v1 sig=b4fcdb3 body=2875d2c -->
public async Task<ConfigObservation?> GetConfigByName(string name)
{
if (string.IsNullOrWhiteSpace(name)) return null;
RefreshCachedConfigObservations();
var cachedItem = CachedConfigObservations.Values
.FirstOrDefault(cached =>
DateTime.Now <= cached.NextRefresh &&
cached.ConfigObservation is { Name: not null } &&
cached.ConfigObservation.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
if (cachedItem != null) return cachedItem.ConfigObservation;
var config = await _configObservationRepository.FindByName(name);
if (config == null) return null;
var newCachedItem = new ConfigObservationCached
{
ConfigObservation = config,
NextRefresh = _refreshTimeout.HasValue
? DateTime.Now.AddSeconds(_refreshTimeout.Value)
: DateTime.MinValue
};
CachedConfigObservations[config.Id!] = newCachedItem;
return config;
}
/// <summary>
/// Removes expired entries from the cached configuration observations when a refresh timeout is configured,
/// performing an early return if no refresh timeout is set.
/// </summary>
/// <!-- aidoc:v1 sig=1aa90f3 body=0a408f4 -->
private void RefreshCachedConfigObservations()
{
if (!_refreshTimeout.HasValue) return;
// Use ConcurrentDictionary's thread-safe features to identify and remove expired keys
var keysToRemove = CachedConfigObservations
.Where(k => DateTime.Now > k.Value.NextRefresh)
.Select(k => k.Key)
.ToList();
foreach (var key in keysToRemove) CachedConfigObservations.TryRemove(key, out _);
}
/// <summary>
/// Processes a configuration observation by applying the default retention policy when none is set, and normalizing the associated retention policy value based on the selected policy.
/// </summary>
/// <param name="confItem">The configuration observation to process. Its retention policy and retention policy value are updated in place.</param>
/// <returns>A task containing the processed <see cref="ConfigObservation"/>.</returns>
/// <!-- aidoc:v1 sig=d6edc85 body=6ba1090 -->
private Task<ConfigObservation> Process(ConfigObservation confItem)
{
confItem.RetentionPolicy ??= _defaultRetentionPolicy;
if (confItem.RetentionPolicy != RetentionPolicy.NoDelete && !confItem.RetentionPolicyValue.HasValue)
{
confItem.RetentionPolicyValue = _defaultRetentionPolicyValue;
if (confItem.RetentionPolicyValue <= 0) confItem.RetentionPolicyValue = null;
}
else
{
confItem.RetentionPolicyValue = null;
}
return Task.FromResult(confItem);
}
/// <summary>
/// Removes expired entries from the cached configuration observation keys based on the configured refresh timeout.
/// If no refresh timeout is set, the method returns without performing any cleanup.
/// Any exceptions encountered during the cleanup are caught and logged.
/// </summary>
/// <!-- aidoc:v1 sig=d3e4c66 body=bb82f36 -->
private void RefreshCachedConfigObservationKeys()
{
try
{
if (!_refreshTimeout.HasValue) return;
// Use ConcurrentDictionary's thread-safe features to identify and remove expired keys
var keysToRemove = CachedConfigObservationKeys
.Where(k => DateTime.Now > k.Value.NextRefresh)
.Select(k => k.Key)
.ToList();
foreach (var key in keysToRemove) CachedConfigObservationKeys.TryRemove(key, out _);
}
catch (Exception ex)
{
_logger.LogError("Error refreshing cached config observation keys. Exception: {ex}", ex);
}
}
/// <summary>
/// Maps configuration settings from a <see cref="ConfigObservation"/> onto a <see cref="BasePatientObservation"/>,
/// applying alert and warning thresholds, evaluating dynamic level conditions, and computing the observation status
/// for numeric and string values. Handles <see cref="PatientObservation"/> and <see cref="PatientObservationAlarm"/>
/// subtypes with their respective properties, including expiration, units, colors, and UI configuration.
/// </summary>
/// <param name="obs">The observation instance to enrich with configuration values. Modified in place.</param>
/// <param name="conf">The configuration observation providing thresholds, colors, expiration, and other settings to apply.</param>
/// <returns>The mapped observation, returned as-is when the observation's coding system is configured to skip status calculation.</returns>
/// <!-- aidoc:v1 sig=4cafe53 body=3ca4d27 -->
private async Task<T?> MapConf<T>(T obs, ConfigObservation conf) where T : BasePatientObservation
{
_logger.LogTrace("Mapping observation: {obs}", obs);
obs.Name = conf.Name;
if (obs is PatientObservation)
{
var noCalculateStatusWithCodingSystem = _apiSettings.Value.NoCalculateStatusWithCodingSystem;
if (noCalculateStatusWithCodingSystem != null &&
obs.CodingSystem == noCalculateStatusWithCodingSystem) return obs;
if (obs is not PatientObservation pobs)
return obs;
if (conf.MaxAlert != null && (conf.ForceAlert || !pobs.Max.HasValue)) pobs.Max = conf.MaxAlert;
if (conf.MinAlert.HasValue && (conf.ForceAlert || !pobs.Min.HasValue)) pobs.Min = conf.MinAlert;
if (conf.MaxWarn.HasValue && (conf.ForceWarn || !pobs.MaxWarn.HasValue)) pobs.MaxWarn = conf.MaxWarn;
if (conf.MinWarn.HasValue && (conf.ForceWarn || !pobs.MinWarn.HasValue)) pobs.MinWarn = conf.MinWarn;
if (conf.LevelCondition != null)
try
{
var evalCondition = await CSharpScript.EvaluateAsync(conf.LevelCondition, globals: pobs);
if (int.TryParse(evalCondition.ToString(), out var evalInt))
pobs.Level = evalInt;
else
_logger.LogError("Error evaluating condition: {evalCondition} for obs: {pobs}",
evalCondition, pobs);
}
catch (CompilationErrorException e)
{
_logger.LogError("Error evaluating expression for obs: {obs} error: {diagnostics}", obs,
e.Diagnostics);
}
catch (Exception e)
{
_logger.LogError("Error evaluating expression for obs: {obs} error: {e}", obs, e);
}
pobs.Status = StatusEnum.Type.Ok;
if (pobs.Value.IsNumber())
{
if (pobs.Min.HasValue && pobs.Min > pobs.Value.ToDouble() &&
conf.Alert is null or '<')
{
pobs.Status = StatusEnum.Type.Alert;
pobs.AlertColor = conf.AlertColor;
}
else if (pobs.Max.HasValue && pobs.Max < pobs.Value.ToDouble() &&
conf.Alert is null or '>')
{
pobs.Status = StatusEnum.Type.Alert;
pobs.AlertColor = conf.AlertColor;
}
else if (pobs.MinWarn.HasValue && pobs.MinWarn > pobs.Value.ToDouble() &&
conf.Alert is null or '<')
{
pobs.Status = StatusEnum.Type.Warning;
pobs.WarnColor = conf.WarnColor;
}
else if (pobs.MaxWarn.HasValue && pobs.MaxWarn < pobs.Value.ToDouble() &&
conf.Alert is null or '>')
{
pobs.Status = StatusEnum.Type.Warning;
pobs.WarnColor = conf.WarnColor;
}
}
else if (pobs.Value is string)
{
if (conf.AlertValues != null && conf.AlertValues.Any() && conf.AlertValues.Contains(pobs.Value))
pobs.Status = StatusEnum.Type.Alert;
else if (conf.WarningValues != null && conf.WarningValues.Any() &&
conf.WarningValues.Contains(pobs.Value)) pobs.Status = StatusEnum.Type.Warning;
}
if (conf.Expires is > 0)
{
pobs.Expires = conf.Expires;
//check if already is expired
var expireTime = obs.Time.ToLocalTime().AddSeconds(Convert.ToDouble(conf.Expires));
pobs.Expired = DateTime.Now.CompareTo(expireTime) > 0;
}
pobs.ShowOnExpired = conf.ShowOnExpired;
if (conf.ColorOnExpired != null) pobs.ColorOnExpired = conf.ColorOnExpired;
if (conf.Persist != null)
pobs.Persist = conf.Persist;
if (conf.UiConfiguration != null && conf.UiConfiguration.Any()) pobs.UiConfiguration = conf.UiConfiguration;
if (conf.Alarm != null) pobs.Alarm = conf.Alarm;
if (conf.TimeFromMessageTime)
if (pobs.MessageTime.CompareTo(DateTime.MinValue) != 0)
pobs.Time = pobs.MessageTime;
if (pobs.Units == null || conf.ForceUnits) pobs.Units = conf.Units;
if (conf.CreateObservation != null) pobs.CreateObservation = conf.CreateObservation;
pobs.CheckObservations = conf.CheckObservations;
}
if (obs is PatientObservationAlarm)
{
if (obs is not PatientObservationAlarm pobs)
return obs;
if (conf.Expires is > 0)
{
pobs.Expires = conf.Expires;
//check if already is expired
var expireTime = obs.Time.ToLocalTime().AddSeconds(Convert.ToDouble(conf.Expires));
pobs.Expired = DateTime.Now.CompareTo(expireTime) > 0;
}
if (conf.Persist != null)
pobs.Persist = conf.Persist;
if (conf.Alarm != null) pobs.AlarmConfig = conf.Alarm;
if (conf.AlertColor != null) pobs.AlertColor = conf.AlertColor;
if (conf.TimeFromMessageTime)
if (pobs.MessageTime.CompareTo(DateTime.MinValue) != 0)
pobs.Time = pobs.MessageTime;
if (pobs.Units == null || conf.ForceUnits) pobs.Units = conf.Units;
if (conf.CreateObservation != null) pobs.CreateObservation = conf.CreateObservation;
pobs.CheckObservations = conf.CheckObservations;
}
_logger.LogTrace("Observation mapped: {obs}", obs);
return obs;
}
/// <summary>
/// Represents a private cached container for configuration observation data.
/// </summary>
/// <remarks>
/// This type is intended to be used internally to store and reuse configuration observation results.
/// </remarks>
/// <!-- aidoc:v1 sig=9f92750 -->
private class ConfigObservationCached
{
public DateTime NextRefresh { get; set; }
public ConfigObservation? ConfigObservation { get; set; }
}
/// <summary>
/// Represents a private cache entry for configuration observation keys, used to store and retrieve previously computed key values associated with configuration observations.
/// </summary>
/// <!-- aidoc:v1 sig=280cb2a -->
private class ConfigObservationKeyCached
{
public DateTime NextRefresh { get; set; }
public string? Key { get; set; }
}
}