Skip to content

Commit

Permalink
feat: listings approval emails (#3600)
Browse files Browse the repository at this point in the history
* fix: wip email setup

* fix: wip BE service work

* fix: wip email endpoint

* fix: wip module error resolution

* fix: nest error resolution

* fix: functional email endpoint

* fix: email formatting completed

* fix: remove console logs

* fix: undo dto approach

* fix: test coverage

* fix: listing service test cleanup

* fix: final clean up

* fix: are tests going to run?

* fix: corrected permissioning

* fix: start of listings approved

* fix: shift to updateAndNotify

* fix: wip all email flow

* fix: all three email flow

* fix: all email test coverage

* fix: translation error resolution

* fix: logic refactoring + cleanup

* fix: no notification case

* fix: improved type approach

* fix: req user corrections

* fix: listing service commenting

* fix: custom error handling

* fix: remove testing error state

* fix: futher commenting

* fix: pr feedback updates

* fix: remove unused email mock

* fix: error message from design
  • Loading branch information
ColinBuyck authored Sep 1, 2023
1 parent 3ccf99d commit b3e4c8f
Show file tree
Hide file tree
Showing 17 changed files with 694 additions and 46 deletions.
152 changes: 152 additions & 0 deletions backend/core/src/email/email.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,35 @@ const translationServiceMock = {
welcomeMessage:
"Thank you for setting up your account on %{appUrl}. It will now be easier for you to start, save, and submit online applications for listings that appear on the site.",
},
requestApproval: {
subject: "Listing Approval Requested",
header: "Listing approval requested",
partnerRequest:
"A Partner has submitted an approval request to publish the %{listingName} listing.",
logInToReviewStart: "Please log into the",
logInToReviewEnd: "and navigate to the listing detail page to review and publish.",
accessListing: "To access the listing after logging in, please click the link below",
},
changesRequested: {
header: "Listing changes requested",
adminRequestStart:
"An administrator is requesting changes to the %{listingName} listing. Please log into the",
adminRequestEnd:
"and navigate to the listing detail page to view the request and edit the listing. To access the listing after logging in, please click the link below",
},
listingApproved: {
header: "New published listing",
adminApproved:
"The %{listingName} listing has been approved and published by an administrator.",
viewPublished: "To view the published listing, please click on the link below",
},
t: {
hello: "Hello",
seeListing: "See Listing",
partnersPortal: "Partners Portal",
viewListing: "View Listing",
editListing: "Edit Listing",
reviewListing: "Review Listing",
},
},
}
Expand Down Expand Up @@ -298,6 +324,132 @@ describe("EmailService", () => {
expect(emailMock.html).toMatch("SPANISH Alameda County Housing Portal is a project of the")
})
})
describe("request approval", () => {
it("should generate html body", async () => {
const emailArr = ["[email protected]", "[email protected]"]
const service = await module.resolve(EmailService)
await service.requestApproval(
user,
{ id: listing.id, name: listing.name },
emailArr,
"http://localhost:3001"
)

expect(sendMock).toHaveBeenCalled()
const emailMock = sendMock.mock.calls[0][0]
expect(emailMock.to).toEqual(emailArr)
expect(emailMock.subject).toEqual("Listing approval requested")
expect(emailMock.html).toMatch(
`<img src="https://res.cloudinary.com/mariposta/image/upload/v1652326298/testing/alameda-portal.png" alt="Alameda County Housing Portal" width="254" height="137" />`
)
expect(emailMock.html).toMatch("Hello,")
expect(emailMock.html).toMatch("Listing approval requested")
expect(emailMock.html).toMatch(
`A Partner has submitted an approval request to publish the ${listing.name} listing.`
)
expect(emailMock.html).toMatch("Please log into the")
expect(emailMock.html).toMatch("Partners Portal")
expect(emailMock.html).toMatch(/http:\/\/localhost:3001/)
expect(emailMock.html).toMatch(
"and navigate to the listing detail page to review and publish."
)
expect(emailMock.html).toMatch(
"To access the listing after logging in, please click the link below"
)
expect(emailMock.html).toMatch("Review Listing")
expect(emailMock.html).toMatch(/http:\/\/localhost:3001\/listings\/Uvbk5qurpB2WI9V6WnNdH/)
expect(emailMock.html).toMatch("Thank you,")
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"
)
})
})

