diff --git a/apis/api-journeys-modern/src/workers/email/service/service.spec.ts b/apis/api-journeys-modern/src/workers/email/service/service.spec.ts index 9a35647e1e9..0edf71b6573 100644 --- a/apis/api-journeys-modern/src/workers/email/service/service.spec.ts +++ b/apis/api-journeys-modern/src/workers/email/service/service.spec.ts @@ -1,4 +1,3 @@ -import { ApolloClient, ApolloQueryResult } from '@apollo/client' import { Job } from 'bullmq' import { @@ -21,7 +20,18 @@ import { } from './prisma.types' import { service } from './service' -jest.mock('@apollo/client') +jest.mock('@core/prisma/users/client', () => ({ + prisma: { + user: { + findUnique: jest.fn(), + findMany: jest.fn() + } + } +})) + +const { prisma: mockPrismaUsers } = jest.requireMock( + '@core/prisma/users/client' +) let args = {} jest.mock('@core/yoga/email', () => ({ @@ -182,17 +192,12 @@ describe('EmailConsumer', () => { describe('teamRemovedEmail', () => { it('should send an email', async () => { - jest.spyOn(ApolloClient.prototype, 'query').mockImplementationOnce( - async () => - await Promise.resolve({ - data: { - user: { - id: 'userid', - email: 'jsmith@exmaple.com' - } - } - } as unknown as ApolloQueryResult) - ) + mockPrismaUsers.user.findUnique.mockResolvedValueOnce({ + id: 'userid', + email: 'jsmith@exmaple.com', + firstName: 'John', + imageUrl: null + }) await service(teamRemoved) expect(sendEmail).toHaveBeenCalled() expect(args).toEqual({ @@ -210,17 +215,12 @@ describe('EmailConsumer', () => { accountNotifications: false }) - jest.spyOn(ApolloClient.prototype, 'query').mockImplementationOnce( - async () => - await Promise.resolve({ - data: { - user: { - id: 'userid', - email: 'jsmith@exmaple.com' - } - } - } as unknown as ApolloQueryResult) - ) + mockPrismaUsers.user.findUnique.mockResolvedValueOnce({ + id: 'userid', + email: 'jsmith@exmaple.com', + firstName: 'John', + imageUrl: null + }) await service(teamRemoved) expect(sendEmail).not.toHaveBeenCalled() }) @@ -228,17 +228,12 @@ describe('EmailConsumer', () => { describe('teamInviteEmail', () => { it('should send an email if user exists', async () => { - jest.spyOn(ApolloClient.prototype, 'query').mockImplementationOnce( - async () => - await Promise.resolve({ - data: { - user: { - id: 'userid', - email: 'jsmith@exmaple.com' - } - } - } as unknown as ApolloQueryResult) - ) + mockPrismaUsers.user.findUnique.mockResolvedValueOnce({ + id: 'userid', + email: 'jsmith@exmaple.com', + firstName: 'John', + imageUrl: null + }) await service(teamInviteJob) expect(sendEmail).toHaveBeenCalled() expect(args).toEqual({ @@ -250,14 +245,7 @@ describe('EmailConsumer', () => { }) it('should send an email if user does not exist', async () => { - jest.spyOn(ApolloClient.prototype, 'query').mockImplementationOnce( - async () => - await Promise.resolve({ - data: { - user: undefined - } - } as unknown as ApolloQueryResult) - ) + mockPrismaUsers.user.findUnique.mockResolvedValueOnce(null) await service(teamInviteJob) expect(sendEmail).toHaveBeenCalled() expect(args).toEqual({ @@ -282,18 +270,24 @@ describe('EmailConsumer', () => { describe('teamInviteAcceptedEmail', () => { it('should send an email', async () => { - jest.spyOn(ApolloClient.prototype, 'query').mockImplementation( - async () => - await Promise.resolve({ - data: { - user: { - id: 'userid', - email: 'jsmith@exmaple.com' - } - } - } as unknown as ApolloQueryResult) - ) + mockPrismaUsers.user.findMany.mockResolvedValue([ + { + id: 'userid', + userId: 'userId', + email: 'jsmith@exmaple.com', + firstName: 'John', + imageUrl: null + }, + { + id: 'userid2', + userId: 'userId2', + email: 'jsmith@exmaple.com', + firstName: 'Jane', + imageUrl: null + } + ]) await service(teamInviteAccepted) + expect(mockPrismaUsers.user.findMany).toHaveBeenCalledTimes(1) expect(sendEmail).toHaveBeenCalledTimes(2) expect(args).toEqual({ to: 'jsmith@exmaple.com', @@ -310,35 +304,36 @@ describe('EmailConsumer', () => { accountNotifications: false }) - jest.spyOn(ApolloClient.prototype, 'query').mockImplementationOnce( - async () => - await Promise.resolve({ - data: { - user: { - id: 'userid', - email: 'jsmith@exmaple.com' - } - } - } as unknown as ApolloQueryResult) - ) + mockPrismaUsers.user.findMany.mockResolvedValueOnce([ + { + id: 'userid', + userId: 'userId', + email: 'jsmith@exmaple.com', + firstName: 'John', + imageUrl: null + }, + { + id: 'userid2', + userId: 'userId2', + email: 'jsmith@exmaple.com', + firstName: 'Jane', + imageUrl: null + } + ]) await service(teamInviteAccepted) + expect(mockPrismaUsers.user.findMany).toHaveBeenCalledTimes(1) expect(sendEmail).not.toHaveBeenCalled() }) }) describe('journeyAccessRequest', () => { it('should send an email', async () => { - jest.spyOn(ApolloClient.prototype, 'query').mockImplementationOnce( - async () => - await Promise.resolve({ - data: { - user: { - id: 'userid', - email: 'jsmith@exmaple.com' - } - } - } as unknown as ApolloQueryResult) - ) + mockPrismaUsers.user.findUnique.mockResolvedValueOnce({ + id: 'userid', + email: 'jsmith@exmaple.com', + firstName: 'John', + imageUrl: null + }) await service(journeyAccessRequest) expect(sendEmail).toHaveBeenCalled() expect(args).toEqual({ @@ -356,17 +351,12 @@ describe('EmailConsumer', () => { accountNotifications: false }) - jest.spyOn(ApolloClient.prototype, 'query').mockImplementationOnce( - async () => - await Promise.resolve({ - data: { - user: { - id: 'userid', - email: 'jsmith@exmaple.com' - } - } - } as unknown as ApolloQueryResult) - ) + mockPrismaUsers.user.findUnique.mockResolvedValueOnce({ + id: 'userid', + email: 'jsmith@exmaple.com', + firstName: 'John', + imageUrl: null + }) await service(journeyAccessRequest) expect(sendEmail).not.toHaveBeenCalled() }) @@ -374,17 +364,12 @@ describe('EmailConsumer', () => { describe('journeyRequestApproved', () => { it('should send an email', async () => { - jest.spyOn(ApolloClient.prototype, 'query').mockImplementationOnce( - async () => - await Promise.resolve({ - data: { - user: { - id: 'userid', - email: 'jsmith@exmaple.com' - } - } - } as unknown as ApolloQueryResult) - ) + mockPrismaUsers.user.findUnique.mockResolvedValueOnce({ + id: 'userid', + email: 'jsmith@exmaple.com', + firstName: 'John', + imageUrl: null + }) await service(journeyRequestApproved) expect(sendEmail).toHaveBeenCalled() expect(args).toEqual({ @@ -402,17 +387,12 @@ describe('EmailConsumer', () => { accountNotifications: false }) - jest.spyOn(ApolloClient.prototype, 'query').mockImplementationOnce( - async () => - await Promise.resolve({ - data: { - user: { - id: 'userid', - email: 'jsmith@exmaple.com' - } - } - } as unknown as ApolloQueryResult) - ) + mockPrismaUsers.user.findUnique.mockResolvedValueOnce({ + id: 'userid', + email: 'jsmith@exmaple.com', + firstName: 'John', + imageUrl: null + }) await service(journeyRequestApproved) expect(sendEmail).not.toHaveBeenCalled() }) @@ -420,36 +400,24 @@ describe('EmailConsumer', () => { describe('journeyEditInvite', () => { it('should send an email if user exists', async () => { - jest.spyOn(ApolloClient.prototype, 'query').mockImplementationOnce( - async () => - await Promise.resolve({ - data: { - user: { - id: 'userid', - email: 'jsmith@exmaple.com' - } - } - } as unknown as ApolloQueryResult) - ) + mockPrismaUsers.user.findUnique.mockResolvedValueOnce({ + id: 'userid', + email: 'jsmith@exmaple.com', + firstName: 'John', + imageUrl: null + }) await service(journeyEditJob) expect(sendEmail).toHaveBeenCalled() expect(args).toEqual({ to: journeyEditJob.data.email, - subject: 'Journey Title has been shared with you', + subject: 'Journey Title has been shared with you on NextSteps', html: expect.any(String), text: expect.any(String) }) }) it('should send an email if user does not exist', async () => { - jest.spyOn(ApolloClient.prototype, 'query').mockImplementationOnce( - async () => - await Promise.resolve({ - data: { - user: undefined - } - } as unknown as ApolloQueryResult) - ) + mockPrismaUsers.user.findUnique.mockResolvedValueOnce(null) await service(journeyEditJob) expect(sendEmail).toHaveBeenCalled() expect(args).toEqual({ @@ -467,17 +435,12 @@ describe('EmailConsumer', () => { accountNotifications: false }) - jest.spyOn(ApolloClient.prototype, 'query').mockImplementationOnce( - async () => - await Promise.resolve({ - data: { - user: { - id: 'userid', - email: 'jsmith@exmaple.com' - } - } - } as unknown as ApolloQueryResult) - ) + mockPrismaUsers.user.findUnique.mockResolvedValueOnce({ + id: 'userid', + email: 'jsmith@exmaple.com', + firstName: 'John', + imageUrl: null + }) await service(journeyEditJob) expect(sendEmail).not.toHaveBeenCalled() }) diff --git a/apis/api-journeys-modern/src/workers/email/service/service.ts b/apis/api-journeys-modern/src/workers/email/service/service.ts index 5d1d36e603f..0e447b8bf33 100644 --- a/apis/api-journeys-modern/src/workers/email/service/service.ts +++ b/apis/api-journeys-modern/src/workers/email/service/service.ts @@ -1,4 +1,3 @@ -import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client' import { render } from '@react-email/render' import { Job } from 'bullmq' @@ -7,7 +6,7 @@ import { UserTeamRole, prisma } from '@core/prisma/journeys/client' -import { graphql } from '@core/shared/gql' +import { prisma as prismaUsers } from '@core/prisma/users/client' import { sendEmail } from '@core/yoga/email' import { JourneyAccessRequestEmail } from '../../../emails/templates/JourneyAccessRequest' @@ -29,41 +28,27 @@ import { TeamRemoved } from './prisma.types' -const httpLink = createHttpLink({ - uri: env.GATEWAY_URL, - headers: { - 'interop-token': env.INTEROP_TOKEN, - 'x-graphql-client-name': 'api-journeys-modern', - 'x-graphql-client-version': env.SERVICE_VERSION - } -}) - -const apollo = new ApolloClient({ - link: httpLink, - cache: new InMemoryCache() -}) - -const GET_USER = graphql(` - query GetUser($userId: ID!) { - user(id: $userId) { - id - email - firstName - imageUrl - } - } -`) - -const GET_USER_BY_EMAIL = graphql(` - query GetUserByEmail($email: String!) { - userByEmail(email: $email) { - id - email - firstName - imageUrl - } +export interface EmailRecipient { + firstName: string + lastName?: string + email: string + imageUrl?: string | null +} + +function toEmailRecipient(user: { + firstName: string + lastName?: string | null + email?: string | null + imageUrl?: string | null +}): EmailRecipient | null { + if (user.email == null) return null + return { + firstName: user.firstName, + lastName: user.lastName ?? undefined, + email: user.email, + imageUrl: user.imageUrl } -`) +} export async function service(job: Job): Promise { switch (job.name) { @@ -89,17 +74,18 @@ export async function service(job: Job): Promise { } export async function teamRemovedEmail(job: Job): Promise { - const { data } = await apollo.query({ - query: GET_USER, - variables: { userId: job.data.userId } + const recipientUser = await prismaUsers.user.findUnique({ + where: { userId: job.data.userId } }) - if (data.user == null) throw new Error('User not found') + if (recipientUser == null) throw new Error('User not found') + const recipient = toEmailRecipient(recipientUser) + if (recipient == null) throw new Error('User has no email') // check recipient preferences const preferences = await prisma.journeysEmailPreference.findFirst({ where: { - email: data.user.email + email: recipient.email } }) // do not send email if team removed notification is not preferred @@ -112,14 +98,14 @@ export async function teamRemovedEmail(job: Job): Promise { const html = await render( TeamRemovedEmail({ teamName: job.data.teamName, - recipient: data.user + recipient }) ) const text = await render( TeamRemovedEmail({ teamName: job.data.teamName, - recipient: data.user + recipient }), { plainText: true @@ -127,7 +113,7 @@ export async function teamRemovedEmail(job: Job): Promise { ) await sendEmail({ - to: data.user.email, + to: recipient.email, subject: `You have been removed from team: ${job.data.teamName}`, text, html @@ -149,12 +135,11 @@ export async function teamInviteEmail(job: Job): Promise { ) return - const { data } = await apollo.query({ - query: GET_USER_BY_EMAIL, - variables: { email: job.data.email } + const recipientUser = await prismaUsers.user.findUnique({ + where: { email: job.data.email } }) - if (data.userByEmail == null) { + if (recipientUser == null) { const html = await render( TeamInviteNoAccountEmail({ teamName: job.data.team.title, @@ -181,10 +166,13 @@ export async function teamInviteEmail(job: Job): Promise { html }) } else { + const recipient = toEmailRecipient(recipientUser) + if (recipient == null) throw new Error('User has no email') + const html = await render( TeamInviteEmail({ teamName: job.data.team.title, - recipient: data.userByEmail, + recipient, inviteLink: url, sender: job.data.sender }) @@ -193,7 +181,7 @@ export async function teamInviteEmail(job: Job): Promise { const text = await render( TeamInviteEmail({ teamName: job.data.team.title, - recipient: data.userByEmail, + recipient, inviteLink: url, sender: job.data.sender }), @@ -218,28 +206,46 @@ export async function teamInviteAcceptedEmail( const recipientUserTeams = job.data.team.userTeams.filter( (userTeam) => userTeam.role === UserTeamRole.manager ) + const recipientUserIds = recipientUserTeams.map((userTeam) => userTeam.userId) - const recipientEmails = await Promise.all( - recipientUserTeams.map(async (userTeam) => { - const { data } = await apollo.query({ - query: GET_USER, - variables: { userId: userTeam.userId } - }) - return data + const recipientUsers = await prismaUsers.user.findMany({ + where: { + userId: { + in: recipientUserIds + } + } + }) + + const recipientUsersByUserId = new Map( + recipientUsers.map((user) => { + const recipient = toEmailRecipient(user) + if (recipient == null) throw new Error('User has no email') + return [user.userId, recipient] as const }) ) - if (recipientEmails == null || recipientEmails.length === 0) { + if (recipientUserIds.length === 0) { throw new Error('Team Managers not found') } - for (const recipient of recipientEmails) { - if (recipient.user == null) throw new Error('User not found') + const missingIds = recipientUserIds.filter( + (id) => !recipientUsersByUserId.has(id) + ) + if (missingIds.length > 0) { + throw new Error( + `Team Managers not found for userIds: ${missingIds.join(', ')}` + ) + } + + const recipients = recipientUserIds.map( + (userId) => recipientUsersByUserId.get(userId)! + ) + for (const recipient of recipients) { // check recipient preferences const preferences = await prisma.journeysEmailPreference.findFirst({ where: { - email: recipient.user.email + email: recipient.email } }) // do not send email if team removed notification is not preferred @@ -254,7 +260,7 @@ export async function teamInviteAcceptedEmail( teamName: job.data.team.title, inviteLink: url, sender: job.data.sender, - recipient: recipient.user + recipient }) ) @@ -263,7 +269,7 @@ export async function teamInviteAcceptedEmail( teamName: job.data.team.title, inviteLink: url, sender: job.data.sender, - recipient: recipient.user + recipient }), { plainText: true @@ -271,7 +277,7 @@ export async function teamInviteAcceptedEmail( ) await sendEmail({ - to: recipient.user.email, + to: recipient.email, subject: `${ job.data.sender.firstName ?? 'A new member' } has been added to your team`, @@ -290,17 +296,18 @@ export async function journeyAccessRequest( if (recipientUserId == null) throw new Error('User not found') - const { data } = await apollo.query({ - query: GET_USER, - variables: { userId: recipientUserId } + const recipientUser = await prismaUsers.user.findUnique({ + where: { userId: recipientUserId } }) - if (data.user == null) throw new Error('User not found') + if (recipientUser == null) throw new Error('User not found') + const recipient = toEmailRecipient(recipientUser) + if (recipient == null) throw new Error('User has no email') // check recipient preferences const preferences = await prisma.journeysEmailPreference.findFirst({ where: { - email: data.user.email + email: recipient.email } }) // do not send email if team removed notification is not preferred @@ -314,7 +321,7 @@ export async function journeyAccessRequest( JourneyAccessRequestEmail({ journey: job.data.journey, inviteLink: job.data.url, - recipient: data.user, + recipient, sender: job.data.sender }) ) @@ -322,7 +329,7 @@ export async function journeyAccessRequest( JourneyAccessRequestEmail({ journey: job.data.journey, inviteLink: job.data.url, - recipient: data.user, + recipient, sender: job.data.sender }), { @@ -331,7 +338,7 @@ export async function journeyAccessRequest( ) await sendEmail({ - to: data.user.email, + to: recipient.email, subject: `${job.data.sender.firstName} requests access to a journey`, html, text @@ -341,17 +348,18 @@ export async function journeyAccessRequest( export async function journeyRequestApproved( job: Job ): Promise { - const { data } = await apollo.query({ - query: GET_USER, - variables: { userId: job.data.userId } + const recipientUser = await prismaUsers.user.findUnique({ + where: { userId: job.data.userId } }) - if (data.user == null) throw new Error('User not found') + if (recipientUser == null) throw new Error('User not found') + const recipient = toEmailRecipient(recipientUser) + if (recipient == null) throw new Error('User has no email') // check recipient preferences const preferences = await prisma.journeysEmailPreference.findFirst({ where: { - email: data.user.email + email: recipient.email } }) // do not send email if team removed notification is not preferred @@ -366,7 +374,7 @@ export async function journeyRequestApproved( journey: job.data.journey, inviteLink: job.data.url, sender: job.data.sender, - recipient: data.user + recipient }) ) @@ -375,14 +383,14 @@ export async function journeyRequestApproved( journey: job.data.journey, inviteLink: job.data.url, sender: job.data.sender, - recipient: data.user + recipient }), { plainText: true } ) await sendEmail({ - to: data.user.email, + to: recipient.email, subject: `${job.data.journey.title} has been shared with you`, html, text @@ -405,12 +413,11 @@ export async function journeyEditInvite( ) return - const { data } = await apollo.query({ - query: GET_USER_BY_EMAIL, - variables: { email: job.data.email } + const recipientUser = await prismaUsers.user.findUnique({ + where: { email: job.data.email } }) - if (data.userByEmail == null) { + if (recipientUser == null) { const url = `${env.JOURNEYS_ADMIN_URL}/` const html = await render( JourneySharedNoAccountEmail({ @@ -438,12 +445,15 @@ export async function journeyEditInvite( text }) } else { + const recipient = toEmailRecipient(recipientUser) + if (recipient == null) throw new Error('User has no email') + const html = await render( JourneySharedEmail({ sender: job.data.sender, journey: job.data.journey, inviteLink: job.data.url, - recipient: data.userByEmail + recipient }) ) const text = await render( @@ -451,7 +461,7 @@ export async function journeyEditInvite( journey: job.data.journey, inviteLink: job.data.url, sender: job.data.sender, - recipient: data.userByEmail + recipient }), { plainText: true