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 9befb772e0..df3a27af91 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" @@ -21,7 +21,9 @@ import { ApplicationFlaggedSetsService } from "../application-flagged-sets/appli import { ListingsQueryBuilder } from "./db/listing-query-builder" import { CachePurgeService } from "./cache-purge.service" import { EmailService } from "../email/email.service" +import { JurisdictionsService } from "../jurisdictions/services/jurisdictions.service" import { ConfigService } from "@nestjs/config" +import { UserRoleEnum } from "../../src/auth/enum/user-role-enum" @Injectable({ scope: Scope.REQUEST }) export class ListingsService { @@ -35,6 +37,7 @@ export class ListingsService { private readonly afsService: ApplicationFlaggedSetsService, private readonly cachePurgeService: CachePurgeService, private readonly emailService: EmailService, + private readonly jurisdictionsService: JurisdictionsService, private readonly configService: ConfigService ) {} @@ -98,7 +101,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, @@ -109,8 +112,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) { @@ -168,6 +186,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 } @@ -216,120 +249,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 efd9b27ca2..af03385711 100644 --- a/backend/core/src/listings/tests/listings.service.spec.ts +++ b/backend/core/src/listings/tests/listings.service.spec.ts @@ -19,6 +19,9 @@ import { HttpService } from "@nestjs/axios" import { CachePurgeService } from "../cache-purge.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 */ @@ -89,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" }), @@ -105,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(), @@ -128,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" @@ -175,14 +189,21 @@ describe("ListingsService", () => { { provide: EmailService, useValue: { - requestApproval: jest.fn(), + requestApproval: requestApprovalMock, + changesRequested: changesRequestedMock, + listingApproved: listingApprovedMock, + }, + }, + { + provide: JurisdictionsService, + useValue: { + findOne: jest.fn(), }, }, { provide: ConfigService, useValue: { get: jest.fn() }, }, - { provide: getRepositoryToken(User), useValue: jest.fn() }, ], }).compile() @@ -445,4 +466,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/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/test/listings/listings.e2e-spec.ts b/backend/core/test/listings/listings.e2e-spec.ts index 2524da7bf3..7b453d9f8e 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 () => { @@ -450,6 +472,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..acfeabf646 100644 --- a/backend/core/types/src/backend-swagger.ts +++ b/backend/core/types/src/backend-swagger.ts @@ -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/.env.template b/sites/partners/.env.template index 12f3c58f18..2d9c370afd 100644 --- a/sites/partners/.env.template +++ b/sites/partners/.env.template @@ -9,5 +9,4 @@ CLOUDINARY_CLOUD_NAME=exygy CLOUDINARY_KEY='abcxyz' CLOUDINARY_SIGNED_PRESET='test123' MAPBOX_TOKEN= -FEATURE_LISTINGS_APPROVAL=FALSE SHOW_SMS_MFA=TRUE 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/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 }) }