import { Injectable } from '@nestjs/common' import { plainToClass } from 'class-transformer' import { addHours, format } from 'date-fns' import { MailService, MessagingService, PushNotificationMessage, PushPayload } from '../../../services' import { AppointmentRepository, CampaignMessageRepository, PatientRepository, UserRepository, } from '../../../repositories' import { Appointment, AppointmentCancellationReasons, CampaignMessage, CampaignType, Patient, User, } from '../../../models' import { CountryCode, getOffsetBy, isNotBlank } from '../../../utils' import { chunkedEach } from '../../../utils/async' import { TranslateNotificationsHelper } from '../i18n' const NOTIFY_APPOINTMENT_START_HOURS_WINDOW = 2 const NOTIFY_APPOINTMENT_START_MINUTES_WINDOW = 5 const APPOINTMENT_START_TEMPLATE = 'appointment_starts_at' const APPOINTMENT_CREATED_TEMPLATE = 'appointment_created' const APPOINTMENT_CREATED_RULES_TEMPLATE = 'appointment_created_rules' const APPOINTMENT_CREATED_RULES_EMAIL_HOURS_DELAY = 2 const APPOINTMENT_CREATED_RULES_EMAIL_CLOSE_HOURS_DELAY = 0.5 const APPOINTMENT_CANCELLED_TEMPLATE = 'appointment_cancelled' const APPOINTMENT_CANCELLED_BY_NUTRI_TEMPLATE = 'appointment_cancelled_by_nutri' const APPOINTMENT_CANCELLED_BY_NUTRI_PATIENT_REQUESTED_TEMPLATE = 'appointment_cancelled_by_nutri_patient_requested' const NOTIFY_PATIENTS_ENABLED_TO_BOOK_RETURN = 'book_appointment_return' const CONSULTATION_RETURN_DIALOG_IDENTIFIER = 'consultation_return' type SendEmailArgs = { countryCode: CountryCode email: string template: string dynamicEnv: Record sendAt?: Date } type SendNotificationsToAppointmentArgs = { appointment: Appointment patient: Patient user: User template: string sendPush?: boolean sendEmail?: boolean } @Injectable() export class Bookings { constructor( private readonly messagingService: MessagingService, private readonly appointmentRepository: AppointmentRepository, private readonly mailService: MailService, private readonly patientRepository: PatientRepository, private readonly userRepository: UserRepository, private readonly campaignMessageRepository: CampaignMessageRepository, private readonly translator: TranslateNotificationsHelper, ) {} async notifyAppointmentCreation(appointment: Appointment): Promise { const patient = await this.patientRepository.get(appointment.patient_id) const user = await this.userRepository.get(appointment.user_id) const count = await this.appointmentRepository.getCountByPatient(appointment.patient_id) const notificationsPromise = this.sendNotificationsToAppointment({ appointment, patient, user, template: APPOINTMENT_CREATED_TEMPLATE, sendPush: false, }) if (count > 1) return await notificationsPromise let sendAt = addHours(new Date(), APPOINTMENT_CREATED_RULES_EMAIL_HOURS_DELAY) if (sendAt.getTime() >= appointment.scheduled_to.getTime()) sendAt = addHours(new Date(), APPOINTMENT_CREATED_RULES_EMAIL_CLOSE_HOURS_DELAY) const rulesPromise = this.sendEmail({ email: patient.email, template: APPOINTMENT_CREATED_RULES_TEMPLATE, countryCode: patient.country_code, dynamicEnv: { nome: patient.name }, sendAt, }) await Promise.all([notificationsPromise, rulesPromise]) } async notifyAppointmentCancelled(appointment: Appointment): Promise { const [patient, user] = await Promise.all([ this.patientRepository.get(appointment.patient_id), this.userRepository.get(appointment.user_id), ]) const { template, sendPush } = this.getAppointmentCancelledTemplate(appointment) await this.sendNotificationsToAppointment({ appointment, patient, user, template, sendPush }) } async notifyAppointmentStart(): Promise { const appointments = await this.appointmentRepository.findThatStartsAtHour({ date: addHours(new Date(), NOTIFY_APPOINTMENT_START_HOURS_WINDOW), windowInMinutes: NOTIFY_APPOINTMENT_START_MINUTES_WINDOW, state: ['scheduled'], }) console.log(`SENDING ${APPOINTMENT_START_TEMPLATE}`, JSON.stringify(appointments.map(item => item.id))) await this.sendNotificationsToAppointments(appointments, APPOINTMENT_START_TEMPLATE) } async notifyToBookReturnAppointment(): Promise { const patients = await this.patientRepository.findEnabledForBookReturn() await chunkedEach( patients, 100, async (patient: Patient) => { await this.sendPush( patient, NOTIFY_PATIENTS_ENABLED_TO_BOOK_RETURN, {}, { dialog: CONSULTATION_RETURN_DIALOG_IDENTIFIER }, ) await this.createCampaignMessageBy(patient, NOTIFY_PATIENTS_ENABLED_TO_BOOK_RETURN, 'appointment_return_push') }, false, ) } private async createCampaignMessageBy(patient: Patient, template: string, type: CampaignType): Promise { const { title, body } = this.getPushTitleAndBody(template, patient.country_code) const msgLog: CampaignMessage = plainToClass(CampaignMessage, { patient_id: patient.id, content: `${title}: ${body}`, type: 'push', cell_phone: patient.cell_phone, campaign_type: type, }) await this.campaignMessageRepository.create(msgLog) } private async sendNotificationsToAppointments( appointments: Array, template: string, ): Promise { await chunkedEach(appointments, 10, async (appointment: Appointment & { user: User; patient: Patient }) => this.sendNotificationsToAppointment({ template, sendPush: true, sendEmail: true, appointment, patient: appointment.patient, user: appointment.user, }), ) } private async sendNotificationsToAppointment({ appointment, patient, user, template, sendPush = true, sendEmail = true, }: SendNotificationsToAppointmentArgs): Promise { const localDate = addHours( appointment.scheduled_to, getOffsetBy(patient.country_code as CountryCode, appointment.scheduled_to), ) const dynamicVars = { nome: patient.name, nutri: user.name, hora: format(localDate, 'HH:mm'), data: format(localDate, 'dd/MM/yyyy'), } const sendPushPromise = sendPush && isNotBlank(patient?.device_id) ? this.sendPush(patient, template, dynamicVars) : Promise.resolve() const sendEmailPromise = sendEmail && isNotBlank(patient?.email) ? this.sendEmail({ countryCode: patient.country_code, email: patient.email, template, dynamicEnv: dynamicVars, }) : Promise.resolve() try { await Promise.all([sendPushPromise, sendEmailPromise]) } catch (error) { console.error(error) } } private async sendPush( patient: Patient, pushNotificationTemplate: string, dynamicEnv?: Record, data?: Record, ): Promise { const { title, body, page } = this.getPushTitleAndBody(pushNotificationTemplate, patient.country_code, dynamicEnv) console.log(`SENDING PUSH ${pushNotificationTemplate} "${title} | ${body}" TO ${patient.device_id}`) const push: PushPayload = plainToClass(PushPayload, { notification: { title, body, }, data: { click_action: 'FLUTTER_NOTIFICATION_CLICK', page, ...data, }, }) await this.messagingService.sendPushNotification([patient.device_id], push) } private getPushTitleAndBody( pushNotificationTemplate: string, countryCode: CountryCode, dynamicEnv?: Record, ): PushNotificationMessage & { page: string } { const message = this.translator.get(countryCode, `pushes.${pushNotificationTemplate}` as any, { params: dynamicEnv ? { ...dynamicEnv, } : {}, }) if (!message) throw new Error('Push notification template not found') return { title: message['title'], body: message['message'], page: message['page'] } } private async sendEmail({ email, template, countryCode, dynamicEnv, sendAt }: SendEmailArgs): Promise { console.log(`SENDING EMAIL ${template} TO ${email} AT ${sendAt ? sendAt.toISOString() : 'NOW'}`) const templateId = this.translator.get(countryCode, `email_templates.${template}` as any) await this.mailService.send({ templateId, to: email, sendAt, dynamicTemplateData: { ...dynamicEnv, }, }) } private getAppointmentCancelledTemplate(appointment: Appointment): { template: string; sendPush: boolean } { switch (appointment.cancellation_reason) { case AppointmentCancellationReasons.PATIENT_CANCELLED: return { template: APPOINTMENT_CANCELLED_TEMPLATE, sendPush: false } case AppointmentCancellationReasons.NUTRI_CANCELLED_BY_PATIENT_REQUEST: return { template: APPOINTMENT_CANCELLED_BY_NUTRI_PATIENT_REQUESTED_TEMPLATE, sendPush: true } default: return { template: APPOINTMENT_CANCELLED_BY_NUTRI_TEMPLATE, sendPush: true } } } }