Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: listings approval emails #3600

Merged
merged 31 commits into from
Sep 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0066019
fix: wip email setup
ColinBuyck Aug 8, 2023
1c68c4c
fix: wip BE service work
ColinBuyck Aug 9, 2023
e6467c0
fix: wip email endpoint
ColinBuyck Aug 9, 2023
2c4fba9
fix: wip module error resolution
ColinBuyck Aug 10, 2023
a202ea4
fix: nest error resolution
ColinBuyck Aug 10, 2023
71b0bd5
fix: functional email endpoint
ColinBuyck Aug 14, 2023
65ab99f
fix: email formatting completed
ColinBuyck Aug 14, 2023
fa8153f
fix: remove console logs
ColinBuyck Aug 14, 2023
b8ccd45
fix: undo dto approach
ColinBuyck Aug 15, 2023
bb4de1a
fix: test coverage
ColinBuyck Aug 15, 2023
a10faae
fix: listing service test cleanup
ColinBuyck Aug 15, 2023
25048d4
fix: final clean up
ColinBuyck Aug 15, 2023
eeb6418
fix: are tests going to run?
ColinBuyck Aug 15, 2023
10e6920
fix: corrected permissioning
ColinBuyck Aug 16, 2023
f51b7fb
fix: start of listings approved
ColinBuyck Aug 16, 2023
f21e5bc
fix: shift to updateAndNotify
ColinBuyck Aug 18, 2023
274ec87
fix: wip all email flow
ColinBuyck Aug 18, 2023
08423b8
fix: all three email flow
ColinBuyck Aug 18, 2023
00b3cea
fix: all email test coverage
ColinBuyck Aug 22, 2023
73ef412
fix: translation error resolution
ColinBuyck Aug 24, 2023
02a0376
fix: logic refactoring + cleanup
ColinBuyck Aug 26, 2023
21e3743
fix: no notification case
ColinBuyck Aug 26, 2023
e3247fc
fix: improved type approach
ColinBuyck Aug 26, 2023
763f80b
fix: req user corrections
ColinBuyck Aug 28, 2023
c554712
fix: listing service commenting
ColinBuyck Aug 28, 2023
c902a72
fix: custom error handling
ColinBuyck Aug 29, 2023
264bac7
fix: remove testing error state
ColinBuyck Aug 29, 2023
3cb2160
fix: futher commenting
ColinBuyck Aug 29, 2023
2274283
fix: pr feedback updates
ColinBuyck Aug 29, 2023
9a63844
fix: remove unused email mock
ColinBuyck Aug 31, 2023
6f52680
fix: error message from design
ColinBuyck Aug 31, 2023
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
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 = ["testOne@xample.com", "testTwo@example.com"]
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 = ["testOne@xample.com", "testTwo@example.com"]
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 = ["testOne@xample.com", "testTwo@example.com"]
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