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.Utils; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using MongoDB.Bson; using MongoDB.Driver; using Patient = adas_core.Domain.Models.MongoModels.Patient; namespace adas_core.Application.Services; public class AppointmentService( IAppointmentRepository appointmentRepository, IAppointmentArchiveRepository appointmentArchiveRepository, Lazy patientService, Lazy observationService, IDiagnosisService diagnosisService, IUnitService unitService, IOptions apiSettings, IOptions cacheSettings, ILogger logger, IHttpContextAccessor httpContextAccessor, ILocalAuditService auditService, IPointOfCareService pointOfCareService, ISubscribersService subscribersService, IClientMessageService clientMessageService, ICacheService cacheService) : IAppointmentService { private readonly bool _createPatientWithSiu = apiSettings.Value.CreatePatientWithSiu; private readonly CacheSettings? _cacheSettings = cacheSettings.Value; /// /// Saves an API request by validating the patient, resolving or creating the patient record, processing the request, and handling associated observations and diagnoses. /// /// The API request containing the patient number, location, type, observations, diagnosis, and related data to be processed. /// Thrown when the has a null or empty patient number. public async Task SaveRequest(ApiRequest apiRequest) { if (string.IsNullOrEmpty(apiRequest.PatientNumber)) { logger.LogDebug("Patient is null"); throw new ApiRequestException("Patient number is null"); } logger.LogDebug("patientNumber: {apiRequestPatientNumber} location: {apiRequestLocation}", apiRequest.PatientNumber, apiRequest.Location); logger.LogDebug("RequestType: {apiRequestType}", apiRequest.Type); var patient = await patientService.Value.FindByPatientNumber(apiRequest.PatientNumber); if (patient == null) { // PATIENT NOT FOUND logger.LogWarning("Patient not Found. {patientNumber}", apiRequest.PatientNumber); if (!_createPatientWithSiu) return; // IGNORE patient = await patientService.Value.CreatePatientFromRequest(apiRequest, true); } await ProcessApiRequest(apiRequest, patient); if (patient != null) { if (apiRequest is { Observations: not null, ObservationData: not null }) observationService.Value.ProcessObservations(apiRequest.Observations, patient, apiRequest.MessageTime, apiRequest.ObservationData); else logger.LogWarning("Observations not Found. {patientNumber} ", patient.PatientNumber); if (apiRequest.Diagnosis != null) _ = diagnosisService.ProcessDiagnosis(apiRequest.Diagnosis, patient, apiRequest.MessageTime); else logger.LogWarning("Diagnosis not Found. {patientNumber}", patient.PatientNumber); } } /// /// Processes an incoming API request for a patient, handling HL7 SIU message types to create, update, or cancel appointments. /// Validates the patient and auto-ADT configuration, then routes the request to the appropriate handler based on message type: SIU_S12-S14 and SIU_S18-S22 (booking/rescheduling/modification), SIU_S15-S17 (cancellation), and other types (blocked slots / no-show), persisting changes, refreshing cache, creating audit logs, and emitting broadcasts. /// /// The API request containing the HL7 message type, timestamp, and appointment payload to process. /// The patient associated with the request; if null, the method returns without processing. public async Task ProcessApiRequest(ApiRequest apiRequest, Patient? patient) { if (patient == null) return; var unitConfig = await unitService.FindById(patient.UnitId); if (!Hl7Utils.ManageAutoAdt(unitConfig, null, logger, "ORU")) return; apiRequest.Appointments ??= []; if (apiRequest.Appointment != null) apiRequest.Appointments.Add(apiRequest.Appointment); //apiRequest.appointments.ForEach(ap => foreach (var ap in apiRequest.Appointments) { if (ap.VisitNumber == null || patient.Person == null) return; ap.PatientId = patient.Id; ap.Patient = patient.Person; var apdb = ap.VisitNumber != null ? await appointmentRepository.FindByPatientAndVisitNumber(ap.PatientId, ap.VisitNumber) : null; apdb ??= await appointmentRepository.FindByPatientAndReason(ap.PatientId, ap.AppointmentReason); if (apdb != null && DateTime.Compare(apdb.UpdateTime, apiRequest.MessageTime) > 0) return; // TODO: Aux var oldAp = await auditService.DeepCopyAsync(ap); switch (apiRequest.Type) { //* SIU_S12 - Notification of new appointment booking //* SIU_S13 - Notification of Appointment Rescheduling //* SIU_S14 - Notification of Appointment Modification //* SIU_S18 - Notification of Addition of Service/Resource on Appointment //* SIU_S19 - Notification of Modification of Service/Resource on Appointment //* SIU_S20 - Notification of Cancellation of Service/Resource on Appointment //* SIU_S21 - Notification of Discontinuation of Service/Resource on Appointment //* SIU_S22 - Notification of Deletion of Service/Resource on Appointment case "SIU_S12": case "SIU_S13": case "SIU_S14": case "SIU_S18": case "SIU_S19": case "SIU_S20": case "SIU_S21": case "SIU_S22": ap.UpdateTime = apiRequest.MessageTime; if (apdb != null) { ap.Id = apdb.Id; ap.CreateTime = apdb.CreateTime; ap.AppointmentOperationType = OperationType.UpdatedAppointment; ap.ApplyResourceGroups(apdb); await appointmentRepository.Update(ap); // Invalidar CACHE (colección completa) await cacheService.DeleteObjectAsync(CacheKeys.PatientAppointmentsToday(ap.PatientId)); //SendBroadcast(ap, OperationType.updatedAppointment); } else { ap.CreateTime = apiRequest.MessageTime; ap.AppointmentOperationType = OperationType.NewAppointment; ap.ApplyResourceGroups(); await appointmentRepository.InsertOneAsync(ap); //SendBroadcast(ap, OperationType.newAppointment); } break; //* SIU_S15 - Notification of Appointment Cancellation //* SIU_S16 - Notification of Appointment Discontinuation //* SIU_S17 - Notification of Appointment Deletion case "SIU_S15": case "SIU_S16": case "SIU_S17": ap.UpdateTime = apiRequest.MessageTime; if (apdb != null) { ap.Id = apdb.Id; ap.CreateTime = apdb.CreateTime; ap.AppointmentOperationType = OperationType.CanceledAppointment; ap.ApplyResourceGroups(apdb); await appointmentRepository.Update(ap); await cacheService.DeleteObjectAsync(CacheKeys.PatientAppointmentsToday(ap.PatientId)); } else { ap.CreateTime = apiRequest.MessageTime; ap.AppointmentOperationType = OperationType.CanceledAppointment; ap.ApplyResourceGroups(); await appointmentRepository.InsertOneAsync(ap); } //SendBroadcast(ap, OperationType.canceledAppointment); break; //* SIU_S23 - Notification of Blocked Schedule Time Slot(S) //* SIU_S24 - Notification of Opened (un-blocked) Schedule Time Slot(s) //* SIU_S26 - Notification That Patient Did Not Show Up for Scheduled Appointment default: ap.UpdateTime = apiRequest.MessageTime; if (apdb != null) { ap.Id = apdb.Id; ap.CreateTime = apdb.CreateTime; //ap.appointmentOperationType = OperationType.updatedAppointment; ap.ApplyResourceGroups(apdb); await appointmentRepository.Update(ap); await cacheService.DeleteObjectAsync(CacheKeys.PatientAppointmentsToday(ap.PatientId)); //SendBroadcast(ap, ap.appointmentOperationType); } else { ap.CreateTime = apiRequest.MessageTime; //ap.appointmentOperationType = OperationType.newAppointment; ap.ApplyResourceGroups(); await appointmentRepository.InsertOneAsync(ap); //SendBroadcast(ap, ap.appointmentOperationType); } break; } if (apdb != null) await auditService.CreateAuditLogAsync(httpContextAccessor.HttpContext?.User!, apdb, ap); else await auditService.CreateAuditLogAsync(httpContextAccessor.HttpContext?.User!, null, ap); if (ap.AppointmentOperationType.HasValue) await SendBroadcast(ap, ap.AppointmentOperationType); } } /// /// Asynchronously saves the provided API request by running the save operation on a background task. /// /// The API request to be saved. /// A task that represents the asynchronous save operation. public Task SaveRequestAsync(ApiRequest apiRequest) { return Task.FromResult(Task.Run(async () => { await SaveRequest(apiRequest); })); } /// /// Archives the specified patient by delegating the operation to using the patient's identifier. /// /// The patient to be archived. public async Task Archive(Patient patient) { await ArchiveByPatientId(patient.Id); } /// /// Archives all appointments associated with the specified patient by copying them to the appointment archive repository and then deleting them from the source collection. /// /// The unique identifier of the patient whose appointments should be archived. public async Task ArchiveByPatientId(ObjectId id) { logger.LogDebug("Archive Appointments by patientId {id}", id); using (var cursor = await FindByPatientIdAsync(id)) { while (await cursor.MoveNextAsync()) foreach (var current in cursor.Current) await appointmentArchiveRepository.InsertOneAsync(current); } await DeleteByPatientId(id); } /// /// Retrieves a list of patient appointments associated with the specified patient identifier by delegating to the appointment repository. /// /// The unique identifier of the patient whose appointments are being requested. /// A task that represents the asynchronous operation, containing a list of records for the given patient. public async Task> GetByPatient(ObjectId patientId) { return await appointmentRepository.GetByPatient(patientId); } /// /// Retrieves the list of appointments scheduled for today (UTC) for the specified patient, using a cache-aside pattern to avoid repeated database queries. /// The full list of patient appointments is fetched from cache (or loaded from the repository on a cache miss) and then filtered locally to include only those whose start time falls on the current UTC date. /// /// The unique identifier of the patient whose appointments are being queried. /// Cancellation token used to cancel the asynchronous operation. /// A task that resolves to a list of instances scheduled for today; an empty list is returned when no appointments match. public async Task> GetTodayByPatient( ObjectId patientId, CancellationToken ct = default) { // Obtener clave + TTL según CacheSettings var (key, ttl) = CacheKeys.PatientAppointmentsTodayKeyWithTtl(_cacheSettings, patientId); // Cachear la lista RAW de citas del paciente (sin filtrar) var allAppointments = await cacheService.GetOrSetObjectAsync( key, async () => { // 1 - Consultar todas las citas del paciente var list = await appointmentRepository.GetByPatient(patientId); // 2 - Devuelve RAW (List), nada filtrado return list; }, ttl, ct); // 3 - Ahora filtramos solo las de "hoy" var today = DateTime.UtcNow.Date; var todayAppointments = allAppointments .Where(a => a.Timings.Any(t => t.StartTime.HasValue && t.StartTime.Value.Date == today)) .ToList(); return todayAppointments; } /// /// Retrieves the patient appointments scheduled for today at the specified point of care. Uses a cache to store the full appointment list for the point of care and filters it by today's date; returns an empty list if the point of care is not found. /// /// The identifier of the point of care whose appointments should be retrieved. /// A cancellation token to cancel the asynchronous operation. /// A task that represents the asynchronous operation, containing the list of patient appointments scheduled for today at the specified point of care. public async Task> GetTodayByPoc( ObjectId pocId, CancellationToken ct = default) { var poc = await pointOfCareService.FindById(pocId); if (poc == null) return []; // Obtener clave + TTL según CacheSettings var (key, ttl) = CacheKeys.PocAppointmentsTodayKeyWithTtl(_cacheSettings, pocId); // Cachear la lista RAW de citas del pointOfCare (sin filtrar) var allAppointments = await cacheService.GetOrSetObjectAsync( key, async () => { // 1 - Consultar todas las citas del pointOfCare var list = await appointmentRepository.FindByPoC(poc); // 2 - Devuelve RAW (List), nada filtrado return list; }, ttl, ct); // 3 - Ahora filtramos solo las de "hoy" var today = DateTime.UtcNow.Date; var todayAppointments = allAppointments .Where(a => a.Timings.Any(t => t.StartTime.HasValue && t.StartTime.Value.Date == today)) .ToList(); return todayAppointments; } /// /// Asynchronously retrieves all patient appointments associated with the specified patient identifier by delegating to the appointment repository. /// /// The unique identifier of the patient whose appointments are to be retrieved. /// A task that represents the asynchronous operation, containing an async cursor over the matching documents. public Task> FindByPatientIdAsync(ObjectId patientId) { return appointmentRepository.FindByPatientIdAsync(patientId); } /// /// Retrieves all patient appointments associated with the specified location. /// /// The location used to filter the patient appointments. /// A task that represents the asynchronous operation, containing a list of patient appointments for the specified location. public async Task> FindByLocation(PatientLocation location) { return await appointmentRepository.FindByLocation(location); } /// /// Deletes all appointments associated with the specified patient identifier, invalidates the appointments cache, and records the action in the audit log. /// /// The unique identifier of the patient whose appointments will be deleted. public async Task DeleteByPatientId(ObjectId id) { var patientApp = await FindByPatientIdAsync(id); logger.LogDebug("Delete Appointments by Patient Id {id}", id); await appointmentRepository.DeleteByPatientId(id); // Invalidar CACHE (colección completa) await cacheService.DeleteByPatternAsync(CacheKeyPatterns.ForEntity(CacheEnum.EntityType.Appointments)); await auditService.CreateAuditLogAsync(httpContextAccessor.HttpContext?.User!, patientApp, null); } /// /// Updates multiple appointment records by replacing the specified with the new , scoped by the given . Delegates the operation to the underlying appointment repository. /// /// The identifier used to scope which appointment records are affected by the update. /// The new ObjectId that will replace the existing one in the matching records. /// The current ObjectId to be replaced in the matching records. public async Task UpdateManyObjectId(string nameId, ObjectId id, ObjectId oldId) { await appointmentRepository.UpdateManyObjectId(nameId, id, oldId); } /// /// Broadcasts a patient appointment operation to all subscribers associated with the appointment's locations. Iterates through each resource group and location, resolving the unit and point of care, and dispatches a fire-and-forget message to every matching subscriber. Skips locations with missing unit/bed data and silently ignores unresolved unit or point-of-care lookups. /// /// The patient appointment whose resource groups and locations will be broadcast to subscribers. /// The optional operation type describing the change performed on the appointment; passed along to the subscriber message. private async Task SendBroadcast(PatientAppointment appointment, OperationType? operationType) { //RECORRE LOS DIFERENTES LOCATIONS DE LA CITA foreach (var resourceGroup in appointment.ResourceGroups) if (resourceGroup.Locations != null) foreach (var location in resourceGroup.Locations) // TODO if (!string.IsNullOrEmpty(location.UnitName) && !string.IsNullOrEmpty(location.Bed)) { var unit = await unitService.FindByName(location.UnitName); if (unit == null) continue; var poc = await pointOfCareService.FindByBedAndUnitId(location.Bed, unit.Id); if (poc == null) continue; var subscribers = subscribersService.GetSubscribers().Where(s => !s.LocationIds.IsNullOrEmpty() && s.LocationIds.Any(c => c == poc.Id )).ToList(); foreach (var subscriber in subscribers) _ = clientMessageService.SendAsync(subscriber.Id, operationType, appointment); } } }