describe("changes requested", () => {
it("should generate html body", async () => {
const emailArr = ["[email protected]", "[email protected]"]
const service = await module.resolve(EmailService)
await service.changesRequested(
user,
{ id: listing.id, name: listing.name },
emailArr,
"http://localhost:3001"
)

expect(sendMock).toHaveBeenCalled()
const emailMock = sendMock.mock.calls[0][0]
expect(emailMock.to).toEqual(emailArr)
expect(emailMock.subject).toEqual("Listing changes requested")
expect(emailMock.html).toMatch(
`<img src="https://res.cloudinary.com/mariposta/image/upload/v1652326298/testing/alameda-portal.png" alt="Alameda County Housing Portal" width="254" height="137" />`
)
expect(emailMock.html).toMatch("Listing changes requested")
expect(emailMock.html).toMatch("Hello,")
expect(emailMock.html).toMatch(
`An administrator is requesting changes to the ${listing.name} listing. Please log into the `
)
expect(emailMock.html).toMatch("Partners Portal")
expect(emailMock.html).toMatch(/http:\/\/localhost:3001/)

expect(emailMock.html).toMatch(
" and navigate to the listing detail page to view the request and edit the listing."
)
expect(emailMock.html).toMatch(
"and navigate to the listing detail page to view the request and edit the listing."
)
expect(emailMock.html).toMatch(/http:\/\/localhost:3001/)
expect(emailMock.html).toMatch(
"To access the listing after logging in, please click the link below"
)
expect(emailMock.html).toMatch("Edit Listing")
expect(emailMock.html).toMatch(/http:\/\/localhost:3001\/listings\/Uvbk5qurpB2WI9V6WnNdH/)
expect(emailMock.html).toMatch("Thank you,")
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"
)
})
})

