From 62920f75d7a75b3815ce779304990d8d328c37f6 Mon Sep 17 00:00:00 2001 From: ColinBuyck <53269332+ColinBuyck@users.noreply.github.com> Date: Wed, 10 May 2023 10:59:19 -0700 Subject: [PATCH] feat: 3291/listing export take 2 (#3424) * fix: functional frontend * fix: hooks and service updates * fix: hitting service file * fix: wip config work * fix: wip config 2 * fix: completed query updates * fix: complete column naming and basic formatting * fix: clean up formatting * fix: wip testing debugging * fix: functional unit tests * fix: cypress tests + formatting * fix: unadded test changes * fix: internal csv testing * fix: exporter test fix * fix: more detailed csv checks * fix: testing + formatting tweaks * fix: exporter testing improvements * fix: updates per pr feedback * fix: match config pattern * fix: add close status option * fix: reset netlify testing * fix: final cleanup * fix: rent type formatting * fix: remove console log --- backend/core/package.json | 1 + .../listings/dto/listings-zip-query-params.ts | 15 ++ backend/core/src/listings/helpers.ts | 87 ++++++++ .../listings/listings-csv-exporter.service.ts | 189 ++++++++++++++++++ .../core/src/listings/listings.controller.ts | 27 ++- backend/core/src/listings/listings.module.ts | 4 +- backend/core/src/listings/listings.service.ts | 89 ++++++++- .../listings-csv-exporter.service.spec.ts | 50 +++++ .../listings/tests/listings.service.spec.ts | 2 + backend/core/src/listings/views/config.ts | 138 +++++++++++++ backend/core/src/listings/views/types.ts | 2 + backend/core/src/listings/views/view.ts | 17 ++ .../src/shared/utils/format-local-date.ts | 21 ++ .../core/test/lib/format-local-date.spec.ts | 19 ++ backend/core/types/src/backend-swagger.ts | 21 ++ shared-helpers/__tests__/testHelpers.ts | 72 +++---- .../__tests__/pages/listings/index.test.tsx | 161 +++++++++++++++ sites/partners/cypress/e2e/03-listing.spec.ts | 16 ++ sites/partners/src/lib/hooks.ts | 43 ++++ sites/partners/src/pages/index.tsx | 59 +++++- yarn.lock | 32 +++ 21 files changed, 1016 insertions(+), 49 deletions(-) create mode 100644 backend/core/src/listings/dto/listings-zip-query-params.ts create mode 100644 backend/core/src/listings/helpers.ts create mode 100644 backend/core/src/listings/listings-csv-exporter.service.ts create mode 100644 backend/core/src/listings/tests/listings-csv-exporter.service.spec.ts create mode 100644 backend/core/src/shared/utils/format-local-date.ts create mode 100644 backend/core/test/lib/format-local-date.spec.ts create mode 100644 sites/partners/__tests__/pages/listings/index.test.tsx diff --git a/backend/core/package.json b/backend/core/package.json index bb9f11973b..53c861b23c 100644 --- a/backend/core/package.json +++ b/backend/core/package.json @@ -67,6 +67,7 @@ "handlebars": "^4.7.6", "joi": "^17.3.0", "jwt-simple": "^0.5.6", + "jszip": "^3.10.1", "lodash": "^4.17.21", "nanoid": "^3.1.12", "nestjs-twilio": "^2.1.0", diff --git a/backend/core/src/listings/dto/listings-zip-query-params.ts b/backend/core/src/listings/dto/listings-zip-query-params.ts new file mode 100644 index 0000000000..4d25b17c8b --- /dev/null +++ b/backend/core/src/listings/dto/listings-zip-query-params.ts @@ -0,0 +1,15 @@ +import { Expose } from "class-transformer" +import { ApiProperty } from "@nestjs/swagger" +import { IsOptional, IsString } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +export class ListingsZipQueryParams { + @Expose() + @ApiProperty({ + type: String, + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + timeZone?: string +} diff --git a/backend/core/src/listings/helpers.ts b/backend/core/src/listings/helpers.ts new file mode 100644 index 0000000000..ab69b84594 --- /dev/null +++ b/backend/core/src/listings/helpers.ts @@ -0,0 +1,87 @@ +import { PaperApplication } from "../../src/paper-applications/entities/paper-application.entity" +import { isEmpty } from "../shared/utils/is-empty" +import { formatLocalDate } from "../shared/utils/format-local-date" + +export const cloudinaryPdfFromId = (publicId: string): string => { + if (isEmpty(publicId)) return "" + const cloudName = process.env.cloudinaryCloudName || process.env.CLOUDINARY_CLOUD_NAME + return `https://res.cloudinary.com/${cloudName}/image/upload/${publicId}.pdf` +} + +export const getPaperAppUrls = (paperApps: PaperApplication[]) => { + if (isEmpty(paperApps)) return "" + const urlArr = paperApps.map((paperApplication) => + cloudinaryPdfFromId(paperApplication.file?.fileId) + ) + const formattedResults = urlArr.join(", ") + return formattedResults +} + +export const formatYesNo = (value: boolean | null): string => { + if (value === null || typeof value == "undefined") return "" + else if (value) return "Yes" + else return "No" +} + +export const formatStatus = { + active: "Public", + closed: "Closed", + pending: "Draft", +} + +export const formatUnitType = { + SRO: "SRO", + studio: "Studio", + oneBdrm: "1 BR", + twoBdrm: "2 BR", + threeBdrm: "3 BR", + fourBdrm: "4 BR", + fiveBdrm: "5 BR", +} + +export const formatCommunityType = { + senior55: "Seniors 55+", + senior62: "Seniors 62+", + specialNeeds: "Special Needs", +} + +export const formatCurrency = (value: string): string => { + return value ? `$${value}` : "" +} + +export const convertToTitleCase = (value: string): string => { + if (isEmpty(value)) return "" + const spacedValue = value.replace(/([A-Z])/g, (match) => ` ${match}`) + const result = spacedValue.charAt(0).toUpperCase() + spacedValue.slice(1) + return result +} + +export const formatOpenHouse = (openHouseArr: any[], tz: string): string => { + const openHouseFormatted = [] + openHouseArr.forEach((openHouse) => { + let openHouseStr = "" + if (openHouse.label) openHouseStr += `${openHouse.label}` + if (openHouse.startTime) { + const date = formatLocalDate(openHouse.startTime, "MM-DD-YYYY", tz) + openHouseStr += `: ${date}` + if (openHouse.endTime) { + const startTime = formatLocalDate(openHouse.startTime, "hh:mmA", tz) + const endTime = formatLocalDate(openHouse.endTime, "hh:mmA z", tz) + openHouseStr += ` (${startTime} - ${endTime})` + } + } + if (openHouseStr !== "") openHouseFormatted.push(openHouseStr) + }) + return openHouseFormatted.join(", ") +} + +export const hideZero = (fieldValue: number | string) => { + if (isEmpty(fieldValue) || fieldValue === 0 || fieldValue === "0") return "" + return fieldValue +} + +export const formatRentType = (monthlyRentAsPercentOfIncome: string, monthlyRent: string) => { + if (!isEmpty(monthlyRentAsPercentOfIncome)) return "% of income" + if (!isEmpty(monthlyRent)) return "Fixed amount" + return "" +} diff --git a/backend/core/src/listings/listings-csv-exporter.service.ts b/backend/core/src/listings/listings-csv-exporter.service.ts new file mode 100644 index 0000000000..7a9dd5a1ed --- /dev/null +++ b/backend/core/src/listings/listings-csv-exporter.service.ts @@ -0,0 +1,189 @@ +import { Injectable, Scope } from "@nestjs/common" +import { CsvBuilder } from "../applications/services/csv-builder.service" +import { + cloudinaryPdfFromId, + formatCurrency, + formatStatus, + formatYesNo, + convertToTitleCase, + getPaperAppUrls, + formatUnitType, + formatCommunityType, + formatOpenHouse, + hideZero, + formatRentType, +} from "./helpers" +import { formatLocalDate } from "../shared/utils/format-local-date" +import { ListingReviewOrder } from "./types/listing-review-order-enum" +@Injectable({ scope: Scope.REQUEST }) +export class ListingsCsvExporterService { + constructor(private readonly csvBuilder: CsvBuilder) {} + + exportListingsFromObject(listings: any[], users: any[], timeZone: string): string { + // restructure user information to listingId->user rather than user->listingId + const partnerAccessHelper = {} + users.forEach((user) => { + const userName = `${user.firstName} ${user.lastName}` + user.leasingAgentInListings.forEach((listing) => { + partnerAccessHelper[listing.id] + ? partnerAccessHelper[listing.id].push(userName) + : (partnerAccessHelper[listing.id] = [userName]) + }) + }) + const listingObj = listings.map((listing) => { + const programsFormatted = [] + const preferencesFormatted = [] + listing.listingMultiselectQuestions?.forEach((question) => { + const questionInfo = question.multiselectQuestion + if (questionInfo?.applicationSection === "preferences") + preferencesFormatted.push(questionInfo?.text) + else if (questionInfo?.applicationSection === "programs") + programsFormatted.push(questionInfo?.text) + }) + + const openHouse = [] + let lottery + listing.events?.forEach((event) => { + if (event.type === "openHouse") { + openHouse.push(event) + } else if (event.type === "publicLottery") { + lottery = event + } + }) + + return { + ID: listing.id, + "Created At Date": formatLocalDate(listing.createdAt, "MM-DD-YYYY hh:mm:ssA z", timeZone), + Jurisdiction: listing.jurisdiction.name, + "Listing Name": listing.name, + "Listing Status": formatStatus[listing.status], + "Publish Date": formatLocalDate(listing.publishedAt, "MM-DD-YYYY hh:mm:ssA z", timeZone), + "Last Updated": formatLocalDate(listing.updatedAt, "MM-DD-YYYY hh:mm:ssA z", timeZone), + Developer: listing.developer, + "Building Street Address": listing.buildingAddress?.street, + "Building City": listing.buildingAddress?.city, + "Building State": listing.buildingAddress?.state, + "Building Zip": listing.buildingAddress?.zipCode, + "Building Neighborhood": listing?.neighborhood, + "Building Year Built": listing.yearBuilt, + "Reserved Community Types": formatCommunityType[listing.reservedCommunityType?.name], + Latitude: listing.buildingAddress?.latitude, + Longitude: listing.buildingAddress?.longitude, + "Number of units": listing.numberOfUnits, + "Listing Availability": + listing?.reviewOrderType === ListingReviewOrder.waitlist + ? "Open Waitlist" + : "Available Units", + "Important Program Rules": listing.programRules, + "Review Order": convertToTitleCase(listing.reviewOrderType), + "Lottery Date": formatLocalDate(lottery?.startTime, "MM-DD-YYYY", timeZone), + "Lottery Start": formatLocalDate(lottery?.startTime, "hh:mmA z", timeZone), + "Lottery End": formatLocalDate(lottery?.endTime, "hh:mmA z", timeZone), + "Lottery Notes": listing.events[0]?.note, + "Housing Preferences": preferencesFormatted.join(", "), + "Housing Programs": programsFormatted.join(", "), + "Application Fee": formatCurrency(listing.applicationFee), + "Deposit Helper Text": listing.depositHelperText, + "Deposit Min": formatCurrency(listing.depositMin), + "Deposit Max": formatCurrency(listing.depositMax), + "Costs Not Included": listing.costsNotIncluded, + "Property Amenities": listing.amenities, + "Additional Accessibility": listing.accessibility, + "Unit Amenities": listing.unitAmenities, + "Smoking Policy": listing.smokingPolicy, + "Pets Policy": listing.petPolicy, + "Services Offered": listing.servicesOffered, + "Eligibility Rules - Credit History": listing.creditHistory, + "Eligibility Rules - Rental History": listing.rentalHistory, + "Eligibility Rules - Criminal Background": listing.criminalBackground, + "Eligibility Rules - Rental Assistance": listing.rentalAssistance, + "Building Selection Criteria": + listing.buildingSelectionCriteria ?? + cloudinaryPdfFromId(listing.buildingSelectionCriteriaFile?.fileId), + "Required Documents": listing.requiredDocuments, + "Special Notes": listing.specialNotes, + Waitlist: formatYesNo(listing.isWaitlistOpen), + "Max Waitlist Size": listing.waitlistMaxSize, + "Leasing Agent Name": listing.leasingAgentName, + "Leasing Agent Email": listing.leasingAgentEmail, + "Leasing Agent Phone": listing.leasingAgentPhone, + "Leasing Agent Title": listing.leasingAgentTitle, + "Leasing Agent Office Hours": listing.leasingAgentOfficeHours, + "Leasing Agent Street Address": listing.leasingAgentAddress?.street, + "Leasing Agent Apt/Unit #": listing.leasingAgentAddress?.street2, + "Leasing Agent City": listing.leasingAgentAddress?.city, + "Leasing Agent Zip": listing.leasingAgentAddress?.zipCode, + "Leasing Agency Mailing Address": listing.applicationMailingAddress?.street, + "Leasing Agency Mailing Address Street 2": listing.applicationMailingAddress?.street2, + "Leasing Agency Mailing Address City": listing.applicationMailingAddress?.city, + "Leasing Agency Mailing Address Zip": listing.applicationMailingAddress?.zipCode, + "Leasing Agency Pickup Address": listing.applicationPickUpAddress?.street, + "Leasing Agency Pickup Address Street 2": listing.applicationPickUpAddress?.street2, + "Leasing Agency Pickup Address City": listing.applicationPickUpAddress?.city, + "Leasing Agency Pickup Address Zip": listing.applicationPickUpAddress?.zipCode, + "Leasing Pick Up Office Hours": listing.applicationPickUpAddressOfficeHours, + "Digital Application": formatYesNo(listing.digitalApplication), + "Digital Application URL": listing.applicationMethods[1]?.externalReference, + "Paper Application": formatYesNo(listing.paperApplication), + "Paper Application URL": getPaperAppUrls(listing.applicationMethods[0]?.paperApplications), + "Referral opportunity?": formatYesNo(listing.referralOpportunity), + "Can applications be mailed in?": formatYesNo( + listing.applicationMailingAddress || listing.applicationMailingAddressType + ), + "Can applications be picked up?": formatYesNo( + listing.applicationPickUpAddress || listing?.applicationPickUpAddressType + ), + "Can applications be dropped off?": formatYesNo( + listing.applicationPickUpAddress || listing?.applicationPickUpAddressType + ), + Postmark: formatLocalDate( + listing.postmarkedApplicationsReceivedByDate, + "MM-DD-YYYY hh:mm:ssA z", + timeZone + ), + "Additional Application Submission Notes": listing.additionalApplicationSubmissionNotes, + "Application Due Date": formatLocalDate(listing.applicationDueDate, "MM-DD-YYYY", timeZone), + "Application Due Time": formatLocalDate(listing.applicationDueDate, "hh:mmA z", timeZone), + "Open House": formatOpenHouse(openHouse, timeZone), + "Partners Who Have Access": partnerAccessHelper[listing.id]?.join(", "), + } + }) + return this.csvBuilder.buildFromIdIndex(listingObj) + } + + exportUnitsFromObject(listings: any[]): string { + const reformattedListings = [] + listings.forEach((listing) => { + listing.units?.forEach((unit) => { + reformattedListings.push({ + id: listing.id, + name: listing.name, + unit, + }) + }) + }) + const unitsFormatted = reformattedListings.map((listing) => { + return { + "Listing ID": listing.id, + "Listing Name": listing.name, + "Unit Number": listing.unit?.number, + "Unit Type": formatUnitType[listing.unit?.unitType?.name], + "Number of Bathrooms": listing.unit?.numBathrooms, + "Unit Floor": hideZero(listing.unit?.floor), + "Square footage": listing.unit?.sqFeet, + "Minimum Occupancy": hideZero(listing.unit?.minOccupancy), + "Max Occupancy": hideZero(listing.unit?.maxOccupancy), + "AMI Chart": listing.unit?.amiChart?.name, + "AMI Level": listing.unit?.amiChart?.items[0]?.percentOfAmi, + "Rent Type": formatRentType( + listing.unit?.monthlyRentAsPercentOfIncome, + listing.unit?.monthlyRent + ), + "Monthly Rent": listing.unit?.monthlyRentAsPercentOfIncome ?? listing.unit?.monthlyRent, + "Minimum Income": listing.unit?.monthlyIncomeMin, + "Accessibility Priority Type": listing.unit?.priorityType?.name, + } + }) + return this.csvBuilder.buildFromIdIndex(unitsFormatted) + } +} diff --git a/backend/core/src/listings/listings.controller.ts b/backend/core/src/listings/listings.controller.ts index 0437c36574..366b3e50f8 100644 --- a/backend/core/src/listings/listings.controller.ts +++ b/backend/core/src/listings/listings.controller.ts @@ -14,6 +14,7 @@ import { ClassSerializerInterceptor, Headers, ParseUUIDPipe, + Header, } from "@nestjs/common" import { ListingsService } from "./listings.service" import { ApiBearerAuth, ApiExtraModels, ApiOperation, ApiTags } from "@nestjs/swagger" @@ -35,6 +36,9 @@ import { ActivityLogInterceptor } from "../activity-log/interceptors/activity-lo import { ActivityLogMetadata } from "../activity-log/decorators/activity-log-metadata.decorator" import { ListingsApiExtraModels } from "./types/listings-api-extra-models" import { IdDto } from "../shared/dto/id.dto" +import { AuthzGuard } from "../../src/auth/guards/authz.guard" +import { ListingsCsvExporterService } from "./listings-csv-exporter.service" +import { ListingsZipQueryParams } from "./dto/listings-zip-query-params" @Controller("listings") @ApiTags("listings") @@ -45,7 +49,10 @@ import { IdDto } from "../shared/dto/id.dto" @ActivityLogMetadata([{ targetPropertyName: "status", propertyPath: "status" }]) @UseInterceptors(ActivityLogInterceptor) export class ListingsController { - constructor(private readonly listingsService: ListingsService) {} + constructor( + private readonly listingsService: ListingsService, + private readonly listingsCsvExporter: ListingsCsvExporterService + ) {} // TODO: Limit requests to defined fields @Get() @@ -65,6 +72,24 @@ export class ListingsController { return mapTo(ListingDto, listing) } + @Get(`csv`) + @UseGuards(OptionalAuthGuard, AuthzGuard) + @ApiOperation({ summary: "Retrieve listings and units in csv", operationId: "listAsCsv" }) + @Header("Content-Type", "text/csv") + async listAsCsv( + @Query(new ValidationPipe(defaultValidationPipeOptions)) + queryParams: ListingsZipQueryParams + ): Promise<{ listingCsv: string; unitCsv: string }> { + const data = await this.listingsService.rawListWithFlagged() + const listingCsv = this.listingsCsvExporter.exportListingsFromObject( + data?.listingData, + data?.userAccessData, + queryParams.timeZone + ) + const unitCsv = this.listingsCsvExporter.exportUnitsFromObject(data?.unitData) + return { listingCsv, unitCsv } + } + @Get(`:id`) @ApiOperation({ summary: "Get listing by id", operationId: "retrieve" }) @UseInterceptors(ClassSerializerInterceptor) diff --git a/backend/core/src/listings/listings.module.ts b/backend/core/src/listings/listings.module.ts index 60975cac6c..ed3f418bef 100644 --- a/backend/core/src/listings/listings.module.ts +++ b/backend/core/src/listings/listings.module.ts @@ -16,6 +16,8 @@ import { ListingRepository } from "./db/listing.repository" import { ListingUtilities } from "./entities/listing-utilities.entity" import { ApplicationFlaggedSetsModule } from "../application-flagged-sets/application-flagged-sets.module" import { ListingsCronService } from "./listings-cron.service" +import { ListingsCsvExporterService } from "./listings-csv-exporter.service" +import { CsvBuilder } from "../../src/applications/services/csv-builder.service" @Module({ imports: [ @@ -35,7 +37,7 @@ import { ListingsCronService } from "./listings-cron.service" ApplicationFlaggedSetsModule, HttpModule, ], - providers: [ListingsService, ListingsCronService, Logger], + providers: [ListingsService, ListingsCronService, Logger, CsvBuilder, ListingsCsvExporterService], exports: [ListingsService], controllers: [ListingsController], }) diff --git a/backend/core/src/listings/listings.service.ts b/backend/core/src/listings/listings.service.ts index b493c44a63..14c0401ff7 100644 --- a/backend/core/src/listings/listings.service.ts +++ b/backend/core/src/listings/listings.service.ts @@ -1,8 +1,8 @@ -import { Inject, Injectable, NotFoundException, Scope } from "@nestjs/common" +import { Inject, Injectable, NotFoundException, Scope, UnauthorizedException } from "@nestjs/common" import { HttpService } from "@nestjs/axios" import { InjectRepository } from "@nestjs/typeorm" import { Pagination } from "nestjs-typeorm-paginate" -import { In, Repository } from "typeorm" +import { Brackets, In, Repository } from "typeorm" import { Listing } from "./entities/listing.entity" import { getView } from "./views/view" import { summarizeUnits, summarizeUnitsByTypeAndRent } from "../shared/units-transformations" @@ -30,6 +30,7 @@ export class ListingsService { @InjectRepository(AmiChart) private readonly amiChartsRepository: Repository, private readonly translationService: TranslationsService, private readonly authzService: AuthzService, + @InjectRepository(User) private readonly userRepository: Repository, @Inject(REQUEST) private req: ExpressRequest, private readonly afsService: ApplicationFlaggedSetsService, private readonly httpService: HttpService @@ -189,6 +190,90 @@ export class ListingsService { return result } + async rawListWithFlagged() { + const userAccess = await this.userRepository + .createQueryBuilder("user") + .select(["user.id", "jurisdictions.id"]) + .leftJoin("user.roles", "userRole") + .leftJoin("user.jurisdictions", "jurisdictions") + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + .where("user.id = :id", { id: (this.req.user as User)?.id }) + .andWhere( + new Brackets((qb) => { + qb.where("userRole.is_admin = :is_admin", { + is_admin: true, + }).orWhere("userRole.is_jurisdictional_admin = :is_jurisdictional_admin", { + is_jurisdictional_admin: true, + }) + }) + ) + .getOne() + + if (!userAccess) { + throw new UnauthorizedException() + } + + // generated out list of permissioned listings + const permissionedListings = await this.listingRepository + .createQueryBuilder("listings") + .select("listings.id") + .where("listings.jurisdiction_id IN (:...jurisdiction)", { + jurisdiction: userAccess.jurisdictions.map((elem) => elem.id), + }) + .getMany() + + // pulled out on the ids + const listingIds = permissionedListings.map((listing) => listing.id) + + // Building and excecuting query for listings csv + const listingsQb = getView( + this.listingRepository.createQueryBuilder("listings"), + "listingsExport" + ).getViewQb() + + const listingData = await listingsQb + .where("listings.id IN (:...listingIds)", { listingIds }) + .getMany() + + // User data to determine listing access for csv + const userAccessData = await this.userRepository + .createQueryBuilder("user") + .select([ + "user.id", + "user.firstName", + "user.lastName", + "userRoles.isAdmin", + "userRoles.isPartner", + "leasingAgentInListings.id", + ]) + .leftJoin("user.leasingAgentInListings", "leasingAgentInListings") + .leftJoin("user.jurisdictions", "jurisdictions") + .leftJoin("user.roles", "userRoles") + .where("userRoles.is_partner = :is_partner", { is_partner: true }) + .getMany() + + // Building and excecuting query for units csv + const unitsQb = getView( + this.listingRepository.createQueryBuilder("listings"), + "unitsExport" + ).getViewQb() + + const unitData = await unitsQb + .where("listings.id IN (:...listingIds)", { listingIds }) + .getMany() + + listingData.forEach((listing) => { + const unitQuantity = unitData.find((unit) => unit.id === listing.id)?.units?.length + listing["numberOfUnits"] = unitQuantity + }) + + return { + unitData, + listingData, + userAccessData, + } + } + private async addUnitsSummarized(listing: Listing) { if (Array.isArray(listing.units) && listing.units.length > 0) { const amiCharts = await this.amiChartsRepository.find({ diff --git a/backend/core/src/listings/tests/listings-csv-exporter.service.spec.ts b/backend/core/src/listings/tests/listings-csv-exporter.service.spec.ts new file mode 100644 index 0000000000..a2ff7af8ab --- /dev/null +++ b/backend/core/src/listings/tests/listings-csv-exporter.service.spec.ts @@ -0,0 +1,50 @@ +import { ListingsCsvExporterService } from "../listings-csv-exporter.service" +import { Test, TestingModule } from "@nestjs/testing" +import { listing, user } from "@bloom-housing/shared-helpers/__tests__/testHelpers" +import { CsvBuilder } from "../../../src/applications/services/csv-builder.service" + +const mockListings = [listing] +const mockUsers = [user] +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect +let service: ListingsCsvExporterService + +describe("ListingsCSVExporterService", () => { + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ListingsCsvExporterService, CsvBuilder], + }).compile() + + service = await module.resolve(ListingsCsvExporterService) + }) + afterEach(() => { + jest.clearAllMocks() + }) + it("should be defined", () => { + expect(service).toBeDefined() + }) + it("should correctly format listings into a csv format", () => { + const listingsCSV = service.exportListingsFromObject( + mockListings, + mockUsers, + "America/Los_Angeles" + ) + expect(listingsCSV).toMatch("First Come First Serve") + expect(listingsCSV).toMatch("2012") + expect(listingsCSV).toMatch( + "No pets allowed. Accommodation animals may be granted to persons with disabilities via a reasonable accommodation request." + ) + expect(listingsCSV).toMatch("Available Units") + expect(listingsCSV).toMatch("Marisela Baca") + }) + it("should correctly format units into a csv format", () => { + const unitsCSV = service.exportUnitsFromObject(mockListings) + expect(unitsCSV).toMatch("1104.0") + expect(unitsCSV).toMatch("285") + expect(unitsCSV).toMatch("Fixed amount") + expect(unitsCSV).toMatch("Archer Studios") + expect(unitsCSV).toMatch("Studio") + }) +}) diff --git a/backend/core/src/listings/tests/listings.service.spec.ts b/backend/core/src/listings/tests/listings.service.spec.ts index 34dfbf2131..4e3eb069f2 100644 --- a/backend/core/src/listings/tests/listings.service.spec.ts +++ b/backend/core/src/listings/tests/listings.service.spec.ts @@ -15,6 +15,7 @@ import { ListingRepository } from "../db/listing.repository" import { ListingsQueryBuilder } from "../db/listing-query-builder" import { UserRepository } from "../../auth/repositories/user-repository" import { HttpService } from "@nestjs/axios" +import { User } from "../../../src/auth/entities/user.entity" /* eslint-disable @typescript-eslint/unbound-method */ @@ -162,6 +163,7 @@ describe("ListingsService", () => { provide: TranslationsService, useValue: { translateListing: jest.fn() }, }, + { provide: getRepositoryToken(User), useValue: jest.fn() }, ], }).compile() diff --git a/backend/core/src/listings/views/config.ts b/backend/core/src/listings/views/config.ts index bb91d8ecca..56f74c9775 100644 --- a/backend/core/src/listings/views/config.ts +++ b/backend/core/src/listings/views/config.ts @@ -225,5 +225,143 @@ views.full = { ["listings.utilities", "listing_utilities"], ], } +views.listingsExport = { + select: [ + "listings.id", + "listings.createdAt", + "jurisdiction.id", + "jurisdiction.name", + "listings.name", + "listings.status", + "listings.publishedAt", + "listings.updatedAt", + "listings.developer", + ...getBaseAddressSelect(["buildingAddress"]), + "listings.neighborhood", + "listings.yearBuilt", + "reservedCommunityType.id", + "reservedCommunityType.name", + "buildingAddress.latitude", + "buildingAddress.longitude", + "listings.reviewOrderType", + "listings.programRules", + "listingEvents.startTime", + "listingEvents.endTime", + "listingEvents.note", + "listingMultiselectQuestions.ordinal", + "listingMultiselectQuestionsMultiselectQuestion.id", + "listingMultiselectQuestionsMultiselectQuestion.text", + "listingMultiselectQuestionsMultiselectQuestion.applicationSection", + "listings.applicationFee", + "listings.depositHelperText", + "listings.depositMin", + "listings.depositMax", + "listings.costsNotIncluded", + "listings.amenities", + "listings.accessibility", + "listings.unitAmenities", + "listings.smokingPolicy", + "listings.petPolicy", + "listings.servicesOffered", + "listings.creditHistory", + "listings.rentalHistory", + "listings.criminalBackground", + "listings.rentalAssistance", + "listings.buildingSelectionCriteria", + "buildingSelectionCriteriaFile.id", + "buildingSelectionCriteriaFile.fileId", + "buildingSelectionCriteriaFile.label", + "listings.requiredDocuments", + "listings.programRules", + "listings.specialNotes", + "listings.isWaitlistOpen", + "listings.waitlistMaxSize", + "listings.leasingAgentName", + "listings.leasingAgentEmail", + "listings.leasingAgentOfficeHours", + "listings.leasingAgentPhone", + "listings.leasingAgentTitle", + ...getBaseAddressSelect([ + "leasingAgentAddress", + "applicationPickUpAddress", + "applicationMailingAddress", + "applicationDropOffAddress", + ]), + "listings.applicationPickUpAddressOfficeHours", + "listings.digitalApplication", + "applicationMethods.id", + "applicationMethods.externalReference", + "listings.paperApplication", + "paperApplications.id", + "paperApplicationFile.id", + "paperApplicationFile.fileId", + "listings.referralOpportunity", + "listings.applicationMailingAddressType", + "listings.applicationMailingAddress", + "listings.applicationPickUpAddressType", + "listings.applicationPickUpAddress", + "listings.applicationDropOffAddressType", + "listings.applicationDropOffAddress", + "listings.postmarkedApplicationsReceivedByDate", + "listings.additionalApplicationSubmissionNotes", + "listings.applicationDueDate", + "listingEvents.id", + "listingEvents.type", + "listingEvents.startTime", + "listingEvents.endTime", + "listingEvents.url", + "listingEvents.note", + "listingEvents.label", + ], + leftJoins: [ + { join: "listings.jurisdiction", alias: "jurisdiction" }, + { join: "listings.buildingSelectionCriteriaFile", alias: "buildingSelectionCriteriaFile" }, + { join: "listings.reservedCommunityType", alias: "reservedCommunityType" }, + { join: "listings.buildingAddress", alias: "buildingAddress" }, + { join: "listings.utilities", alias: "utilities" }, + { join: "listings.events", alias: "listingEvents" }, + { join: "listings.features", alias: "features" }, + { join: "listings.leasingAgentAddress", alias: "leasingAgentAddress" }, + { join: "listings.applicationPickUpAddress", alias: "applicationPickUpAddress" }, + { join: "listings.applicationMailingAddress", alias: "applicationMailingAddress" }, + { join: "listings.applicationDropOffAddress", alias: "applicationDropOffAddress" }, + { join: "listings.applicationMethods", alias: "applicationMethods" }, + { join: "applicationMethods.paperApplications", alias: "paperApplications" }, + { join: "paperApplications.file", alias: "paperApplicationFile" }, + { join: "listings.listingMultiselectQuestions", alias: "listingMultiselectQuestions" }, + { + join: "listingMultiselectQuestions.multiselectQuestion", + alias: "listingMultiselectQuestionsMultiselectQuestion", + }, + ], +} + +views.unitsExport = { + select: [ + "listings.id", + "listings.name", + "units.id", + "units.number", + "unitType.name", + "units.numBathrooms", + "units.floor", + "units.sqFeet", + "units.minOccupancy", + "units.maxOccupancy", + "units.monthlyIncomeMin", + "amiChart.id", + "amiChart.items", + "amiChart.name", + "units.monthlyRent", + "units.monthlyRentAsPercentOfIncome", + "accessibilityType.name", + ], + leftJoins: [ + { join: "listings.units", alias: "units" }, + { join: "units.unitType", alias: "unitType" }, + { join: "units.amiChart", alias: "amiChart" }, + { join: "units.priorityType", alias: "accessibilityType" }, + ], +} export { views } diff --git a/backend/core/src/listings/views/types.ts b/backend/core/src/listings/views/types.ts index ca7c33f559..bd5123a6a5 100644 --- a/backend/core/src/listings/views/types.ts +++ b/backend/core/src/listings/views/types.ts @@ -5,6 +5,8 @@ export enum ListingViewEnum { detail = "detail", full = "full", partnerList = "partnerList", + listingsExport = "listingsExport", + unitsExport = "unitsExport", } export type Views = { diff --git a/backend/core/src/listings/views/view.ts b/backend/core/src/listings/views/view.ts index 85e6a1868f..c2215ee798 100644 --- a/backend/core/src/listings/views/view.ts +++ b/backend/core/src/listings/views/view.ts @@ -9,6 +9,10 @@ export function getView(qb: ListingsQueryBuilder, view?: string) { return new BaseListingView(qb) case views.detail: return new DetailView(qb) + case views.listingsExport: + return new ListingsExportView(qb) + case views.unitsExport: + return new UnitsExportView(qb) case views.full: default: return new FullView(qb) @@ -49,6 +53,19 @@ export class DetailView extends BaseListingView { this.view = views.detail } } +export class ListingsExportView extends BaseListingView { + constructor(qb: ListingsQueryBuilder) { + super(qb) + this.view = views.listingsExport + } +} + +export class UnitsExportView extends BaseListingView { + constructor(qb: ListingsQueryBuilder) { + super(qb) + this.view = views.unitsExport + } +} export class FullView extends BaseListingView { constructor(qb: ListingsQueryBuilder) { diff --git a/backend/core/src/shared/utils/format-local-date.ts b/backend/core/src/shared/utils/format-local-date.ts new file mode 100644 index 0000000000..453c3179e1 --- /dev/null +++ b/backend/core/src/shared/utils/format-local-date.ts @@ -0,0 +1,21 @@ +import dayjs from "dayjs" +import utc from "dayjs/plugin/utc" +import tz from "dayjs/plugin/timezone" +import advanced from "dayjs/plugin/advancedFormat" +import { isEmpty } from "./is-empty" +dayjs.extend(utc) +dayjs.extend(tz) +dayjs.extend(advanced) + +export const formatLocalDate = ( + rawDate: string | Date, + format: string, + timeZone?: string +): string => { + if (!isEmpty(rawDate)) { + const utcDate = dayjs.utc(rawDate) + if (!isEmpty(timeZone)) return utcDate.tz(timeZone.replace("-", "/")).format(format) + return utcDate.format(format) + } + return "" +} diff --git a/backend/core/test/lib/format-local-date.spec.ts b/backend/core/test/lib/format-local-date.spec.ts new file mode 100644 index 0000000000..9146baf6f6 --- /dev/null +++ b/backend/core/test/lib/format-local-date.spec.ts @@ -0,0 +1,19 @@ +import { formatLocalDate } from "../../src/shared/utils/format-local-date" + +declare const expect: jest.Expect + +describe("formatLocalDate", () => { + test("with an empty date string", () => { + expect(formatLocalDate("", "MM-DD-YYYY hh:mm:ssA z", "America/Detroit")).toEqual("") + }) + test("with a format and no timezone", () => { + expect(formatLocalDate("2023-04-01T17:00:00.000Z", "MM-DD-YYYY hh:mm:ssA")).toEqual( + "04-01-2023 05:00:00PM" + ) + }) + test("with a format and timezone", () => { + expect( + formatLocalDate("2023-04-01T17:00:00.000Z", "MM-DD-YYYY hh:mm:ssA z", "America/Detroit") + ).toEqual("04-01-2023 01:00:00PM EDT") + }) +}) diff --git a/backend/core/types/src/backend-swagger.ts b/backend/core/types/src/backend-swagger.ts index de47c2e26c..a1ced6d1c6 100644 --- a/backend/core/types/src/backend-swagger.ts +++ b/backend/core/types/src/backend-swagger.ts @@ -1373,6 +1373,27 @@ export class ListingsService { axios(configs, resolve, reject) }) } + /** + * Retrieve listings and units in csv + */ + listAsCsv( + params: { + /** */ + timeZone?: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/listings/csv" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + configs.params = { timeZone: params["timeZone"] } + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } /** * Get listing by id */ diff --git a/shared-helpers/__tests__/testHelpers.ts b/shared-helpers/__tests__/testHelpers.ts index 1dc8587eec..fff64c6489 100644 --- a/shared-helpers/__tests__/testHelpers.ts +++ b/shared-helpers/__tests__/testHelpers.ts @@ -50,13 +50,13 @@ export const user = { email: "first.last@bloom.com", failedLoginAttemptsCount: 0, firstName: "First", - hitConfirmationURL: null, + hitConfirmationURL: undefined, id: "user_1", jurisdictions: [ { id: "e50e64bc-4bc8-4cef-a4d1-1812add9981b" }, { id: "d6b652a0-9947-418a-b69b-cd72028ed913" }, ], - language: null, + language: undefined, lastLoginAt: new Date(), lastName: "Last", leasingAgentInListings: [], @@ -64,7 +64,7 @@ export const user = { middleName: "Middle", passwordUpdatedAt: new Date(), passwordValidForDays: 180, - phoneNumber: null, + phoneNumber: undefined, phoneNumberVerified: false, roles: { user: { id: "user_1" }, isAdmin: true, isJurisdictionalAdmin: false, isPartner: false }, updatedAt: new Date(), @@ -80,10 +80,10 @@ export const unit: Unit = { maxOccupancy: 2, minOccupancy: 1, monthlyRent: "1104.0", - numBathrooms: null, - numBedrooms: null, - number: null, - priorityType: null, + numBathrooms: undefined, + numBedrooms: undefined, + number: undefined, + priorityType: undefined, sqFeet: "285", unitType: { @@ -95,14 +95,14 @@ export const unit: Unit = { }, createdAt: new Date("2019-07-09T21:20:05.783Z"), updatedAt: new Date("2019-08-14T23:05:43.913Z"), - monthlyRentAsPercentOfIncome: null, + monthlyRentAsPercentOfIncome: undefined, } export const jurisdiction: Jurisdiction = { name: "Alameda", notificationsSignUpURL: "https://public.govdelivery.com/accounts/CAALAME/signup/29652", languages: [EnumJurisdictionLanguages.en], - partnerTerms: null, + partnerTerms: undefined, publicUrl: "", emailFromAddress: "Alameda: Housing Bay Area ", rentalAssistanceDefault: @@ -151,7 +151,7 @@ export const listing: Listing = { applicationPickUpAddress: undefined, applicationPickUpAddressOfficeHours: "", applicationDropOffAddress: null, - applicationDropOffAddressOfficeHours: null, + applicationDropOffAddressOfficeHours: undefined, applicationMailingAddress: null, countyCode: "San Jose", jurisdiction: { @@ -237,7 +237,7 @@ export const listing: Listing = { smokingPolicy: "Non-smoking building", unitsAvailable: 0, unitsSummary: [], - unitsSummarized: undefined, + unitsSummarized: null, unitAmenities: "Dishwasher", developer: "Charities Housing ", yearBuilt: 2012, @@ -272,10 +272,10 @@ export const listing: Listing = { maxOccupancy: 2, minOccupancy: 1, monthlyRent: "1104.0", - numBathrooms: null, - numBedrooms: null, - number: null, - priorityType: null, + numBathrooms: undefined, + numBedrooms: undefined, + number: undefined, + priorityType: undefined, sqFeet: "285", unitType: { @@ -287,7 +287,7 @@ export const listing: Listing = { }, createdAt: new Date("2019-08-14T22:53:09.982Z"), updatedAt: new Date("2019-08-14T23:06:59.015Z"), - monthlyRentAsPercentOfIncome: null, + monthlyRentAsPercentOfIncome: undefined, }, { id: "9XQrfuAPOn8wtD7HlhCTR", @@ -299,10 +299,10 @@ export const listing: Listing = { maxOccupancy: 2, minOccupancy: 1, monthlyRent: "1104.0", - numBathrooms: null, - numBedrooms: null, - number: null, - priorityType: null, + numBathrooms: undefined, + numBedrooms: undefined, + number: undefined, + priorityType: undefined, sqFeet: "285", unitType: { @@ -314,7 +314,7 @@ export const listing: Listing = { }, createdAt: new Date("2019-08-14T22:52:08.758Z"), updatedAt: new Date("2019-08-14T23:06:59.023Z"), - monthlyRentAsPercentOfIncome: null, + monthlyRentAsPercentOfIncome: undefined, }, { id: "bamrJpZA9JmnLSMEbTlI4", @@ -326,10 +326,10 @@ export const listing: Listing = { maxOccupancy: 2, minOccupancy: 1, monthlyRent: "1104.0", - numBathrooms: null, - numBedrooms: null, - number: null, - priorityType: null, + numBathrooms: undefined, + numBedrooms: undefined, + number: undefined, + priorityType: undefined, sqFeet: "285", unitType: { @@ -341,7 +341,7 @@ export const listing: Listing = { }, createdAt: new Date("2019-08-14T22:52:08.766Z"), updatedAt: new Date("2019-08-14T23:06:59.031Z"), - monthlyRentAsPercentOfIncome: null, + monthlyRentAsPercentOfIncome: undefined, }, { id: "BCwOFAHJDpyPbKcVBjIUM", @@ -353,10 +353,10 @@ export const listing: Listing = { maxOccupancy: 2, minOccupancy: 1, monthlyRent: "1104.0", - numBathrooms: null, - numBedrooms: null, - number: null, - priorityType: null, + numBathrooms: undefined, + numBedrooms: undefined, + number: undefined, + priorityType: undefined, sqFeet: "285", unitType: { @@ -369,7 +369,7 @@ export const listing: Listing = { createdAt: new Date("2019-08-14T22:52:08.771Z"), updatedAt: new Date("2019-08-14T23:06:59.039Z"), // amiChart: SanMateoHUD2019, - monthlyRentAsPercentOfIncome: null, + monthlyRentAsPercentOfIncome: undefined, }, { id: "5t56gXJdJLZiksBuX8BtL", @@ -381,10 +381,10 @@ export const listing: Listing = { maxOccupancy: 2, minOccupancy: 1, monthlyRent: "1104.0", - numBathrooms: null, - numBedrooms: null, - number: null, - priorityType: null, + numBathrooms: undefined, + numBedrooms: undefined, + number: undefined, + priorityType: undefined, sqFeet: "285", unitType: { @@ -396,7 +396,7 @@ export const listing: Listing = { }, createdAt: new Date("2019-08-14T22:52:08.777Z"), updatedAt: new Date("2019-08-14T23:06:59.046Z"), - monthlyRentAsPercentOfIncome: null, + monthlyRentAsPercentOfIncome: undefined, }, ], } diff --git a/sites/partners/__tests__/pages/listings/index.test.tsx b/sites/partners/__tests__/pages/listings/index.test.tsx new file mode 100644 index 0000000000..bbe5e961e4 --- /dev/null +++ b/sites/partners/__tests__/pages/listings/index.test.tsx @@ -0,0 +1,161 @@ +import { AuthProvider, ConfigProvider } from "@bloom-housing/shared-helpers" + +import { fireEvent, render } from "@testing-library/react" +import { rest } from "msw" +import { setupServer } from "msw/node" +import ListingsList from "../../../src/pages/index" +import React from "react" +import { listing } from "@bloom-housing/shared-helpers/__tests__/testHelpers" +import { mockNextRouter } from "../../testUtils" + +//Mock the jszip package used for Export +const mockFile = jest.fn() +let mockFolder: jest.Mock +function mockJszip() { + mockFolder = jest.fn(mockJszip) + return { + folder: mockFolder, + file: mockFile, + generateAsync: jest.fn().mockImplementation(() => { + const blob = {} + const response = { blob } + return Promise.resolve(response) + }), + } +} +jest.mock("jszip", () => { + return { + __esModule: true, + default: mockJszip, + } +}) + +const server = setupServer() + +beforeAll(() => { + server.listen() + mockNextRouter() +}) + +afterEach(() => { + server.resetHandlers() + window.sessionStorage.clear() +}) + +afterAll(() => server.close()) + +describe("listings", () => { + it("should not render Export to CSV when user is not admin", async () => { + window.URL.createObjectURL = jest.fn() + document.cookie = "access-token-available=True" + server.use( + rest.get("http://localhost:3100/listings", (_req, res, ctx) => { + return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) + }), + rest.get("http://localhost/api/adapter/listings", (_req, res, ctx) => { + return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) + }), + rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { + return res( + ctx.json({ id: "user1", roles: { id: "user1", isAdmin: false, isPartner: true } }) + ) + }), + rest.post("http://localhost:3100/auth/token", (_req, res, ctx) => { + return res(ctx.json("")) + }) + ) + + const { findByText, queryByText } = render( + + + + + + ) + const header = await findByText("Partners Portal") + expect(header).toBeInTheDocument() + const exportButton = queryByText("Export to CSV") + expect(exportButton).not.toBeInTheDocument() + }) + + it("should render the error text when listings csv api call fails", async () => { + window.URL.createObjectURL = jest.fn() + document.cookie = "access-token-available=True" + server.use( + rest.get("http://localhost:3100/listings", (_req, res, ctx) => { + return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) + }), + rest.get("http://localhost/api/adapter/listings", (_req, res, ctx) => { + return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) + }), + rest.get("http://localhost/api/adapter/listings/csv", (_req, res, ctx) => { + return res(ctx.status(500), ctx.json("")) + }), + rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { + return res(ctx.json({ id: "user1", roles: { id: "user1", isAdmin: true } })) + }), + rest.post("http://localhost:3100/auth/token", (_req, res, ctx) => { + return res(ctx.json("")) + }) + ) + + const { findByText, getByText } = render( + + + + + + ) + const header = await findByText("Partners Portal") + expect(header).toBeInTheDocument() + const exportButton = getByText("Export to CSV") + expect(exportButton).toBeInTheDocument() + fireEvent.click(exportButton) + const error = await findByText( + "Export failed. Please try again later. If the problem persists, please email supportbloom@exygy.com", + { + exact: false, + } + ) + expect(error).toBeInTheDocument() + }) + + it("should render Export to CSV when user is admin and success message when clicked", async () => { + window.URL.createObjectURL = jest.fn() + //Prevent error from clicking anchor tag within test + HTMLAnchorElement.prototype.click = jest.fn() + document.cookie = "access-token-available=True" + server.use( + rest.get("http://localhost:3100/listings", (_req, res, ctx) => { + return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) + }), + rest.get("http://localhost/api/adapter/listings/csv", (_req, res, ctx) => { + return res(ctx.json({ listingCSV: "", unitCSV: "" })) + }), + rest.get("http://localhost:3100/listings/csv", (_req, res, ctx) => { + return res(ctx.json({ listingCSV: "", unitCSV: "" })) + }), + rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { + return res(ctx.json({ id: "user1", roles: { id: "user1", isAdmin: true } })) + }), + rest.post("http://localhost:3100/auth/token", (_req, res, ctx) => { + return res(ctx.json("")) + }) + ) + + const { findByText, getByText } = render( + + + + + + ) + const header = await findByText("Partners Portal") + expect(header).toBeInTheDocument() + const exportButton = getByText("Export to CSV") + expect(exportButton).toBeInTheDocument() + fireEvent.click(exportButton) + const success = await findByText("The file has been exported") + expect(success).toBeInTheDocument() + }) +}) diff --git a/sites/partners/cypress/e2e/03-listing.spec.ts b/sites/partners/cypress/e2e/03-listing.spec.ts index db23fb54e8..92b619f1bc 100644 --- a/sites/partners/cypress/e2e/03-listing.spec.ts +++ b/sites/partners/cypress/e2e/03-listing.spec.ts @@ -269,4 +269,20 @@ describe("Listing Management Tests", () => { cy.getByTestId("listingIsAlreadyLiveButton").contains("Save").click() cy.getByTestId("page-header").should("have.text", listing["editedName"]) } + it("as admin user, should be able to download listings export zip", () => { + const convertToString = (value: number) => { + return value < 10 ? `0${value}` : `${value}` + } + cy.visit("/") + cy.getByTestId("export-listings").click() + const now = new Date() + const dateString = `${now.getFullYear()}-${convertToString( + now.getMonth() + 1 + )}-${convertToString(now.getDate())}` + const timeString = `${convertToString(now.getHours())}-${convertToString(now.getMinutes())}` + const zipName = `${dateString}_${timeString}-complete-listing-data.zip` + const downloadFolder = Cypress.config("downloadsFolder") + const completeZipPath = `${downloadFolder}/${zipName}` + cy.readFile(completeZipPath) + }) }) diff --git a/sites/partners/src/lib/hooks.ts b/sites/partners/src/lib/hooks.ts index eddf2dffd7..4a94c70196 100644 --- a/sites/partners/src/lib/hooks.ts +++ b/sites/partners/src/lib/hooks.ts @@ -2,6 +2,7 @@ import { useCallback, useContext, useState } from "react" import useSWR from "swr" import qs from "qs" import dayjs from "dayjs" +import JSZip from "jszip" import { AuthContext } from "@bloom-housing/shared-helpers" import { ApplicationSection, @@ -117,6 +118,48 @@ export function useListingsData({ } } +export const useListingZip = () => { + const { listingsService } = useContext(AuthContext) + + const [zipExportLoading, setZipExportLoading] = useState(false) + const [zipExportError, setZipExportError] = useState(false) + const [zipCompleted, setZipCompleted] = useState(false) + + const onExport = useCallback(async () => { + setZipExportError(false) + setZipCompleted(false) + setZipExportLoading(true) + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone.replace("/", "-") + + try { + const content = await listingsService.listAsCsv({ timeZone }) + const now = new Date() + const dateString = dayjs(now).format("YYYY-MM-DD_HH-mm") + const zip = new JSZip() + zip.file(dateString + "_listing_data.csv", content?.listingCsv) + zip.file(dateString + "_unit_data.csv", content?.unitCsv) + await zip.generateAsync({ type: "blob" }).then(function (blob) { + const fileLink = document.createElement("a") + fileLink.setAttribute("download", `${dateString}-complete-listing-data.zip`) + fileLink.href = URL.createObjectURL(blob) + fileLink.click() + }) + setZipCompleted(true) + setSiteAlertMessage(t("t.exportSuccess"), "success") + } catch (err) { + setZipExportError(true) + } + setZipExportLoading(false) + }, [listingsService]) + + return { + onExport, + zipCompleted, + zipExportLoading, + zipExportError, + } +} + export function useSingleApplicationData(applicationId: string) { const { applicationsService } = useContext(AuthContext) const backendSingleApplicationsEndpointUrl = `/api/adapter/applications/${applicationId}` diff --git a/sites/partners/src/pages/index.tsx b/sites/partners/src/pages/index.tsx index 61b0878312..408660fc8c 100644 --- a/sites/partners/src/pages/index.tsx +++ b/sites/partners/src/pages/index.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useContext } from "react" +import React, { useMemo, useContext, useState, useEffect } from "react" import Head from "next/head" import { t, @@ -7,15 +7,18 @@ import { AgTable, useAgTable, AppearanceSizeType, + AlertBox, SiteAlert, + AppearanceStyleType, } from "@bloom-housing/ui-components" import { AuthContext } from "@bloom-housing/shared-helpers" import dayjs from "dayjs" import { ColDef, ColGroupDef } from "ag-grid-community" -import { useListingsData } from "../lib/hooks" +import { useListingsData, useListingZip } from "../lib/hooks" import Layout from "../layouts" import { MetaTags } from "../components/shared/MetaTags" import { NavigationHeader } from "../components/shared/NavigationHeader" +import { faFileExport } from "@fortawesome/free-solid-svg-icons" class formatLinkCell { link: HTMLAnchorElement @@ -65,9 +68,13 @@ class ListingsLink extends formatLinkCell { export default function ListingsList() { const metaDescription = t("pageDescription.welcome", { regionName: t("region.name") }) - + const [errorAlert, setErrorAlert] = useState(false) const { profile } = useContext(AuthContext) - const isAdmin = profile.roles?.isAdmin || profile.roles?.isJurisdictionalAdmin || false + const isAdmin = profile?.roles?.isAdmin || profile?.roles?.isJurisdictionalAdmin || false + const { onExport, zipCompleted, zipExportLoading, zipExportError } = useListingZip() + useEffect(() => { + setErrorAlert(zipExportError) + }, [zipExportError]) const tableOptions = useAgTable() @@ -143,9 +150,26 @@ export default function ListingsList() { - + + {zipCompleted && ( +
+ +
+ )} +
+ {errorAlert && ( + setErrorAlert(false)} + closeable + type="alert" + inverted + > + {t("errors.alert.exportFailed")} + + )} {isAdmin && ( - - + + - + )} } diff --git a/yarn.lock b/yarn.lock index f244d03879..b0438342d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10117,6 +10117,11 @@ image-meta@^0.1.1: resolved "https://registry.npmjs.org/image-meta/-/image-meta-0.1.1.tgz" integrity sha512-+oXiHwOEPr1IE5zY0tcBLED/CYcre15J4nwL50x3o0jxWqEkyjrusiKP3YSU+tr9fvJp33ZcP5Gpj2295g3aEw== +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== + immutable@^4.0.0: version "4.1.0" resolved "https://registry.npmjs.org/immutable/-/immutable-4.1.0.tgz" @@ -12084,6 +12089,16 @@ jsx-ast-utils@^3.2.1: array-includes "^3.1.5" object.assign "^4.1.3" +jszip@^3.10.1: + version "3.10.1" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2" + integrity sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g== + dependencies: + lie "~3.3.0" + pako "~1.0.2" + readable-stream "~2.3.6" + setimmediate "^1.0.5" + jwa@^1.4.1: version "1.4.1" resolved "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz" @@ -12269,6 +12284,13 @@ libphonenumber-js@^1.10.14: resolved "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.18.tgz" integrity sha512-NS4ZEgNhwbcPz1gfSXCGFnQm0xEiyTSPRthIuWytDzOiEG9xnZ2FbLyfJC4tI2BMAAXpoWbNxHYH75pa3Dq9og== +lie@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" + integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ== + dependencies: + immediate "~3.0.5" + lilconfig@^2.0.3: version "2.0.3" resolved "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.3.tgz" @@ -14306,6 +14328,11 @@ pacote@^11.2.6: ssri "^8.0.1" tar "^6.1.0" +pako@~1.0.2: + version "1.0.11" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" + integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== + parent-module@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz" @@ -16250,6 +16277,11 @@ set-value@^2.0.0, set-value@^2.0.1: is-plain-object "^2.0.3" split-string "^3.0.1" +setimmediate@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== + setprototypeof@1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz"