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

527 lines
26 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Text;
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.MongoModels;
using adas_core.Domain.Models.Recording;
using adas_core.Domain.Models.Responses;
using adas_core.Domain.Utils;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Serilog;
using Patient = adas_core.Domain.Models.MongoModels.Patient;
namespace adas_core.Application.Services;
/// <summary>
/// Provides functionality for recording operations as defined by the <see cref="IRecordingService"/> contract.
/// </summary>
public class RecordingService : IRecordingService
{
private readonly IAuthService _authService;
private readonly IClientMessageService _clientMessageService;
private readonly HttpClient _httpClient;
private readonly ILogger<RecordingService> _logger;
private readonly Lazy<IPatientService> _patientService;
private readonly IPublisherService _publisherService;
private readonly RabbitMqSettings _rabbitMqSettings;
private readonly RecordingSettings _recordingSettings;
private readonly bool _startRecordingWithoutPatientNumber;
private readonly ISubscribersService _subscribersService;
private readonly string? _url;
private AccessGrant? _accessGrant;
public RecordingService(IOptions<RabbitMqSettings> rabbitMqSettings,
IOptions<RecordingSettings> recordingSettings,
ILogger<RecordingService> logger,
IHttpClientFactory httpClientFactory,
IPublisherService publisherService,
IAuthService authService,
IOptions<ApiSettings> apiSettings, IClientMessageService clientMessageService,
ISubscribersService subscribersService,
Lazy<IPatientService> patientService)
{
_logger = logger;
_httpClient = httpClientFactory.CreateClient();
_accessGrant ??= new AccessGrant();
_rabbitMqSettings = rabbitMqSettings.Value;
_recordingSettings = recordingSettings.Value ??
throw new Exception("RecordingSettings must be defined on appSettings");
_httpClient.Timeout = new TimeSpan(0, 0, _recordingSettings.HttpClientTimeout);
_url = _recordingSettings.RecordingApiUrl;
_publisherService = publisherService;
_authService = authService;
_clientMessageService = clientMessageService;
_subscribersService = subscribersService;
_patientService = patientService;
RecordingQueueName = _rabbitMqSettings.RecordingQueue;
ErrorRecordingQueueName = $"{RecordingQueueName}_Error";
_startRecordingWithoutPatientNumber = apiSettings.Value.StartRecordingWithoutPatientNumber;
}
private string RecordingQueueName { get; }
private string ErrorRecordingQueueName { get; }
/// <summary>
/// Sends a cancel recording request to the recording API for the specified patient and point of care.
/// Throws an exception if the recording API URL is not configured or a valid authentication token cannot be obtained.
/// </summary>
/// <param name="patient">The patient whose recording in progress should be cancelled.</param>
/// <param name="poc">The point of care associated with the recording.</param>
/// <returns>A task that resolves to <c>true</c> if the cancel request succeeded; otherwise, <c>false</c>.</returns>
/// <exception cref="Exception">Thrown when the recording API URL is not defined or the authentication token is null or empty.</exception>
public async Task<bool> SendCancelRecordingToRecordingApi(Patient patient, PointOfCare poc)
{
var token = await _authService.GetToken();
if (string.IsNullOrEmpty(_url) || string.IsNullOrEmpty(token))
throw new Exception(
"send recording data to recording api require RecordingOrApiUrl defined and valid token");
var body = JsonConvert.SerializeObject(GenerateRecordingData(patient, poc, DateTime.MinValue,
null, null, null, null, AlarmEnum.Severity.None, ""));
var request =
new HttpRequestMessage(HttpMethod.Post,
$"{_url}/videos/delete-video-in-progress") //las fechas no se mandan
{
Content = new StringContent(body, Encoding.UTF8)
};
request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {token}");
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
var response = await _httpClient.SendAsync(request);
_logger.LogDebug(
"send cancel to recording api status code:{responseStatusCode} url: {url} body: {body}",
response.StatusCode, _url, body);
return response.IsSuccessStatusCode;
}
/// <summary>
/// Sends the manual recording data to the processing queue, forwarding the recording's start and stop times along with the patient and point of care information.
/// </summary>
/// <param name="patient">The patient associated with the manual recording.</param>
/// <param name="poc">The point of care where the recording was performed.</param>
/// <param name="manualRecording">The manual recording whose start and stop times are forwarded to the queue.</param>
/// <param name="start">A flag indicating whether the recording is being started or stopped.</param>
public async Task SendRecordingData(Patient patient, PointOfCare poc, ManualRecording manualRecording,
bool start)
{
await SendRecordingDataToQueue(patient, poc, manualRecording.Recording?.StartRecordingTime,
manualRecording.Recording?.StopRecordingTime, null, null, AlarmEnum.Severity.None, null, start);
}
/// <summary>
/// Sends automatic recording data for the specified patient and point of care to the recording data queue.
/// Validates that the alarm name can be parsed to <see cref="AlarmEnum.Name"/>; returns <c>false</c> if the name is invalid or if an exception occurs during the send operation.
/// </summary>
/// <param name="patient">The patient associated with the automatic recording.</param>
/// <param name="poc">The point of care where the recording was captured.</param>
/// <param name="automaticRecording">The automatic recording payload, including alarm name, start/stop/event timestamps, severity, and description.</param>
/// <returns>A task that resolves to <c>true</c> if the recording data was queued successfully; otherwise, <c>false</c> when the alarm name cannot be parsed or the send operation fails.</returns>
public async Task<bool> SendAutoRecordingData(Patient patient, PointOfCare poc, RecordingData automaticRecording)
{
try
{
if (!Enum.TryParse<AlarmEnum.Name>(automaticRecording.AlarmName, out var alarmName))
return false;
await SendRecordingDataToQueue(patient, poc, automaticRecording.StartRecordingTime,
automaticRecording.StopRecordingTime, automaticRecording.EventTime,
alarmName, automaticRecording.Severity, automaticRecording.AlarmDescription,
type: AlarmEnum.Type.Auto);
return true;
}
catch (Exception ex)
{
Log.Error("Exception sending automatic recording data. Exception: {ex}", ex);
return false;
}
}
/// <summary>
/// Sends recording data for a patient to the recording message queue, broadcasting the recording after a successful send.
/// Validates the patient number when the start-without-patient-number feature is disabled, ensures the point of care is in use,
/// defaults a missing alarm description to "UNKNOWN", and retries the send on transient HTTP connection failures up to the configured maximum,
/// routing the last failure to the error recording queue.
/// </summary>
/// <param name="patient">The patient whose recording is being sent; its patient number is validated when required.</param>
/// <param name="poc">The point of care (box) that must be in use to allow the recording to be sent.</param>
/// <param name="date">Optional recording date used to build the recording payload.</param>
/// <param name="endDate">Optional end date used to build the recording payload.</param>
/// <param name="eventDate">Optional event date used to build the recording payload.</param>
/// <param name="alarmName">Optional alarm name to associate with the recording.</param>
/// <param name="severity">The alarm severity for the recording.</param>
/// <param name="alarmDescription">Optional alarm description; when null or empty it is replaced with "UNKNOWN".</param>
/// <param name="start">Flag indicating whether the recording is a start event; defaults to true.</param>
/// <param name="type">The alarm type for the recording; defaults to <see cref="AlarmEnum.Type.Manual"/>.</param>
/// <exception cref="Exception">Thrown when the point of care status is not <see cref="StatusEnum.PointOfCare.InUse"/>, including the serialized point of care in the message.</exception>
public async Task SendRecordingDataToQueue(Patient patient, PointOfCare poc, DateTime? date, DateTime? endDate,
DateTime? eventDate, AlarmEnum.Name? alarmName, AlarmEnum.Severity severity, string? alarmDescription,
bool start = true, AlarmEnum.Type type = AlarmEnum.Type.Manual)
{
if (!_startRecordingWithoutPatientNumber && string.IsNullOrEmpty(patient.PatientNumber))
{
_logger.LogError(
"Error sending recording data to queue. Patient number is null or empty. PatientId: {patientId}",
patient.PatientId);
return;
}
if (poc.Status != StatusEnum.PointOfCare.InUse)
throw new Exception($"Box does not contain patients:->{JsonConvert.SerializeObject(poc)}");
if (string.IsNullOrEmpty(alarmDescription)) alarmDescription = "UNKNOWN";
var retryCount = 1;
while (retryCount <= _rabbitMqSettings.MaxRetries)
{
try
{
ApiRequest recordingRequest = new()
{
Recording = GenerateRecordingData(patient, poc, date, endDate, eventDate, null, alarmName,
severity, alarmDescription, type)
};
_logger.LogDebug("Send recording data patient {patient}", patient);
await _publisherService.SendMessage(recordingRequest, RecordingQueueName);
if (recordingRequest.Recording != null)
_ = SendRecordingBroadcast([recordingRequest.Recording]);
return;
}
catch (HttpRequestException ce)
{
retryCount++;
_logger.LogError("Connection error sending recording data to queue. {ce} Retrying...", ce.Message);
}
catch (Exception e)
{
_logger.LogError("Error sending recording data to queue.{eError} {eMessage} ", e, e.Message);
await _publisherService.SendMessage(e.Message, ErrorRecordingQueueName);
throw;
}
if (retryCount == _rabbitMqSettings.MaxRetries)
await _publisherService.SendMessage("Error: Connection retries exceeded.", ErrorRecordingQueueName);
}
}
/// <summary>
/// Asynchronously retrieves the list of in-progress recordings for the specified room from the recording API.
/// Performs token-based authentication, retries transient HTTP request failures up to the configured maximum, and falls back to an empty list or <c>null</c> when the API is unreachable, the room has no recordings, or retries are exhausted.
/// </summary>
/// <param name="roomId">The identifier of the room whose recordings should be fetched.</param>
/// <returns>A task that resolves to a list of <see cref="RecordingData"/> for the room, an empty list when the request fails or the room has no recordings, or <c>null</c> when the recording API URL or authentication token is not configured or when the maximum number of retries is reached.</returns>
public async Task<List<RecordingData>?> GetRecordings(int roomId)
{
var retryCount = 1;
while (retryCount <= _rabbitMqSettings.MaxRetries)
try
{
var token = await _authService.GetToken();
if (string.IsNullOrEmpty(_recordingSettings.RecordingApiUrl) || string.IsNullOrEmpty(token))
{
Log.Warning(
"Not recording url defined at recordingSettings or empty token, ignoring recordings");
return null;
}
var request = new HttpRequestMessage(HttpMethod.Get,
$"{_recordingSettings.RecordingApiUrl}/videos/allInProgress/{roomId}");
request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {token}");
var response = await _httpClient.SendAsync(request);
_logger.LogDebug(
"GetByCodeSysAndCode recordings to recording api status code:{responseStatusCode} url: {url} body",
response.StatusCode, _url);
var responseContent = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
_accessGrant = null;
_logger.LogError(
"error connecting to RecordingOrApi url:{requestRequestUri} errorCode: {responseStatusCode} error {responseContent} token: {}",
request.RequestUri, response.StatusCode, response.Content,
_accessGrant?.AccessToken ?? "not exist");
}
else if (response.StatusCode == HttpStatusCode.NotFound)
{
return [];
}
else
{
_logger.LogError(
"error connecting to RecordingOrApi url:{requestRequestUri} errorCode: {responseStatusCode} error {responseContent}",
request.RequestUri, response.StatusCode, response.Content);
return [];
}
if (retryCount == _rabbitMqSettings.MaxRetries)
await _publisherService.SendMessage("Error: Connection retries exceeded.",
ErrorRecordingQueueName);
return [];
}
var records = JsonConvert.DeserializeObject<List<RecordingData>>(responseContent);
return records;
}
catch (HttpRequestException ce)
{
retryCount++;
_logger.LogError(
"Connection error sending recording data to queue. {ce} Retrying... {retryCount} of {maxRetrys}",
ce.Message, retryCount, _rabbitMqSettings.MaxRetries);
//return new List<RecordingData>();
}
catch (Exception e)
{
_logger.LogError("Error getting recordings room: {roomId} error: {eMessage} ", roomId, e.Message);
await _publisherService.SendMessage(e.Message, ErrorRecordingQueueName);
return [];
}
if (retryCount == _rabbitMqSettings.MaxRetries)
{
await _publisherService.SendMessage("Error: Connection retries exceeded.", ErrorRecordingQueueName);
return null;
}
return [];
}
/// <summary>
/// Sends the recording data from the given API request to the Recording API with authentication, retrying on failure up to the configured maximum and routing unsuccessful requests to the error queue. Clears the access token on 401 responses, maps the returned video data into a recording broadcast message on success, and falls back to the error queue when retries are exhausted or a connection error occurs.
/// </summary>
/// <param name="apiRequest">The API request whose recording data and patient information will be sent to the Recording API.</param>
/// <exception cref="Exception">Rethrown via Task.FromException when a non-HTTP error occurs while sending the recording request.</exception>
public async Task SaveRequestAsync(ApiRequest apiRequest)
{
var retryCount = 1;
while (retryCount <= _rabbitMqSettings.MaxRetries)
{
try
{
var token = await _authService.GetToken();
_logger.LogDebug("sending recording to Recording or api");
var recordingRequest = apiRequest.Recording;
var request = new HttpRequestMessage(HttpMethod.Post,
$"{_recordingSettings.RecordingApiUrl}/videos/save-recording-data")
{
Content = new StringContent(JsonConvert.SerializeObject(recordingRequest), Encoding.UTF8)
};
request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {token}");
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
var response = await _httpClient.SendAsync(request);
_logger.LogDebug("Save Request to recording api status code:{responseStatusCode}",
response.StatusCode);
var responseContent = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == HttpStatusCode.Unauthorized) _accessGrant = null;
_logger.LogError(
"error connecting to RecordingOrApi url:{requestRequestUri} errorCode: {responseStatusCode} error {responseContent}",
request.RequestUri, response.StatusCode, response.Content);
retryCount++;
}
else
{
// Response y en Data esta el videoDTO
var resp = JsonConvert.DeserializeObject<Response<VideoDto>>(responseContent);
var video = resp?.Data;
if (video == null)
return;
RecordingData recording = new()
{
StartRecordingTime = video.StartDate,
StopRecordingTime = video.EndDate,
EventTime = video.Date,
RoomId = video.RoomId,
AlarmType = video.Alarm,
Store = video.Store,
Status = video.VideoStatus.ToString(),
Patient = new Domain.Models.Recording.Patient
{
FirstName = apiRequest.Patient?.FirstName ??
apiRequest.Recording?.Patient?.FirstName ?? string.Empty,
Id = apiRequest.PatientNumber ?? apiRequest.Recording?.Patient?.Id ?? string.Empty,
LastName = apiRequest.Patient?.LastName ??
apiRequest.Recording?.Patient?.LastName ?? string.Empty
}
};
_ = SendRecordingBroadcast([recording]);
return;
}
}
catch (HttpRequestException ce)
{
retryCount++;
_logger.LogError("Connection error sending recording data to queue. {ce} Retrying...", ce.Message);
await _publisherService.SendMessage(apiRequest, ErrorRecordingQueueName);
}
catch (Exception e)
{
_logger.LogError("Error sending recording request to Recording or {eMessage}", e.Message);
await _publisherService.SendMessage(apiRequest, ErrorRecordingQueueName);
await Task.FromException(e);
}
if (retryCount > _rabbitMqSettings.MaxRetries)
await _publisherService.SendMessage(
$"Error: Connection retries exceeded. for api request: {apiRequest}", ErrorRecordingQueueName);
}
}
/// <summary>
/// Saves the specified API request. The method is not yet implemented.
/// </summary>
/// <param name="apiRequest">The API request to save.</param>
/// <exception cref="NotImplementedException">Always thrown because the method has not been implemented.</exception>
public Task SaveRequest(ApiRequest apiRequest)
{
//return Task.CompletedTask;
throw new NotImplementedException();
}
/// <summary>
/// Generates a <see cref="RecordingData"/> object for the specified patient at the given point of care, incorporating alarm metadata and timing details.
/// Returns <c>null</c> and logs an error when the patient number is missing, the room identifier is unavailable, or the room identifier cannot be parsed as an integer.
/// The stop recording time is derived from <paramref name="endDate"/> or, when not provided, calculated from <paramref name="minToExpired"/>; alarm names are translated to their description via <see cref="EnumUtils.GetDescription"/>.
/// </summary>
/// <param name="patient">The patient whose recording data is being generated; used to populate patient demographics and validate the patient number.</param>
/// <param name="poc">The point of care providing the room identifier for the recording.</param>
/// <param name="startDate">The optional start time of the recording.</param>
/// <param name="endDate">The optional stop time of the recording; when omitted, the stop time is derived from <paramref name="minToExpired"/>.</param>
/// <param name="eventTime">The optional time at which the triggering event occurred.</param>
/// <param name="minToExpired">The optional number of minutes added to the current UTC time to compute the stop recording time when <paramref name="endDate"/> is not supplied.</param>
/// <param name="alarmName">The optional alarm name whose description is resolved via the enum utility; stored as a string when present.</param>
/// <param name="severity">The severity associated with the alarm.</param>
/// <param name="alarmDescription">A textual description of the alarm.</param>
/// <param name="alarm">The alarm type, defaulting to <see cref="AlarmEnum.Type.Manual"/> when not specified.</param>
/// <returns>A configured <see cref="RecordingData"/> instance, or <c>null</c> if any required identifier is invalid.</returns>
private RecordingData? GenerateRecordingData(Patient patient, PointOfCare poc, DateTime? startDate,
DateTime? endDate, DateTime? eventTime, int? minToExpired, AlarmEnum.Name? alarmName,
AlarmEnum.Severity severity,
string alarmDescription, AlarmEnum.Type alarm = AlarmEnum.Type.Manual)
{
if (patient.PatientNumber == null)
{
_logger.LogError("Error Generating Recording Data . Patient number is null: {patient}", patient);
return null;
}
var roomId = poc.Configuration?.Id;
if (roomId == null)
{
_logger.LogError("Error Generating Recording Data on roomId");
return null;
}
var patientRecording = new Domain.Models.Recording.Patient
{
Id = patient.PatientNumber,
FirstName = patient.Person?.FirstName ?? "X",
LastName = $"{patient.Person?.SecondName} {patient.Person?.LastName}".TrimEnd()
};
if (!int.TryParse(roomId.ToString(), out var roomIdParsed))
{
_logger.LogError("Error Generating Recording Data on roomId parse: {roomId}", roomId);
return null;
}
var recordingData = new RecordingData
{
Patient = patientRecording,
RoomId = roomIdParsed,
StartRecordingTime = startDate,
StopRecordingTime =
endDate ?? (minToExpired != null ? DateTime.UtcNow.AddMinutes(minToExpired.Value) : null),
EventTime = eventTime,
AlarmType = alarm,
Severity = severity,
AlarmName = alarmName.HasValue ? EnumUtils.GetDescription(alarmName) : null,
AlarmDescription = alarmDescription,
Retry = 0
};
recordingData.GenerateStore();
return recordingData;
}
/// <summary>
/// Sends a recording status broadcast to WebSocket subscribers associated with the patient's bed and unit.
/// Skips processing when the recording list is empty, logs an error and returns when the patient number is missing,
/// and silently returns when the patient cannot be resolved.
/// </summary>
/// <param name="recording">The list of recording entries to broadcast; the first item is used to resolve the patient and room context.</param>
private async Task SendRecordingBroadcast(List<RecordingData> recording)
{
if (!recording.IsNullOrEmpty())
{
var patientNumber = recording[0].Patient?.Id;
if (string.IsNullOrEmpty(patientNumber))
{
_logger.LogError("Box not found by RoomId {roomId}", recording[0].RoomId);
return;
}
var patient = await _patientService.Value.FindByPatientNumber(patientNumber);
if (patient == null)
return;
var type = OperationType.RecordingStatus;
var subscribers = _subscribersService.GetSubscribers().Where(s =>
s.SubscriptionType == SubscriptionEnum.WsType.Box && s.Box == patient.Location.Bed &&
s.Section == patient.Location.UnitName).ToList();
foreach (var subscriber in subscribers)
await _clientMessageService.SendAsync(subscriber.Id, type, recording);
}
}
/// <summary>
/// Encodes the specified plain text to a Base64 string using UTF-8 encoding.
/// </summary>
/// <param name="plainText">The text to encode.</param>
/// <returns>The Base64 encoded representation of the input text.</returns>
protected static string Base64Encode(string plainText)
{
var plainTextBytes = Encoding.UTF8.GetBytes(plainText);
return Convert.ToBase64String(plainTextBytes);
}
}