Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@
"eslint-plugin-prettier": "^4.0.0",
"jest": "~29.6.2",
"jest-extended": "^7.0.0",
"juice": "^12.1.1",
"prettier": "^2.3.2",
"supertest": "^6.1.3",
"ts-jest": "~29.1.1",
Expand Down
2 changes: 2 additions & 0 deletions api/prisma/seed-staging/seed-bridge-bay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
MultiselectQuestionsApplicationSectionEnum,
MultiselectQuestionsStatusEnum,
PrismaClient,
UserRoleEnum,
} from '@prisma/client';
import { randomInt } from 'crypto';
import dayjs from 'dayjs';
Expand Down Expand Up @@ -2585,6 +2586,7 @@ export const createBridgeBayJurisdictions = async (
publicSiteBaseURL,
featureFlags: [...optionalV2MSQ, ...featureFlags],
languages: languages,
listingApprovalPermissions: [UserRoleEnum.admin],
listingFeaturesConfiguration: defaultListingFeatureConfiguration,
raceEthnicityConfiguration: raceEthnicityConfiguration,
visibleSpokenLanguages: visibleSpokenLanguages,
Expand Down
13 changes: 8 additions & 5 deletions api/src/modules/email.module.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,30 @@
import { Logger, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AwsSesService } from '../services/aws-ses.service';
import { EmailProvider } from '../services/email-provider.service';
import { EmailService } from '../services/email.service';
import { JurisdictionService } from '../services/jurisdiction.service';
import { TranslationService } from '../services/translation.service';
import { GoogleTranslateService } from '../services/google-translate.service';
import { EmailProvider } from '../services/email-provider.service';
import { GovDeliveryService } from '../services/gov-delivery.service';
import { JurisdictionService } from '../services/jurisdiction.service';
import { SendGridService } from '../services/sendgrid.service';
import { AwsSesService } from '../services/aws-ses.service';
import { TranslationService } from '../services/translation.service';
import { HttpModule, HttpService } from '@nestjs/axios';

const emailProvider = {
provide: EmailProvider,
useClass: process.env.USE_AWS_SES ? AwsSesService : SendGridService,
};

@Module({
imports: [],
imports: [HttpModule],
controllers: [],
providers: [
EmailService,
JurisdictionService,
TranslationService,
ConfigService,
GoogleTranslateService,
GovDeliveryService,
Logger,
emailProvider,
],
Expand Down
102 changes: 96 additions & 6 deletions api/src/services/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
} from '../utilities/listing-data-formatters';
import { unitTypeMapping } from '../../prisma/seed-helpers/unit-type-factory';
import { UnitAccessibilityPriorityTypeEnum } from '../enums/units/accessibility-priority-type-enum';
import { GovDeliveryService } from './gov-delivery.service';
dayjs.extend(utc);
dayjs.extend(tz);
dayjs.extend(advanced);
Expand All @@ -56,6 +57,7 @@ export class EmailService {
private readonly emailProvider: EmailProvider,
private readonly translationService: TranslationService,
private readonly jurisdictionService: JurisdictionService,
private readonly govDeliveryService: GovDeliveryService,
@Inject(Logger)
private logger = new Logger(EmailService.name),
) {
Expand Down Expand Up @@ -1052,7 +1054,11 @@ export class EmailService {
listingUnitsSummary: ListingUnitsSummary,
variant: ListingNotificationVariant = 'standard',
): { label: string; value: string | number }[] {
const listingDetails: { label: string; value: string | number }[] = [];
const listingDetails: {
label: string;
value: string | number;
bolded?: boolean;
}[] = [];

if (listing?.reservedCommunityTypes?.name) {
listingDetails.push({
Expand All @@ -1074,10 +1080,10 @@ export class EmailService {
} else if (listing?.applicationDueDate) {
listingDetails.push({
label: this.polyglot.t('rentalOpportunity.applicationsDue'),
value: this.formatLocalDate(
listing.applicationDueDate,
'MMMM DD, YYYY',
),
value: dayjs(listing.applicationDueDate)
.tz(process.env.TIME_ZONE)
.format('MMMM D, YYYY [at] h:mma z'),
bolded: true,
});
}

Expand Down Expand Up @@ -1302,7 +1308,91 @@ export class EmailService {
);
}
} catch (err) {
console.log('listing approval email failed', err);
console.log('rental opportunity email failed', err);
throw new HttpException('email failed', 500);
}
}

public async listingPublishNotificationViaGovDelivery(
jurisdictionId: IdDTO,
listing: Listing,
priorityTypes: UnitAccessibilityPriorityTypeEnum[],
variant: ListingNotificationVariant = 'standard',
) {
try {
const jurisdiction = await this.getJurisdiction([jurisdictionId]);
const listingUnitsSummary = summarizeListingUnitsByType(listing.units);
const subjectKey =
variant === 'comingSoon'
? 'rentalOpportunity.comingSoon.subject'
: 'rentalOpportunity.subject';
const introKey =
variant === 'comingSoon'
? 'rentalOpportunity.comingSoon.intro'
: 'rentalOpportunity.intro';

void (await this.loadTranslations(jurisdiction, 'en'));

this.logger.log(
`Sending lottery published govDelivery email for listing ${listing.name}`,
);

const listingDetails = this.buildListingDetails(
listing,
priorityTypes,
listingUnitsSummary,
variant,
);

const emailButtons = jurisdiction.languages.map((code) => ({
name: this.polyglot.t(`rentalOpportunity.viewButton.${code}`),
url: `${jurisdiction.publicUrl}/${code}/listing/${listing.id}/${listing.urlSlug}`,
}));

const footerLinks = [];
let hasFooterLinks = true;
while (hasFooterLinks) {
if (
this.polyglot.has(
`rentalOpportunity.footer.additionalLink${footerLinks.length}.text`,
)
) {
footerLinks.push({
text: this.polyglot.t(
`rentalOpportunity.footer.additionalLink${footerLinks.length}.text`,
),
name: this.polyglot.t(
`rentalOpportunity.footer.additionalLink${footerLinks.length}.name`,
),
url: this.polyglot.t(
`rentalOpportunity.footer.additionalLink${footerLinks.length}.url`,
),
});
} else {
hasFooterLinks = false;
}
}

await this.govDeliveryService.send({
to: [],
from: process.env.GOVDELIVERY_FROM_EMAIL_ID,
subject: this.polyglot.t(subjectKey, {
listingName: listing.name,
}),
body: this.template('listing-opportunity')({
listingName: listing.name,
introKey,
tableRows: listingDetails,
languageUrls: emailButtons,
accessibleMarketingFlyerUrl: listing.accessibleMarketingFlyer,
disclaimerText: this.polyglot.has('rentalOpportunity.disclaimer')
? this.polyglot.t('rentalOpportunity.disclaimer')
: undefined,
footerLinks,
}),
});
} catch (err) {
console.log('govDelivery rental opportunity email failed', err);
throw new HttpException('email failed', 500);
}
}
Expand Down
49 changes: 49 additions & 0 deletions api/src/services/gov-delivery.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common';
import juice from 'juice';
import { firstValueFrom } from 'rxjs';
import { SendEmailInput } from './email-provider.service';

@Injectable()
export class GovDeliveryService {
constructor(private readonly httpService: HttpService) {}

async send(input: SendEmailInput): Promise<unknown> {
const {
GOVDELIVERY_API_URL,
GOVDELIVERY_USERNAME,
GOVDELIVERY_PASSWORD,
GOVDELIVERY_TOPIC,
} = process.env;
const isGovConfigured =
!!GOVDELIVERY_API_URL &&
!!GOVDELIVERY_USERNAME &&
!!GOVDELIVERY_PASSWORD &&
!!GOVDELIVERY_TOPIC;
if (!isGovConfigured) {
console.warn(
'failed to configure Govdelivery, ensure that all env variables are provided',
);
return;
}

// If there is no from email address configured, govDelivery will use the default email address associated with the account
const fromEmailAddress = process.env.GOVDELIVERY_FROM_EMAIL_ID || '';

// juice inlines css to allow for email styling
const inlineHtml = juice(input.body);
const govEmailXml = `<bulletin>\n <subject>${input.subject}</subject>\n <body><![CDATA[\n
${inlineHtml}\n ]]></body>\n <sms_body nil='true'></sms_body>\n <from_address_id>${fromEmailAddress}</from_address_id>\n <publish_rss type='boolean'>false</publish_rss>\n <open_tracking type='boolean'>true</open_tracking>\n <click_tracking type='boolean'>true</click_tracking>\n <share_content_enabled type='boolean'>true</share_content_enabled>\n <topics type='array'>\n <topic>\n <code>${GOVDELIVERY_TOPIC}</code>\n </topic>\n </topics>\n <categories type='array' />\n </bulletin>`;

await firstValueFrom(
this.httpService.post(GOVDELIVERY_API_URL, govEmailXml, {
headers: {
'Content-Type': 'application/xml',
Authorization: `Basic ${Buffer.from(
`${GOVDELIVERY_USERNAME}:${GOVDELIVERY_PASSWORD}`,
).toString('base64')}`,
},
}),
);
}
}
26 changes: 26 additions & 0 deletions api/src/services/listing.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1935,6 +1935,19 @@ export class ListingService implements OnModuleInit {
rawJurisdiction.publicUrl || '',
);
await this.sendListingPublishNotification(mappedListing);
if (
doJurisdictionHaveFeatureFlagSet(
rawJurisdiction as unknown as Jurisdiction,
FeatureFlagEnum.enableListingOpportunity,
)
) {
await this.emailService.listingPublishNotificationViaGovDelivery(
{ id: rawJurisdiction.id },
mappedListing,
[],
'standard',
);
}
}
await this.cachePurge(undefined, dto.status, mappedListing.id);
return mappedListing;
Expand Down Expand Up @@ -3076,6 +3089,19 @@ export class ListingService implements OnModuleInit {
mappedListing,
useComingSoon ? 'comingSoon' : 'standard',
);
if (
doJurisdictionHaveFeatureFlagSet(
rawJurisdiction as unknown as Jurisdiction,
FeatureFlagEnum.enableListingOpportunity,
)
) {
await this.emailService.listingPublishNotificationViaGovDelivery(
{ id: rawJurisdiction.id },
mappedListing,
[],
useComingSoon ? 'comingSoon' : 'standard',
);
}
}

if (enableV2MSQ && mappedListing.status === ListingsStatusEnum.active) {
Expand Down
19 changes: 14 additions & 5 deletions api/src/views/listing-opportunity.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,20 @@
</td>
</tr>
{{/if}}
<tr>
<td align="center">
<a href="{{emailSettingsUrl}}" target="_blank">{{t "rentalOpportunity.footer.unsubscribeAndEmailSettings"}}</a>
</td>
</tr>
{{#if disclaimerText}}
<tr>
<td align="center">
<p>{{disclaimerText}}</p>
</td>
</tr>
{{/if}}
{{#if emailSettingsUrl}}
<tr>
<td align="center">
<a href="{{emailSettingsUrl}}" target="_blank">{{t "rentalOpportunity.footer.unsubscribeAndEmailSettings"}}</a>
</td>
</tr>
{{/if}}
</tbody>
</table>
{{/layout_default }}
10 changes: 10 additions & 0 deletions api/src/views/partials/footer.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@
<br /><span class="apple-link">{{t "footer.line2"}}</span>
</td>
</tr>
{{#if footerLinks}}
<tr>
<td class="content-block">
{{#each footerLinks}}
<span>{{this.text}}<a href="{{this.url}}" target="_blank">{{this.name}}</a></span>
<br />
{{/each}}
</td>
</tr>
{{/if}}
</table>
</div>
<!-- END FOOTER -->
Expand Down
2 changes: 1 addition & 1 deletion api/test/jest-e2e.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@ module.exports = {
},
],
},
setupFilesAfterEnv: ['jest-extended/all'],
setupFilesAfterEnv: ['jest-extended/all', './test/jest.setup.js'],
};
2 changes: 1 addition & 1 deletion api/test/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@ module.exports = {
},
],
},
setupFilesAfterEnv: ['jest-extended/all'],
setupFilesAfterEnv: ['jest-extended/all', './test/jest.setup.js'],
};
2 changes: 2 additions & 0 deletions api/test/jest.setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// the juice library is used to inline CSS in HTML emails, but it doesn't work in the test environment. This mock allows us to bypass that functionality for testing purposes.
jest.mock('juice', () => (html) => html);
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ describe('Testing application bulk upload services', () => {
mockApplication({
id: randomUUID(),
position: 1,
submissionDate: new Date(2026, 4, 19, 22, 0, 0),
submissionDate: new Date(1779228000000),
applicant: {
firstName: 'Colleen',
lastName: 'Tawnee',
Expand All @@ -132,7 +132,7 @@ describe('Testing application bulk upload services', () => {
mockApplication({
id: randomUUID(),
position: 2,
submissionDate: new Date(2026, 3, 2, 10, 0, 0),
submissionDate: new Date(1775124000000),
applicant: {
firstName: 'Erin',
lastName: 'Patsy',
Expand All @@ -143,7 +143,7 @@ describe('Testing application bulk upload services', () => {
mockApplication({
id: randomUUID(),
position: 3,
submissionDate: new Date(2026, 6, 23, 15, 30, 0),
submissionDate: new Date(1784820600000),
applicant: {
firstName: 'Nanny',
lastName: 'Hayley',
Expand Down
Loading
Loading