From 11f2716b66a9a38130d087f09dc2bcc0b78f6575 Mon Sep 17 00:00:00 2001 From: Morgan Ludtke Date: Tue, 10 Oct 2023 14:11:00 -0500 Subject: [PATCH 1/3] fix: jurisdiction permission array approach (#3644) --- .../entities/jurisdiction.entity.ts | 9 + .../core/src/listings/listings.controller.ts | 19 +- backend/core/src/listings/listings.service.ts | 210 +++--- .../listings/tests/listings.service.spec.ts | 106 ++- ...0645798-listings-approval-permissioning.ts | 21 + backend/core/src/seeder/seed.ts | 2 + backend/core/src/seeder/seeder.module.ts | 2 + .../core/src/seeder/seeds/jurisdictions.ts | 5 + .../seeds/listings/listing-default-draft.ts | 13 + .../core/test/listings/listings.e2e-spec.ts | 197 ++++- backend/core/types/src/backend-swagger.ts | 51 +- .../listings/ListingFormActions.test.tsx | 690 +++++++++++++++++- .../listings/ListingFormActions.tsx | 27 +- .../listings/PaperListingForm/index.tsx | 15 +- 14 files changed, 1182 insertions(+), 185 deletions(-) create mode 100644 backend/core/src/migration/1694130645798-listings-approval-permissioning.ts create mode 100644 backend/core/src/seeder/seeds/listings/listing-default-draft.ts 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/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 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..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/__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 }) } From 16eb043ea185eb0d85805fe27440a669ee9d0cd0 Mon Sep 17 00:00:00 2001 From: Morgan Ludtke <42942267+ludtkemorgan@users.noreply.github.com> Date: Wed, 27 Sep 2023 14:11:29 -0500 Subject: [PATCH 2/3] fix: remove electron (#3655) --- sites/partners/package.json | 1 - yarn.lock | 253 ++---------------------------------- 2 files changed, 10 insertions(+), 244 deletions(-) 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/yarn.lock b/yarn.lock index 884c103c98..0a26b5b134 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1848,22 +1848,6 @@ debug "^3.1.0" lodash.once "^4.1.1" -"@electron/get@^1.13.0": - version "1.14.1" - resolved "https://registry.npmjs.org/@electron/get/-/get-1.14.1.tgz" - integrity sha512-BrZYyL/6m0ZXz/lDxy/nlVhQz+WF+iPS6qXolEU8atw7h6v1aYkjwJZ63m+bJMBTxDE66X+r2tPS4a/8C82sZw== - dependencies: - debug "^4.1.1" - env-paths "^2.2.0" - fs-extra "^8.1.0" - got "^9.6.0" - progress "^2.0.3" - semver "^6.2.0" - sumchecker "^3.0.1" - optionalDependencies: - global-agent "^3.0.0" - global-tunnel-ng "^2.7.1" - "@eslint/eslintrc@^0.1.3": version "0.1.3" resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.1.3.tgz" @@ -4200,11 +4184,6 @@ resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.25.24.tgz#8c7688559979f7079aacaf31aa881c3aa410b718" integrity sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ== -"@sindresorhus/is@^0.14.0": - version "0.14.0" - resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz" - integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== - "@sindresorhus/is@^4.0.0": version "4.6.0" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f" @@ -4250,13 +4229,6 @@ dependencies: tslib "^2.4.0" -"@szmarczak/http-timer@^1.1.2": - version "1.1.2" - resolved "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz" - integrity sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA== - dependencies: - defer-to-connect "^1.0.1" - "@szmarczak/http-timer@^4.0.5": version "4.0.6" resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-4.0.6.tgz#b4a914bb62e7c272d4e5989fe4440f812ab1d807" @@ -4737,11 +4709,6 @@ resolved "https://registry.npmjs.org/@types/node/-/node-14.18.10.tgz" integrity sha512-6iihJ/Pp5fsFJ/aEDGyvT4pHGmCpq7ToQ/yf4bl5SbVAvwpspYJ+v3jO7n8UyjhQVHTy+KNszOozDdv+O6sovQ== -"@types/node@^16.11.26": - version "16.18.3" - resolved "https://registry.npmjs.org/@types/node/-/node-16.18.3.tgz" - integrity sha512-jh6m0QUhIRcZpNv7Z/rpN+ZWXOicUUQbSoWks7Htkbb9IjFQj4kzcX/xFCkjstCj5flMsN8FiSvt+q+Tcs4Llg== - "@types/node@^16.7.13": version "16.18.23" resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.23.tgz#b6e934fe427eb7081d0015aad070acb3373c3c90" @@ -6296,11 +6263,6 @@ body-parser@1.20.2: type-is "~1.6.18" unpipe "1.0.0" -boolean@^3.0.1: - version "3.2.0" - resolved "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz" - integrity sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw== - brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -6529,19 +6491,6 @@ cacheable-lookup@^5.0.3: resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz#5a6b865b2c44357be3d5ebc2a467b032719a7005" integrity sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA== -cacheable-request@^6.0.0: - version "6.1.0" - resolved "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz" - integrity sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg== - dependencies: - clone-response "^1.0.2" - get-stream "^5.1.0" - http-cache-semantics "^4.0.0" - keyv "^3.0.0" - lowercase-keys "^2.0.0" - normalize-url "^4.1.0" - responselike "^1.0.2" - cacheable-request@^7.0.2: version "7.0.4" resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-7.0.4.tgz#7a33ebf08613178b403635be7b899d3e69bbe817" @@ -7154,7 +7103,7 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== -concat-stream@^1.5.2, concat-stream@^1.6.2: +concat-stream@^1.5.2: version "1.6.2" resolved "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz" integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== @@ -7189,7 +7138,7 @@ concurrently@^5.3.0: tree-kill "^1.2.2" yargs "^13.3.0" -config-chain@^1.1.11, config-chain@^1.1.12: +config-chain@^1.1.12: version "1.1.13" resolved "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz" integrity sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ== @@ -7729,13 +7678,6 @@ decode-uri-component@^0.2.0: resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9" integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ== -decompress-response@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3" - integrity sha512-BzRPQuY1ip+qDonAOz42gRm/pg9F768C+npV/4JOsxRC2sq+Rlk+Q4ZCAsOhnIaMrgarILY+RMUIvMmmX1qAEA== - dependencies: - mimic-response "^1.0.0" - decompress-response@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" @@ -7805,11 +7747,6 @@ defaults@^1.0.3: dependencies: clone "^1.0.2" -defer-to-connect@^1.0.1: - version "1.1.3" - resolved "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz" - integrity sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ== - defer-to-connect@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587" @@ -7940,11 +7877,6 @@ detect-node-es@^1.1.0: resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493" integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ== -detect-node@^2.0.4: - version "2.1.0" - resolved "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz" - integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== - detective@^5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/detective/-/detective-5.2.1.tgz#6af01eeda11015acb0e73f933242b70f24f91034" @@ -8080,11 +8012,6 @@ dotenv@^8.2.0: resolved "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz" integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw== -duplexer3@^0.1.4: - version "0.1.5" - resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.5.tgz#0b5e4d7bad5de8901ea4440624c8e1d20099217e" - integrity sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA== - duplexer@^0.1.1, duplexer@^0.1.2: version "0.1.2" resolved "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz" @@ -8140,15 +8067,6 @@ electron-to-chromium@^1.4.71: resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.73.tgz" integrity sha512-RlCffXkE/LliqfA5m29+dVDPB2r72y2D2egMMfIy3Le8ODrxjuZNVo4NIC2yPL01N4xb4nZQLwzi6Z5tGIGLnA== -electron@^18.3.7: - version "18.3.15" - resolved "https://registry.npmjs.org/electron/-/electron-18.3.15.tgz" - integrity sha512-frkBt8skyo8SmlG4TbByDxZw6/tqttRYYIBaeTBfkoG18OyD59IVwVaXXHO8UYKB5/1C2Rce0Gj6uoxlAHQHzQ== - dependencies: - "@electron/get" "^1.13.0" - "@types/node" "^16.11.26" - extract-zip "^1.0.3" - element-addclass@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/element-addclass/-/element-addclass-1.0.2.tgz#e91585ada601aa1f7409ef6eacee809fab91d0d3" @@ -8189,7 +8107,7 @@ emojis-list@^3.0.0: resolved "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz" integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== -encodeurl@^1.0.2, encodeurl@~1.0.2: +encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz" integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= @@ -8452,7 +8370,7 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" -es6-error@^4.0.1, es6-error@^4.1.1: +es6-error@^4.0.1: version "4.1.1" resolved "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz" integrity sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg== @@ -8477,11 +8395,6 @@ escape-string-regexp@^2.0.0: resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz" integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== -escape-string-regexp@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz" - integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== - escodegen@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz" @@ -9008,16 +8921,6 @@ extract-zip@2.0.1: optionalDependencies: "@types/yauzl" "^2.9.1" -extract-zip@^1.0.3: - version "1.7.0" - resolved "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz" - integrity sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA== - dependencies: - concat-stream "^1.6.2" - debug "^2.6.9" - mkdirp "^0.5.4" - yauzl "^2.10.0" - extsprintf@1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz" @@ -9432,7 +9335,7 @@ fs-constants@^1.0.0: resolved "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz" integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== -fs-extra@8.1.0, fs-extra@^8.1.0: +fs-extra@8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz" integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== @@ -9638,7 +9541,7 @@ get-stdin@^6.0.0: resolved "https://registry.npmjs.org/get-stdin/-/get-stdin-6.0.0.tgz" integrity sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g== -get-stream@^4.0.0, get-stream@^4.1.0: +get-stream@^4.0.0: version "4.1.0" resolved "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz" integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== @@ -9826,18 +9729,6 @@ glob@^9.2.0: minipass "^4.2.4" path-scurry "^1.6.1" -global-agent@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz" - integrity sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q== - dependencies: - boolean "^3.0.1" - es6-error "^4.1.1" - matcher "^3.0.0" - roarr "^2.15.3" - semver "^7.3.2" - serialize-error "^7.0.1" - global-dirs@^0.1.1: version "0.1.1" resolved "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz" @@ -9872,16 +9763,6 @@ global-prefix@^1.0.1: is-windows "^1.0.1" which "^1.2.14" -global-tunnel-ng@^2.7.1: - version "2.7.1" - resolved "https://registry.npmjs.org/global-tunnel-ng/-/global-tunnel-ng-2.7.1.tgz" - integrity sha512-4s+DyciWBV0eK148wqXxcmVAbFVPqtc3sEtUE/GTQfuU80rySLcMhUmHKSHI7/LDj8q0gDYI1lIhRRB7ieRAqg== - dependencies: - encodeurl "^1.0.2" - lodash "^4.17.10" - npm-conf "^1.1.3" - tunnel "^0.0.6" - globals@^11.1.0: version "11.12.0" resolved "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz" @@ -9894,7 +9775,7 @@ globals@^12.1.0: dependencies: type-fest "^0.8.1" -globalthis@^1.0.1, globalthis@^1.0.3: +globalthis@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz" integrity sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA== @@ -9999,23 +9880,6 @@ got@^11.8.5: p-cancelable "^2.0.0" responselike "^2.0.0" -got@^9.6.0: - version "9.6.0" - resolved "https://registry.npmjs.org/got/-/got-9.6.0.tgz" - integrity sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q== - dependencies: - "@sindresorhus/is" "^0.14.0" - "@szmarczak/http-timer" "^1.1.2" - cacheable-request "^6.0.0" - decompress-response "^3.3.0" - duplexer3 "^0.1.4" - get-stream "^4.1.0" - lowercase-keys "^1.0.1" - mimic-response "^1.0.1" - p-cancelable "^1.0.0" - to-readable-stream "^1.0.0" - url-parse-lax "^3.0.0" - graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2: version "4.2.4" resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz" @@ -12234,11 +12098,6 @@ json-bigint@^1.0.0: dependencies: bignumber.js "^9.0.0" -json-buffer@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" - integrity sha512-CuUqjv0FUZIdXkHPI8MezCnFCdaTAacej1TZYulLoAg1h/PhwkdXFN4V/gzY4g+fMBCOV2xF+rp7t2XD2ns/NQ== - json-buffer@3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" @@ -12455,13 +12314,6 @@ kdbush@^4.0.1, kdbush@^4.0.2: resolved "https://registry.yarnpkg.com/kdbush/-/kdbush-4.0.2.tgz#2f7b7246328b4657dd122b6c7f025fbc2c868e39" integrity sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA== -keyv@^3.0.0: - version "3.1.0" - resolved "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz" - integrity sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA== - dependencies: - json-buffer "3.0.0" - keyv@^4.0.0: version "4.5.2" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.2.tgz#0e310ce73bf7851ec702f2eaf46ec4e3805cce56" @@ -12886,7 +12738,7 @@ lodash.topath@^4.5.2: resolved "https://registry.yarnpkg.com/lodash.topath/-/lodash.topath-4.5.2.tgz#3616351f3bba61994a0931989660bd03254fd009" integrity sha512-1/W4dM+35DwvE/iEd1M9ekewOSTlpFekhw9mhAtrwjVqUr83/ilQiyAvmg4tVX7Unkcfl1KC+i9WdaT4B6aQcg== -lodash@4.17.21, lodash@4.x, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0: +lodash@4.17.21, lodash@4.x, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0: version "4.17.21" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -12933,11 +12785,6 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: dependencies: js-tokens "^3.0.0 || ^4.0.0" -lowercase-keys@^1.0.0, lowercase-keys@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz" - integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA== - lowercase-keys@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" @@ -13141,13 +12988,6 @@ markdown-to-jsx@^6.11.4: prop-types "^15.6.2" unquote "^1.1.0" -matcher@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz" - integrity sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng== - dependencies: - escape-string-regexp "^4.0.0" - md5@^2.2.1: version "2.3.0" resolved "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz" @@ -13330,7 +13170,7 @@ mimic-fn@^2.1.0: resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== -mimic-response@^1.0.0, mimic-response@^1.0.1: +mimic-response@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz" integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== @@ -14012,11 +13852,6 @@ normalize-range@^0.1.2: resolved "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz" integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI= -normalize-url@^4.1.0: - version "4.5.1" - resolved "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz" - integrity sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA== - normalize-url@^6.0.1, normalize-url@^6.1.0: version "6.1.0" resolved "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz" @@ -14029,14 +13864,6 @@ npm-bundled@^1.1.1: dependencies: npm-normalize-package-bin "^1.0.1" -npm-conf@^1.1.3: - version "1.1.3" - resolved "https://registry.npmjs.org/npm-conf/-/npm-conf-1.1.3.tgz" - integrity sha512-Yic4bZHJOt9RCFbRP3GgpqhScOY4HH3V2P8yBj6CeYq118Qr+BLXqT2JvpJ00mryLESpgOxf5XlFv4ZjXxLScw== - dependencies: - config-chain "^1.1.11" - pify "^3.0.0" - npm-install-checks@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-4.0.0.tgz" @@ -14481,11 +14308,6 @@ outvariant@^1.2.1, outvariant@^1.3.0: resolved "https://registry.npmjs.org/outvariant/-/outvariant-1.3.0.tgz" integrity sha512-yeWM9k6UPfG/nzxdaPlJkB2p08hCg4xP6Lx99F+vP8YF7xyZVfTmJjrrNalkmzudD4WFvNLVudQikqUmF8zhVQ== -p-cancelable@^1.0.0: - version "1.1.0" - resolved "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz" - integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw== - p-cancelable@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf" @@ -15140,11 +14962,6 @@ prelude-ls@~1.1.2: resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz" integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w== -prepend-http@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" - integrity sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA== - prettier-linter-helpers@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz" @@ -16207,13 +16024,6 @@ resolve@^1.10.0, resolve@^1.20.0: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -responselike@^1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz" - integrity sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec= - dependencies: - lowercase-keys "^1.0.0" - responselike@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/responselike/-/responselike-2.0.1.tgz#9a0bc8fdc252f3fb1cca68b016591059ba1422bc" @@ -16293,18 +16103,6 @@ rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" -roarr@^2.15.3: - version "2.15.4" - resolved "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz" - integrity sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A== - dependencies: - boolean "^3.0.1" - detect-node "^2.0.4" - globalthis "^1.0.1" - json-stringify-safe "^5.0.1" - semver-compare "^1.0.0" - sprintf-js "^1.1.2" - rollup@2.78.0: version "2.78.0" resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.78.0.tgz#00995deae70c0f712ea79ad904d5f6b033209d9e" @@ -16542,7 +16340,7 @@ semver@7.x, semver@^7.1.1, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^ dependencies: lru-cache "^6.0.0" -semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0: +semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== @@ -16585,13 +16383,6 @@ send@0.18.0: range-parser "~1.2.1" statuses "2.0.1" -serialize-error@^7.0.1: - version "7.0.1" - resolved "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz" - integrity sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw== - dependencies: - type-fest "^0.13.1" - serialize-javascript@^6.0.0: version "6.0.0" resolved "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz" @@ -17033,11 +16824,6 @@ split@^1.0.0: dependencies: through "2" -sprintf-js@^1.1.2: - version "1.1.2" - resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz" - integrity sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug== - sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz" @@ -17413,13 +17199,6 @@ styled-jsx@5.1.1: dependencies: client-only "0.0.1" -sumchecker@^3.0.1: - version "3.0.1" - resolved "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz" - integrity sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg== - dependencies: - debug "^4.1.0" - superagent@^8.0.0: version "8.0.0" resolved "https://registry.npmjs.org/superagent/-/superagent-8.0.0.tgz" @@ -17862,11 +17641,6 @@ to-object-path@^0.3.0: dependencies: kind-of "^3.0.2" -to-readable-stream@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz" - integrity sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q== - to-regex-range@^2.1.0: version "2.1.1" resolved "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz" @@ -18479,13 +18253,6 @@ urix@^0.1.0: resolved "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz" integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= -url-parse-lax@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-3.0.0.tgz#16b5cafc07dbe3676c1b1999177823d6503acb0c" - integrity sha512-NjFKA0DidqPa5ciFcSrXnAltTtzz84ogy+NebPvfEgAck0+TNg4UJ4IN+fB7zRZfbgUf0syOo9MDxFkDSMuFaQ== - dependencies: - prepend-http "^2.0.0" - url-parse@^1.5.3, url-parse@^1.5.9: version "1.5.10" resolved "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz" From 3e90572bfb089e2ced213d61b062105b84296c75 Mon Sep 17 00:00:00 2001 From: Yazeed Loonat Date: Fri, 6 Oct 2023 14:33:25 -0700 Subject: [PATCH 3/3] feat: application export now emailed (#3661) --- .../applications/applications.controller.ts | 18 ++------ .../services/applications.service.ts | 27 ++++++++++- backend/core/src/email/email.service.ts | 46 ++++++++++++++++++- .../1696353656895-csv-export-translations.ts | 33 +++++++++++++ backend/core/src/shared/views/csv-export.hbs | 30 ++++++++++++ .../applications/applications.e2e-spec.ts | 5 +- backend/core/types/src/backend-swagger.ts | 2 +- sites/partners/src/lib/hooks.ts | 38 +++++++++++++-- .../locale_overrides/general.json | 1 + .../listings/[id]/applications/index.tsx | 3 +- 10 files changed, 175 insertions(+), 28 deletions(-) create mode 100644 backend/core/src/migration/1696353656895-csv-export-translations.ts create mode 100644 backend/core/src/shared/views/csv-export.hbs 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/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/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/types/src/backend-swagger.ts b/backend/core/types/src/backend-swagger.ts index acfeabf646..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" 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 && (