diff --git a/backend/core/src/applications/applications.controller.ts b/backend/core/src/applications/applications.controller.ts index 237f3a8112..b9c1420bef 100644 --- a/backend/core/src/applications/applications.controller.ts +++ b/backend/core/src/applications/applications.controller.ts @@ -3,7 +3,6 @@ import { Controller, Delete, Get, - Header, Param, ParseUUIDPipe, Post, @@ -22,7 +21,6 @@ import { ApplicationDto } from "./dto/application.dto" import { ValidationsGroupsEnum } from "../shared/types/validations-groups-enum" import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" import { applicationMultiselectQuestionApiExtraModels } from "./types/application-multiselect-question-api-extra-models" -import { ApplicationCsvExporterService } from "./services/application-csv-exporter.service" import { ApplicationsService } from "./services/applications.service" import { ActivityLogInterceptor } from "../activity-log/interceptors/activity-log.interceptor" import { PaginatedApplicationListQueryParams } from "./dto/paginated-application-list-query-params" @@ -35,6 +33,7 @@ import { PaginatedApplicationDto } from "./dto/paginated-application.dto" import { ApplicationCreateDto } from "./dto/application-create.dto" import { ApplicationUpdateDto } from "./dto/application-update.dto" import { IdDto } from "../shared/dto/id.dto" +import { StatusDto } from "../shared/dto/status.dto" @Controller("applications") @ApiTags("applications") @@ -50,10 +49,7 @@ import { IdDto } from "../shared/dto/id.dto" ) @ApiExtraModels(...applicationMultiselectQuestionApiExtraModels, ApplicationsApiExtraModel) export class ApplicationsController { - constructor( - private readonly applicationsService: ApplicationsService, - private readonly applicationCsvExporter: ApplicationCsvExporterService - ) {} + constructor(private readonly applicationsService: ApplicationsService) {} @Get() @ApiOperation({ summary: "List applications", operationId: "list" }) @@ -65,17 +61,11 @@ export class ApplicationsController { @Get(`csv`) @ApiOperation({ summary: "List applications as csv", operationId: "listAsCsv" }) - @Header("Content-Type", "text/csv") async listAsCsv( @Query(new ValidationPipe(defaultValidationPipeOptions)) queryParams: ApplicationsCsvListQueryParams - ): Promise { - const applications = await this.applicationsService.rawListWithFlagged(queryParams) - return this.applicationCsvExporter.exportFromObject( - applications, - queryParams.timeZone, - queryParams.includeDemographics - ) + ): Promise { + return await this.applicationsService.sendExport(queryParams) } @Get(`rawApplicationsList`) diff --git a/backend/core/src/applications/services/applications.service.ts b/backend/core/src/applications/services/applications.service.ts index f5af937083..4e18bcaf81 100644 --- a/backend/core/src/applications/services/applications.service.ts +++ b/backend/core/src/applications/services/applications.service.ts @@ -26,6 +26,9 @@ import { ApplicationCreateDto } from "../dto/application-create.dto" import { ApplicationUpdateDto } from "../dto/application-update.dto" import { ApplicationsCsvListQueryParams } from "../dto/applications-csv-list-query-params" import { Listing } from "../../listings/entities/listing.entity" +import { ApplicationCsvExporterService } from "./application-csv-exporter.service" +import { User } from "../../auth/entities/user.entity" +import { StatusDto } from "../../shared/dto/status.dto" @Injectable({ scope: Scope.REQUEST }) export class ApplicationsService { @@ -34,6 +37,7 @@ export class ApplicationsService { private readonly authzService: AuthzService, private readonly listingsService: ListingsService, private readonly emailService: EmailService, + private readonly applicationCsvExporter: ApplicationCsvExporterService, @InjectRepository(Application) private readonly repository: Repository, @InjectRepository(Listing) private readonly listingsRepository: Repository ) {} @@ -253,6 +257,25 @@ export class ApplicationsService { return await this.repository.softRemove({ id: applicationId }) } + async sendExport(queryParams: ApplicationsCsvListQueryParams): Promise { + const applications = await this.rawListWithFlagged(queryParams) + const csvString = this.applicationCsvExporter.exportFromObject( + applications, + queryParams.timeZone, + queryParams.includeDemographics + ) + const listing = await this.listingsRepository.findOne({ where: { id: queryParams.listingId } }) + await this.emailService.sendCSV( + (this.req.user as unknown) as User, + listing.name, + listing.id, + csvString + ) + return { + status: "Success", + } + } + private _getQb(params: PaginatedApplicationListQueryParams, view = "base", withSelect = true) { /** * Map used to generate proper parts @@ -417,7 +440,9 @@ export class ApplicationsService { private async authorizeCSVExport(user, listingId) { /** - * Checking authorization for each application is very expensive. By making lisitngId required, we can check if the user has update permissions for the listing, since right now if a user has that they also can run the export for that listing + * Checking authorization for each application is very expensive. + * By making listingId required, we can check if the user has update permissions for the listing, since right now if a user has that + * they also can run the export for that listing */ const jurisdictionId = await this.listingsService.getJurisdictionIdByListingId(listingId) diff --git a/backend/core/src/email/email.service.ts b/backend/core/src/email/email.service.ts index 39553fbbd0..187965572e 100644 --- a/backend/core/src/email/email.service.ts +++ b/backend/core/src/email/email.service.ts @@ -1,6 +1,7 @@ import { HttpException, Injectable, Logger, Scope } from "@nestjs/common" import { SendGridService } from "@anchan828/nest-sendgrid" import { ResponseError } from "@sendgrid/helpers/classes" +import { MailDataRequired } from "@sendgrid/helpers/classes/mail" import merge from "lodash/merge" import Handlebars from "handlebars" import path from "path" @@ -18,6 +19,13 @@ 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 { formatLocalDate } from "../shared/utils/format-local-date" + +type EmailAttachmentData = { + data: string + name: string + type: string +} @Injectable({ scope: Scope.REQUEST }) export class EmailService { @@ -293,15 +301,26 @@ export class EmailService { from: string, subject: string, body: string, - retry = 3 + retry = 3, + attachment?: EmailAttachmentData ) { const multipleRecipients = Array.isArray(to) - const emailParams = { + const emailParams: Partial = { to, from, subject, html: body, } + if (attachment) { + emailParams.attachments = [ + { + content: Buffer.from(attachment.data).toString("base64"), + filename: attachment.name, + type: attachment.type, + disposition: "attachment", + }, + ] + } const handleError = (error) => { if (error instanceof ResponseError) { const { response } = error @@ -415,4 +434,27 @@ export class EmailService { throw new HttpException("email failed", 500) } } + + async sendCSV(user: User, listingName: string, listingId: string, applicationData: string) { + void (await this.loadTranslations( + user.jurisdictions?.length === 1 ? user.jurisdictions[0] : null, + user.language || Language.en + )) + const jurisdiction = await this.getUserJurisdiction(user) + await this.send( + user.email, + jurisdiction.emailFromAddress, + `${listingName} applications export`, + this.template("csv-export")({ + user: user, + appOptions: { listingName, appUrl: this.configService.get("PARTNERS_PORTAL_URL") }, + }), + undefined, + { + data: applicationData, + name: `applications-${listingId}-${formatLocalDate(new Date(), "YYYY-MM-DD_HH:mm:ss")}.csv`, + type: "text/csv", + } + ) + } } diff --git a/backend/core/src/jurisdictions/entities/jurisdiction.entity.ts b/backend/core/src/jurisdictions/entities/jurisdiction.entity.ts index 3465e4a459..74cf2a3035 100644 --- a/backend/core/src/jurisdictions/entities/jurisdiction.entity.ts +++ b/backend/core/src/jurisdictions/entities/jurisdiction.entity.ts @@ -14,6 +14,7 @@ import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enu import { Language } from "../../shared/types/language-enum" import { Expose, Type } from "class-transformer" import { MultiselectQuestion } from "../../multiselect-question/entities/multiselect-question.entity" +import { UserRoleEnum } from "../../../src/auth/enum/user-role-enum" @Entity({ name: "jurisdictions" }) export class Jurisdiction extends AbstractEntity { @@ -36,6 +37,14 @@ export class Jurisdiction extends AbstractEntity { @IsEnum(Language, { groups: [ValidationsGroupsEnum.default], each: true }) languages: Language[] + @Column({ type: "enum", enum: UserRoleEnum, array: true, nullable: true }) + @Expose() + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(256, { groups: [ValidationsGroupsEnum.default] }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(UserRoleEnum, { groups: [ValidationsGroupsEnum.default], each: true }) + listingApprovalPermissions?: UserRoleEnum[] + @ManyToMany( () => MultiselectQuestion, (multiselectQuestion) => multiselectQuestion.jurisdictions, diff --git a/backend/core/src/listings/listings.controller.ts b/backend/core/src/listings/listings.controller.ts index 62f5eee3ad..7ff7a43443 100644 --- a/backend/core/src/listings/listings.controller.ts +++ b/backend/core/src/listings/listings.controller.ts @@ -68,8 +68,8 @@ export class ListingsController { @Post() @ApiOperation({ summary: "Create listing", operationId: "create" }) @UsePipes(new ListingCreateValidationPipe(defaultValidationPipeOptions)) - async create(@Body() listingDto: ListingCreateDto): Promise { - const listing = await this.listingsService.create(listingDto) + async create(@Request() req, @Body() listingDto: ListingCreateDto): Promise { + const listing = await this.listingsService.create(listingDto, req.user) return mapTo(ListingDto, listing) } @@ -121,21 +121,6 @@ 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.service.ts b/backend/core/src/listings/listings.service.ts index b7af9abf00..2f267a655c 100644 --- a/backend/core/src/listings/listings.service.ts +++ b/backend/core/src/listings/listings.service.ts @@ -5,7 +5,7 @@ import { Brackets, In, Repository } from "typeorm" import { Listing } from "./entities/listing.entity" import { getView } from "./views/view" import { summarizeUnits, summarizeUnitsByTypeAndRent } from "../shared/units-transformations" -import { Language, ListingReviewOrder } from "../../types" +import { IdName, Language, ListingReviewOrder } from "../../types" import { AmiChart } from "../ami-charts/entities/ami-chart.entity" import { ListingCreateDto } from "./dto/listing-create.dto" import { ListingUpdateDto } from "./dto/listing-update.dto" @@ -24,6 +24,7 @@ import { JurisdictionsService } from "../jurisdictions/services/jurisdictions.se import { Jurisdiction } from "../jurisdictions/entities/jurisdiction.entity" import { EmailService } from "../email/email.service" import { ConfigService } from "@nestjs/config" +import { UserRoleEnum } from "../../src/auth/enum/user-role-enum" @Injectable({ scope: Scope.REQUEST }) export class ListingsService { @@ -38,6 +39,7 @@ export class ListingsService { private readonly cachePurgeService: CachePurgeService, private readonly jurisdictionService: JurisdictionsService, private readonly emailService: EmailService, + private readonly jurisdictionsService: JurisdictionsService, private readonly configService: ConfigService ) {} @@ -114,7 +116,7 @@ export class ListingsService { } } - async create(listingDto: ListingCreateDto) { + async create(listingDto: ListingCreateDto, user: User) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion await this.authzService.canOrThrow(this.req.user as User, "listing", authzActions.create, { jurisdictionId: listingDto.jurisdiction.id, @@ -136,8 +138,23 @@ export class ListingsService { publishedAt: listingDto.status === ListingStatus.active ? new Date() : null, closedAt: listingDto.status === ListingStatus.closed ? new Date() : null, }) - - return await listing.save() + const saveResponse = await listing.save() + // only listings approval state possible from creation + if (listing.status === ListingStatus.pendingReview) { + const listingApprovalPermissions = ( + await this.jurisdictionsService.findOne({ + where: { id: saveResponse.jurisdiction.id }, + }) + )?.listingApprovalPermissions + await this.listingApprovalNotify({ + user, + listingInfo: { id: saveResponse.id, name: saveResponse.name }, + status: listing.status, + approvingRoles: listingApprovalPermissions, + jurisId: listing.jurisdiction.id, + }) + } + return saveResponse } async update(listingDto: ListingUpdateDto, user: User) { @@ -207,6 +224,21 @@ export class ListingsService { }) const saveResponse = await this.listingRepository.save(listing) + const listingApprovalPermissions = ( + await this.jurisdictionsService.findOne({ + where: { id: listing.jurisdiction.id }, + }) + )?.listingApprovalPermissions + + if (listingApprovalPermissions?.length > 0) + await this.listingApprovalNotify({ + user, + listingInfo: { id: listing.id, name: listing.name }, + approvingRoles: listingApprovalPermissions, + status: listing.status, + previousStatus, + jurisId: listing.jurisdiction.id, + }) await this.cachePurgeService.cachePurgeForSingleListing(previousStatus, newStatus, saveResponse) return saveResponse } @@ -255,120 +287,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, + public async getUserEmailInfo( + userRoles: UserRoleEnum | UserRoleEnum[], + listingId?: string, + jurisId?: string, getPublicUrl = false ): Promise<{ emails: string[]; publicUrl?: string | null }> { + //determine select statement const selectFields = ["user.email", "jurisdictions.id"] getPublicUrl && selectFields.push("jurisdictions.publicUrl") - const nonApprovingUsers = await this.userRepository + + //build potential where statements + const admin = new Brackets((qb) => { + qb.where("userRoles.is_admin = :is_admin", { + is_admin: true, + }) + }) + const jurisdictionAdmin = new Brackets((qb) => { + qb.where("userRoles.is_jurisdictional_admin = :is_jurisdictional_admin", { + is_jurisdictional_admin: true, + }).andWhere("jurisdictions.id = :jurisId", { + jurisId: jurisId, + }) + }) + const partner = new Brackets((qb) => { + qb.where("userRoles.is_partner = :is_partner", { + is_partner: true, + }).andWhere("leasingAgentInListings.id = :listingId", { + listingId: listingId, + }) + }) + + let userQueryBuilder = 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() + + // determine where clause(s) + if (userRoles.includes(UserRoleEnum.admin)) userQueryBuilder = userQueryBuilder.where(admin) + if (userRoles.includes(UserRoleEnum.partner)) userQueryBuilder = userQueryBuilder.where(partner) + if (userRoles.includes(UserRoleEnum.jurisdictionAdmin)) { + userQueryBuilder = userQueryBuilder.orWhere(jurisdictionAdmin) + } + + const userResults = await userQueryBuilder.getMany() // account for users having access to multiple jurisdictions const publicUrl = getPublicUrl - ? nonApprovingUsers[0]?.jurisdictions?.find((juris) => juris.id === jurisId)?.publicUrl + ? userResults[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 } + const userEmails: string[] = [] + userResults?.forEach((user) => user?.email && userEmails.push(user.email)) + return { emails: userEmails, 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() + public async listingApprovalNotify(params: { + user: User + listingInfo: IdName + status: ListingStatus + approvingRoles: UserRoleEnum[] + previousStatus?: ListingStatus + jurisId?: string + }) { + const nonApprovingRoles = [UserRoleEnum.partner] + if (!params.approvingRoles.includes(UserRoleEnum.jurisdictionAdmin)) + nonApprovingRoles.push(UserRoleEnum.jurisdictionAdmin) + if (params.status === ListingStatus.pendingReview) { + const userInfo = await this.getUserEmailInfo( + params.approvingRoles, + params.listingInfo.id, + params.jurisId + ) await this.emailService.requestApproval( - user, - { id: listingData.id, name: listingData.name }, - approvingUserEmails, + params.user, + { id: params.listingInfo.id, name: params.listingInfo.name }, + userInfo.emails, 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 + else if (params.status === ListingStatus.changesRequested) { + const userInfo = await this.getUserEmailInfo( + nonApprovingRoles, + params.listingInfo.id, + params.jurisId ) await this.emailService.changesRequested( - user, - { id: listingData.id, name: listingData.name }, - nonApprovingUserInfo.emails, + params.user, + { id: params.listingInfo.id, name: params.listingInfo.name }, + userInfo.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 + else if (params.status === ListingStatus.active) { + // if newly published listing, notify non-approving users with access if ( - previousStatus.status !== ListingStatus.pendingReview && - previousStatus.status !== ListingStatus.changesRequested + params.previousStatus === ListingStatus.pendingReview || + params.previousStatus === ListingStatus.changesRequested || + params.previousStatus === ListingStatus.pending ) { - return result + const userInfo = await this.getUserEmailInfo( + nonApprovingRoles, + params.listingInfo.id, + params.jurisId, + true + ) + await this.emailService.listingApproved( + params.user, + { id: params.listingInfo.id, name: params.listingInfo.name }, + userInfo.emails, + userInfo.publicUrl + ) } - // 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() { diff --git a/backend/core/src/listings/tests/listings.service.spec.ts b/backend/core/src/listings/tests/listings.service.spec.ts index c9b32f1ec9..7a132298c9 100644 --- a/backend/core/src/listings/tests/listings.service.spec.ts +++ b/backend/core/src/listings/tests/listings.service.spec.ts @@ -17,9 +17,11 @@ 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 { JurisdictionsService } from "../../jurisdictions/services/jurisdictions.service" import { EmailService } from "../../../src/email/email.service" import { ConfigService } from "@nestjs/config" +import { JurisdictionsService } from "../../../src/jurisdictions/services/jurisdictions.service" +import { ListingStatus } from "../types/listing-status-enum" +import { UserRoleEnum } from "../../../src/auth/enum/user-role-enum" /* eslint-disable @typescript-eslint/unbound-method */ @@ -90,6 +92,7 @@ const mockInnerQueryBuilder = { addGroupBy: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), + orWhere: jest.fn().mockReturnThis(), offset: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnThis(), getParameters: jest.fn().mockReturnValue({ param1: "param1value" }), @@ -106,6 +109,7 @@ const mockQueryBuilder = { leftJoinAndSelect: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), + orWhere: jest.fn().mockReturnThis(), setParameters: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), addOrderBy: jest.fn().mockReturnThis(), @@ -129,11 +133,20 @@ const mockListingsRepo = { const mockUserRepo = { findOne: jest.fn(), save: jest.fn(), - createQueryBuilder: jest.fn(), + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), findByEmail: jest.fn(), findByResetToken: jest.fn(), } +const requestApprovalMock = jest.fn() +const changesRequestedMock = jest.fn() +const listingApprovedMock = jest.fn() + +const user = new User() +user.firstName = "Test" +user.lastName = "User" +user.email = "test@xample.com" + describe("ListingsService", () => { beforeEach(async () => { process.env.APP_SECRET = "SECRET" @@ -182,7 +195,15 @@ describe("ListingsService", () => { { provide: EmailService, useValue: { - requestApproval: jest.fn(), + requestApproval: requestApprovalMock, + changesRequested: changesRequestedMock, + listingApproved: listingApprovedMock, + }, + }, + { + provide: JurisdictionsService, + useValue: { + findOne: jest.fn(), }, }, { @@ -451,4 +472,83 @@ describe("ListingsService", () => { ) }) }) + describe("ListingsService.listingApprovalNotify", () => { + it("request approval email", async () => { + jest.spyOn(service, "getUserEmailInfo").mockResolvedValueOnce({ emails: ["admin@email.com"] }) + await service.listingApprovalNotify({ + user, + listingInfo: { id: "id", name: "name" }, + status: ListingStatus.pendingReview, + approvingRoles: [UserRoleEnum.admin], + }) + + expect(service.getUserEmailInfo).toBeCalledWith(["admin"], "id", undefined) + expect(requestApprovalMock).toBeCalledWith( + user, + { id: "id", name: "name" }, + ["admin@email.com"], + undefined + ) + }) + it("changes requested email", async () => { + jest + .spyOn(service, "getUserEmailInfo") + .mockResolvedValueOnce({ emails: ["jurisAdmin@email.com", "partner@email.com"] }) + await service.listingApprovalNotify({ + user, + listingInfo: { id: "id", name: "name" }, + status: ListingStatus.changesRequested, + approvingRoles: [UserRoleEnum.admin], + }) + + expect(service.getUserEmailInfo).toBeCalledWith( + ["partner", "jurisdictionAdmin"], + "id", + undefined + ) + expect(changesRequestedMock).toBeCalledWith( + user, + { id: "id", name: "name" }, + ["jurisAdmin@email.com", "partner@email.com"], + undefined + ) + }) + it("listing approved email", async () => { + jest.spyOn(service, "getUserEmailInfo").mockResolvedValueOnce({ + emails: ["jurisAdmin@email.com", "partner@email.com"], + publicUrl: "public.housing.gov", + }) + await service.listingApprovalNotify({ + user, + listingInfo: { id: "id", name: "name" }, + status: ListingStatus.active, + previousStatus: ListingStatus.pendingReview, + approvingRoles: [UserRoleEnum.admin], + }) + + expect(service.getUserEmailInfo).toBeCalledWith( + ["partner", "jurisdictionAdmin"], + "id", + undefined, + true + ) + expect(listingApprovedMock).toBeCalledWith( + user, + { id: "id", name: "name" }, + ["jurisAdmin@email.com", "partner@email.com"], + "public.housing.gov" + ) + }) + it("active listing not requiring email", async () => { + await service.listingApprovalNotify({ + user, + listingInfo: { id: "id", name: "name" }, + status: ListingStatus.active, + previousStatus: ListingStatus.active, + approvingRoles: [UserRoleEnum.admin], + }) + + expect(listingApprovedMock).toBeCalledTimes(0) + }) + }) }) diff --git a/backend/core/src/migration/1694130645798-listings-approval-permissioning.ts b/backend/core/src/migration/1694130645798-listings-approval-permissioning.ts new file mode 100644 index 0000000000..fada5f15dd --- /dev/null +++ b/backend/core/src/migration/1694130645798-listings-approval-permissioning.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class listingsApprovalPermissioning1694130645798 implements MigrationInterface { + name = "listingsApprovalPermissioning1694130645798" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "public"."jurisdictions_listing_approval_permissions_enum" AS ENUM('user', 'partner', 'admin', 'jurisdictionAdmin')` + ) + await queryRunner.query( + `ALTER TABLE "jurisdictions" ADD "listing_approval_permissions" "public"."jurisdictions_listing_approval_permissions_enum" array` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "jurisdictions" DROP COLUMN "listing_approval_permissions"` + ) + await queryRunner.query(`DROP TYPE "public"."jurisdictions_listing_approval_permissions_enum"`) + } +} diff --git a/backend/core/src/migration/1696353656895-csv-export-translations.ts b/backend/core/src/migration/1696353656895-csv-export-translations.ts new file mode 100644 index 0000000000..e8c716b18c --- /dev/null +++ b/backend/core/src/migration/1696353656895-csv-export-translations.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class csvExportTranslations1696353656895 implements MigrationInterface { + name = "csvExportTranslations1696353656895" + + 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.csvExport = { + title: "%{listingName} applications export", + body: "The attached file is an applications export for %{listingName}. If you have any questions, please reach out to your administrator.", + hello: "Hello,", + } + data = JSON.stringify(data) + await queryRunner.query(` + UPDATE translations + SET translations = '${data.replace(/'/g, "''")}' + WHERE id = '${translation.id}' + `) + }) + } + + public async down(queryRunner: QueryRunner): Promise { + // no down migration + } +} diff --git a/backend/core/src/seeder/seed.ts b/backend/core/src/seeder/seed.ts index a3e4a6ce81..719945dc38 100644 --- a/backend/core/src/seeder/seed.ts +++ b/backend/core/src/seeder/seed.ts @@ -6,6 +6,7 @@ import { getRepositoryToken } from "@nestjs/typeorm" import { INestApplicationContext } from "@nestjs/common" import { ListingDefaultSeed } from "./seeds/listings/listing-default-seed" import { ListingColiseumSeed } from "./seeds/listings/listing-coliseum-seed" +import { ListingDefaultDraftSeed } from "./seeds/listings/listing-default-draft" import { ListingDefaultOpenSoonSeed } from "./seeds/listings/listing-default-open-soon" import { ListingDefaultOnePreferenceSeed } from "./seeds/listings/listing-default-one-preference-seed" import { ListingDefaultNoPreferenceSeed } from "./seeds/listings/listing-default-no-preference-seed" @@ -64,6 +65,7 @@ const argv = yargs.scriptName("seed").options({ const listingSeeds: any[] = [ ListingDefaultSeed, ListingColiseumSeed, + ListingDefaultDraftSeed, ListingDefaultOpenSoonSeed, ListingDefaultOnePreferenceSeed, ListingDefaultNoPreferenceSeed, diff --git a/backend/core/src/seeder/seeder.module.ts b/backend/core/src/seeder/seeder.module.ts index 0e9d5d63d1..83009bdaac 100644 --- a/backend/core/src/seeder/seeder.module.ts +++ b/backend/core/src/seeder/seeder.module.ts @@ -24,6 +24,7 @@ import { ListingColiseumSeed } from "../seeder/seeds/listings/listing-coliseum-s import { ListingDefaultOnePreferenceSeed } from "../seeder/seeds/listings/listing-default-one-preference-seed" import { ListingDefaultNoPreferenceSeed } from "../seeder/seeds/listings/listing-default-no-preference-seed" import { MultiselectQuestion } from "../multiselect-question/entities/multiselect-question.entity" +import { ListingDefaultDraftSeed } from "./seeds/listings/listing-default-draft" import { ListingDefaultFCFSSeed } from "../seeder/seeds/listings/listing-default-fcfs-seed" import { ListingDefaultOpenSoonSeed } from "../seeder/seeds/listings/listing-default-open-soon" import { @@ -98,6 +99,7 @@ export class SeederModule { ListingColiseumSeed, ListingDefaultOnePreferenceSeed, ListingDefaultNoPreferenceSeed, + ListingDefaultDraftSeed, ListingDefaultFCFSSeed, ListingDefaultOpenSoonSeed, ListingDefaultBmrChartSeed, diff --git a/backend/core/src/seeder/seeds/jurisdictions.ts b/backend/core/src/seeder/seeds/jurisdictions.ts index ed94af2cbd..a2c269fd9b 100644 --- a/backend/core/src/seeder/seeds/jurisdictions.ts +++ b/backend/core/src/seeder/seeds/jurisdictions.ts @@ -2,6 +2,7 @@ import { INestApplicationContext } from "@nestjs/common" import { JurisdictionCreateDto } from "../../jurisdictions/dto/jurisdiction-create.dto" import { Language } from "../../shared/types/language-enum" import { JurisdictionsService } from "../../jurisdictions/services/jurisdictions.service" +import { UserRoleEnum } from "../../../src/auth/enum/user-role-enum" export const activeJurisdictions: JurisdictionCreateDto[] = [ { @@ -15,6 +16,7 @@ export const activeJurisdictions: JurisdictionCreateDto[] = [ enablePartnerSettings: true, enableAccessibilityFeatures: false, enableUtilitiesIncluded: true, + listingApprovalPermissions: [UserRoleEnum.admin], }, { name: "San Jose", @@ -27,6 +29,7 @@ export const activeJurisdictions: JurisdictionCreateDto[] = [ enablePartnerSettings: false, enableAccessibilityFeatures: false, enableUtilitiesIncluded: true, + listingApprovalPermissions: [UserRoleEnum.admin, UserRoleEnum.jurisdictionAdmin], }, { name: "San Mateo", @@ -39,6 +42,7 @@ export const activeJurisdictions: JurisdictionCreateDto[] = [ enablePartnerSettings: true, enableAccessibilityFeatures: false, enableUtilitiesIncluded: false, + listingApprovalPermissions: [UserRoleEnum.admin, UserRoleEnum.jurisdictionAdmin], }, { name: "Detroit", @@ -51,6 +55,7 @@ export const activeJurisdictions: JurisdictionCreateDto[] = [ enablePartnerSettings: false, enableAccessibilityFeatures: false, enableUtilitiesIncluded: false, + listingApprovalPermissions: [UserRoleEnum.admin, UserRoleEnum.jurisdictionAdmin], }, ] diff --git a/backend/core/src/seeder/seeds/listings/listing-default-draft.ts b/backend/core/src/seeder/seeds/listings/listing-default-draft.ts new file mode 100644 index 0000000000..da0d195a5e --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-default-draft.ts @@ -0,0 +1,13 @@ +import { ListingStatus } from "../../../listings/types/listing-status-enum" +import { ListingDefaultSeed } from "./listing-default-seed" + +export class ListingDefaultDraftSeed extends ListingDefaultSeed { + async seed() { + const listing = await super.seed() + return await this.listingRepository.save({ + ...listing, + name: "Test: Draft", + status: ListingStatus.pending, + }) + } +} diff --git a/backend/core/src/shared/views/csv-export.hbs b/backend/core/src/shared/views/csv-export.hbs new file mode 100644 index 0000000000..af51532ba1 --- /dev/null +++ b/backend/core/src/shared/views/csv-export.hbs @@ -0,0 +1,30 @@ +{{#> layout_default }} +

+ {{ t "csvExport.title" appOptions }} +

+ + + + + + + + + + +
+

+ {{t "csvExport.hello" appOptions}} +

+

+ {{t "csvExport.body" appOptions}} +

+
+

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

+

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

+
+{{/layout_default }} diff --git a/backend/core/test/applications/applications.e2e-spec.ts b/backend/core/test/applications/applications.e2e-spec.ts index e95702af95..f828a0a07e 100644 --- a/backend/core/test/applications/applications.e2e-spec.ts +++ b/backend/core/test/applications/applications.e2e-spec.ts @@ -47,7 +47,7 @@ describe("Applications", () => { beforeEach(async () => { /* eslint-disable @typescript-eslint/no-empty-function */ - const testEmailService = { confirmation: async () => {} } + const testEmailService = { confirmation: async () => {}, sendCSV: async () => {} } /* eslint-enable @typescript-eslint/no-empty-function */ const moduleRef = await Test.createTestingModule({ imports: [ @@ -441,8 +441,7 @@ describe("Applications", () => { .get(`/applications/csv/?listingId=${listing1Id}`) .set(...setAuthorization(adminAccessToken)) .expect(200) - expect(typeof res.text === "string") - expect(new RegExp(/Flagged/).test(res.text)).toEqual(true) + expect(res.body.status).toEqual("Success") }) it(`should allow an admin to delete user's applications`, async () => { diff --git a/backend/core/test/listings/listings.e2e-spec.ts b/backend/core/test/listings/listings.e2e-spec.ts index 152924d7e2..833372ff9b 100644 --- a/backend/core/test/listings/listings.e2e-spec.ts +++ b/backend/core/test/listings/listings.e2e-spec.ts @@ -9,13 +9,18 @@ import { setAuthorization } from "../utils/set-authorization-helper" import { AssetCreateDto } from "../../src/assets/dto/asset.dto" import { ApplicationMethodCreateDto } from "../../src/application-methods/dto/application-method.dto" import { ApplicationMethodType } from "../../src/application-methods/types/application-method-type-enum" -import { ApplicationSection, Language } from "../../types" +import { + ApplicationSection, + EnumJurisdictionListingApprovalPermissions, + Language, +} from "../../types" import { AssetsModule } from "../../src/assets/assets.module" import { ApplicationMethodsModule } from "../../src/application-methods/applications-methods.module" import { PaperApplicationsModule } from "../../src/paper-applications/paper-applications.module" import { ListingEventCreateDto } from "../../src/listings/dto/listing-event.dto" import { ListingEventType } from "../../src/listings/types/listing-event-type-enum" import { Listing } from "../../src/listings/entities/listing.entity" +import { ListingStatus } from "../../src/listings/types/listing-status-enum" import qs from "qs" import { ListingUpdateDto } from "../../src/listings/dto/listing-update.dto" import { MultiselectQuestion } from "../../src//multiselect-question/entities/multiselect-question.entity" @@ -29,6 +34,8 @@ import dbOptions from "../../ormconfig.test" import { MultiselectQuestionDto } from "../../src/multiselect-question/dto/multiselect-question.dto" import cookieParser from "cookie-parser" +import { EmailService } from "../../src/email/email.service" +import { User } from "../../src/auth/entities/user.entity" // Cypress brings in Chai types for the global expect, but we want to use jest // expect here so we need to re-declare it. @@ -41,6 +48,17 @@ describe("Listings", () => { let questionRepository: Repository let adminAccessToken: string let jurisdictionsRepository: Repository + let userRepository: Repository + + const testEmailService = { + /* eslint-disable @typescript-eslint/no-empty-function */ + requestApproval: async () => {}, + changesRequested: async () => {}, + listingApproved: async () => {}, + } + const mockChangesRequested = jest.spyOn(testEmailService, "changesRequested") + const mockRequestApproval = jest.spyOn(testEmailService, "requestApproval") + const mockListingApproved = jest.spyOn(testEmailService, "listingApproved") beforeAll(async () => { const moduleRef = await Test.createTestingModule({ @@ -52,7 +70,10 @@ describe("Listings", () => { ApplicationMethodsModule, PaperApplicationsModule, ], - }).compile() + }) + .overrideProvider(EmailService) + .useValue(testEmailService) + .compile() app = moduleRef.createNestApplication() app = applicationSetup(app) @@ -65,6 +86,7 @@ describe("Listings", () => { jurisdictionsRepository = moduleRef.get>( getRepositoryToken(Jurisdiction) ) + userRepository = moduleRef.get>(getRepositoryToken(User)) }) it("should return all listings", async () => { @@ -138,7 +160,7 @@ describe("Listings", () => { } const query = qs.stringify(queryParams) const res = await supertest(app.getHttpServer()).get(`/listings?${query}`).expect(200) - expect(res.body.items.length).toBe(15) + expect(res.body.items.length).toBe(16) }) it("should return listings with matching San Jose jurisdiction", async () => { @@ -462,6 +484,175 @@ describe("Listings", () => { expect(listingsSearchResponse.body.items.length).toBe(1) expect(listingsSearchResponse.body.items[0].name).toBe(newListingName) }) + describe("listings approval notification", () => { + let listing: ListingUpdateDto, adminId, alameda + beforeAll(async () => { + adminId = (await userRepository.find({ where: { email: "admin@example.com" } }))?.[0]?.id + alameda = (await jurisdictionsRepository.find({ where: { name: "Alameda" } }))[0] + const queryParams = { + limit: "all", + filter: [ + { + $comparison: "=", + name: "Test: Draft", + }, + ], + } + const query = qs.stringify(queryParams) + const res = await supertest(app.getHttpServer()).get(`/listings?${query}`).expect(200) + listing = { ...res.body.items[0] } + }) + it("update status to pending approval and notify appropriate users", async () => { + listing.status = ListingStatus.pendingReview + const putPendingApprovalResponse = await supertest(app.getHttpServer()) + .put(`/listings/${listing.id}`) + .send(listing) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + + const listingPendingApprovalResponse = await supertest(app.getHttpServer()) + .get(`/listings/${putPendingApprovalResponse.body.id}`) + .expect(200) + + expect(listingPendingApprovalResponse.body.status).toBe(ListingStatus.pendingReview) + expect(mockRequestApproval).toBeCalledWith( + expect.objectContaining({ + id: adminId, + }), + { id: listing.id, name: listing.name }, + expect.arrayContaining(["admin@example.com", "mfauser@bloom.com"]), + process.env.PARTNERS_PORTAL_URL + ) + //ensure juris admin is not included since don't have approver permissions in alameda seed + expect(mockRequestApproval.mock.calls[0]["emails"]).toEqual( + expect.not.arrayContaining(["alameda-admin@example.com"]) + ) + }) + it("update status to changes requested and notify appropriate users", async () => { + listing.status = ListingStatus.changesRequested + const putChangesRequestedResponse = await supertest(app.getHttpServer()) + .put(`/listings/${listing.id}`) + .send(listing) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + + const listingChangesRequestedResponse = await supertest(app.getHttpServer()) + .get(`/listings/${putChangesRequestedResponse.body.id}`) + .expect(200) + + expect(listingChangesRequestedResponse.body.status).toBe(ListingStatus.changesRequested) + expect(mockChangesRequested).toBeCalledWith( + expect.objectContaining({ + id: adminId, + }), + { id: listing.id, name: listing.name }, + expect.arrayContaining(["leasing-agent-2@example.com", "alameda-admin@example.com"]), + process.env.PARTNERS_PORTAL_URL + ) + }) + it("update status to listing approved and notify appropriate users", async () => { + listing.status = ListingStatus.active + const putApprovedResponse = await supertest(app.getHttpServer()) + .put(`/listings/${listing.id}`) + .send(listing) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + + const listingApprovedResponse = await supertest(app.getHttpServer()) + .get(`/listings/${putApprovedResponse.body.id}`) + .expect(200) + + expect(listingApprovedResponse.body.status).toBe(ListingStatus.active) + expect(mockListingApproved).toBeCalledWith( + expect.objectContaining({ + id: adminId, + }), + { id: listing.id, name: listing.name }, + expect.arrayContaining(["leasing-agent-2@example.com", "alameda-admin@example.com"]), + alameda.publicUrl + ) + }) + + it("should create pending review listing and notify appropriate users", async () => { + const newListingCreateDto = makeTestListing(alameda.id) + const newListingName = "New Alameda Listing" + newListingCreateDto.name = newListingName + newListingCreateDto.status = ListingStatus.pendingReview + newListingCreateDto.units = [ + { + listing: newListingName, + amiChart: null, + amiPercentage: "30", + annualIncomeMax: "45600", + annualIncomeMin: "36168", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 3, + minOccupancy: 1, + monthlyIncomeMin: "3014", + monthlyRent: "1219", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 0, + numBedrooms: 1, + number: null, + sqFeet: "635", + }, + ] + + const listingResponse = await supertest(app.getHttpServer()) + .post(`/listings`) + .send(newListingCreateDto) + .set(...setAuthorization(adminAccessToken)) + + expect(listingResponse.body.name).toBe(newListingName) + expect(listingResponse.body.status).toBe(ListingStatus.pendingReview) + expect(mockRequestApproval).toBeCalledWith( + expect.objectContaining({ + id: adminId, + }), + { id: listingResponse.body.id, name: listingResponse.body.name }, + expect.arrayContaining(["admin@example.com", "mfauser@bloom.com"]), + process.env.PARTNERS_PORTAL_URL + ) + }) + it("should email different users based on jurisdiction permissions", async () => { + alameda.listingApprovalPermissions = [ + EnumJurisdictionListingApprovalPermissions.admin, + EnumJurisdictionListingApprovalPermissions.jurisdictionAdmin, + ] + alameda.multiselectQuestions = [] + await supertest(app.getHttpServer()) + .put(`/jurisdictions/${alameda.id}`) + .send(alameda) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + + listing.status = ListingStatus.pendingReview + const putPendingApprovalResponse = await supertest(app.getHttpServer()) + .put(`/listings/${listing.id}`) + .send(listing) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + + const listingPendingApprovalResponse = await supertest(app.getHttpServer()) + .get(`/listings/${putPendingApprovalResponse.body.id}`) + .expect(200) + + expect(listingPendingApprovalResponse.body.status).toBe(ListingStatus.pendingReview) + expect(mockRequestApproval).toBeCalledWith( + expect.objectContaining({ + id: adminId, + }), + { id: listing.id, name: listing.name }, + expect.arrayContaining([ + "admin@example.com", + "mfauser@bloom.com", + "alameda-admin@example.com", + ]), + process.env.PARTNERS_PORTAL_URL + ) + }) + }) afterEach(() => { jest.clearAllMocks() diff --git a/backend/core/types/src/backend-swagger.ts b/backend/core/types/src/backend-swagger.ts index 07f99d597e..fbd056ac24 100644 --- a/backend/core/types/src/backend-swagger.ts +++ b/backend/core/types/src/backend-swagger.ts @@ -566,7 +566,7 @@ export class ApplicationsService { includeDemographics?: boolean } = {} as any, options: IRequestOptions = {} - ): Promise { + ): Promise { return new Promise((resolve, reject) => { let url = basePath + "/applications/csv" @@ -1462,30 +1462,6 @@ 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) }) @@ -3816,6 +3792,9 @@ export interface Jurisdiction { /** */ languages: EnumJurisdictionLanguages[] + /** */ + listingApprovalPermissions?: EnumJurisdictionListingApprovalPermissions[] + /** */ partnerTerms?: string @@ -4247,6 +4226,9 @@ export interface JurisdictionCreate { /** */ languages: EnumJurisdictionCreateLanguages[] + /** */ + listingApprovalPermissions?: EnumJurisdictionCreateListingApprovalPermissions[] + /** */ partnerTerms?: string @@ -4285,6 +4267,9 @@ export interface JurisdictionUpdate { /** */ languages: EnumJurisdictionUpdateLanguages[] + /** */ + listingApprovalPermissions?: EnumJurisdictionUpdateListingApprovalPermissions[] + /** */ partnerTerms?: string @@ -6276,6 +6261,12 @@ export enum EnumJurisdictionLanguages { "zh" = "zh", "tl" = "tl", } +export enum EnumJurisdictionListingApprovalPermissions { + "user" = "user", + "partner" = "partner", + "admin" = "admin", + "jurisdictionAdmin" = "jurisdictionAdmin", +} export type CombinedRolesTypes = UserRoles export enum EnumUserFilterParamsComparison { "=" = "=", @@ -6291,6 +6282,12 @@ export enum EnumJurisdictionCreateLanguages { "zh" = "zh", "tl" = "tl", } +export enum EnumJurisdictionCreateListingApprovalPermissions { + "user" = "user", + "partner" = "partner", + "admin" = "admin", + "jurisdictionAdmin" = "jurisdictionAdmin", +} export enum EnumJurisdictionUpdateLanguages { "en" = "en", "es" = "es", @@ -6298,6 +6295,12 @@ export enum EnumJurisdictionUpdateLanguages { "zh" = "zh", "tl" = "tl", } +export enum EnumJurisdictionUpdateListingApprovalPermissions { + "user" = "user", + "partner" = "partner", + "admin" = "admin", + "jurisdictionAdmin" = "jurisdictionAdmin", +} export enum EnumListingFilterParamsComparison { "=" = "=", "<>" = "<>", diff --git a/sites/partners/__tests__/components/listings/ListingFormActions.test.tsx b/sites/partners/__tests__/components/listings/ListingFormActions.test.tsx index 793a289e8d..116fd2c338 100644 --- a/sites/partners/__tests__/components/listings/ListingFormActions.test.tsx +++ b/sites/partners/__tests__/components/listings/ListingFormActions.test.tsx @@ -1,6 +1,12 @@ import React from "react" import { cleanup } from "@testing-library/react" -import { ListingStatus, User } from "@bloom-housing/backend-core" +import { + EnumJurisdictionLanguages, + EnumJurisdictionListingApprovalPermissions, + Jurisdiction, + ListingStatus, + User, +} from "@bloom-housing/backend-core" import { AuthContext } from "@bloom-housing/shared-helpers" import { listing } from "@bloom-housing/shared-helpers/__tests__/testHelpers" import { ListingContext } from "../../../src/components/listings/ListingContext" @@ -11,6 +17,35 @@ import { mockNextRouter, render } from "../../testUtils" afterEach(cleanup) +const mockBaseJurisdiction: Jurisdiction = { + id: "id", + createdAt: new Date(), + updatedAt: new Date(), + name: "San Jose", + multiselectQuestions: [], + languages: [EnumJurisdictionLanguages.en], + 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.", + enablePartnerSettings: true, + enableAccessibilityFeatures: false, + enableUtilitiesIncluded: true, +} + +const mockAdminOnlyJurisdiction: Jurisdiction = { + ...mockBaseJurisdiction, + listingApprovalPermissions: [EnumJurisdictionListingApprovalPermissions.admin], +} + +const mockAdminJurisAdminJurisdiction: Jurisdiction = { + ...mockBaseJurisdiction, + listingApprovalPermissions: [ + EnumJurisdictionListingApprovalPermissions.admin, + EnumJurisdictionListingApprovalPermissions.jurisdictionAdmin, + ], +} + const mockUser: User = { id: "123", email: "test@test.com", @@ -26,12 +61,17 @@ const mockUser: User = { agreedToTermsOfService: true, } -const adminUser: User = { +let adminUser: User = { ...mockUser, roles: { user: { id: "123" }, userId: "123", isAdmin: true }, } -const partnerUser: User = { +let jurisdictionAdminUser = { + ...mockUser, + roles: { user: { id: "123" }, userId: "123", isJurisdictionalAdmin: true }, +} + +let partnerUser: User = { ...mockUser, roles: { user: { id: "123" }, userId: "123", isPartner: true }, } @@ -42,11 +82,14 @@ describe("", () => { }) describe("with listings approval off", () => { + beforeAll(() => (adminUser = { ...adminUser, jurisdictions: [mockBaseJurisdiction] })) it("renders correct buttons in a new listing edit state", () => { const { getByText } = render( - - - + + + + + ) expect(getByText("Save as Draft")).toBeTruthy() expect(getByText("Publish")).toBeTruthy() @@ -55,9 +98,11 @@ describe("", () => { it("renders correct buttons in a draft detail state", () => { const { getByText } = render( - - - + + + + + ) expect(getByText("Edit")).toBeTruthy() expect(getByText("Preview")).toBeTruthy() @@ -65,9 +110,11 @@ describe("", () => { it("renders correct buttons in a draft edit state", () => { const { getByText } = render( - - - + + + + + ) expect(getByText("Save & Exit")).toBeTruthy() expect(getByText("Publish")).toBeTruthy() @@ -76,9 +123,11 @@ describe("", () => { it("renders correct buttons in an open detail state", () => { const { getByText } = render( - - - + + + + + ) expect(getByText("Edit")).toBeTruthy() expect(getByText("Preview")).toBeTruthy() @@ -86,9 +135,11 @@ describe("", () => { it("renders correct buttons in an open edit state", () => { const { getByText } = render( - - - + + + + + ) expect(getByText("Save & Exit")).toBeTruthy() expect(getByText("Close")).toBeTruthy() @@ -98,9 +149,11 @@ describe("", () => { it("renders correct buttons in a closed detail state", () => { const { getByText } = render( - - - + + + + + ) expect(getByText("Edit")).toBeTruthy() expect(getByText("Preview")).toBeTruthy() @@ -108,9 +161,11 @@ describe("", () => { it("renders correct buttons in a closed edit state", () => { const { getByText } = render( - - - + + + + + ) expect(getByText("Reopen")).toBeTruthy() expect(getByText("Save & Exit")).toBeTruthy() @@ -119,19 +174,13 @@ describe("", () => { }) }) - describe("with listings approval on", () => { - const OLD_ENV = process.env - + describe("with listings approval on for admin only", () => { beforeEach(() => { jest.resetModules() - process.env = { ...OLD_ENV, featureListingsApproval: "TRUE" } - }) - - afterAll(() => { - process.env = OLD_ENV }) describe("as an admin", () => { + beforeAll(() => (adminUser = { ...adminUser, jurisdictions: [mockAdminOnlyJurisdiction] })) it("renders correct buttons in a new listing edit state", () => { const { getByText } = render( @@ -275,8 +324,585 @@ describe("", () => { expect(getByText("Cancel")).toBeTruthy() }) }) + describe("as a jurisdictional admin", () => { + beforeAll( + () => + (jurisdictionAdminUser = { + ...jurisdictionAdminUser, + jurisdictions: [mockAdminOnlyJurisdiction], + }) + ) + it("renders correct buttons in a new listing edit state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Submit")).toBeTruthy() + expect(getByText("Save as Draft")).toBeTruthy() + expect(getByText("Cancel")).toBeTruthy() + }) + + it("renders correct buttons in a draft detail state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Edit")).toBeTruthy() + expect(getByText("Preview")).toBeTruthy() + }) + + it("renders correct buttons in a draft edit state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Submit")).toBeTruthy() + expect(getByText("Save & Exit")).toBeTruthy() + expect(getByText("Cancel")).toBeTruthy() + }) + + it("renders correct buttons in a pending approval detail state", () => { + const { getByText, queryByText } = render( + + + + + + ) + expect(getByText("Preview")).toBeTruthy() + expect(queryByText("Edit")).toBeFalsy() + }) + + it("renders correct buttons in a changes requested detail state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Edit")).toBeTruthy() + expect(getByText("Preview")).toBeTruthy() + }) + + it("renders correct buttons in a changes requested edit state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Submit")).toBeTruthy() + expect(getByText("Save & Exit")).toBeTruthy() + expect(getByText("Cancel")).toBeTruthy() + }) + + it("renders correct buttons in an open detail state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Edit")).toBeTruthy() + expect(getByText("Preview")).toBeTruthy() + }) + + it("renders correct buttons in an open edit state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Save & Exit")).toBeTruthy() + expect(getByText("Close")).toBeTruthy() + expect(getByText("Unpublish")).toBeTruthy() + expect(getByText("Cancel")).toBeTruthy() + }) + + it("renders correct buttons in a closed detail state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Edit")).toBeTruthy() + expect(getByText("Preview")).toBeTruthy() + }) + + it("renders correct buttons in a closed edit state", () => { + const { getByText, queryByText } = render( + + + + + + ) + expect(queryByText("Reopen")).toBeFalsy() + expect(getByText("Save & Exit")).toBeTruthy() + expect(getByText("Unpublish")).toBeTruthy() + expect(getByText("Post Results")).toBeTruthy() + expect(getByText("Cancel")).toBeTruthy() + }) + }) describe("as a partner", () => { + beforeAll( + () => (partnerUser = { ...partnerUser, jurisdictions: [mockAdminOnlyJurisdiction] }) + ) + it("renders correct buttons in a new listing edit state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Submit")).toBeTruthy() + expect(getByText("Save as Draft")).toBeTruthy() + expect(getByText("Cancel")).toBeTruthy() + }) + + it("renders correct buttons in a draft detail state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Edit")).toBeTruthy() + expect(getByText("Preview")).toBeTruthy() + }) + + it("renders correct buttons in a draft edit state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Submit")).toBeTruthy() + expect(getByText("Save & Exit")).toBeTruthy() + expect(getByText("Cancel")).toBeTruthy() + }) + + it("renders correct buttons in a pending approval detail state", () => { + const { getByText, queryByText } = render( + + + + + + ) + expect(getByText("Preview")).toBeTruthy() + expect(queryByText("Edit")).toBeFalsy() + }) + + it("renders correct buttons in a changes requested detail state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Edit")).toBeTruthy() + expect(getByText("Preview")).toBeTruthy() + }) + + it("renders correct buttons in a changes requested edit state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Submit")).toBeTruthy() + expect(getByText("Save & Exit")).toBeTruthy() + expect(getByText("Cancel")).toBeTruthy() + }) + + it("renders correct buttons in an open detail state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Edit")).toBeTruthy() + expect(getByText("Preview")).toBeTruthy() + }) + + it("renders correct buttons in an open edit state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Save & Exit")).toBeTruthy() + expect(getByText("Close")).toBeTruthy() + expect(getByText("Unpublish")).toBeTruthy() + expect(getByText("Cancel")).toBeTruthy() + }) + + it("renders correct buttons in a closed detail state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Edit")).toBeTruthy() + expect(getByText("Preview")).toBeTruthy() + }) + + it("renders correct buttons in a closed edit state", () => { + const { getByText, queryByText } = render( + + + + + + ) + expect(queryByText("Reopen")).toBeFalsy() + expect(getByText("Save & Exit")).toBeTruthy() + expect(getByText("Unpublish")).toBeTruthy() + expect(getByText("Post Results")).toBeTruthy() + expect(getByText("Cancel")).toBeTruthy() + }) + }) + }) + + describe("with listings approval on for admin and jurisdictional admin", () => { + beforeEach(() => { + jest.resetModules() + }) + + describe("as an admin", () => { + beforeAll( + () => (adminUser = { ...adminUser, jurisdictions: [mockAdminJurisAdminJurisdiction] }) + ) + it("renders correct buttons in a new listing edit state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Publish")).toBeTruthy() + expect(getByText("Save as Draft")).toBeTruthy() + expect(getByText("Cancel")).toBeTruthy() + }) + + it("renders correct buttons in a draft detail state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Edit")).toBeTruthy() + expect(getByText("Preview")).toBeTruthy() + }) + + it("renders correct buttons in a draft edit state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Publish")).toBeTruthy() + expect(getByText("Save & Exit")).toBeTruthy() + expect(getByText("Cancel")).toBeTruthy() + }) + + it("renders correct buttons in a pending approval detail state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Approve & Publish")).toBeTruthy() + expect(getByText("Edit")).toBeTruthy() + expect(getByText("Preview")).toBeTruthy() + }) + + it("renders correct buttons in a pending approval edit state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Approve & Publish")).toBeTruthy() + expect(getByText("Save & Exit")).toBeTruthy() + expect(getByText("Request Changes")).toBeTruthy() + expect(getByText("Cancel")).toBeTruthy() + }) + + it("renders correct buttons in a changes requested detail state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Approve & Publish")).toBeTruthy() + expect(getByText("Edit")).toBeTruthy() + expect(getByText("Preview")).toBeTruthy() + }) + + it("renders correct buttons in a changes requested edit state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Approve & Publish")).toBeTruthy() + expect(getByText("Save & Exit")).toBeTruthy() + expect(getByText("Cancel")).toBeTruthy() + }) + it("renders correct buttons in an open detail state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Edit")).toBeTruthy() + expect(getByText("Preview")).toBeTruthy() + }) + + it("renders correct buttons in an open edit state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Save & Exit")).toBeTruthy() + expect(getByText("Close")).toBeTruthy() + expect(getByText("Unpublish")).toBeTruthy() + expect(getByText("Cancel")).toBeTruthy() + }) + + it("renders correct buttons in a closed detail state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Edit")).toBeTruthy() + expect(getByText("Preview")).toBeTruthy() + }) + + it("renders correct buttons in a closed edit state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Reopen")).toBeTruthy() + expect(getByText("Save & Exit")).toBeTruthy() + expect(getByText("Unpublish")).toBeTruthy() + expect(getByText("Post Results")).toBeTruthy() + expect(getByText("Cancel")).toBeTruthy() + }) + }) + describe("as a jurisdictional admin", () => { + beforeAll( + () => + (jurisdictionAdminUser = { + ...jurisdictionAdminUser, + jurisdictions: [mockAdminJurisAdminJurisdiction], + }) + ) + it("renders correct buttons in a new listing edit state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Publish")).toBeTruthy() + expect(getByText("Save as Draft")).toBeTruthy() + expect(getByText("Cancel")).toBeTruthy() + }) + + it("renders correct buttons in a draft detail state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Edit")).toBeTruthy() + expect(getByText("Preview")).toBeTruthy() + }) + + it("renders correct buttons in a draft edit state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Publish")).toBeTruthy() + expect(getByText("Save & Exit")).toBeTruthy() + expect(getByText("Cancel")).toBeTruthy() + }) + + it("renders correct buttons in a pending approval detail state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Approve & Publish")).toBeTruthy() + expect(getByText("Edit")).toBeTruthy() + expect(getByText("Preview")).toBeTruthy() + }) + + it("renders correct buttons in a pending approval edit state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Approve & Publish")).toBeTruthy() + expect(getByText("Save & Exit")).toBeTruthy() + expect(getByText("Request Changes")).toBeTruthy() + expect(getByText("Cancel")).toBeTruthy() + }) + + it("renders correct buttons in a changes requested detail state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Approve & Publish")).toBeTruthy() + expect(getByText("Edit")).toBeTruthy() + expect(getByText("Preview")).toBeTruthy() + }) + + it("renders correct buttons in a changes requested edit state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Approve & Publish")).toBeTruthy() + expect(getByText("Save & Exit")).toBeTruthy() + expect(getByText("Cancel")).toBeTruthy() + }) + it("renders correct buttons in an open detail state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Edit")).toBeTruthy() + expect(getByText("Preview")).toBeTruthy() + }) + + it("renders correct buttons in an open edit state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Save & Exit")).toBeTruthy() + expect(getByText("Close")).toBeTruthy() + expect(getByText("Unpublish")).toBeTruthy() + expect(getByText("Cancel")).toBeTruthy() + }) + + it("renders correct buttons in a closed detail state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Edit")).toBeTruthy() + expect(getByText("Preview")).toBeTruthy() + }) + + it("renders correct buttons in a closed edit state", () => { + const { getByText } = render( + + + + + + ) + expect(getByText("Reopen")).toBeTruthy() + expect(getByText("Save & Exit")).toBeTruthy() + expect(getByText("Unpublish")).toBeTruthy() + expect(getByText("Post Results")).toBeTruthy() + expect(getByText("Cancel")).toBeTruthy() + }) + }) + + describe("as a partner", () => { + beforeAll( + () => (partnerUser = { ...partnerUser, jurisdictions: [mockAdminOnlyJurisdiction] }) + ) it("renders correct buttons in a new listing edit state", () => { const { getByText } = render( diff --git a/sites/partners/package.json b/sites/partners/package.json index 29d1b0ffb6..64e2fbe4f5 100644 --- a/sites/partners/package.json +++ b/sites/partners/package.json @@ -37,7 +37,6 @@ "axios-cookiejar-support": "4.0.6", "dayjs": "^1.10.7", "dotenv": "^8.2.0", - "electron": "^18.3.7", "http-cookie-agent": "5.0.2", "nanoid": "^3.1.12", "next": "^13.2.4", diff --git a/sites/partners/src/components/listings/ListingFormActions.tsx b/sites/partners/src/components/listings/ListingFormActions.tsx index d005d8747c..f8f3ff9980 100644 --- a/sites/partners/src/components/listings/ListingFormActions.tsx +++ b/sites/partners/src/components/listings/ListingFormActions.tsx @@ -15,7 +15,11 @@ import { } from "@bloom-housing/ui-components" import { pdfUrlFromListingEvents, AuthContext } from "@bloom-housing/shared-helpers" import { ListingContext } from "./ListingContext" -import { ListingEventType, ListingStatus } from "@bloom-housing/backend-core/types" +import { + EnumJurisdictionListingApprovalPermissions, + ListingEventType, + ListingStatus, +} from "@bloom-housing/backend-core/types" import { StatusAside } from "../shared/StatusAside" export enum ListingFormActionsType { @@ -45,7 +49,18 @@ const ListingFormActions = ({ const { profile, listingsService } = useContext(AuthContext) const router = useRouter() - const isListingApprover = profile?.roles?.isAdmin + // single jurisdiction check covers jurisAdmin adding a listing (listing is undefined then) + const listingApprovalPermissions = (profile?.jurisdictions?.length === 1 + ? profile?.jurisdictions[0] + : profile?.jurisdictions?.find((juris) => juris.id === listing?.jurisdiction?.id) + )?.listingApprovalPermissions + + const isListingApprover = + profile?.roles.isAdmin || + (profile?.roles.isJurisdictionalAdmin && + listingApprovalPermissions?.includes( + EnumJurisdictionListingApprovalPermissions.jurisdictionAdmin + )) const listingId = listing?.id @@ -234,9 +249,8 @@ const ListingFormActions = ({ 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({ + const result = await listingsService.update({ id: listing.id, body: { ...listing, status: ListingStatus.active }, }) @@ -444,12 +458,11 @@ const ListingFormActions = ({ return elements } - return process.env.featureListingsApproval === "TRUE" - ? getApprovalActions() - : getDefaultActions() + return listingApprovalPermissions?.length > 0 ? getApprovalActions() : getDefaultActions() }, [ isListingApprover, listing, + listingApprovalPermissions?.length, listingId, listingsService, router, diff --git a/sites/partners/src/components/listings/PaperListingForm/index.tsx b/sites/partners/src/components/listings/PaperListingForm/index.tsx index 3808fd2e7a..40cbd17dfe 100644 --- a/sites/partners/src/components/listings/PaperListingForm/index.tsx +++ b/sites/partners/src/components/listings/PaperListingForm/index.tsx @@ -179,17 +179,10 @@ const ListingForm = ({ listing, editMode }: ListingFormProps) => { const formattedData = await dataPipeline.run() 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 }, - }) - } + result = await listingsService.update({ + id: listing.id, + body: { id: listing.id, ...formattedData }, + }) } else { result = await listingsService.create({ body: formattedData }) } diff --git a/sites/partners/src/lib/hooks.ts b/sites/partners/src/lib/hooks.ts index 827cbde61b..c0b8da6008 100644 --- a/sites/partners/src/lib/hooks.ts +++ b/sites/partners/src/lib/hooks.ts @@ -499,13 +499,41 @@ export const createDateStringFromNow = (format = "YYYY-MM-DD_HH:mm:ss"): string } export const useApplicationsExport = (listingId: string, includeDemographics: boolean) => { - const { applicationsService } = useContext(AuthContext) + const { applicationsService, profile } = useContext(AuthContext) const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone.replace("/", "-") - return useCsvExport( - () => applicationsService.listAsCsv({ listingId, timeZone, includeDemographics }), - `applications-${listingId}-${createDateStringFromNow()}.csv` - ) + const [csvExportLoading, setCsvExportLoading] = useState(false) + const [csvExportError, setCsvExportError] = useState(false) + const [csvExportSuccess, setCsvExportSuccess] = useState(false) + + const onExport = useCallback(async () => { + setCsvExportError(false) + setCsvExportSuccess(false) + setCsvExportLoading(true) + + try { + await applicationsService.listAsCsv({ listingId, timeZone, includeDemographics }) + setCsvExportSuccess(true) + setSiteAlertMessage( + t("t.emailingExportSuccess", { + email: profile?.email, + }), + "success" + ) + } catch (err) { + console.log(err) + setCsvExportError(true) + } + + setCsvExportLoading(false) + }, [applicationsService, includeDemographics, listingId, profile?.email, timeZone]) + + return { + onExport, + csvExportLoading, + csvExportError, + csvExportSuccess, + } } export const useUsersExport = () => { diff --git a/sites/partners/src/page_content/locale_overrides/general.json b/sites/partners/src/page_content/locale_overrides/general.json index 7f96cb773a..d9f6b8ed7e 100644 --- a/sites/partners/src/page_content/locale_overrides/general.json +++ b/sites/partners/src/page_content/locale_overrides/general.json @@ -372,6 +372,7 @@ "t.descriptionTitle": "Description", "t.done": "Done", "t.draft": "Draft", + "t.emailingExportSuccess": "An email containing the exported file has been sent to %{email}", "t.end": "End", "t.endTime": "End Time", "t.enterAmount": "Enter amount", diff --git a/sites/partners/src/pages/listings/[id]/applications/index.tsx b/sites/partners/src/pages/listings/[id]/applications/index.tsx index 5c5beec0d5..76a49be0d7 100644 --- a/sites/partners/src/pages/listings/[id]/applications/index.tsx +++ b/sites/partners/src/pages/listings/[id]/applications/index.tsx @@ -109,10 +109,9 @@ const ApplicationsList = () => { {t("nav.siteTitlePartners")} - {csvExportSuccess && } + {csvExportSuccess && } {csvExportError && (