Skip to content

Commit

Permalink
Release/2023 09 01 (#330)
Browse files Browse the repository at this point in the history
  • Loading branch information
emilyjablonski committed Sep 7, 2023
2 parents 53e8e13 + ae649c4 commit 0eb841a
Show file tree
Hide file tree
Showing 62 changed files with 1,158 additions and 127 deletions.
24 changes: 23 additions & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,29 @@ workflows:
working_directory: sites/partners
yarn: true
build: |
echo 'export FEATURE_LISTINGS_APPROVAL=FALSE' >> "$BASH_ENV"
source "$BASH_ENV"
yarn test:backend:core:dbsetup
start: yarn dev:all-cypress
start: |
yarn dev:all-cypress
command: |
npx cypress run --spec cypress/e2e/default/**/*.{js,jsx,ts,tsx}
wait-on: "http://0.0.0.0:3001"
store_artifacts: true
- cypress/run:
name: "cypress-partners-listings-approval"
requires:
- setup
executor: cypress-node
working_directory: sites/partners
yarn: true
build: |
echo 'export FEATURE_LISTINGS_APPROVAL=TRUE' >> "$BASH_ENV"
source "$BASH_ENV"
yarn test:backend:core:dbsetup
start: |
yarn dev:all-cypress
command: |
npx cypress run --spec cypress/e2e/listings-approval/**/*.{js,jsx,ts,tsx}
wait-on: "http://0.0.0.0:3001"
store_artifacts: true
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { Column, Entity, ManyToOne, OneToMany } from "typeorm"
import { Expose, Type } from "class-transformer"
import { IsBoolean, IsEnum, IsOptional, IsString, MaxLength, ValidateNested } from "class-validator"
import {
IsBoolean,
IsEnum,
IsOptional,
IsString,
IsUrl,
MaxLength,
ValidateIf,
ValidateNested,
} from "class-validator"
import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum"
import { AbstractEntity } from "../../shared/entities/abstract.entity"
import { ApiProperty } from "@nestjs/swagger"
import { Listing } from "../../listings/entities/listing.entity"
import { ApplicationMethodType } from "../types/application-method-type-enum"
import { PaperApplication } from "../../paper-applications/entities/paper-application.entity"
import { hasHttps } from "../../shared/decorators/hasHttps.decorator"

@Entity({ name: "application_methods" })
export class ApplicationMethod extends AbstractEntity {
Expand All @@ -29,6 +39,11 @@ export class ApplicationMethod extends AbstractEntity {
@IsOptional({ groups: [ValidationsGroupsEnum.default] })
@IsString({ groups: [ValidationsGroupsEnum.default] })
@MaxLength(4096, { groups: [ValidationsGroupsEnum.default] })
@ValidateIf((o) => o.type === ApplicationMethodType.ExternalLink, {
groups: [ValidationsGroupsEnum.default],
})
@hasHttps({ groups: [ValidationsGroupsEnum.default] })
@IsUrl({ require_protocol: true }, { groups: [ValidationsGroupsEnum.default] })
externalReference?: string | null

@Column({ type: "bool", nullable: true })
Expand Down
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="300" height="65" />`
)
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="300" height="65" />`
)
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="300" height="65" />`
)
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)
}
}
}
4 changes: 2 additions & 2 deletions backend/core/src/listings/db/listing-query-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ export class ListingsQueryBuilder extends SelectQueryBuilder<Listing> {
if (orderByCondition.orderBy === "listings.status") {
const orderStr =
orderByCondition.orderDir === "ASC"
? `CASE WHEN ${orderByCondition.orderBy} = '${ListingStatus.pending}' THEN 1 WHEN ${orderByCondition.orderBy} = '${ListingStatus.active}' THEN 2 WHEN ${orderByCondition.orderBy} = '${ListingStatus.closed}' THEN 3 END`
: `CASE WHEN ${orderByCondition.orderBy} = '${ListingStatus.closed}' THEN 1 WHEN ${orderByCondition.orderBy} = '${ListingStatus.active}' THEN 2 WHEN ${orderByCondition.orderBy} = '${ListingStatus.pending}' THEN 3 END`
? `CASE WHEN ${orderByCondition.orderBy} = '${ListingStatus.pendingReview}' THEN 1 WHEN ${orderByCondition.orderBy} = '${ListingStatus.changesRequested}' THEN 2 WHEN ${orderByCondition.orderBy} = '${ListingStatus.pending}' THEN 3 WHEN ${orderByCondition.orderBy} = '${ListingStatus.active}' THEN 4 WHEN ${orderByCondition.orderBy} = '${ListingStatus.closed}' THEN 5 END`
: `CASE WHEN ${orderByCondition.orderBy} = '${ListingStatus.closed}' THEN 1 WHEN ${orderByCondition.orderBy} = '${ListingStatus.active}' THEN 2 WHEN ${orderByCondition.orderBy} = '${ListingStatus.pending}' THEN 3 WHEN ${orderByCondition.orderBy} = '${ListingStatus.changesRequested}' THEN 4 WHEN ${orderByCondition.orderBy} = '${ListingStatus.pendingReview}' THEN 5 END`
this.addOrderBy(orderStr)
this.addOrderBy("listings.applicationDueDate", orderByCondition.orderDir)
} else {
Expand Down
1 change: 1 addition & 0 deletions backend/core/src/listings/dto/listing-create.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export class ListingCreateDto extends OmitType(ListingDto, [
"publishedAt",
"closedAt",
"afsLastRunAt",
"requestedChangesUser",
] as const) {
@Expose()
@IsDefined({ groups: [ValidationsGroupsEnum.default] })
Expand Down
Loading

0 comments on commit 0eb841a

Please sign in to comment.