diff --git a/functions/models/src/types/userMessage.ts b/functions/models/src/types/userMessage.ts index ef352718..28d778dc 100644 --- a/functions/models/src/types/userMessage.ts +++ b/functions/models/src/types/userMessage.ts @@ -91,6 +91,7 @@ export class UserMessage { creationDate?: Date userId: string userName?: string + reference: string }): UserMessage { return new UserMessage({ creationDate: input.creationDate ?? new Date(), @@ -103,7 +104,7 @@ export class UserMessage { action: `users/${input.userId}`, type: UserMessageType.inactive, isDismissible: true, - reference: `users/${input.userId}`, + reference: input.reference, }) } @@ -153,6 +154,7 @@ export class UserMessage { creationDate?: Date userId: string userName?: string + reference: string }): UserMessage { return new UserMessage({ creationDate: input.creationDate ?? new Date(), @@ -162,7 +164,7 @@ export class UserMessage { description: new LocalizedText({ en: `${input.userName ?? 'Patient'} may be eligible for med changes. You can review med information on the user detail page.`, }), - reference: `users/${input.userId}`, + reference: input.reference, action: `users/${input.userId}/medications`, type: UserMessageType.medicationUptitration, isDismissible: true, @@ -271,6 +273,7 @@ export class UserMessage { creationDate?: Date userId: string userName?: string + reference: string }): UserMessage { return new UserMessage({ creationDate: input.creationDate ?? new Date(), @@ -281,7 +284,7 @@ export class UserMessage { en: `Weight increase over 3 lbs for ${input.userName ?? 'patient'}.`, }), action: `users/${input.userId}/medications`, - reference: `users/${input.userId}`, + reference: input.reference, type: UserMessageType.weightGain, isDismissible: true, }) diff --git a/functions/src/services/message/defaultMessageService.ts b/functions/src/services/message/defaultMessageService.ts index 71a4dab2..5075cf97 100644 --- a/functions/src/services/message/defaultMessageService.ts +++ b/functions/src/services/message/defaultMessageService.ts @@ -130,7 +130,7 @@ export class DefaultMessageService implements MessageService { notify: boolean user?: User | null }, - ): Promise { + ): Promise | undefined> { const newMessage = await this.databaseService.runTransaction( async (collections, transaction) => { logger.debug( @@ -180,30 +180,13 @@ export class DefaultMessageService implements MessageService { `DatabaseMessageService.addMessage(user: ${userId}): System will notify user unless user settings prevent it`, ) - const user = - options.user ?? (await this.userService.getUser(userId))?.content - if (!user) return - - switch (message.type) { - case UserMessageType.medicationChange: - if (!user.receivesMedicationUpdates) return - case UserMessageType.weightGain: - if (!user.receivesWeightAlerts) return - case UserMessageType.medicationUptitration: - if (!user.receivesRecommendationUpdates) return - case UserMessageType.welcome: - break - case UserMessageType.vitals: - if (!user.receivesVitalsReminders) return - case UserMessageType.symptomQuestionnaire: - if (!user.receivesQuestionnaireReminders) return - case UserMessageType.preAppointment: - if (!user.receivesAppointmentReminders) return - } - - await this.sendNotification(userId, newMessage, { - language: user.language, + await this.sendNotificationIfNeeded({ + userId: userId, + user: options.user ?? null, + message: newMessage, }) + + return newMessage } } @@ -340,6 +323,37 @@ export class DefaultMessageService implements MessageService { // Helpers - Notifications + private async sendNotificationIfNeeded(input: { + userId: string + user: User | null + message: Document + }) { + const user = + input.user ?? (await this.userService.getUser(input.userId))?.content + if (!user) return + + switch (input.message.content.type) { + case UserMessageType.medicationChange: + if (!user.receivesMedicationUpdates) return + case UserMessageType.weightGain: + if (!user.receivesWeightAlerts) return + case UserMessageType.medicationUptitration: + if (!user.receivesRecommendationUpdates) return + case UserMessageType.welcome: + break + case UserMessageType.vitals: + if (!user.receivesVitalsReminders) return + case UserMessageType.symptomQuestionnaire: + if (!user.receivesQuestionnaireReminders) return + case UserMessageType.preAppointment: + if (!user.receivesAppointmentReminders) return + } + + await this.sendNotification(input.userId, input.message, { + language: user.language, + }) + } + private async sendNotification( userId: string, message: Document, diff --git a/functions/src/services/message/messageService.ts b/functions/src/services/message/messageService.ts index 48f05cbd..f7699b1e 100644 --- a/functions/src/services/message/messageService.ts +++ b/functions/src/services/message/messageService.ts @@ -13,6 +13,7 @@ import { type UserMessage, type UserMessageType, } from '@stanfordbdhg/engagehf-models' +import { type Document } from '../database/databaseService' export interface MessageService { // Notifications @@ -33,7 +34,7 @@ export interface MessageService { notify: boolean user?: User | null }, - ): Promise + ): Promise | undefined> completeMessages( userId: string, diff --git a/functions/src/services/seeding/debugData/debugDataService.ts b/functions/src/services/seeding/debugData/debugDataService.ts index 09f229aa..7fe3af2b 100644 --- a/functions/src/services/seeding/debugData/debugDataService.ts +++ b/functions/src/services/seeding/debugData/debugDataService.ts @@ -184,10 +184,12 @@ export class DebugDataService extends SeedingService { UserMessage.createInactiveForClinician({ userId: patient.id, userName: patient.name, + reference: '', }), UserMessage.createMedicationUptitrationForClinician({ userId: patient.id, userName: patient.name, + reference: '', }), UserMessage.createPreAppointmentForClinician({ userId: patient.id, @@ -197,6 +199,7 @@ export class DebugDataService extends SeedingService { UserMessage.createWeightGainForClinician({ userId: patient.id, userName: patient.name, + reference: '', }), ]) await this.replaceCollection( diff --git a/functions/src/services/trigger/triggerService.test.ts b/functions/src/services/trigger/triggerService.test.ts index 143ffde9..a14b227b 100644 --- a/functions/src/services/trigger/triggerService.test.ts +++ b/functions/src/services/trigger/triggerService.test.ts @@ -25,6 +25,11 @@ import { describeWithEmulators } from '../../tests/functions/testEnvironment.js' describeWithEmulators('TriggerService', (env) => { describe('every15Minutes', () => { it('should create a message for an upcoming appointment', async () => { + const ownerId = await env.createUser({ + type: UserType.owner, + organization: 'stanford', + }) + const clinicianId = await env.createUser({ type: UserType.clinician, organization: 'stanford', @@ -64,11 +69,22 @@ describeWithEmulators('TriggerService', (env) => { expect(clinicianMessages.docs).to.have.length(1) const clinicianMessage = clinicianMessages.docs.at(0)?.data() expect(clinicianMessage?.type).to.equal(UserMessageType.preAppointment) - expect(clinicianMessage?.reference).to.equal(appointmentRef.path) + expect(clinicianMessage?.reference).to.equal( + patientMessages.docs.at(0)?.ref.path, + ) expect(clinicianMessage?.completionDate).to.be.undefined + + const ownerMessages = await env.collections.userMessages(ownerId).get() + expect(ownerMessages.docs).to.have.length(1) + 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, + ) + expect(ownerMessage?.completionDate).to.be.undefined }) - it('should create a complete a message for a past appointment', async () => { + it('should complete a message for a past appointment', async () => { const clinicianId = await env.createUser({ type: UserType.clinician, organization: 'stanford', @@ -91,16 +107,22 @@ describeWithEmulators('TriggerService', (env) => { const appointmentRef = env.collections.userAppointments(patientId).doc() await appointmentRef.set(appointment) - const message = UserMessage.createPreAppointment({ + const patientMessage = UserMessage.createPreAppointment({ reference: appointmentRef.path, }) const patientMessageRef = env.collections.userMessages(patientId).doc() - await patientMessageRef.set(message) + await patientMessageRef.set(patientMessage) + + const clinicianMessage = UserMessage.createPreAppointmentForClinician({ + userId: patientId, + userName: 'Mock', + reference: patientMessageRef.path, + }) const clinicianMessageRef = env.collections .userMessages(clinicianId) .doc() - await clinicianMessageRef.set(message) + await clinicianMessageRef.set(clinicianMessage) await env.factory.trigger().every15Minutes() @@ -108,19 +130,21 @@ describeWithEmulators('TriggerService', (env) => { .userMessages(patientId) .get() expect(patientMessages.docs).to.have.length(1) - const patientMessage = patientMessages.docs.at(0)?.data() - expect(patientMessage?.type).to.equal(UserMessageType.preAppointment) - expect(patientMessage?.reference).to.equal(appointmentRef.path) - expect(patientMessage?.completionDate).to.exist + const patientMessageData = patientMessages.docs.at(0)?.data() + expect(patientMessageData?.type).to.equal(UserMessageType.preAppointment) + expect(patientMessageData?.reference).to.equal(appointmentRef.path) + expect(patientMessageData?.completionDate).to.exist const clinicianMessages = await env.collections - .userMessages(patientId) + .userMessages(clinicianId) .get() expect(clinicianMessages.docs).to.have.length(1) - const clinicianMessage = clinicianMessages.docs.at(0)?.data() - expect(clinicianMessage?.type).to.equal(UserMessageType.preAppointment) - expect(clinicianMessage?.reference).to.equal(appointmentRef.path) - expect(clinicianMessage?.completionDate).to.exist + const clinicianMessageData = clinicianMessages.docs.at(0)?.data() + expect(clinicianMessageData?.type).to.equal( + UserMessageType.preAppointment, + ) + expect(clinicianMessageData?.reference).to.equal(patientMessageRef.path) + expect(clinicianMessageData?.completionDate).to.be.undefined // this message will need to be manually completed }) }) @@ -182,6 +206,11 @@ describeWithEmulators('TriggerService', (env) => { }) it('create a message about inactivity', async () => { + const ownerId = await env.createUser({ + type: UserType.owner, + organization: 'stanford', + }) + const clinicianId = await env.createUser({ type: UserType.clinician, organization: 'stanford', @@ -221,11 +250,31 @@ describeWithEmulators('TriggerService', (env) => { expect(clinicianMessages.docs).to.have.length(1) const clinicianMessage = clinicianMessages.docs.at(0)?.data() expect(clinicianMessage?.type).to.equal(UserMessageType.inactive) - expect(clinicianMessage?.reference).to.equal(`users/${patientId}`) + expect(clinicianMessage?.reference).to.equal( + patientMessages.docs + .filter((doc) => doc.data().type === UserMessageType.inactive) + .at(0)?.ref.path, + ) expect(clinicianMessage?.completionDate).to.be.undefined + + const ownerMessages = await env.collections.userMessages(ownerId).get() + expect(ownerMessages.docs).to.have.length(1) + const ownerMessage = clinicianMessages.docs.at(0)?.data() + expect(ownerMessage?.type).to.equal(UserMessageType.inactive) + expect(ownerMessage?.reference).to.equal( + patientMessages.docs + .filter((doc) => doc.data().type === UserMessageType.inactive) + .at(0)?.ref.path, + ) + expect(ownerMessage?.completionDate).to.be.undefined }) it('create no message about inactivity', async () => { + const ownerId = await env.createUser({ + type: UserType.owner, + organization: 'stanford', + }) + const clinicianId = await env.createUser({ type: UserType.clinician, organization: 'stanford', @@ -253,6 +302,9 @@ describeWithEmulators('TriggerService', (env) => { .userMessages(clinicianId) .get() expect(clinicianMessages.docs).to.have.length(0) + + const ownerMessages = await env.collections.userMessages(ownerId).get() + expect(ownerMessages.docs).to.have.length(0) }) }) @@ -261,6 +313,11 @@ describeWithEmulators('TriggerService', (env) => { it('should create a weight gain message for a user', async () => { const triggerService = env.factory.trigger() + const ownerId = await env.createUser({ + type: UserType.owner, + organization: 'stanford', + }) + const clinicianId = await env.createUser({ type: UserType.clinician, organization: 'stanford', @@ -304,6 +361,9 @@ describeWithEmulators('TriggerService', (env) => { .get() expect(clinicianMessages0.docs).to.have.length(0) + const ownerMessages0 = await env.collections.userMessages(ownerId).get() + expect(ownerMessages0.docs).to.have.length(0) + const slightlyHigherWeight = FHIRObservation.createSimple({ id: 'observation-10', code: LoincCode.bodyWeight, @@ -328,13 +388,19 @@ describeWithEmulators('TriggerService', (env) => { expect(patientMessage1?.completionDate).to.be.undefined const clinicianMessages1 = await env.collections - .userMessages(patientId) + .userMessages(clinicianId) .get() expect(clinicianMessages1.docs, 'clinicianMessages1').to.have.length(1) const clinicianMessage1 = clinicianMessages1.docs.at(0)?.data() expect(clinicianMessage1?.type).to.equal(UserMessageType.weightGain) expect(clinicianMessage1?.completionDate).to.be.undefined + const ownerMessages1 = await env.collections.userMessages(ownerId).get() + expect(ownerMessages1.docs, 'ownerMessages1').to.have.length(1) + const ownerMessage1 = clinicianMessages1.docs.at(0)?.data() + expect(ownerMessage1?.type).to.equal(UserMessageType.weightGain) + expect(ownerMessage1?.completionDate).to.be.undefined + const actuallyHigherWeight = FHIRObservation.createSimple({ id: 'observation-11', code: LoincCode.bodyWeight, @@ -359,12 +425,18 @@ describeWithEmulators('TriggerService', (env) => { expect(patientMessage2?.completionDate).to.be.undefined const clinicianMessages2 = await env.collections - .userMessages(patientId) + .userMessages(clinicianId) .get() expect(clinicianMessages2.docs, 'clinicianMessages2').to.have.length(1) const clinicianMessage2 = clinicianMessages1.docs.at(0)?.data() expect(clinicianMessage2?.type).to.equal(UserMessageType.weightGain) expect(clinicianMessage2?.completionDate).to.be.undefined + + const ownerMessages2 = await env.collections.userMessages(ownerId).get() + expect(ownerMessages2.docs, 'ownerMessages2').to.have.length(1) + const ownerMessage2 = ownerMessages2.docs.at(0)?.data() + expect(ownerMessage2?.type).to.equal(UserMessageType.weightGain) + expect(ownerMessage2?.completionDate).to.be.undefined }) }) }) diff --git a/functions/src/services/trigger/triggerService.ts b/functions/src/services/trigger/triggerService.ts index 03dd2ce3..6f911cf5 100644 --- a/functions/src/services/trigger/triggerService.ts +++ b/functions/src/services/trigger/triggerService.ts @@ -20,6 +20,7 @@ import { UserMessageType, VideoReference, UserObservationCollection, + type User, CachingStrategy, StaticDataComponent, UserType, @@ -28,9 +29,12 @@ import { } from '@stanfordbdhg/engagehf-models' import { logger } from 'firebase-functions' import { _updateStaticData } from '../../functions/updateStaticData.js' +import { type Document } from '../database/databaseService.js' import { type ServiceFactory } from '../factory/serviceFactory.js' +import { type MessageService } from '../message/messageService.js' import { type PatientService } from '../patient/patientService.js' import { type RecommendationVitals } from '../recommendation/recommendationService.js' +import { type UserService } from '../user/userService.js' export class TriggerService { // Properties @@ -65,7 +69,7 @@ export class TriggerService { await Promise.all( upcomingAppointments.map(async (appointment) => { const userId = appointment.path.split('/')[1] - await messageService.addMessage( + const messageDoc = await messageService.addMessage( userId, UserMessage.createPreAppointment({ reference: appointment.path, @@ -73,21 +77,21 @@ export class TriggerService { { notify: true }, ) const user = await userService.getUser(userId) - const clinicianId = user?.content.clinician - logger.debug( - `TriggerService.every15Minutes: About to add clinician message for clinician ${clinicianId} and appointment ${appointment.path}.`, - ) - if (clinicianId !== undefined) { + if (user !== undefined && messageDoc !== undefined) { const userAuth = await userService.getAuth(userId) - await messageService.addMessage( - clinicianId, - UserMessage.createPreAppointmentForClinician({ + const forwardedMessage = UserMessage.createPreAppointmentForClinician( + { userId: userId, userName: userAuth.displayName, - reference: appointment.path, - }), - { notify: true }, + reference: messageDoc.path, + }, ) + await this.forwardMessageToOwnersAndClinician({ + user, + userService, + message: forwardedMessage, + messageService, + }) } }), ) @@ -207,22 +211,25 @@ export class TriggerService { ) await Promise.all( inactivePatients.map(async (user) => { - await messageService.addMessage( + const messageDoc = await messageService.addMessage( user.id, UserMessage.createInactive({}), { notify: true }, ) - if (user.content.clinician !== undefined) { + if (messageDoc !== undefined) { const userAuth = await userService.getAuth(user.id) - await messageService.addMessage( - user.content.clinician, - UserMessage.createInactiveForClinician({ - userId: user.id, - userName: userAuth.displayName, - }), - { notify: true }, - ) + const forwardedMessage = UserMessage.createInactiveForClinician({ + userId: user.id, + userName: userAuth.displayName, + reference: messageDoc.path, + }) + await this.forwardMessageToOwnersAndClinician({ + user, + userService, + message: forwardedMessage, + messageService, + }) } }), ) @@ -353,7 +360,7 @@ export class TriggerService { ) if (mostRecentBodyWeight - bodyWeightMedian >= 7) { const messageService = this.factory.message() - await messageService.addMessage( + const messageDoc = await messageService.addMessage( userId, UserMessage.createWeightGain(), { notify: true }, @@ -361,18 +368,19 @@ export class TriggerService { const userService = this.factory.user() const user = await userService.getUser(userId) - const clinicianId = user?.content.clinician - - if (clinicianId !== undefined) { + if (user !== undefined && messageDoc !== undefined) { const userAuth = await userService.getAuth(userId) - await messageService.addMessage( - clinicianId, - UserMessage.createWeightGainForClinician({ - userId: userId, - userName: userAuth.displayName, - }), - { notify: true }, - ) + const forwardedMessage = UserMessage.createWeightGainForClinician({ + userId: userId, + userName: userAuth.displayName, + reference: messageDoc.path, + }) + await this.forwardMessageToOwnersAndClinician({ + user, + userService, + message: forwardedMessage, + messageService, + }) } } } catch (error) { @@ -667,21 +675,55 @@ export class TriggerService { if (!hasImprovementAvailable) return false const message = UserMessage.createMedicationUptitration() const messageService = this.factory.message() - await messageService.addMessage(input.userId, message, { notify: true }) - - const user = await this.factory.user().getUser(input.userId) - const clinicianId = user?.content.clinician - if (clinicianId !== undefined) { - const userAuth = await this.factory.user().getAuth(input.userId) - await messageService.addMessage( - clinicianId, + const messageDoc = await messageService.addMessage(input.userId, message, { + notify: true, + }) + + const userService = this.factory.user() + const user = await userService.getUser(input.userId) + if (messageDoc !== undefined && user !== undefined) { + const userAuth = await userService.getAuth(input.userId) + const forwardedMessage = UserMessage.createMedicationUptitrationForClinician({ - userName: userAuth.displayName, userId: input.userId, - }), - { notify: true }, - ) + userName: userAuth.displayName, + reference: messageDoc.path, + }) + await this.forwardMessageToOwnersAndClinician({ + user, + userService, + message: forwardedMessage, + messageService, + }) + return true + } else { + return false + } + } + + private async forwardMessageToOwnersAndClinician(input: { + user: Document + message: UserMessage + userService: UserService + messageService: MessageService + }) { + const owners = + input.user.content.organization !== undefined ? + await input.userService.getAllOwners(input.user.content.organization) + : [] + const clinican = input.user.content.clinician + + const recipientIds = owners.map((owner) => owner.id) + if (clinican !== undefined) recipientIds.push(clinican) + + logger.debug( + `TriggerService.forwardMessageToOwnersAndClinician(${input.user.id}): Found ${recipientIds.length} recipients (${recipientIds.join(', ')}).`, + ) + + for (const recipientId of recipientIds) { + await input.messageService.addMessage(recipientId, input.message, { + notify: true, + }) } - return true } } diff --git a/functions/src/services/user/databaseUserService.ts b/functions/src/services/user/databaseUserService.ts index 54352a18..8d70508a 100644 --- a/functions/src/services/user/databaseUserService.ts +++ b/functions/src/services/user/databaseUserService.ts @@ -261,6 +261,14 @@ export class DatabaseUserService implements UserService { // Users + async getAllOwners(organizationId: string): Promise>> { + return this.databaseService.getQuery((collections) => + collections.users + .where('type', '==', UserType.owner) + .where('organization', '==', organizationId), + ) + } + async getAllPatients(): Promise>> { return this.databaseService.getQuery((collections) => collections.users.where('type', '==', UserType.patient), diff --git a/functions/src/services/user/userService.mock.ts b/functions/src/services/user/userService.mock.ts index 00aa17ca..f68f35c1 100644 --- a/functions/src/services/user/userService.mock.ts +++ b/functions/src/services/user/userService.mock.ts @@ -123,6 +123,10 @@ export class MockUserService implements UserService { // Methods - User + async getAllOwners(organizationId: string): Promise>> { + return [] + } + async getAllPatients(): Promise>> { return [] } diff --git a/functions/src/services/user/userService.ts b/functions/src/services/user/userService.ts index 2dd1b812..2efa496e 100644 --- a/functions/src/services/user/userService.ts +++ b/functions/src/services/user/userService.ts @@ -51,6 +51,7 @@ export interface UserService { // Users + getAllOwners(organizationId: string): Promise>> getAllPatients(): Promise>> getUser(userId: string): Promise | undefined> updateLastActiveDate(userId: string): Promise