describe("published listing", () => {
it("should generate html body", async () => {
const emailArr = ["[email protected]", "[email protected]"]
const service = await module.resolve(EmailService)
await service.listingApproved(
user,
{ id: listing.id, name: listing.name },
emailArr,
"http://localhost:3000"
)

expect(sendMock).toHaveBeenCalled()
const emailMock = sendMock.mock.calls[0][0]
expect(emailMock.to).toEqual(emailArr)
expect(emailMock.subject).toEqual("New published listing")
expect(emailMock.html).toMatch(
`<img src="https://res.cloudinary.com/mariposta/image/upload/v1652326298/testing/alameda-portal.png" alt="Alameda County Housing Portal" width="254" height="137" />`
)
expect(emailMock.html).toMatch("New published listing")
expect(emailMock.html).toMatch("Hello,")
expect(emailMock.html).toMatch(
`The ${listing.name} listing has been approved and published by an administrator.`
)
expect(emailMock.html).toMatch(
"To view the published listing, please click on the link below"
)
expect(emailMock.html).toMatch("View Listing")
expect(emailMock.html).toMatch(/http:\/\/localhost:3000\/listing\/Uvbk5qurpB2WI9V6WnNdH/)
expect(emailMock.html).toMatch("Thank you,")
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
113 changes: 94 additions & 19 deletions backend/core/src/email/email.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, Logger, Scope } from "@nestjs/common"
import { HttpException, Injectable, Logger, Scope } from "@nestjs/common"
import { SendGridService } from "@anchan828/nest-sendgrid"
import { ResponseError } from "@sendgrid/helpers/classes"
import merge from "lodash/merge"
Expand All @@ -17,6 +17,7 @@ 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"

@Injectable({ scope: Scope.REQUEST })
export class EmailService {
Expand Down Expand Up @@ -287,26 +288,36 @@ export class EmailService {
return partials
}

private async send(to: string, from: string, subject: string, body: string, retry = 3) {
await this.sendGrid.send(
{
to: to,
from,
subject: subject,
html: body,
},
false,
(error) => {
if (error instanceof ResponseError) {
const { response } = error
const { body: errBody } = response
console.error(`Error sending email to: ${to}! Error body: ${errBody}`)
if (retry > 0) {
void this.send(to, from, subject, body, retry - 1)
}
private async send(
to: string | string[],
from: string,
subject: string,
body: string,
retry = 3
) {
const multipleRecipients = Array.isArray(to)
const emailParams = {
to,
from,
subject,
html: body,
}
const handleError = (error) => {
if (error instanceof ResponseError) {
const { response } = error
const { body: errBody } = response
console.error(
`Error sending email to: ${
multipleRecipients ? to.toString() : to
}! Error body: ${errBody}`
)
if (retry > 0) {
void this.send(to, from, subject, body, retry - 1)
}
}
)
}

await this.sendGrid.send(emailParams, multipleRecipients, handleError)
}

async invite(user: User, appUrl: string, confirmationUrl: string) {
Expand Down Expand Up @@ -340,4 +351,68 @@ export class EmailService {
})
)
}

public async requestApproval(user: User, listingInfo: IdName, emails: string[], appUrl: string) {
try {
const jurisdiction = await this.getUserJurisdiction(user)
void (await this.loadTranslations(jurisdiction, Language.en))
await this.send(
emails,
jurisdiction.emailFromAddress,
this.polyglot.t("requestApproval.header"),
this.template("request-approval")({
user,
appOptions: { listingName: listingInfo.name },
appUrl: appUrl,
listingUrl: `${appUrl}/listings/${listingInfo.id}`,
})
)
} catch (err) {
throw new HttpException("email failed", 500)
}
}

public async changesRequested(user: User, listingInfo: IdName, emails: string[], appUrl: string) {
try {
const jurisdiction = await this.getUserJurisdiction(user)
void (await this.loadTranslations(jurisdiction, Language.en))
await this.send(
emails,
jurisdiction.emailFromAddress,
this.polyglot.t("changesRequested.header"),
this.template("changes-requested")({
user,
appOptions: { listingName: listingInfo.name },
appUrl: appUrl,
listingUrl: `${appUrl}/listings/${listingInfo.id}`,
})
)
} catch (err) {
throw new HttpException("email failed", 500)
}
}

public async listingApproved(
user: User,
listingInfo: IdName,
emails: string[],
publicUrl: string
) {
try {
const jurisdiction = await this.getUserJurisdiction(user)
void (await this.loadTranslations(jurisdiction, Language.en))
await this.send(
emails,
jurisdiction.emailFromAddress,
this.polyglot.t("listingApproved.header"),
this.template("listing-approved")({
user,
appOptions: { listingName: listingInfo.name },
listingUrl: `${publicUrl}/listing/${listingInfo.id}`,
})
)
} catch (err) {
throw new HttpException("email failed", 500)
}
}
}
15 changes: 15 additions & 0 deletions backend/core/src/listings/listings.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,21 @@ export class ListingsController {
return mapTo(ListingDto, listing)
}

@Put(`updateAndNotify/:id`)
@ApiOperation({
summary: "Update listing by id and notify relevant users",
operationId: "updateAndNotify",
})
@UsePipes(new ListingUpdateValidationPipe(defaultValidationPipeOptions))
async updateAndNotify(
@Request() req,
@Param("id") listingId: string,
@Body() listingUpdateDto: ListingUpdateDto
): Promise<ListingDto> {
const listing = await this.listingsService.updateAndNotify(listingUpdateDto, req.user)
return mapTo(ListingDto, listing)
}

@Delete()
@ApiOperation({ summary: "Delete listing by id", operationId: "delete" })
@UsePipes(new ValidationPipe(defaultValidationPipeOptions))
Expand Down
6 changes: 6 additions & 0 deletions backend/core/src/listings/listings.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import { ListingsCronService } from "./listings-cron.service"
import { ListingsCsvExporterService } from "./listings-csv-exporter.service"
import { CsvBuilder } from "../../src/applications/services/csv-builder.service"
import { CachePurgeService } from "./cache-purge.service"
import { ConfigService } from "@nestjs/config"
import { EmailModule } from "../../src/email/email.module"
import { JurisdictionsModule } from "../../src/jurisdictions/jurisdictions.module"

@Module({
imports: [
Expand All @@ -35,6 +38,8 @@ import { CachePurgeService } from "./cache-purge.service"
ActivityLogModule,
ApplicationFlaggedSetsModule,
HttpModule,
EmailModule,
JurisdictionsModule,
],
providers: [
ListingsService,
Expand All @@ -43,6 +48,7 @@ import { CachePurgeService } from "./cache-purge.service"
CsvBuilder,
ListingsCsvExporterService,
CachePurgeService,
ConfigService,
],
exports: [ListingsService],
controllers: [ListingsController],
Expand Down
Loading

0 comments on commit b3e4c8f

Please sign in to comment.