Skip to content

Commit

Permalink
fix: calculate fields for auto email
Browse files Browse the repository at this point in the history
  • Loading branch information
ludtkemorgan committed Nov 7, 2023
1 parent 280232f commit 020b4eb
Show file tree
Hide file tree
Showing 9 changed files with 282 additions and 28 deletions.
65 changes: 64 additions & 1 deletion backend/core/src/email/email.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { JurisdictionsService } from "../jurisdictions/services/jurisdictions.se
import { GeneratedListingTranslation } from "../translations/entities/generated-listing-translation.entity"
import { GoogleTranslateService } from "../translations/services/google-translate.service"
import { ListingReviewOrder } from "../listings/types/listing-review-order-enum"
import Listing from "../listings/entities/listing.entity"

declare const expect: jest.Expect
jest.setTimeout(30000)
Expand Down Expand Up @@ -163,6 +164,25 @@ const translationServiceMock = {
"The %{listingName} listing has been approved and published by an administrator.",
viewPublished: "To view the published listing, please click on the link below",
},
rentalOpportunity: {
subject: "New rental opportunity",
intro: "Rental opportunity at",
applicationsDue: "Applications Due",
community: "Community",
address: "Address",
rent: "Rent",
minIncome: "Minimum Income",
maxIncome: "Maximum Income",
lottery: "Lottery Date",
viewButton: "View Listing & Apply",
studio: "Studios",
oneBdrm: "1 Bedrooms",
twoBdrm: "2 Bedrooms",
threeBdrm: "3 Bedrooms",
fourBdrm: "4 Bedrooms",
fiveBdrm: "5 Bedrooms",
SRO: "SROs",
},
t: {
hello: "Hello",
seeListing: "See Listing",
Expand Down Expand Up @@ -210,7 +230,11 @@ describe("EmailService", () => {
},
{
provide: JurisdictionsService,
useValue: {},
useValue: {
findOne: () => ({
emailFromAddress: "myeamil@from",
}),
},
},
{
provide: JurisdictionResolverService,
Expand Down Expand Up @@ -451,6 +475,45 @@ describe("EmailService", () => {
})
})

describe("Listing Opportunity", () => {
it("should generate html body", async () => {
const service = await module.resolve(EmailService)
await service.listingOpportunity(
({ ...listing, reservedCommunityType: { name: "senior55" } } as unknown) as Listing,
"[email protected]"
)

expect(sendMock).toHaveBeenCalled()
const emailMock = sendMock.mock.calls[0][0]
expect(emailMock.subject).toEqual("New rental opportunity")
expect(emailMock.html).toMatch(
`<img src="https://res.cloudinary.com/mariposta/image/upload/v1652326298/testing/alameda-portal.png" alt="Alameda County Housing Portal" width="300" height="65" />`
)
expect(emailMock.html).toMatch("Rental opportunity at")
expect(emailMock.html).toMatch("Archer Studios")
expect(emailMock.html).toMatch("Community")
expect(emailMock.html).toMatch("Seniors 55+")
expect(emailMock.html).toMatch("Applications Due")
expect(emailMock.html).toMatch("December 31, 2019")
expect(emailMock.html).toMatch("Address")
expect(emailMock.html).toMatch("98 Archer Place, Dixon CA 95620")
expect(emailMock.html).toMatch("Studios")
expect(emailMock.html).toMatch("41 units, 285 sqft")
expect(emailMock.html).toMatch("Rent")
expect(emailMock.html).toMatch("$719 - $1,104 per month")
expect(emailMock.html).toMatch("Minimum Income")
expect(emailMock.html).toMatch("$1,438 - $2,208 per month")
expect(emailMock.html).toMatch("Maximum Income")
expect(emailMock.html).toMatch("$2,562.5 - $3,843.75 per month")
expect(emailMock.html).toMatch("View Listing & Apply")
expect(emailMock.html).toMatch("Alameda County Housing Portal")
expect(emailMock.html).toMatch("Alameda County Housing Portal is a project of the")
expect(emailMock.html).toMatch(
"Alameda County - Housing and Community Development (HCD) Department"
)
})
})

afterAll(async () => {
await module.close()
})
Expand Down
145 changes: 124 additions & 21 deletions backend/core/src/email/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import { SendGridService } from "@anchan828/nest-sendgrid"
import { ResponseError } from "@sendgrid/helpers/classes"
import { MailDataRequired } from "@sendgrid/helpers/classes/mail"
import axios from "axios"
import { ConfigService } from "@nestjs/config"
import merge from "lodash/merge"
import Handlebars from "handlebars"
import path from "path"
import Polyglot from "node-polyglot"
import fs from "fs"
import juice from "juice"
import { ConfigService } from "@nestjs/config"
import dayjs from "dayjs"
import { TranslationsService } from "../translations/services/translations.service"
import { JurisdictionResolverService } from "../jurisdictions/services/jurisdiction-resolver.service"
import { User } from "../auth/entities/user.entity"
Expand All @@ -20,15 +21,44 @@ import { Jurisdiction } from "../jurisdictions/entities/jurisdiction.entity"
import { Language } from "../shared/types/language-enum"
import { JurisdictionsService } from "../jurisdictions/services/jurisdictions.service"
import { Translation } from "../translations/entities/translation.entity"
import { IdName } from "../../types"
import { IdName, ListingEventType, Unit } from "../../types"
import { formatLocalDate } from "../shared/utils/format-local-date"

import { formatCommunityType } from "../listings/helpers"

type EmailAttachmentData = {
data: string
name: string
type: string
}

const formatPricing = (values: number[]): string => {
const minPrice = Math.min(...values)
const maxPrice = Math.max(...values)
return `$${minPrice.toLocaleString()}${
minPrice !== maxPrice ? " - $" + maxPrice.toLocaleString() : ""
} per month`
}

const formatUnitDetails = (
units: Unit[],
field: string,
label: string,
pluralLabel?: string
): string => {
const mappedField = units
.map((unit) => (unit[field] ? Number.parseFloat(unit[field]) : null))
.filter((value) => value)
if (mappedField?.length) {
const minValue = Math.min(...mappedField)
const maxValue = Math.max(...mappedField)
return `, ${minValue.toLocaleString()}${
minValue !== maxValue ? " - " + maxValue.toLocaleString() : ""
} ${pluralLabel && maxValue === 1 ? pluralLabel : label}`
}
return ""
}

@Injectable({ scope: Scope.REQUEST })
export class EmailService {
polyglot: Polyglot
Expand Down Expand Up @@ -224,34 +254,107 @@ export class EmailService {
)
}

public async listingOpportunity(user: User, appUrl: string) {
const jurisdiction = await this.getUserJurisdiction(user)
void (await this.loadTranslations(jurisdiction, user.language))
public async listingOpportunity(listing: Listing) {
const jurisdiction = await this.jurisdictionService.findOne({
where: {
id: listing.jurisdiction.id,
},
})
void (await this.loadTranslations(jurisdiction, Language.en))
const compiledTemplate = this.template("listing-opportunity")

if (this.configService.get<string>("NODE_ENV") == "production") {
Logger.log(
`Preparing to send a listing opportunity email to ${user.email} from ${jurisdiction.emailFromAddress}...`
`Preparing to send a listing opportunity email for ${listing.name} from ${jurisdiction.emailFromAddress}...`
)
}

const rawHtml = compiledTemplate({
appUrl,
// TODO: This is mock data just for the template that will need to be updated
tableRows: [
{ label: "Community", value: "Senior 55+" },
{ label: "Applications Due", value: "August 11, 2021" },
{ label: "Address", value: "2330 Webster Street, Oakland CA 94612" },
{ label: "1 Bedrooms", value: "1 unit, 1 bath, 668 sqft" },
{ label: "2 Bedrooms", value: "2 units, 1-2 baths, 900 - 968 sqft" },
{ label: "Rent", value: "$1,251 - $1,609 per month" },
{ label: "Minimum Income", value: "$2,502 - $3,218 per month" },
{ label: "Maximum Income", value: "$6,092 - $10,096 per month" },
{ label: "Lottery Date", value: "August 31, 2021" },
],
// Gather all variables from each unit into one place
const units: {
bedrooms: { [key: number]: Unit[] }
rent: number[]
minIncome: number[]
maxIncome: number[]
} = listing.units?.reduce(
(summaries, unit) => {
if (unit.monthlyIncomeMin) {
summaries.minIncome.push(Number.parseFloat(unit.monthlyIncomeMin))
}
if (unit.annualIncomeMax) {
summaries.maxIncome.push(Number.parseFloat(unit.annualIncomeMax) / 12.0)
}
summaries.rent.push(Number.parseFloat(unit.monthlyRent))
const thisBedroomInfo = summaries.bedrooms[unit.unitType?.name]
summaries.bedrooms[unit.unitType?.name] = thisBedroomInfo
? [...thisBedroomInfo, unit]
: [unit]
return summaries
},
{
bedrooms: {},
rent: [],
minIncome: [],
maxIncome: [],
}
)
const tableRows = []
if (listing.reservedCommunityType) {
tableRows.push({
label: this.polyglot.t("rentalOpportunity.community"),
value: formatCommunityType[listing.reservedCommunityType?.name],
})
}
if (listing.applicationDueDate) {
tableRows.push({
label: this.polyglot.t("rentalOpportunity.applicationsDue"),
value: dayjs(listing.applicationDueDate).format("MMMM D, YYYY"),
})
}
tableRows.push({
label: this.polyglot.t("rentalOpportunity.address"),
value: `${listing.buildingAddress.street}, ${listing.buildingAddress.city} ${listing.buildingAddress.state} ${listing.buildingAddress.zipCode}`,
})
Object.entries(units.bedrooms).forEach(([key, bedroom]) => {
const sqFtString = formatUnitDetails(bedroom, "sqFeet", "sqft")
const bathroomstring = formatUnitDetails(bedroom, "numBathrooms", "bath", "baths")
tableRows.push({
label: this.polyglot.t(`rentalOpportunity.${key}`),
value: `${bedroom.length} unit${
bedroom.length > 1 ? "s" : ""
}${bathroomstring}${sqFtString}`,
})
})
tableRows.push({
label: this.polyglot.t("rentalOpportunity.rent"),
value: formatPricing(units.rent),
})
tableRows.push({
label: this.polyglot.t("rentalOpportunity.minIncome"),
value: formatPricing(units.minIncome),
})
tableRows.push({
label: this.polyglot.t("rentalOpportunity.maxIncome"),
value: formatPricing(units.maxIncome),
})
if (listing.events && listing.events.length > 0) {
const lotteryEvent = listing.events.find(
(event) => event.type === ListingEventType.publicLottery
)
if (lotteryEvent && lotteryEvent.startDate) {
tableRows.push({
label: this.polyglot.t("rentalOpportunity.lottery"),
value: dayjs(lotteryEvent.startDate).format("MMMM D, YYYY"),
})
}
}

const compiled = compiledTemplate({
listingName: listing.name,
listingUrl: jurisdiction.publicUrl,
tableRows,
})

await this.govSend(rawHtml, "Listing Opportunity")
await this.govSend(compiled, "Listing Opportunity")
}

private async loadTranslations(jurisdiction: Jurisdiction | null, language: Language) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,9 @@ export class Jurisdiction extends AbstractEntity {
@Expose()
@IsBoolean({ groups: [ValidationsGroupsEnum.default] })
enableUtilitiesIncluded: boolean | null

@Column({ type: "boolean", nullable: false, default: false })
@Expose()
@IsBoolean({ groups: [ValidationsGroupsEnum.default] })
enableListingOpportunity: boolean | null
}
17 changes: 12 additions & 5 deletions backend/core/src/listings/listings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,11 +233,10 @@ export class ListingsService {
})

const saveResponse = await this.listingRepository.save(listing)
const listingApprovalPermissions = (
await this.jurisdictionsService.findOne({
where: { id: listing.jurisdiction.id },
})
)?.listingApprovalPermissions
const jurisdiction = await this.jurisdictionsService.findOne({
where: { id: listing.jurisdiction.id },
})
const listingApprovalPermissions = jurisdiction?.listingApprovalPermissions

if (listingApprovalPermissions?.length > 0)
await this.listingApprovalNotify({
Expand All @@ -249,6 +248,14 @@ export class ListingsService {
jurisId: listing.jurisdiction.id,
})
await this.cachePurgeService.cachePurgeForSingleListing(previousStatus, newStatus, saveResponse)

if (
listing.status === ListingStatus.active &&
previousStatus !== ListingStatus.active &&
jurisdiction.enableListingOpportunity
) {
await this.emailService.listingOpportunity(saveResponse)
}
return saveResponse
}

Expand Down
49 changes: 49 additions & 0 deletions backend/core/src/migration/1699380281858-listing-opportunity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { MigrationInterface, QueryRunner } from "typeorm"

export class listingOpportunity1699380281858 implements MigrationInterface {
name = "listingOpportunity1699380281858"

public async up(queryRunner: QueryRunner): Promise<void> {
const translations = await queryRunner.query(`
SELECT
id,
translations
FROM translations
WHERE language = 'en'
`)
translations.forEach(async (translation) => {
let data = translation.translations
data.rentalOpportunity = {
subject: "New rental opportunity",
intro: "Rental opportunity at",
applicationsDue: "Applications Due",
community: "Community",
address: "Address",
rent: "Rent",
minIncome: "Minimum Income",
maxIncome: "Maximum Income",
lottery: "Lottery Date",
viewButton: "View Listing & Apply",
studio: "Studios",
oneBdrm: "1 Bedrooms",
twoBdrm: "2 Bedrooms",
threeBdrm: "3 Bedrooms",
fourBdrm: "4 Bedrooms",
fiveBdrm: "5 Bedrooms",
SRO: "SROs"
}
data = JSON.stringify(data)
await queryRunner.query(`
UPDATE translations
SET translations = '${data.replace(/'/g, "''")}'
WHERE id = '${translation.id}'
`)
})

await queryRunner.query(
`ALTER TABLE "jurisdictions" ADD "enable_listing_opportunity" boolean default FALSE`
)
}

public async down(queryRunner: QueryRunner): Promise<void> {}
}
1 change: 1 addition & 0 deletions backend/core/src/seeder/seeds/jurisdictions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const activeJurisdictions: JurisdictionCreateDto[] = [
enablePartnerSettings: true,
enableAccessibilityFeatures: false,
enableUtilitiesIncluded: true,
enableListingOpportunity: false,
listingApprovalPermissions: [UserRoleEnum.admin],
},
]
Expand Down
2 changes: 1 addition & 1 deletion backend/core/src/shared/views/listing-opportunity.hbs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{{#> layout_default }}

<h1><span class="intro">Rental opportunity at</span> <br />Listing name</h1>
<h1><span class="intro"> {{t "rentalOpportunity.intro"}}</span> <br />{{listingName}}</h1>
<table class="inset fit-content" role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
Expand Down
Loading

0 comments on commit 020b4eb

Please sign in to comment.