diff --git a/functions/src/functions/onSchedule.ts b/functions/src/functions/onSchedule.ts index 37dac6cc..64526af8 100644 --- a/functions/src/functions/onSchedule.ts +++ b/functions/src/functions/onSchedule.ts @@ -19,15 +19,6 @@ export const onScheduleEveryMorning = onSchedule( async () => getServiceFactory().trigger().everyMorning(), ) -export const onScheduleEvery15Minutes = onSchedule( - { - schedule: '*/15 * * * *', - timeZone: 'America/Los_Angeles', - serviceAccount: serviceAccount, - }, - async () => getServiceFactory().trigger().every15Minutes(), -) - export const onScheduleUpdateMedicationRecommendations = onSchedule( { schedule: '0 0 * * *', diff --git a/functions/src/services/trigger/triggerService.test.ts b/functions/src/services/trigger/triggerService.test.ts index a14b227b..919f9e07 100644 --- a/functions/src/services/trigger/triggerService.test.ts +++ b/functions/src/services/trigger/triggerService.test.ts @@ -23,7 +23,7 @@ import { expect } from 'chai' import { describeWithEmulators } from '../../tests/functions/testEnvironment.js' describeWithEmulators('TriggerService', (env) => { - describe('every15Minutes', () => { + describe('everyMorning', () => { it('should create a message for an upcoming appointment', async () => { const ownerId = await env.createUser({ type: UserType.owner, @@ -52,13 +52,17 @@ describeWithEmulators('TriggerService', (env) => { const appointmentRef = env.collections.userAppointments(patientId).doc() await appointmentRef.set(appointment) - await env.factory.trigger().every15Minutes() + await env.factory.trigger().everyMorning() const patientMessages = await env.collections .userMessages(patientId) .get() - expect(patientMessages.docs).to.have.length(1) - const patientMessage = patientMessages.docs.at(0)?.data() + expect(patientMessages.docs).to.have.length(3) + const preAppointmentMessages = patientMessages.docs.filter( + (doc) => doc.data().type === UserMessageType.preAppointment, + ) + expect(preAppointmentMessages).to.have.length(1) + const patientMessage = preAppointmentMessages.at(0)?.data() expect(patientMessage?.type).to.equal(UserMessageType.preAppointment) expect(patientMessage?.reference).to.equal(appointmentRef.path) expect(patientMessage?.completionDate).to.be.undefined @@ -70,7 +74,7 @@ describeWithEmulators('TriggerService', (env) => { const clinicianMessage = clinicianMessages.docs.at(0)?.data() expect(clinicianMessage?.type).to.equal(UserMessageType.preAppointment) expect(clinicianMessage?.reference).to.equal( - patientMessages.docs.at(0)?.ref.path, + preAppointmentMessages.at(0)?.ref.path, ) expect(clinicianMessage?.completionDate).to.be.undefined @@ -79,7 +83,7 @@ describeWithEmulators('TriggerService', (env) => { const ownerMessage = clinicianMessages.docs.at(0)?.data() expect(ownerMessage?.type).to.equal(UserMessageType.preAppointment) expect(ownerMessage?.reference).to.equal( - patientMessages.docs.at(0)?.ref.path, + preAppointmentMessages.at(0)?.ref.path, ) expect(ownerMessage?.completionDate).to.be.undefined }) @@ -124,13 +128,17 @@ describeWithEmulators('TriggerService', (env) => { .doc() await clinicianMessageRef.set(clinicianMessage) - await env.factory.trigger().every15Minutes() + await env.factory.trigger().everyMorning() const patientMessages = await env.collections .userMessages(patientId) .get() - expect(patientMessages.docs).to.have.length(1) - const patientMessageData = patientMessages.docs.at(0)?.data() + expect(patientMessages.docs).to.have.length(3) + const preAppointmentMessages = patientMessages.docs.filter( + (doc) => doc.data().type === UserMessageType.preAppointment, + ) + expect(preAppointmentMessages).to.have.length(1) + const patientMessageData = preAppointmentMessages.at(0)?.data() expect(patientMessageData?.type).to.equal(UserMessageType.preAppointment) expect(patientMessageData?.reference).to.equal(appointmentRef.path) expect(patientMessageData?.completionDate).to.exist @@ -146,9 +154,7 @@ describeWithEmulators('TriggerService', (env) => { expect(clinicianMessageData?.reference).to.equal(patientMessageRef.path) expect(clinicianMessageData?.completionDate).to.be.undefined // this message will need to be manually completed }) - }) - describe('everyMorning', () => { it('should create a message to remind about vitals', async () => { const userId = await env.createUser({ type: UserType.patient, diff --git a/functions/src/services/trigger/triggerService.ts b/functions/src/services/trigger/triggerService.ts index ea4c0488..ddf5daf1 100644 --- a/functions/src/services/trigger/triggerService.ts +++ b/functions/src/services/trigger/triggerService.ts @@ -8,7 +8,6 @@ import { advanceDateByDays, - advanceDateByMinutes, type FHIRMedicationRequest, type FHIRQuestionnaireResponse, median, @@ -26,6 +25,7 @@ import { UserType, Invitation, UserRegistration, + advanceDateByHours, } from '@stanfordbdhg/engagehf-models' import { logger } from 'firebase-functions' import { _updateStaticData } from '../../functions/updateStaticData.js' @@ -49,102 +49,6 @@ export class TriggerService { // Methods - Schedule - async every15Minutes() { - const now = new Date() - const tomorrow = advanceDateByDays(now, 1) - const yesterday = advanceDateByDays(now, -1) - const patientService = this.factory.patient() - const userService = this.factory.user() - const messageService = this.factory.message() - - const upcomingAppointments = await patientService.getEveryAppoinment( - advanceDateByMinutes(tomorrow, -15), - advanceDateByMinutes(tomorrow, 15), - ) - - logger.debug( - `every15Minutes: Found ${upcomingAppointments.length} upcoming appointments`, - ) - - await Promise.all( - upcomingAppointments.map(async (appointment) => { - const userId = appointment.path.split('/')[1] - const messageDoc = await messageService.addMessage( - userId, - UserMessage.createPreAppointment({ - reference: appointment.path, - }), - { notify: true }, - ) - const user = await userService.getUser(userId) - if (user !== undefined && messageDoc !== undefined) { - const userAuth = await userService.getAuth(userId) - const forwardedMessage = UserMessage.createPreAppointmentForClinician( - { - userId: userId, - userName: userAuth.displayName, - reference: messageDoc.path, - }, - ) - await this.forwardMessageToOwnersAndClinician({ - user, - userService, - message: forwardedMessage, - messageService, - }) - } - }), - ) - - const pastAppointments = await patientService.getEveryAppoinment( - advanceDateByMinutes(yesterday, -15), - advanceDateByMinutes(yesterday, 15), - ) - - logger.debug( - `TriggerService.every15Minutes: Found ${upcomingAppointments.length} past appointments`, - ) - - await Promise.all( - pastAppointments.map(async (appointment) => - messageService.completeMessages( - appointment.path.split('/')[1], - UserMessageType.preAppointment, - (message) => message.reference === appointment.path, - ), - ), - ) - - try { - const isEmpty = await this.factory.history().isEmpty() - if (isEmpty) { - await this.factory.user().createInvitation( - new Invitation({ - code: '', - user: new UserRegistration({ - type: UserType.admin, - receivesAppointmentReminders: false, - receivesInactivityReminders: false, - receivesMedicationUpdates: false, - receivesQuestionnaireReminders: false, - receivesRecommendationUpdates: false, - receivesVitalsReminders: false, - receivesWeightAlerts: false, - }), - }), - ) - await _updateStaticData(this.factory, { - only: Object.values(StaticDataComponent), - cachingStrategy: CachingStrategy.updateCacheIfNeeded, - }) - } - } catch (error) { - logger.error( - `TriggerService.every15Minutes: Error updating static data '${String(error)}'.`, - ) - } - } - async everyMorning() { const now = new Date() const messageService = this.factory.message() @@ -153,86 +57,22 @@ export class TriggerService { logger.debug(`everyMorning: Found ${patients.length} patients`) - const symptomReminderMessage = UserMessage.createSymptomQuestionnaire({ - questionnaireReference: QuestionnaireReference.enUS, - }) - const vitalsMessage = UserMessage.createVitals() - - await Promise.all( - patients.map(async (user) => { - try { - await messageService.addMessage(user.id, vitalsMessage, { - notify: true, - user: user.content, - }) - - const enrollmentDuration = Math.abs( - user.content.dateOfEnrollment.getTime() - now.getTime(), - ) - const durationOfOneDayInMilliseconds = 24 * 60 * 60 * 1000 - - logger.debug( - `everyMorning(user: ${user.id}): enrolled on ${user.content.dateOfEnrollment.toISOString()}, which was ${enrollmentDuration} ms ago`, - ) - if ( - enrollmentDuration % (durationOfOneDayInMilliseconds * 14) < - durationOfOneDayInMilliseconds - ) { - await messageService.addMessage(user.id, symptomReminderMessage, { - notify: true, - user: user.content, - }) - } - - if ( - enrollmentDuration % (durationOfOneDayInMilliseconds * 14) > - durationOfOneDayInMilliseconds * 2 && - enrollmentDuration % (durationOfOneDayInMilliseconds * 14) < - durationOfOneDayInMilliseconds * 3 - ) { - const recommendations = await this.factory - .patient() - .getMedicationRecommendations(user.id) - await this.addMedicationUptitrationMessageIfNeeded({ - userId: user.id, - recommendations: recommendations.map((doc) => doc.content), - }) - } - } catch (error) { - logger.error( - `everyMorning(user: ${user.id}): Failed due to ${String(error)}`, - ) - } + await Promise.all([ + this.addDailyReminderMessages({ + patients, + messageService, + now, }), - ) - - const inactivePatients = patients.filter( - (patient) => advanceDateByDays(patient.content.lastActiveDate, 7) < now, - ) - await Promise.all( - inactivePatients.map(async (user) => { - const messageDoc = await messageService.addMessage( - user.id, - UserMessage.createInactive({}), - { notify: true }, - ) - - if (messageDoc !== undefined) { - const userAuth = await userService.getAuth(user.id) - const forwardedMessage = UserMessage.createInactiveForClinician({ - userId: user.id, - userName: userAuth.displayName, - reference: messageDoc.path, - }) - await this.forwardMessageToOwnersAndClinician({ - user, - userService, - message: forwardedMessage, - messageService, - }) - } + this.addInactivityReminderMessages({ + patients, + now, + messageService, + userService, }), - ) + this.addAppointmentReminderMessages(now), + this.completeAppointmentReminderMessages(now), + this.seedStaticDataIfNeeded(), + ]) } // Methods - Triggers @@ -732,4 +572,215 @@ export class TriggerService { }) } } + + private async addDailyReminderMessages(options: { + patients: Array> + messageService: MessageService + now: Date + }) { + const symptomReminderMessage = UserMessage.createSymptomQuestionnaire({ + questionnaireReference: QuestionnaireReference.enUS, + }) + const vitalsMessage = UserMessage.createVitals() + + await Promise.all( + options.patients.map(async (user) => { + try { + await options.messageService.addMessage(user.id, vitalsMessage, { + notify: true, + user: user.content, + }) + + const enrollmentDuration = Math.abs( + user.content.dateOfEnrollment.getTime() - options.now.getTime(), + ) + const durationOfOneDayInMilliseconds = 24 * 60 * 60 * 1000 + + logger.debug( + `everyMorning(user: ${user.id}): enrolled on ${user.content.dateOfEnrollment.toISOString()}, which was ${enrollmentDuration} ms ago`, + ) + if ( + enrollmentDuration % (durationOfOneDayInMilliseconds * 14) < + durationOfOneDayInMilliseconds + ) { + await options.messageService.addMessage( + user.id, + symptomReminderMessage, + { + notify: true, + user: user.content, + }, + ) + } + + if ( + enrollmentDuration % (durationOfOneDayInMilliseconds * 14) > + durationOfOneDayInMilliseconds * 2 && + enrollmentDuration % (durationOfOneDayInMilliseconds * 14) < + durationOfOneDayInMilliseconds * 3 + ) { + const recommendations = await this.factory + .patient() + .getMedicationRecommendations(user.id) + await this.addMedicationUptitrationMessageIfNeeded({ + userId: user.id, + recommendations: recommendations.map((doc) => doc.content), + }) + } + } catch (error) { + logger.error( + `everyMorning(user: ${user.id}): Failed due to ${String(error)}`, + ) + } + }), + ) + } + + private async addInactivityReminderMessages(options: { + patients: Array> + now: Date + messageService: MessageService + userService: UserService + }) { + const inactivePatients = options.patients.filter( + (patient) => + advanceDateByDays(patient.content.lastActiveDate, 7) < options.now, + ) + await Promise.all( + inactivePatients.map(async (user) => { + const messageDoc = await options.messageService.addMessage( + user.id, + UserMessage.createInactive({}), + { notify: true }, + ) + + if (messageDoc !== undefined) { + const userAuth = await options.userService.getAuth(user.id) + const forwardedMessage = UserMessage.createInactiveForClinician({ + userId: user.id, + userName: userAuth.displayName, + reference: messageDoc.path, + }) + await this.forwardMessageToOwnersAndClinician({ + user, + userService: options.userService, + message: forwardedMessage, + messageService: options.messageService, + }) + } + }), + ) + } + + private async addAppointmentReminderMessages(now: Date) { + const patientService = this.factory.patient() + const userService = this.factory.user() + const messageService = this.factory.message() + + const tomorrow = advanceDateByDays(now, 1) + const upcomingAppointments = await patientService.getEveryAppoinment( + advanceDateByHours(tomorrow, -8), + advanceDateByHours(tomorrow, 16), + ) + + logger.debug( + `TriggerService.addAppointmentReminderMessages: Found ${upcomingAppointments.length} upcoming appointments`, + ) + + await Promise.all( + upcomingAppointments.map(async (appointment) => { + try { + const userId = appointment.path.split('/')[1] + const messageDoc = await messageService.addMessage( + userId, + UserMessage.createPreAppointment({ + reference: appointment.path, + }), + { notify: true }, + ) + const user = await userService.getUser(userId) + if (user !== undefined && messageDoc !== undefined) { + const userAuth = await userService.getAuth(userId) + const forwardedMessage = + UserMessage.createPreAppointmentForClinician({ + userId: userId, + userName: userAuth.displayName, + reference: messageDoc.path, + }) + await this.forwardMessageToOwnersAndClinician({ + user, + userService, + message: forwardedMessage, + messageService, + }) + } + } catch (error) { + logger.error( + `TriggerService.addAppointmentReminderMessages: Error adding messages for appointment ${appointment.path}: ${String(error)}`, + ) + } + }), + ) + } + + private async completeAppointmentReminderMessages(now: Date) { + const patientService = this.factory.patient() + const messageService = this.factory.message() + const yesterday = advanceDateByDays(now, -1) + const pastAppointments = await patientService.getEveryAppoinment( + advanceDateByHours(yesterday, -8), + advanceDateByHours(yesterday, 16), + ) + + logger.debug( + `TriggerService.completeAppointmentReminderMessages: Found ${pastAppointments.length} past appointments`, + ) + + await Promise.all( + pastAppointments.map(async (appointment) => { + try { + await messageService.completeMessages( + appointment.path.split('/')[1], + UserMessageType.preAppointment, + (message) => message.reference === appointment.path, + ) + } catch (error) { + logger.error( + `TriggerService.completeAppointmentReminderMessages: Error completing messages for appointment ${appointment.path}: ${String(error)}`, + ) + } + }), + ) + } + + private async seedStaticDataIfNeeded() { + try { + const isEmpty = await this.factory.history().isEmpty() + if (isEmpty) { + await this.factory.user().createInvitation( + new Invitation({ + code: '', + user: new UserRegistration({ + type: UserType.admin, + receivesAppointmentReminders: false, + receivesInactivityReminders: false, + receivesMedicationUpdates: false, + receivesQuestionnaireReminders: false, + receivesRecommendationUpdates: false, + receivesVitalsReminders: false, + receivesWeightAlerts: false, + }), + }), + ) + await _updateStaticData(this.factory, { + only: Object.values(StaticDataComponent), + cachingStrategy: CachingStrategy.updateCacheIfNeeded, + }) + } + } catch (error) { + logger.error( + `TriggerService.updateStaticData: Error updating static data '${String(error)}'.`, + ) + } + } }