import { Injectable } from '@nestjs/common' import { isNil } from 'lodash' import { plainToClass } from 'class-transformer' import { differenceInMinutes, subMinutes } from 'date-fns' import { BusinessExceptionError, NotFoundError, UnprocessableEntityError } from '../../../errors' import { AppointmentRepository, AppointmentCancelReasonRepository, AvailabilityRepository, AvailabilityTimeRepository, PatientRepository, SeparateCredits, UserRepository, } from '../../../repositories' import { Appointment, AppointmentCancellationReasons, Availability, AvailabilityTime, CreditAuthor, Patient, User, } from '../../../models' import { alowTransferByReason, BookingCancellationReason, BookingConditions } from '../types' import { TeleconsultationsCreditService, TransferService } from '../../../services' import { chunkedEach } from '../../../utils/async' export const RESCHEDULE_CREDIT = 1 export const APPOINTMENT_STATE_CARRIED_OUT = ['food_plan_pending', 'food_plan_in_construction', 'finished'] export const LIMIT_MINUTES_TO_TRANSFER = 10 const LIMIT_HOURS_TO_NOT_DECREASE_CREDIT = 2 const TOLERANCE_MINUTES_FOR_NUTRITIONIST_ARRIVE = 10 type ObjectCached = { [patientId: string]: Value } @Injectable() export class Booking { private lastAppointmentCached: ObjectCached = {} private patientCached: ObjectCached = {} constructor( private readonly availabilityRepository: AvailabilityRepository, private readonly availabilityTimeRepository: AvailabilityTimeRepository, private readonly patientRepository: PatientRepository, private readonly appointmentRepository: AppointmentRepository, private readonly teleconsultationsCreditService: TeleconsultationsCreditService, private readonly appointmentCancelReasonRepository: AppointmentCancelReasonRepository, private readonly userRepository: UserRepository, private readonly transferService: TransferService, ) {} async book( patientId: string, availabilityTimeId: string, appointmentIdRescheduled?: string, requester?: Patient | User, ): Promise { const { availability, availabilityTime, patient, lastFinishedAppointment } = await this.getBookingRelatedEntities( patientId, availabilityTimeId, ) const isReturnReschedule = this.checkIfNextAppointmentMustBeAReturn(patientId) && availability.user_id !== patient.user_id availabilityTime.setScheduled() await this.availabilityTimeRepository.updateWhenStatusEqual(availabilityTime, 'available') const appointment = await this.createAndSetAppointment({ availability, availabilityTime, patient, lastFinishedAppointment, appointmentIdRescheduled, isReturnReschedule, }) await this.discountCreditsIfNecessary(appointment, patient, requester || patient) if (patient.user_id !== availability.user_id) await this.patientRepository.updateUser(patient, availability.user_id) this.clearCache() return appointment.id } async cancel( appointmentId: string, requester: CreditAuthor, cancellationReason: BookingCancellationReason = { value: AppointmentCancellationReasons.PATIENT_CANCELLED, }, ): Promise { const appointment = await this.validateAngGetAppointment(appointmentId, cancellationReason.value) await this.checkIfTransferOrCancel({ appointment, requester, cancellationReason }) this.clearCache() } async reallocateNutri(fromAvailabilityTimeId: string, toAvailabilityTimeId: string): Promise { const from = await this.availabilityTimeRepository.get(fromAvailabilityTimeId) const to = await this.availabilityTimeRepository.get(toAvailabilityTimeId) const toAvailability = await this.availabilityRepository.get(to.availability_id) const appointment = await this.appointmentRepository.get(from.appointment_id) from.free() await this.availabilityTimeRepository.update(from) to.setScheduled(appointment.id) await this.availabilityTimeRepository.update(to) appointment.reallocate(toAvailability, to) await this.appointmentRepository.reallocate(appointment) await this.patientRepository.updateUser(new Patient({ id: appointment.patient_id }), appointment.user_id) this.clearCache() return appointment.id } async reschedule( appointmentId: string, availabilityTimeId: string, requester: Patient | User, cancellationReason: BookingCancellationReason = { value: AppointmentCancellationReasons.PATIENT_RESCHEDULED, }, ): Promise { const appointment = await this.appointmentRepository.get(appointmentId) if (!appointment) throw new NotFoundError('Appointment time not found') const availabilityTime = await this.availabilityTimeRepository.get(availabilityTimeId) if (!availabilityTime) throw new NotFoundError('Availability time not found') if (!availabilityTime.isAvailable) throw new BusinessExceptionError('Availability time isn`t available') await this.cancel(appointmentId, requester, cancellationReason) this.clearCache() return this.book(appointment.patient_id, availabilityTime.id, appointmentId, requester) } async checkBookingConditions(patientId: string): Promise { const patient = await this.getPatient(patientId) const lastAppointment = await this.getLastAppointment(patient.id) const rescheduleCredit = await this.calcRescheduleCredit(patient, lastAppointment) const teleconsultationsCredit = await this.getTeleconsultationsCredit(patient) const moreTwoHoursLeft = isNil(lastAppointment) || differenceInMinutes(lastAppointment.scheduled_to, new Date()) > LIMIT_HOURS_TO_NOT_DECREASE_CREDIT * 60 this.clearCache() return { loses_credit_if_cancel: lastAppointment?.losesRescheduleCreditIfCancel || false, allow_reschedule: rescheduleCredit >= 0 && moreTwoHoursLeft, allow_schedule: teleconsultationsCredit.teleconsultation > 0, } } async cancelAllAppointmentsWithNutriNoArrived(): Promise { const now = new Date() const appointments = await this.appointmentRepository.findAppointmentsWithNutriNoArrived( subMinutes(now, TOLERANCE_MINUTES_FOR_NUTRITIONIST_ARRIVE), ) await this.cancelListAppointments(appointments, { value: AppointmentCancellationReasons.NUTRI_WO, }) } async cancelBySubscriptionCancellation(): Promise { const appointments = await this.appointmentRepository.getScheduledAppointmentsWithCancelledSubscription() await this.cancelListAppointments(appointments, { value: AppointmentCancellationReasons.SUBSCRIPTION_CANCELLED, }) } async cancelBySubscriptionSuspension(patientId: string): Promise { const appointments = await this.appointmentRepository.findScheduledAppointmentsByPatient(patientId) await this.cancelListAppointments(appointments, { value: AppointmentCancellationReasons.SUBSCRIPTION_SUSPENDED, }) } private async validateAngGetAppointment( appointmentId: string, cancellationReason: AppointmentCancellationReasons, ): Promise { const appointment = await this.appointmentRepository.get(appointmentId) if (!appointment) throw new NotFoundError('Appointment not found') const cancelReason = await this.appointmentCancelReasonRepository.get(cancellationReason) if (!cancelReason) throw new NotFoundError('Cancellation reason not found') return appointment } private async discountCreditsIfNecessary( appointment: Appointment, patient: Patient, author: CreditAuthor, ): Promise { if (patient.internal_collaborator) return const creditToUse = await this.teleconsultationsCreditService.getNextValidCreditToUse(patient.id) await this.teleconsultationsCreditService.createTransaction({ patientId: appointment.patient_id, reason: 'appointment_use', value: -1, objectModel: appointment, author, creditId: creditToUse?.id || null, subscriptionId: creditToUse?.subscription_id || null, }) } private async checkIfTransferOrCancel({ appointment, requester, cancellationReason, }: { appointment: Appointment requester: CreditAuthor cancellationReason?: BookingCancellationReason }): Promise { if ( requester instanceof User && appointment.state === 'scheduled' && alowTransferByReason(cancellationReason?.value) && differenceInMinutes(appointment.scheduled_to, new Date()) > LIMIT_MINUTES_TO_TRANSFER ) return this.transferService.transfer(appointment, cancellationReason) return this.cancelAppointmentAndDebitCredit(appointment, requester, cancellationReason) } private async cancelAppointmentAndDebitCredit( appointment: Appointment, requester: CreditAuthor, cancellationReason?: BookingCancellationReason, ): Promise { const isBlocked = appointment.isBlocked const availabilityTime = await this.availabilityTimeRepository.getByAppointmentId(appointment.id) await this.cancelAppointment(appointment, availabilityTime, cancellationReason) if ( isBlocked || !(await this.allowDecreaseCredit(appointment)) || cancellationReason.value === AppointmentCancellationReasons.PATIENT_RESCHEDULED ) { const usedCredit = await this.teleconsultationsCreditService.getByAppointmentId(appointment.id) await this.teleconsultationsCreditService.createTransaction({ patientId: appointment.patient_id, reason: 'appointment_cancellation', value: 1, author: requester, objectModel: appointment, creditId: usedCredit?.credit_id || null, }) } } private async cancelAppointment( appointment: Appointment, availabilityTime: AvailabilityTime, cancellationReason?: BookingCancellationReason, ): Promise { const currentState = appointment.state appointment.cancel(cancellationReason.value, cancellationReason.comment) await this.appointmentRepository.update(appointment) if (!availabilityTime) return try { availabilityTime.free() await this.availabilityTimeRepository.update(availabilityTime) } catch (error) { appointment.state = currentState appointment.canceled_at = null appointment.cancellation_reason = null appointment.cancellation_reason_comment = null await this.appointmentRepository.update(appointment) throw error } } private async getBookingRelatedEntities( patientId: string, availabilityTimeId: string, ): Promise<{ availability: Availability availabilityTime: AvailabilityTime patient: Patient lastFinishedAppointment: Appointment }> { const patient = await this.getPatientAndValidateCredits(patientId) const availabilityTime = await this.availabilityTimeRepository.get(availabilityTimeId) if (!availabilityTime) throw new NotFoundError('Availability time not found') if (!availabilityTime.isAvailable) throw new BusinessExceptionError('Availability time isn`t available') const availability = await this.availabilityRepository.get(availabilityTime.availability_id) if (!availability) throw new NotFoundError('Availability not found') if (availabilityTime.getUTCDate(availability.date).getTime() <= new Date().getTime()) throw new BusinessExceptionError(`Can't book in past time`) const userCountryCode = await this.userRepository.getCountryCode(availability.user_id) if (!userCountryCode) throw new NotFoundError('User not found') if (patient.country_code !== userCountryCode) throw new BusinessExceptionError('Availability time is from a different country than the patient') const lastFinishedAppointment = await this.appointmentRepository.getLastValidUsedAppointment(patientId) return { availability, availabilityTime, patient, lastFinishedAppointment } } private async getPatientAndValidateCredits(patientId: string): Promise { const patient = await this.getPatient(patientId) const teleconsultationsCredit = await this.getTeleconsultationsCredit(patient) if (teleconsultationsCredit.teleconsultation <= 0) throw new BusinessExceptionError('Patient can not schedule appointment') if (await this.appointmentRepository.hasScheduledAppointment(patientId)) throw new UnprocessableEntityError(`Patient already has a scheduled appointment`) return patient } private async createAndSetAppointment({ availability, availabilityTime, patient, lastFinishedAppointment, appointmentIdRescheduled, isReturnReschedule = false, }: { availability: Availability availabilityTime: AvailabilityTime patient: Patient lastFinishedAppointment?: Appointment appointmentIdRescheduled?: string isReturnReschedule?: boolean }): Promise { try { const appointment = Appointment.build({ availability, availabilityTime, patient, lastFinishedAppointment, appointmentIdRescheduled, isReturnReschedule, }) const appointmentId = await this.appointmentRepository.create(appointment) await this.updateAvailabilityTimeWithAppointment(availabilityTime, appointmentId) return plainToClass(Appointment, { ...appointment, id: appointmentId }) } catch (error) { availabilityTime.free() await this.availabilityTimeRepository.update(availabilityTime) throw error } } private async updateAvailabilityTimeWithAppointment( availabilityTime: AvailabilityTime, appointmentId: string, ): Promise { try { availabilityTime.setScheduled(appointmentId) await this.availabilityTimeRepository.update(availabilityTime) } catch (error) { await this.appointmentRepository.delete(appointmentId) throw error } } private async calcRescheduleCredit(patient: Patient, lastAppointment: Appointment): Promise { if (patient.internal_collaborator) return 1 if (isNil(lastAppointment)) return RESCHEDULE_CREDIT if (APPOINTMENT_STATE_CARRIED_OUT.includes(lastAppointment.state)) return RESCHEDULE_CREDIT if (lastAppointment.losesRescheduleCreditIfCancel) return lastAppointment.reschedule_credit - 1 return lastAppointment.reschedule_credit } private async getTeleconsultationsCredit(patient: Patient): Promise { if (patient.internal_collaborator || patient.collaborator) return { teleconsultation: 1 } return this.teleconsultationsCreditService.getSeparateCredits(patient.id) } private async cancellationReasonDecreasesCredit(value: AppointmentCancellationReasons): Promise { const cancelReason = await this.appointmentCancelReasonRepository.get(value) if (isNil(cancelReason)) throw new NotFoundError('Cancellation reason not found') return cancelReason.decrease_credit } private async allowDecreaseCredit( appointment: Appointment, cancellationReason?: AppointmentCancellationReasons, ): Promise { const reason = cancellationReason || appointment.cancellation_reason if (!isNil(reason)) { const decreaseCredit = await this.cancellationReasonDecreasesCredit(reason) if (!decreaseCredit) return false } return true } private async cancelListAppointments( appointments: Appointment[], cancellationReason: BookingCancellationReason, requester: CreditAuthor = { type: 'system' }, ): Promise { await chunkedEach(appointments, 10, async appointment => { try { await this.cancel(appointment.id, requester, cancellationReason) } catch (error) { console.error(error) } }) } private async getLastAppointment(patientId: string): Promise { if (!isNil(this.lastAppointmentCached[patientId])) return this.lastAppointmentCached[patientId] const lastAppointment = await this.appointmentRepository.getLastAppointment(patientId) return (this.lastAppointmentCached[patientId] = lastAppointment) } private async getPatient(patientId: string): Promise { if (!isNil(this.patientCached[patientId])) return this.patientCached[patientId] const patient = await this.patientRepository.get(patientId) if (!patient) throw new NotFoundError('Patient not found') return (this.patientCached[patientId] = patient) } private async checkIfNextAppointmentMustBeAReturn(patientId: string): Promise { const countAppointments = await this.appointmentRepository.getCountUsedAppointments(patientId) return countAppointments > 0 } private clearCache(): void { this.lastAppointmentCached = {} this.patientCached = {} } }