diff --git a/api/src/services/email.service.ts b/api/src/services/email.service.ts index 2dab3f2604..e1eab9692a 100644 --- a/api/src/services/email.service.ts +++ b/api/src/services/email.service.ts @@ -411,12 +411,14 @@ export class EmailService { listingInfo: IdDTO, emails: string[], appUrl: string, + jurisEmail: string, ) { const jurisdiction = await this.getJurisdiction([jurisdictionId]); void (await this.loadTranslations(jurisdiction)); await this.sendSES({ - to: emails, + to: jurisEmail, + bcc: emails, subject: this.polyglot.t('requestApproval.header'), html: this.template('request-approval')({ appOptions: { listingName: listingInfo.name }, @@ -431,6 +433,7 @@ export class EmailService { listingInfo: listingInfo, emails: string[], appUrl: string, + jurisEmail: string, ) { const jurisdiction = listingInfo.juris ? await this.getJurisdiction([{ id: listingInfo.juris }]) @@ -438,7 +441,8 @@ export class EmailService { void (await this.loadTranslations(jurisdiction)); await this.sendSES({ - to: emails, + to: jurisEmail, + bcc: emails, subject: this.polyglot.t('changesRequested.header'), html: this.template('changes-requested')({ appOptions: { listingName: listingInfo.name }, @@ -453,12 +457,14 @@ export class EmailService { listingInfo: IdDTO, emails: string[], publicUrl: string, + jurisEmail: string, ) { const jurisdiction = await this.getJurisdiction([jurisdictionId]); void (await this.loadTranslations(jurisdiction)); await this.sendSES({ - to: emails, + to: jurisEmail, + bcc: emails, subject: this.polyglot.t('listingApproved.header'), html: this.template('listing-approved')({ appOptions: { listingName: listingInfo.name }, @@ -615,9 +621,11 @@ export class EmailService { listingInfo: listingInfo, emails: string[], appUrl: string, + jurisEmail: string, ) { await this.sendSES({ - to: emails, + to: jurisEmail, + bcc: emails, subject: this.polyglot.t('lotteryReleased.header', { listingName: listingInfo.name, }), @@ -633,13 +641,15 @@ export class EmailService { listingInfo: listingInfo, emails: string[], appUrl: string, + jurisEmail: string, ) { const jurisdiction = await this.getJurisdiction([ { id: listingInfo.juris }, ]); void (await this.loadTranslations(jurisdiction)); await this.sendSES({ - to: emails, + to: jurisEmail, + bcc: emails, subject: this.polyglot.t('lotteryPublished.header', { listingName: listingInfo.name, }), @@ -664,8 +674,8 @@ export class EmailService { for (const language in emails) { void (await this.loadTranslations(null, language as LanguagesEnum)); await this.sendSES({ - to: emails[language], - + to: jurisdiction.emailFromAddress, + bcc: emails[language], subject: this.polyglot.t('lotteryAvailable.header', { listingName: listingInfo.name, }), diff --git a/api/src/services/listing.service.ts b/api/src/services/listing.service.ts index 3f1f2d8465..a6653bc38f 100644 --- a/api/src/services/listing.service.ts +++ b/api/src/services/listing.service.ts @@ -364,7 +364,12 @@ export class ListingService implements OnModuleInit { listingId?: string, jurisId?: string, getPublicUrl = false, - ): Promise<{ emails: string[]; publicUrl?: string | null }> { + getEmailFromAddress = false, + ): Promise<{ + emails: string[]; + publicUrl?: string | null; + emailFromAddress?: string | null; + }> { // determine where clause(s) const userRolesWhere: Prisma.UserAccountsWhereInput[] = []; if (userRoles.includes(UserRoleEnum.admin)) @@ -381,28 +386,37 @@ export class ListingService implements OnModuleInit { }); } - const userResults = await this.prisma.userAccounts.findMany({ - include: { - jurisdictions: { - select: { - id: true, - publicUrl: getPublicUrl, - }, - }, + const rawUsers = await this.prisma.userAccounts.findMany({ + select: { + id: true, + email: true, }, where: { OR: userRolesWhere, }, }); - // account for users having access to multiple jurisdictions - const publicUrl = getPublicUrl - ? userResults[0]?.jurisdictions?.find((juris) => juris.id === jurisId) - ?.publicUrl + const rawJuris = await this.prisma.jurisdictions.findFirst({ + select: { + id: true, + publicUrl: getPublicUrl, + emailFromAddress: getEmailFromAddress, + }, + where: { + id: jurisId, + }, + }); + + const publicUrl = getPublicUrl ? rawJuris?.publicUrl : null; + const emailFromAddress = getEmailFromAddress + ? rawJuris?.emailFromAddress : null; - const userEmails: string[] = []; - userResults?.forEach((user) => user?.email && userEmails.push(user.email)); - return { emails: userEmails, publicUrl }; + + const userEmails: string[] = rawUsers?.reduce((userEmails, user) => { + if (user?.email) userEmails.push(user.email); + return userEmails; + }, []); + return { emails: userEmails, publicUrl, emailFromAddress }; } public async listingApprovalNotify(params: { @@ -424,12 +438,15 @@ export class ListingService implements OnModuleInit { params.approvingRoles, params.listingInfo.id, params.jurisId, + false, + true, ); await this.emailService.requestApproval( { id: params.jurisId }, { id: params.listingInfo.id, name: params.listingInfo.name }, userInfo.emails, this.configService.get('PARTNERS_PORTAL_URL'), + userInfo.emailFromAddress, ); } // admin updates status to changes requested when approval requires partner changes @@ -441,6 +458,8 @@ export class ListingService implements OnModuleInit { nonApprovingRoles, params.listingInfo.id, params.jurisId, + false, + true, ); await this.emailService.changesRequested( params.user, @@ -451,6 +470,7 @@ export class ListingService implements OnModuleInit { }, userInfo.emails, this.configService.get('PARTNERS_PORTAL_URL'), + userInfo.emailFromAddress, ); } // check if status of active requires notification @@ -466,12 +486,14 @@ export class ListingService implements OnModuleInit { params.listingInfo.id, params.jurisId, true, + true, ); await this.emailService.listingApproved( { id: params.jurisId }, { id: params.listingInfo.id, name: params.listingInfo.name }, userInfo.emails, userInfo.publicUrl, + userInfo.emailFromAddress, ); } } diff --git a/api/src/services/lottery.service.ts b/api/src/services/lottery.service.ts index 6414b64e13..8ee935c99d 100644 --- a/api/src/services/lottery.service.ts +++ b/api/src/services/lottery.service.ts @@ -406,6 +406,8 @@ export class LotteryService { ], listing.id, listing.jurisdictions?.id, + false, + true, ); const publicUserEmailInfo = await this.getPublicUserEmailInfo(listing.id); @@ -423,6 +425,7 @@ export class LotteryService { }, partnerUserEmailInfo.emails, this.configService.get('PARTNERS_PORTAL_URL'), + partnerUserEmailInfo.emailFromAddress, ); await this.emailService.lotteryPublishedApplicant( @@ -508,6 +511,8 @@ export class LotteryService { ], storedListing.id, storedListing.jurisdictionId, + false, + true, ); await this.emailService.lotteryReleased( @@ -518,6 +523,7 @@ export class LotteryService { }, partnerUserEmailInfo.emails, this.configService.get('PARTNERS_PORTAL_URL'), + partnerUserEmailInfo.emailFromAddress, ); break; } diff --git a/api/test/integration/listing.e2e-spec.ts b/api/test/integration/listing.e2e-spec.ts index b5a14bf62a..513c3f95fa 100644 --- a/api/test/integration/listing.e2e-spec.ts +++ b/api/test/integration/listing.e2e-spec.ts @@ -51,6 +51,7 @@ describe('Listing Controller Tests', () => { let app: INestApplication; let prisma: PrismaService; let jurisdictionAId: string; + let jurisdictionAEmail: string; let adminAccessToken: string; const testEmailService = { @@ -87,6 +88,7 @@ describe('Listing Controller Tests', () => { data: jurisdictionFactory(), }); jurisdictionAId = jurisdiction.id; + jurisdictionAEmail = jurisdiction.emailFromAddress; await reservedCommunityTypeFactoryAll(jurisdictionAId, prisma); await unitAccessibilityPriorityTypeFactoryAll(prisma); const adminUser = await prisma.userAccounts.create({ @@ -940,6 +942,7 @@ describe('Listing Controller Tests', () => { { id: listing.id, name: val.name }, expect.arrayContaining([adminUser.email, jurisAdmin.email]), process.env.PARTNERS_PORTAL_URL, + jurisdictionAEmail, ); //ensure juris admin is not included since don't have approver permissions in alameda seed expect(mockRequestApproval.mock.calls[0]['emails']).toEqual( @@ -980,6 +983,7 @@ describe('Listing Controller Tests', () => { { id: listing.id, name: val.name }, expect.arrayContaining([partnerUser.email]), jurisdictionA.publicUrl, + jurisdictionA.emailFromAddress, ); expect(mockListingOpportunity).toBeCalledWith( expect.objectContaining({ @@ -1022,6 +1026,7 @@ describe('Listing Controller Tests', () => { { id: listing.id, name: val.name, juris: expect.anything() }, expect.arrayContaining([partnerUser.email]), process.env.PARTNERS_PORTAL_URL, + jurisdictionAEmail, ); }); }); diff --git a/api/test/integration/lottery.e2e-spec.ts b/api/test/integration/lottery.e2e-spec.ts index acd005132d..cca9da797d 100644 --- a/api/test/integration/lottery.e2e-spec.ts +++ b/api/test/integration/lottery.e2e-spec.ts @@ -44,6 +44,7 @@ describe('Lottery Controller Tests', () => { let cookies = ''; let adminAccessToken: string; let jurisdictionAId: string; + let jurisdictionAEmail: string; let unitTypeA: UnitTypes; const testEmailService = { @@ -101,6 +102,7 @@ describe('Lottery Controller Tests', () => { data: jurisdictionFactory(), }); jurisdictionAId = jurisdiction.id; + jurisdictionAEmail = jurisdiction.emailFromAddress; await reservedCommunityTypeFactoryAll(jurisdictionAId, prisma); await unitTypeFactoryAll(prisma); unitTypeA = await unitTypeFactorySingle(prisma, UnitTypeEnum.oneBdrm); @@ -895,6 +897,7 @@ describe('Lottery Controller Tests', () => { }, expect.arrayContaining([partnerUser.email, adminUser.email]), process.env.PARTNERS_PORTAL_URL, + jurisdictionAEmail, ); const activityLogResult = await prisma.activityLog.findFirst({ @@ -1078,6 +1081,7 @@ describe('Lottery Controller Tests', () => { }, expect.arrayContaining([partnerUser.email, adminUser.email]), process.env.PARTNERS_PORTAL_URL, + jurisdictionAEmail, ); expect(mockLotteryPublishedApplicant).toBeCalledWith( diff --git a/api/test/unit/services/email.service.spec.ts b/api/test/unit/services/email.service.spec.ts index 3caf02eec6..07ea3cef24 100644 --- a/api/test/unit/services/email.service.spec.ts +++ b/api/test/unit/services/email.service.spec.ts @@ -336,7 +336,7 @@ describe('Testing email service', () => { expect(sendMock).toHaveBeenCalled(); const emailMock = sendMock.mock.calls[0][0]; - expect(emailMock.to).toEqual(emailArr); + expect(emailMock.bcc).toEqual(emailArr); expect(emailMock.subject).toEqual('Listing approval requested'); expect(emailMock.html).toMatch( `Bloom Housing Portal`, @@ -378,7 +378,7 @@ describe('Testing email service', () => { expect(sendMock).toHaveBeenCalled(); const emailMock = sendMock.mock.calls[0][0]; - expect(emailMock.to).toEqual(emailArr); + expect(emailMock.bcc).toEqual(emailArr); expect(emailMock.subject).toEqual('Listing changes requested'); expect(emailMock.html).toMatch( `Bloom Housing Portal`, @@ -423,7 +423,7 @@ describe('Testing email service', () => { expect(sendMock).toHaveBeenCalled(); const emailMock = sendMock.mock.calls[0][0]; - expect(emailMock.to).toEqual(emailArr); + expect(emailMock.bcc).toEqual(emailArr); expect(emailMock.subject).toEqual('New published listing'); expect(emailMock.html).toMatch( `Bloom Housing Portal`, diff --git a/api/test/unit/services/listing.service.spec.ts b/api/test/unit/services/listing.service.spec.ts index 4dc1defbf0..f219671ab2 100644 --- a/api/test/unit/services/listing.service.spec.ts +++ b/api/test/unit/services/listing.service.spec.ts @@ -3131,9 +3131,10 @@ describe('Testing listing service', () => { describe('Test listingApprovalNotify endpoint', () => { it('listingApprovalNotify request approval email', async () => { - jest - .spyOn(service, 'getUserEmailInfo') - .mockResolvedValueOnce({ emails: ['admin@email.com'] }); + jest.spyOn(service, 'getUserEmailInfo').mockResolvedValueOnce({ + emails: ['admin@email.com'], + emailFromAddress: 'no-reply@housingbayarea.org', + }); await service.listingApprovalNotify({ user, listingInfo: { id: 'id', name: 'name' }, @@ -3146,18 +3147,22 @@ describe('Testing listing service', () => { ['admin'], 'id', 'jurisId', + false, + true, ); expect(requestApprovalMock).toBeCalledWith( { id: 'jurisId' }, { id: 'id', name: 'name' }, ['admin@email.com'], config.get('PARTNERS_PORTAL_URL'), + 'no-reply@housingbayarea.org', ); }); it('listingApprovalNotify changes requested email', async () => { jest.spyOn(service, 'getUserEmailInfo').mockResolvedValueOnce({ emails: ['jurisAdmin@email.com', 'partner@email.com'], + emailFromAddress: 'no-reply@housingbayarea.org', }); await service.listingApprovalNotify({ user, @@ -3171,12 +3176,15 @@ describe('Testing listing service', () => { ['partner', 'jurisdictionAdmin'], 'id', 'jurisId', + false, + true, ); expect(changesRequestedMock).toBeCalledWith( user, { id: 'id', name: 'name', juris: 'jurisId' }, ['jurisAdmin@email.com', 'partner@email.com'], config.get('PARTNERS_PORTAL_URL'), + 'no-reply@housingbayarea.org', ); }); @@ -3184,6 +3192,7 @@ describe('Testing listing service', () => { jest.spyOn(service, 'getUserEmailInfo').mockResolvedValueOnce({ emails: ['jurisAdmin@email.com', 'partner@email.com'], publicUrl: 'public.housing.gov', + emailFromAddress: 'no-reply@housingbayarea.org', }); await service.listingApprovalNotify({ user, @@ -3199,12 +3208,14 @@ describe('Testing listing service', () => { 'id', 'jurisId', true, + true, ); expect(listingApprovedMock).toBeCalledWith( expect.objectContaining({ id: 'jurisId' }), { id: 'id', name: 'name' }, ['jurisAdmin@email.com', 'partner@email.com'], 'public.housing.gov', + 'no-reply@housingbayarea.org', ); }); diff --git a/api/test/unit/services/lottery.service.spec.ts b/api/test/unit/services/lottery.service.spec.ts index 026231c996..7a959d5867 100644 --- a/api/test/unit/services/lottery.service.spec.ts +++ b/api/test/unit/services/lottery.service.spec.ts @@ -87,14 +87,6 @@ describe('Testing lottery service', () => { prisma = module.get(PrismaService); listingService = module.get(ListingService); config = module.get(ConfigService); - - jest.spyOn(listingService, 'getUserEmailInfo').mockResolvedValueOnce({ - emails: ['admin@email.com', 'partner@email.com'], - }); - - jest.spyOn(service, 'getPublicUserEmailInfo').mockResolvedValueOnce({ - en: ['applicant@email.com'], - }); }); describe('Testing lotteryRandomizerHelper()', () => { @@ -685,6 +677,7 @@ describe('Testing lottery service', () => { jest.spyOn(listingService, 'getUserEmailInfo').mockResolvedValueOnce({ emails: ['admin@email.com', 'partner@email.com'], + emailFromAddress: 'no-reply@housingbayarea.org', }); jest.spyOn(service, 'getPublicUserEmailInfo').mockResolvedValueOnce({ @@ -721,12 +714,15 @@ describe('Testing lottery service', () => { ['admin', 'jurisdictionAdmin', 'partner'], 'example id', 'jurisId', + false, + true, ); expect(lotteryReleasedMock).toBeCalledWith( { id: 'example id', juris: 'jurisId', name: 'example name' }, ['admin@email.com', 'partner@email.com'], config.get('PARTNERS_PORTAL_URL'), + 'no-reply@housingbayarea.org', ); }); diff --git a/yarn.lock b/yarn.lock index 0e514ae6a4..67b4bb1305 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14631,13 +14631,6 @@ rfdc@^1.3.0: resolved "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz" integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA== -rimraf@3.0.2, rimraf@^3.0.0, rimraf@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" - integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== - dependencies: - glob "^7.1.3" - rimraf@^2.6.3: version "2.7.1" resolved "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz" @@ -14645,6 +14638,13 @@ rimraf@^2.6.3: dependencies: glob "^7.1.3" +rimraf@^3.0.0, rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + rollup@2.78.0: version "2.78.0" resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.78.0.tgz#00995deae70c0f712ea79ad904d5f6b033209d9e"