diff --git a/backend/core/src/email/email.service.spec.ts b/backend/core/src/email/email.service.spec.ts index 3084f1f808..38d930be27 100644 --- a/backend/core/src/email/email.service.spec.ts +++ b/backend/core/src/email/email.service.spec.ts @@ -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", }, }, } @@ -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( + `Alameda County Housing Portal` + ) + 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( + `Alameda County Housing Portal` + ) + 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( + `Alameda County Housing Portal` + ) + 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() diff --git a/backend/core/src/email/email.service.ts b/backend/core/src/email/email.service.ts index 4a5bd9c640..39553fbbd0 100644 --- a/backend/core/src/email/email.service.ts +++ b/backend/core/src/email/email.service.ts @@ -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" @@ -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 { @@ -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) { @@ -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) + } + } } diff --git a/backend/core/src/listings/listings.controller.ts b/backend/core/src/listings/listings.controller.ts index 141fc30101..62f5eee3ad 100644 --- a/backend/core/src/listings/listings.controller.ts +++ b/backend/core/src/listings/listings.controller.ts @@ -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 { + 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)) diff --git a/backend/core/src/listings/listings.module.ts b/backend/core/src/listings/listings.module.ts index 92d47cfc7d..775100f64c 100644 --- a/backend/core/src/listings/listings.module.ts +++ b/backend/core/src/listings/listings.module.ts @@ -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: [ @@ -35,6 +38,8 @@ import { CachePurgeService } from "./cache-purge.service" ActivityLogModule, ApplicationFlaggedSetsModule, HttpModule, + EmailModule, + JurisdictionsModule, ], providers: [ ListingsService, @@ -43,6 +48,7 @@ import { CachePurgeService } from "./cache-purge.service" CsvBuilder, ListingsCsvExporterService, CachePurgeService, + ConfigService, ], exports: [ListingsService], controllers: [ListingsController], diff --git a/backend/core/src/listings/listings.service.ts b/backend/core/src/listings/listings.service.ts index 415f26d916..9befb772e0 100644 --- a/backend/core/src/listings/listings.service.ts +++ b/backend/core/src/listings/listings.service.ts @@ -20,6 +20,8 @@ import { User } from "../auth/entities/user.entity" import { ApplicationFlaggedSetsService } from "../application-flagged-sets/application-flagged-sets.service" import { ListingsQueryBuilder } from "./db/listing-query-builder" import { CachePurgeService } from "./cache-purge.service" +import { EmailService } from "../email/email.service" +import { ConfigService } from "@nestjs/config" @Injectable({ scope: Scope.REQUEST }) export class ListingsService { @@ -31,7 +33,9 @@ export class ListingsService { @InjectRepository(User) private readonly userRepository: Repository, @Inject(REQUEST) private req: ExpressRequest, private readonly afsService: ApplicationFlaggedSetsService, - private readonly cachePurgeService: CachePurgeService + private readonly cachePurgeService: CachePurgeService, + private readonly emailService: EmailService, + private readonly configService: ConfigService ) {} private getFullyJoinedQueryBuilder() { @@ -212,6 +216,122 @@ export class ListingsService { return listing.jurisdiction.id } + public async getApprovingUserEmails(): Promise { + const approvingUsers = await this.userRepository + .createQueryBuilder("user") + .select(["user.email"]) + .leftJoin("user.roles", "userRoles") + .where("userRoles.is_admin = :is_admin", { + is_admin: true, + }) + .getMany() + const approvingUserEmails: string[] = [] + approvingUsers?.forEach((user) => user?.email && approvingUserEmails.push(user.email)) + return approvingUserEmails + } + + public async getNonApprovingUserInfo( + listingId: string, + jurisId: string, + getPublicUrl = false + ): Promise<{ emails: string[]; publicUrl?: string | null }> { + const selectFields = ["user.email", "jurisdictions.id"] + getPublicUrl && selectFields.push("jurisdictions.publicUrl") + const nonApprovingUsers = await this.userRepository + .createQueryBuilder("user") + .select(selectFields) + .leftJoin("user.leasingAgentInListings", "leasingAgentInListings") + .leftJoin("user.roles", "userRoles") + .leftJoin("user.jurisdictions", "jurisdictions") + .where( + new Brackets((qb) => { + qb.where("userRoles.is_partner = :is_partner", { + is_partner: true, + }).andWhere("leasingAgentInListings.id = :listingId", { + listingId: listingId, + }) + }) + ) + .orWhere( + new Brackets((qb) => { + qb.where("userRoles.is_jurisdictional_admin = :is_jurisdictional_admin", { + is_jurisdictional_admin: true, + }).andWhere("jurisdictions.id = :jurisId", { + jurisId: jurisId, + }) + }) + ) + .getMany() + + // account for users having access to multiple jurisdictions + const publicUrl = getPublicUrl + ? nonApprovingUsers[0]?.jurisdictions?.find((juris) => juris.id === jurisId)?.publicUrl + : null + const nonApprovingUserEmails: string[] = [] + nonApprovingUsers?.forEach((user) => user?.email && nonApprovingUserEmails.push(user.email)) + return { emails: nonApprovingUserEmails, publicUrl } + } + + async updateAndNotify(listingData: ListingUpdateDto, user: User) { + let result + // partners updates status to pending review when requesting admin approval + if (listingData.status === ListingStatus.pendingReview) { + result = await this.update(listingData, user) + const approvingUserEmails = await this.getApprovingUserEmails() + await this.emailService.requestApproval( + user, + { id: listingData.id, name: listingData.name }, + approvingUserEmails, + this.configService.get("PARTNERS_PORTAL_URL") + ) + } + // admin updates status to changes requested when approval requires partner changes + else if (listingData.status === ListingStatus.changesRequested) { + result = await this.update(listingData, user) + const nonApprovingUserInfo = await this.getNonApprovingUserInfo( + listingData.id, + listingData.jurisdiction.id + ) + await this.emailService.changesRequested( + user, + { id: listingData.id, name: listingData.name }, + nonApprovingUserInfo.emails, + this.configService.get("PARTNERS_PORTAL_URL") + ) + } + // check if status of active requires notification + else if (listingData.status === ListingStatus.active) { + const previousStatus = await this.listingRepository + .createQueryBuilder("listings") + .select("listings.status") + .where("id = :id", { id: listingData.id }) + .getOne() + result = await this.update(listingData, user) + // if not new published listing, skip notification and return update response + if ( + previousStatus.status !== ListingStatus.pendingReview && + previousStatus.status !== ListingStatus.changesRequested + ) { + return result + } + // otherwise get user info and send listing approved email + const nonApprovingUserInfo = await this.getNonApprovingUserInfo( + listingData.id, + listingData.jurisdiction.id, + true + ) + await this.emailService.listingApproved( + user, + { id: listingData.id, name: listingData.name }, + nonApprovingUserInfo.emails, + nonApprovingUserInfo.publicUrl + ) + } else { + result = await this.update(listingData, user) + } + return result + } + async rawListWithFlagged() { const userAccess = await this.userRepository .createQueryBuilder("user") diff --git a/backend/core/src/listings/tests/listings.service.spec.ts b/backend/core/src/listings/tests/listings.service.spec.ts index e9199e050e..efd9b27ca2 100644 --- a/backend/core/src/listings/tests/listings.service.spec.ts +++ b/backend/core/src/listings/tests/listings.service.spec.ts @@ -17,6 +17,8 @@ import { User } from "../../auth/entities/user.entity" import { UserService } from "../../auth/services/user.service" import { HttpService } from "@nestjs/axios" import { CachePurgeService } from "../cache-purge.service" +import { EmailService } from "../../../src/email/email.service" +import { ConfigService } from "@nestjs/config" /* eslint-disable @typescript-eslint/unbound-method */ @@ -170,6 +172,16 @@ describe("ListingsService", () => { getJurisdiction: jest.fn(), }, }, + { + provide: EmailService, + useValue: { + requestApproval: jest.fn(), + }, + }, + { + provide: ConfigService, + useValue: { get: jest.fn() }, + }, { provide: getRepositoryToken(User), useValue: jest.fn() }, ], }).compile() diff --git a/backend/core/src/migration/1692122923259-listings-approval-emails.ts b/backend/core/src/migration/1692122923259-listings-approval-emails.ts new file mode 100644 index 0000000000..999cbfa2e5 --- /dev/null +++ b/backend/core/src/migration/1692122923259-listings-approval-emails.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class listingsApprovalEmails1692122923259 implements MigrationInterface { + name = "listingsApprovalEmails1692122923259" + + public async up(queryRunner: QueryRunner): Promise { + const translations: { id: string; translations: any }[] = await queryRunner.query(` + SELECT + id, + translations + FROM translations + WHERE language = 'en' + `) + translations.forEach(async (translation) => { + let data = translation.translations + data.t = { + ...data.t, + hello: "Hello", + partnersPortal: "Partners Portal", + viewListing: "View Listing", + editListing: "Edit Listing", + reviewListing: "Review Listing", + } + + data.footer = { + ...data.footer, + thankYou: "Thank you", + } + + data.requestApproval = { + 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", + } + + data.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.", + } + + data.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", + } + data = JSON.stringify(data) + await queryRunner.query(` + UPDATE translations + SET translations = '${data.replace(/'/g, "''")}' + WHERE id = '${translation.id}' + `) + }) + } + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/backend/core/src/seeder/seeds/jurisdictions.ts b/backend/core/src/seeder/seeds/jurisdictions.ts index 0b65fa8d78..ed94af2cbd 100644 --- a/backend/core/src/seeder/seeds/jurisdictions.ts +++ b/backend/core/src/seeder/seeds/jurisdictions.ts @@ -8,7 +8,7 @@ export const activeJurisdictions: JurisdictionCreateDto[] = [ name: "Alameda", multiselectQuestions: [], languages: [Language.en], - publicUrl: "", + publicUrl: "http://localhost:3000", emailFromAddress: "Alameda: Housing Bay Area ", rentalAssistanceDefault: "Housing Choice Vouchers, Section 8 and other valid rental assistance programs will be considered for this property. In the case of a valid rental subsidy, the required minimum income will be based on the portion of the rent that the tenant pays after use of the subsidy.", @@ -20,7 +20,7 @@ export const activeJurisdictions: JurisdictionCreateDto[] = [ name: "San Jose", multiselectQuestions: [], languages: [Language.en], - publicUrl: "", + publicUrl: "http://localhost:3000", emailFromAddress: "SJ: HousingBayArea.org ", rentalAssistanceDefault: "Housing Choice Vouchers, Section 8 and other valid rental assistance programs will be considered for this property. In the case of a valid rental subsidy, the required minimum income will be based on the portion of the rent that the tenant pays after use of the subsidy.", @@ -32,7 +32,7 @@ export const activeJurisdictions: JurisdictionCreateDto[] = [ name: "San Mateo", multiselectQuestions: [], languages: [Language.en], - publicUrl: "", + publicUrl: "http://localhost:3000", emailFromAddress: "SMC: HousingBayArea.org ", rentalAssistanceDefault: "Housing Choice Vouchers, Section 8 and other valid rental assistance programs will be considered for this property. In the case of a valid rental subsidy, the required minimum income will be based on the portion of the rent that the tenant pays after use of the subsidy.", @@ -44,7 +44,7 @@ export const activeJurisdictions: JurisdictionCreateDto[] = [ name: "Detroit", multiselectQuestions: [], languages: [Language.en], - publicUrl: "", + publicUrl: "http://localhost:3000", emailFromAddress: "Detroit Housing ", rentalAssistanceDefault: "Housing Choice Vouchers, Section 8 and other valid rental assistance programs will be considered for this property. In the case of a valid rental subsidy, the required minimum income will be based on the portion of the rent that the tenant pays after use of the subsidy.", diff --git a/backend/core/src/shared/views/changes-requested.hbs b/backend/core/src/shared/views/changes-requested.hbs new file mode 100644 index 0000000000..6dec5019d3 --- /dev/null +++ b/backend/core/src/shared/views/changes-requested.hbs @@ -0,0 +1,53 @@ +{{#> layout_default }} +

+ {{ t "changesRequested.header"}} +

+ + + + + + +
+

+ {{t "t.hello"}}, +

+ {{t "changesRequested.adminRequestStart" appOptions}} {{t "t.partnersPortal"}} {{t "changesRequested.adminRequestEnd"}} +

+ {{t "requestApproval.accessListing"}} +

+

+
+ + + + + + +
+ + {{t "t.editListing"}} + +
+ + + + + + +
+

+ {{t "footer.thankYou"}}, +

+ {{t "header.logoTitle"}} +

+
+ + +{{/layout_default }} diff --git a/backend/core/src/shared/views/listing-approved.hbs b/backend/core/src/shared/views/listing-approved.hbs new file mode 100644 index 0000000000..c1ad7ff2d2 --- /dev/null +++ b/backend/core/src/shared/views/listing-approved.hbs @@ -0,0 +1,53 @@ +{{#> layout_default }} +

+ {{ t "listingApproved.header"}} +

+ + + + + + +
+

+ {{t "t.hello"}}, +

+ {{t "listingApproved.adminApproved" appOptions}} +

+ {{t "listingApproved.viewPublished"}} +

+

+
+ + + + + + +
+ + {{t "t.viewListing"}} + +
+ + + + + + +
+

+ {{t "footer.thankYou"}}, +

+ {{t "header.logoTitle"}} +

+
+ + +{{/layout_default }} diff --git a/backend/core/src/shared/views/request-approval.hbs b/backend/core/src/shared/views/request-approval.hbs new file mode 100644 index 0000000000..6c79a1c757 --- /dev/null +++ b/backend/core/src/shared/views/request-approval.hbs @@ -0,0 +1,55 @@ +{{#> layout_default }} +

+ {{ t "requestApproval.header"}} +

+ + + + + + +
+

+ {{t "t.hello"}}, +

+ {{t "requestApproval.partnerRequest" appOptions}} +

+ {{t "requestApproval.logInToReviewStart"}} {{t "t.partnersPortal"}} {{t "requestApproval.logInToReviewEnd"}} +

+ {{t "requestApproval.accessListing"}} +

+

+
+ + + + + + +
+ + {{t "t.reviewListing"}} + +
+ + + + + + +
+

+ {{t "footer.thankYou"}}, +

+ {{t "header.logoTitle"}} +

+
+ + +{{/layout_default }} diff --git a/backend/core/src/translations/services/translations.service.ts b/backend/core/src/translations/services/translations.service.ts index 4c23c7261d..2603290401 100644 --- a/backend/core/src/translations/services/translations.service.ts +++ b/backend/core/src/translations/services/translations.service.ts @@ -58,6 +58,9 @@ export class TranslationsService extends AbstractServiceFactory< }), }, }) + } else if (e instanceof NotFoundException && jurisdictionId && language === Language.en) { + console.warn(`English translations don't exist for jurisdiction ${jurisdictionId}`) + return null } else { throw e } diff --git a/backend/core/types/src/backend-swagger.ts b/backend/core/types/src/backend-swagger.ts index 6f36449cc3..07f99d597e 100644 --- a/backend/core/types/src/backend-swagger.ts +++ b/backend/core/types/src/backend-swagger.ts @@ -1462,6 +1462,30 @@ export class ListingsService { let data = params.body + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Update listing by id and notify relevant users + */ + updateAndNotify( + params: { + /** */ + id: string + /** requestBody */ + body?: ListingUpdate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/listings/updateAndNotify/{id}" + url = url.replace("{id}", params["id"] + "") + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + configs.data = data axios(configs, resolve, reject) }) diff --git a/shared-helpers/src/locales/general.json b/shared-helpers/src/locales/general.json index bed95e79d7..e1974e1d04 100644 --- a/shared-helpers/src/locales/general.json +++ b/shared-helpers/src/locales/general.json @@ -548,6 +548,7 @@ "eligibility.accessibility.wheelchairRamp": "Wheelchair Ramp", "errors.agreeError": "You must agree to the terms in order to continue", "errors.alert.badRequest": "Looks like something went wrong. Please try again. \n\nContact your housing department if you're still experiencing issues.", + "errors.alert.listingsApprovalEmailError": "This listing was updated, but the associated emails failed to send.", "errors.alert.timeoutPleaseTryAgain": "Oops! Looks like something went wrong. Please try again.", "errors.alert.applicationSubmissionVerificationError": "Your application is missing required fields. Please go back and correct this before submitting.", "errors.cityError": "Please enter a city", diff --git a/sites/partners/src/components/listings/ListingFormActions.tsx b/sites/partners/src/components/listings/ListingFormActions.tsx index 0ff27ffd25..d005d8747c 100644 --- a/sites/partners/src/components/listings/ListingFormActions.tsx +++ b/sites/partners/src/components/listings/ListingFormActions.tsx @@ -230,23 +230,28 @@ const ListingFormActions = ({ type="button" fullWidth onClick={async () => { - // TODO throw a modal - try { - const result = await listingsService.update({ - id: listing.id, - body: { ...listing, status: ListingStatus.active }, - }) - - if (result) { - setSiteAlertMessage(t("listings.approval.listingPublished"), "success") - if (router.pathname.includes("edit")) { - await router.push(`/listings/${result.id}`) - } else { - router.reload() + // utilize same submit logic if updating status from edit view + if (type === ListingFormActionsType.edit) { + submitFormWithStatus(false, ListingStatus.active) + } else { + // button only exists for listings approval so can call updateAndNotify directly + try { + const result = await listingsService.updateAndNotify({ + id: listing.id, + body: { ...listing, status: ListingStatus.active }, + }) + if (result) { + setSiteAlertMessage(t("listings.approval.listingPublished"), "success") + await router.push(`/`) } + } catch (err) { + setSiteAlertMessage( + err.response?.data?.message === "email failed" + ? "errors.alert.listingsApprovalEmailError" + : "errors.somethingWentWrong", + "warn" + ) } - } catch (err) { - setSiteAlertMessage("errors.somethingWentWrong", "warn") } }} > diff --git a/sites/partners/src/components/listings/PaperListingForm/index.tsx b/sites/partners/src/components/listings/PaperListingForm/index.tsx index eb2b81399b..3808fd2e7a 100644 --- a/sites/partners/src/components/listings/PaperListingForm/index.tsx +++ b/sites/partners/src/components/listings/PaperListingForm/index.tsx @@ -177,13 +177,23 @@ const ListingForm = ({ listing, editMode }: ListingFormProps) => { customMapPositionChosen, }) const formattedData = await dataPipeline.run() - - const result = editMode - ? await listingsService.update({ + let result + if (editMode) { + if (process.env.featureListingsApproval) { + result = await listingsService.updateAndNotify({ + id: listing.id, + body: { ...formattedData }, + }) + } else { + result = await listingsService.update({ id: listing.id, body: { id: listing.id, ...formattedData }, }) - : await listingsService.create({ body: formattedData }) + } + } else { + result = await listingsService.create({ body: formattedData }) + } + reset(formData) if (result) { @@ -238,9 +248,10 @@ const ListingForm = ({ listing, editMode }: ListingFormProps) => { } }) setAlert("form") - } else { - setAlert("api") - } + } else if (data.message === "email failed") { + setSiteAlertMessage(t("errors.alert.listingsApprovalEmailError"), "warn") + await router.push(`/listings/${formData.id}/`) + } else setAlert("api") } } }, diff --git a/sites/partners/src/pages/listings/[id]/index.tsx b/sites/partners/src/pages/listings/[id]/index.tsx index 973843fcd5..a812515dde 100644 --- a/sites/partners/src/pages/listings/[id]/index.tsx +++ b/sites/partners/src/pages/listings/[id]/index.tsx @@ -53,6 +53,7 @@ export default function ListingDetail(props: ListingProps) { {t("nav.siteTitlePartners")} +