From fb239029cddcc1671ceac18d6f05e0ba80d634f5 Mon Sep 17 00:00:00 2001 From: Yazeed Loonat Date: Thu, 1 Aug 2024 14:38:04 -0700 Subject: [PATCH] feat: spreadsheetification of lottery export (#4228) * feat: spreadsheetification of lottery export * fix: updates for tests * fix: updates per pr comments * fix: adding test for coverage * fix: removes un ranked applications from export --- api/package.json | 2 + api/src/controllers/application.controller.ts | 20 - api/src/controllers/lottery.controller.ts | 20 +- .../application-csv-export.service.ts | 673 +----------------- api/src/services/lottery.service.ts | 372 +++++++++- api/src/types/CsvExportInterface.ts | 7 + .../utilities/application-export-helpers.ts | 585 +++++++++++++++ api/test/integration/lottery.e2e-spec.ts | 86 +++ .../application-csv-export.service.spec.ts | 469 +----------- .../unit/services/application.service.spec.ts | 2 +- .../application-export-helpers.spec.ts | 394 ++++++++++ api/yarn.lock | 387 +++++++++- shared-helpers/src/types/backend-swagger.ts | 58 +- sites/partners/src/lib/hooks.ts | 45 +- .../src/pages/api/adapter/[...backendUrl].ts | 2 +- .../src/pages/listings/[id]/lottery.tsx | 11 +- 16 files changed, 1907 insertions(+), 1226 deletions(-) create mode 100644 api/src/utilities/application-export-helpers.ts create mode 100644 api/test/unit/utilities/application-export-helpers.spec.ts diff --git a/api/package.json b/api/package.json index 624c72da91..3b7c8e0de3 100644 --- a/api/package.json +++ b/api/package.json @@ -60,6 +60,7 @@ "compression": "^1.7.4", "cookie-parser": "~1.4.6", "dayjs": "~1.11.9", + "exceljs": "^4.4.0", "handlebars": "~4.7.8", "jsonwebtoken": "~9.0.1", "lodash": "~4.17.21", @@ -80,6 +81,7 @@ "@nestjs/schematics": "^10.1.1", "@nestjs/testing": "^10.3.2", "@types/compression": "^1.7.5", + "@types/exceljs": "1.3.0", "@types/express": "~4.17.17", "@types/jest": "~29.5.3", "@types/node": "^18.7.14", diff --git a/api/src/controllers/application.controller.ts b/api/src/controllers/application.controller.ts index 34999085bf..9794a342eb 100644 --- a/api/src/controllers/application.controller.ts +++ b/api/src/controllers/application.controller.ts @@ -95,26 +95,6 @@ export class ApplicationController { return await this.applicationService.mostRecentlyCreated(queryParams, req); } - @Get(`getLotteryResults`) - @ApiOperation({ - summary: 'Get applications lottery results', - operationId: 'lotteryResults', - }) - @Header('Content-Type', 'text/csv') - @UseInterceptors(ExportLogInterceptor) - async lotteryExport( - @Request() req: ExpressRequest, - @Res({ passthrough: true }) res: Response, - @Query(new ValidationPipe(defaultValidationPipeOptions)) - queryParams: ApplicationCsvQueryParams, - ): Promise { - return await this.applicationCsvExportService.lotteryExport( - req, - res, - queryParams, - ); - } - @Get(`csv`) @ApiOperation({ summary: 'Get applications as csv', diff --git a/api/src/controllers/lottery.controller.ts b/api/src/controllers/lottery.controller.ts index 761f4c68c2..6f0e77c216 100644 --- a/api/src/controllers/lottery.controller.ts +++ b/api/src/controllers/lottery.controller.ts @@ -1,10 +1,13 @@ import { Body, Controller, + Get, Header, Put, + Query, Request, Res, + StreamableFile, UseGuards, UseInterceptors, UsePipes, @@ -36,7 +39,6 @@ export class LotteryController { summary: 'Generate the lottery results for a listing', operationId: 'lotteryGenerate', }) - @Header('Content-Type', 'text/csv') @UseInterceptors(ExportLogInterceptor) async lotteryGenerate( @Request() req: ExpressRequest, @@ -45,4 +47,20 @@ export class LotteryController { ): Promise { return await this.lotteryService.lotteryGenerate(req, res, queryParams); } + + @Get(`getLotteryResults`) + @ApiOperation({ + summary: 'Get applications lottery results', + operationId: 'lotteryResults', + }) + @Header('Content-Type', 'application/zip') + @UseInterceptors(ExportLogInterceptor) + async lotteryExport( + @Request() req: ExpressRequest, + @Res({ passthrough: true }) res: Response, + @Query(new ValidationPipe(defaultValidationPipeOptions)) + queryParams: ApplicationCsvQueryParams, + ): Promise { + return await this.lotteryService.lotteryExport(req, res, queryParams); + } } diff --git a/api/src/services/application-csv-export.service.ts b/api/src/services/application-csv-export.service.ts index c0b11e20cf..b555453d98 100644 --- a/api/src/services/application-csv-export.service.ts +++ b/api/src/services/application-csv-export.service.ts @@ -4,27 +4,17 @@ import { Injectable, StreamableFile } from '@nestjs/common'; import { Request as ExpressRequest, Response } from 'express'; import { view } from './application.service'; import { PrismaService } from './prisma.service'; -import { ApplicationSubmissionTypeEnum } from '@prisma/client'; import { MultiselectQuestionService } from './multiselect-question.service'; import { ApplicationCsvQueryParams } from '../dtos/applications/application-csv-query-params.dto'; -import { UnitType } from '../dtos/unit-types/unit-type.dto'; -import { Address } from '../dtos/addresses/address.dto'; import { ApplicationMultiselectQuestion } from '../dtos/applications/application-multiselect-question.dto'; -import MultiselectQuestion from '../dtos/multiselect-questions/multiselect-question.dto'; -import { ApplicationFlaggedSet } from '../dtos/application-flagged-sets/application-flagged-set.dto'; import { User } from '../dtos/users/user.dto'; import { ListingService } from './listing.service'; import { PermissionService } from './permission.service'; import { permissionActions } from '../enums/permissions/permission-actions-enum'; -import { formatLocalDate } from '../utilities/format-local-date'; -import { - CsvExporterServiceInterface, - CsvHeader, -} from '../types/CsvExportInterface'; +import { CsvHeader } from '../types/CsvExportInterface'; import { mapTo } from '../utilities/mapTo'; import { Application } from '../dtos/applications/application.dto'; -import { OrderByEnum } from '../enums/shared/order-by-enum'; -import { ApplicationLotteryPosition } from '../dtos/applications/application-lottery-position.dto'; +import { getExportHeaders } from '../utilities/application-export-helpers'; view.csv = { ...view.details, @@ -36,22 +26,10 @@ view.csv = { listings: false, }; -export const typeMap = { - SRO: 'SRO', - studio: 'Studio', - oneBdrm: 'One Bedroom', - twoBdrm: 'Two Bedroom', - threeBdrm: 'Three Bedroom', - fourBdrm: 'Four+ Bedroom', - fiveBdrm: 'Five Bedroom', -}; - const NUMBER_TO_PAGINATE_BY = 500; @Injectable() -export class ApplicationCsvExporterService - implements CsvExporterServiceInterface -{ +export class ApplicationCsvExporterService { readonly dateFormat: string = 'MM-DD-YYYY hh:mm:ssA z'; constructor( private prisma: PrismaService, @@ -125,11 +103,13 @@ export class ApplicationCsvExporterService } }); - const csvHeaders = await this.getCsvHeaders( + const csvHeaders = getExportHeaders( maxHouseholdMembers, multiSelectQuestions, queryParams.timeZone, queryParams.includeDemographics, + false, + this.dateFormat, ); return this.csvExportHelper( @@ -140,535 +120,6 @@ export class ApplicationCsvExporterService ); } - getHouseholdCsvHeaders(maxHouseholdMembers: number): CsvHeader[] { - const headers = []; - for (let i = 0; i < maxHouseholdMembers; i++) { - const j = i + 1; - headers.push( - { - path: `householdMember.${i}.firstName`, - label: `Household Member (${j}) First Name`, - }, - { - path: `householdMember.${i}.middleName`, - label: `Household Member (${j}) Middle Name`, - }, - { - path: `householdMember.${i}.lastName`, - label: `Household Member (${j}) Last Name`, - }, - { - path: `householdMember.${i}.firstName`, - label: `Household Member (${j}) First Name`, - }, - { - path: `householdMember.${i}.birthDay`, - label: `Household Member (${j}) Birth Day`, - }, - { - path: `householdMember.${i}.birthMonth`, - label: `Household Member (${j}) Birth Month`, - }, - { - path: `householdMember.${i}.birthYear`, - label: `Household Member (${j}) Birth Year`, - }, - { - path: `householdMember.${i}.sameAddress`, - label: `Household Member (${j}) Same as Primary Applicant`, - }, - { - path: `householdMember.${i}.relationship`, - label: `Household Member (${j}) Relationship`, - }, - { - path: `householdMember.${i}.workInRegion`, - label: `Household Member (${j}) Work in Region`, - }, - { - path: `householdMember.${i}.householdMemberAddress.street`, - label: `Household Member (${j}) Street`, - }, - { - path: `householdMember.${i}.householdMemberAddress.street2`, - label: `Household Member (${j}) Street 2`, - }, - { - path: `householdMember.${i}.householdMemberAddress.city`, - label: `Household Member (${j}) City`, - }, - { - path: `householdMember.${i}.householdMemberAddress.state`, - label: `Household Member (${j}) State`, - }, - { - path: `householdMember.${i}.householdMemberAddress.zipCode`, - label: `Household Member (${j}) Zip Code`, - }, - ); - } - - return headers; - } - - constructMultiselectQuestionHeaders( - applicationSection: string, - labelString: string, - multiSelectQuestions: MultiselectQuestion[], - ): CsvHeader[] { - const headers: CsvHeader[] = []; - - multiSelectQuestions - .filter((question) => question.applicationSection === applicationSection) - .forEach((question) => { - headers.push({ - path: `${applicationSection}.${question.text}.claimed`, - label: `${labelString} ${question.text}`, - format: (val: any): string => { - const claimedString: string[] = []; - val?.options?.forEach((option) => { - if (option.checked) { - claimedString.push(option.key); - } - }); - return claimedString.join(', '); - }, - }); - /** - * there are other input types for extra data besides address - * that are not used on the old backend, but could be added here - */ - question.options - ?.filter((option) => option.collectAddress) - .forEach((option) => { - headers.push({ - path: `${applicationSection}.${question.text}.address`, - label: `${labelString} ${question.text} - ${option.text} - Address`, - format: (val: ApplicationMultiselectQuestion): string => { - return this.multiselectQuestionFormat( - val, - option.text, - 'address', - ); - }, - }); - if (option.validationMethod) { - headers.push({ - path: `${applicationSection}.${question.text}.address`, - label: `${labelString} ${question.text} - ${option.text} - Passed Address Check`, - format: (val: ApplicationMultiselectQuestion): string => { - return this.multiselectQuestionFormat( - val, - option.text, - 'geocodingVerified', - ); - }, - }); - } - if (option.collectName) { - headers.push({ - path: `${applicationSection}.${question.text}.address`, - label: `${labelString} ${question.text} - ${option.text} - Name of Address Holder`, - format: (val: ApplicationMultiselectQuestion): string => { - return this.multiselectQuestionFormat( - val, - option.text, - 'addressHolderName', - ); - }, - }); - } - if (option.collectRelationship) { - headers.push({ - path: `${applicationSection}.${question.text}.address`, - label: `${labelString} ${question.text} - ${option.text} - Relationship to Address Holder`, - format: (val: ApplicationMultiselectQuestion): string => { - return this.multiselectQuestionFormat( - val, - option.text, - 'addressHolderRelationship', - ); - }, - }); - } - }); - }); - - return headers; - } - - async getCsvHeaders( - maxHouseholdMembers: number, - multiSelectQuestions: MultiselectQuestion[], - timeZone: string, - includeDemographics = false, - forLottery = false, - ): Promise { - const headers: CsvHeader[] = [ - { - path: 'id', - label: 'Application Id', - }, - { - path: 'confirmationCode', - label: 'Application Confirmation Code', - }, - { - path: 'submissionType', - label: 'Application Type', - format: (val: string): string => - val === ApplicationSubmissionTypeEnum.electronical - ? 'electronic' - : val, - }, - { - path: 'submissionDate', - label: 'Application Submission Date', - format: (val: string): string => - formatLocalDate( - val, - this.dateFormat, - timeZone ?? process.env.TIME_ZONE, - ), - }, - { - path: 'applicant.firstName', - label: 'Primary Applicant First Name', - }, - { - path: 'applicant.middleName', - label: 'Primary Applicant Middle Name', - }, - { - path: 'applicant.lastName', - label: 'Primary Applicant Last Name', - }, - { - path: 'applicant.birthDay', - label: 'Primary Applicant Birth Day', - }, - { - path: 'applicant.birthMonth', - label: 'Primary Applicant Birth Month', - }, - { - path: 'applicant.birthYear', - label: 'Primary Applicant Birth Year', - }, - { - path: 'applicant.emailAddress', - label: 'Primary Applicant Email Address', - }, - { - path: 'applicant.phoneNumber', - label: 'Primary Applicant Phone Number', - }, - { - path: 'applicant.phoneNumberType', - label: 'Primary Applicant Phone Type', - }, - { - path: 'additionalPhoneNumber', - label: 'Primary Applicant Additional Phone Number', - }, - { - path: 'contactPreferences', - label: 'Primary Applicant Preferred Contact Type', - }, - { - path: 'applicant.applicantAddress.street', - label: `Primary Applicant Street`, - }, - { - path: 'applicant.applicantAddress.street2', - label: `Primary Applicant Street 2`, - }, - { - path: 'applicant.applicantAddress.city', - label: `Primary Applicant City`, - }, - { - path: 'applicant.applicantAddress.state', - label: `Primary Applicant State`, - }, - { - path: 'applicant.applicantAddress.zipCode', - label: `Primary Applicant Zip Code`, - }, - { - path: 'applicationsMailingAddress.street', - label: `Primary Applicant Mailing Street`, - }, - { - path: 'applicationsMailingAddress.street2', - label: `Primary Applicant Mailing Street 2`, - }, - { - path: 'applicationsMailingAddress.city', - label: `Primary Applicant Mailing City`, - }, - { - path: 'applicationsMailingAddress.state', - label: `Primary Applicant Mailing State`, - }, - { - path: 'applicationsMailingAddress.zipCode', - label: `Primary Applicant Mailing Zip Code`, - }, - { - path: 'applicant.applicantWorkAddress.street', - label: `Primary Applicant Work Street`, - }, - { - path: 'applicant.applicantWorkAddress.street2', - label: `Primary Applicant Work Street 2`, - }, - { - path: 'applicant.applicantWorkAddress.city', - label: `Primary Applicant Work City`, - }, - { - path: 'applicant.applicantWorkAddress.state', - label: `Primary Applicant Work State`, - }, - { - path: 'applicant.applicantWorkAddress.zipCode', - label: `Primary Applicant Work Zip Code`, - }, - { - path: 'alternateContact.firstName', - label: 'Alternate Contact First Name', - }, - { - path: 'alternateContact.lastName', - label: 'Alternate Contact Last Name', - }, - { - path: 'alternateContact.type', - label: 'Alternate Contact Type', - }, - { - path: 'alternateContact.agency', - label: 'Alternate Contact Agency', - }, - { - path: 'alternateContact.otherType', - label: 'Alternate Contact Other Type', - }, - { - path: 'alternateContact.emailAddress', - label: 'Alternate Contact Email Address', - }, - { - path: 'alternateContact.phoneNumber', - label: 'Alternate Contact Phone Number', - }, - { - path: 'alternateContact.address.street', - label: `Alternate Contact Street`, - }, - { - path: 'alternateContact.address.street2', - label: `Alternate Contact Street 2`, - }, - { - path: 'alternateContact.address.city', - label: `Alternate Contact City`, - }, - { - path: 'alternateContact.address.state', - label: `Alternate Contact State`, - }, - { - path: 'alternateContact.address.zipCode', - label: `Alternate Contact Zip Code`, - }, - { - path: 'income', - label: 'Income', - }, - { - path: 'incomePeriod', - label: 'Income Period', - format: (val: string): string => - val === 'perMonth' ? 'per month' : 'per year', - }, - { - path: 'accessibility.mobility', - label: 'Accessibility Mobility', - }, - { - path: 'accessibility.vision', - label: 'Accessibility Vision', - }, - { - path: 'accessibility.hearing', - label: 'Accessibility Hearing', - }, - { - path: 'householdExpectingChanges', - label: 'Expecting Household Changes', - }, - { - path: 'householdStudent', - label: 'Household Includes Student or Member Nearing 18', - }, - { - path: 'incomeVouchers', - label: 'Vouchers or Subsidies', - }, - { - path: 'preferredUnitTypes', - label: 'Requested Unit Types', - format: (val: UnitType[]): string => { - return val - .map((unit) => this.unitTypeToReadable(unit.name)) - .join(','); - }, - }, - ]; - - // add preferences to csv headers - const preferenceHeaders = this.constructMultiselectQuestionHeaders( - 'preferences', - 'Preference', - multiSelectQuestions, - ); - headers.push(...preferenceHeaders); - - // add programs to csv headers - const programHeaders = this.constructMultiselectQuestionHeaders( - 'programs', - 'Program', - multiSelectQuestions, - ); - headers.push(...programHeaders); - - headers.push({ - path: 'householdSize', - label: 'Household Size', - }); - - // add household member headers to csv - if (maxHouseholdMembers) { - headers.push(...this.getHouseholdCsvHeaders(maxHouseholdMembers)); - } - - headers.push( - { - path: 'markedAsDuplicate', - label: 'Marked As Duplicate', - }, - { - path: 'applicationFlaggedSet', - label: 'Flagged As Duplicate', - format: (val: ApplicationFlaggedSet[]): boolean => { - return val.length > 0; - }, - }, - ); - - if (includeDemographics) { - headers.push( - { - path: 'demographics.ethnicity', - label: 'Ethnicity', - }, - { - path: 'demographics.race', - label: 'Race', - format: (val: string[]): string => - val - .map((race) => this.convertDemographicRaceToReadable(race)) - .join(','), - }, - { - path: 'demographics.howDidYouHear', - label: 'How did you Hear?', - }, - ); - } - - // if its for the lottery insert the lottery position - if (forLottery) { - headers.unshift({ - path: 'applicationLotteryPositions', - label: 'Raw Lottery Rank', - format: (val: ApplicationLotteryPosition[]): number => { - if (val?.length) { - return val[0].ordinal; - } - }, - }); - } - return headers; - } - - addressToString(address: Address): string { - return `${address.street}${address.street2 ? ' ' + address.street2 : ''} ${ - address.city - }, ${address.state} ${address.zipCode}`; - } - - multiselectQuestionFormat( - question: ApplicationMultiselectQuestion, - optionText: string, - key: string, - ): string { - if (!question) return ''; - const selectedOption = question.options.find( - (option) => option.key === optionText, - ); - const extraData = selectedOption?.extraData.find( - (data) => data.key === key, - ); - if (!extraData) { - return ''; - } - if (key === 'address') { - return this.addressToString(extraData.value as Address); - } - if (key === 'geocodingVerified') { - return extraData.value === 'unknown' - ? 'Needs Manual Verification' - : extraData.value.toString(); - } - return extraData.value as string; - } - - convertDemographicRaceToReadable(type: string): string { - const [rootKey, customValue = ''] = type.split(':'); - const typeMap = { - americanIndianAlaskanNative: 'American Indian / Alaskan Native', - asian: 'Asian', - 'asian-asianIndian': 'Asian[Asian Indian]', - 'asian-otherAsian': `Asian[Other Asian:${customValue}]`, - blackAfricanAmerican: 'Black / African American', - 'asian-chinese': 'Asian[Chinese]', - declineToRespond: 'Decline to Respond', - 'asian-filipino': 'Asian[Filipino]', - 'nativeHawaiianOtherPacificIslander-guamanianOrChamorro': - 'Native Hawaiian / Other Pacific Islander[Guamanian or Chamorro]', - 'asian-japanese': 'Asian[Japanese]', - 'asian-korean': 'Asian[Korean]', - 'nativeHawaiianOtherPacificIslander-nativeHawaiian': - 'Native Hawaiian / Other Pacific Islander[Native Hawaiian]', - nativeHawaiianOtherPacificIslander: - 'Native Hawaiian / Other Pacific Islander', - otherMultiracial: `Other / Multiracial:${customValue}`, - 'nativeHawaiianOtherPacificIslander-otherPacificIslander': `Native Hawaiian / Other Pacific Islander[Other Pacific Islander:${customValue}]`, - 'nativeHawaiianOtherPacificIslander-samoan': - 'Native Hawaiian / Other Pacific Islander[Samoan]', - 'asian-vietnamese': 'Asian[Vietnamese]', - white: 'White', - }; - return typeMap[rootKey] ?? rootKey; - } - - unitTypeToReadable(type: string): string { - return typeMap[type] ?? type; - } - async authorizeCSVExport(user, listingId): Promise { /** * Checking authorization for each application is very expensive. @@ -689,110 +140,6 @@ export class ApplicationCsvExporterService ); } - /** - * - * @param queryParams - * @param req - * @returns generates the lottery export file via helper function and returns the streamable file - */ - async lotteryExport( - req: ExpressRequest, - res: Response, - queryParams: QueryParams, - ): Promise { - const user = mapTo(User, req['user']); - await this.authorizeCSVExport(user, queryParams.listingId); - - const filename = join( - process.cwd(), - `src/temp/lottery-listing-${queryParams.listingId}-applications-${ - user.id - }-${new Date().getTime()}.csv`, - ); - - await this.createLotterySheet(filename, { - ...queryParams, - includeDemographics: true, - }); - const file = createReadStream(filename); - return new StreamableFile(file); - } - - /** - * - * @param filename - * @param queryParams - * @returns generates the lottery sheet - */ - async createLotterySheet( - filename: string, - queryParams: QueryParams, - ): Promise { - let applications = await this.prisma.applications.findMany({ - select: { - id: true, - preferences: true, - householdMember: { - select: { - id: true, - }, - }, - applicationLotteryPositions: { - select: { - ordinal: true, - multiselectQuestionId: true, - }, - where: { - multiselectQuestionId: null, - }, - orderBy: { - ordinal: OrderByEnum.DESC, - }, - }, - }, - where: { - listingId: queryParams.listingId, - deletedAt: null, - markedAsDuplicate: false, - }, - }); - - // get all multiselect questions for a listing to build csv headers - const multiSelectQuestions = - await this.multiselectQuestionService.findByListingId( - queryParams.listingId, - ); - - // get maxHouseholdMembers associated to the selected applications - let maxHouseholdMembers = 0; - applications.forEach((app) => { - if (app.householdMember?.length > maxHouseholdMembers) { - maxHouseholdMembers = app.householdMember.length; - } - }); - - const csvHeaders = await this.getCsvHeaders( - maxHouseholdMembers, - multiSelectQuestions, - queryParams.timeZone, - queryParams.includeDemographics, - true, - ); - - applications = applications.sort( - (a, b) => - a.applicationLotteryPositions[0].ordinal - - b.applicationLotteryPositions[0].ordinal, - ); - return this.csvExportHelper( - filename, - mapTo(Application, applications), - csvHeaders, - queryParams, - true, - ); - } - /** * * @param filename the name of the file to write to @@ -941,8 +288,12 @@ export class ApplicationCsvExporterService return acc[curr]; }, app); - value = - value === undefined ? '' : value === null ? '' : value; + if (value === undefined) { + value = ''; + } else if (value === null) { + value = ''; + } + if (header.format) { value = header.format(value); } diff --git a/api/src/services/lottery.service.ts b/api/src/services/lottery.service.ts index 4335261c63..80ecc8b1d8 100644 --- a/api/src/services/lottery.service.ts +++ b/api/src/services/lottery.service.ts @@ -2,22 +2,44 @@ import { BadRequestException, ForbiddenException, Injectable, + StreamableFile, } from '@nestjs/common'; -import { Request as ExpressRequest, Response } from 'express'; -import { PrismaService } from './prisma.service'; import { LotteryStatusEnum, MultiselectQuestionsApplicationSectionEnum, } from '@prisma/client'; -import { MultiselectQuestionService } from './multiselect-question.service'; +import archiver from 'archiver'; +import Excel, { Column, Row } from 'exceljs'; +import { Request as ExpressRequest, Response } from 'express'; +import fs, { createReadStream } from 'fs'; +import { join } from 'path'; +import { view } from './application.service'; import { ApplicationCsvQueryParams } from '../dtos/applications/application-csv-query-params.dto'; +import { ApplicationMultiselectQuestion } from '../dtos/applications/application-multiselect-question.dto'; +import { Application } from '../dtos/applications/application.dto'; import MultiselectQuestion from '../dtos/multiselect-questions/multiselect-question.dto'; +import { SuccessDTO } from '../dtos/shared/success.dto'; import { User } from '../dtos/users/user.dto'; +import { permissionActions } from '../enums/permissions/permission-actions-enum'; +import { OrderByEnum } from '../enums/shared/order-by-enum'; import { ListingService } from './listing.service'; +import { MultiselectQuestionService } from './multiselect-question.service'; +import { PermissionService } from './permission.service'; +import { PrismaService } from './prisma.service'; +import { CsvHeader } from '../types/CsvExportInterface'; +import { getExportHeaders } from '../utilities/application-export-helpers'; import { mapTo } from '../utilities/mapTo'; -import { Application } from '../dtos/applications/application.dto'; -import { OrderByEnum } from '../enums/shared/order-by-enum'; -import { SuccessDTO } from 'src/dtos/shared/success.dto'; + +view.csv = { + ...view.details, + applicationFlaggedSet: { + select: { + id: true, + }, + }, + listings: false, +}; +const NUMBER_TO_PAGINATE_BY = 500; @Injectable() export class LotteryService { @@ -26,6 +48,7 @@ export class LotteryService { private prisma: PrismaService, private multiselectQuestionService: MultiselectQuestionService, private listingService: ListingService, + private permissionService: PermissionService, ) {} /** @@ -127,6 +150,12 @@ export class LotteryService { return { success: true }; } + /** + * @param listingId listing id we are going to randomize + * @param applications set of applications to generate lottery ranks for + * @param preferencesOnListing the set of preferences on the listing + * @description creates a random rank for the applications on this lottery as well as the preference specific ranks + */ async lotteryRandomizer( listingId: string, applications: Application[], @@ -201,6 +230,10 @@ export class LotteryService { } } + /** + * @param filterApplicationsArray the filtered applications we generate the random ordering for + * @returns ranked array + */ lotteryRandomizerHelper(filterApplicationsArray: Application[]): number[] { // prep our supporting array const ordinalArray: number[] = []; @@ -224,4 +257,331 @@ export class LotteryService { return ordinalArray; } + + /** + * + * @param queryParams + * @param req + * @returns generates the lottery export file via helper function and returns the streamable file + */ + async lotteryExport( + req: ExpressRequest, + res: Response, + queryParams: QueryParams, + ): Promise { + const user = mapTo(User, req['user']); + await this.authorizeLotteryExport(user, queryParams.listingId); + + const workbook = new Excel.Workbook(); + + const filename = join( + process.cwd(), + `src/temp/lottery-listing-${queryParams.listingId}-applications-${ + user.id + }-${new Date().getTime()}.xlsx`, + ); + + const zipFilePath = join( + process.cwd(), + `src/temp/lottery-listing-${queryParams.listingId}-applications-${ + user.id + }-${new Date().getTime()}.zip`, + ); + + await this.createLotterySheet(workbook, { + ...queryParams, + includeDemographics: true, + }); + + await workbook.xlsx.writeFile(filename); + + const readStream = createReadStream(filename); + + return new Promise((resolve) => { + // Create a writable stream to the zip file + const output = fs.createWriteStream(zipFilePath); + const archive = archiver('zip', { + zlib: { level: 9 }, + }); + output.on('close', () => { + const zipFile = createReadStream(zipFilePath); + resolve(new StreamableFile(zipFile)); + }); + + archive.pipe(output); + archive.append(readStream, { + name: `lottery-${queryParams.listingId}-${new Date().getTime()}.xlsx`, + }); + archive.finalize(); + }); + } + + /** + * + * @param filename + * @param queryParams + * @returns generates the lottery sheet + */ + async createLotterySheet( + workbook: Excel.Workbook, + queryParams: QueryParams, + ): Promise { + let applications = await this.prisma.applications.findMany({ + select: { + id: true, + preferences: true, + householdMember: { + select: { + id: true, + }, + }, + applicationLotteryPositions: { + select: { + ordinal: true, + multiselectQuestionId: true, + }, + where: { + multiselectQuestionId: null, + }, + orderBy: { + ordinal: OrderByEnum.DESC, + }, + }, + }, + where: { + listingId: queryParams.listingId, + deletedAt: null, + markedAsDuplicate: false, + }, + }); + + // get all multiselect questions for a listing to build csv headers + const multiSelectQuestions = + await this.multiselectQuestionService.findByListingId( + queryParams.listingId, + ); + + // get maxHouseholdMembers associated to the selected applications + let maxHouseholdMembers = 0; + applications.forEach((app) => { + if (app.householdMember?.length > maxHouseholdMembers) { + maxHouseholdMembers = app.householdMember.length; + } + }); + + const columns = getExportHeaders( + maxHouseholdMembers, + multiSelectQuestions, + queryParams.timeZone, + queryParams.includeDemographics, + true, + ); + + applications = applications.filter( + (elem) => !!elem.applicationLotteryPositions?.length, + ); + + applications = applications.sort( + (a, b) => + a.applicationLotteryPositions[0].ordinal - + b.applicationLotteryPositions[0].ordinal, + ); + await this.lotteryExportHelper( + workbook, + mapTo(Application, applications), + columns, + queryParams, + true, + ); + } + + /** + * @param user the user attempting to get the lottery export + * @param listingId the listing we are trying the export is for + */ + async authorizeLotteryExport(user, listingId): Promise { + /** + * 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.listingService.getJurisdictionIdByListingId(listingId); + + await this.permissionService.canOrThrow( + user, + 'listing', + permissionActions.update, + { + id: listingId, + jurisdictionId, + }, + ); + } + + /** + * + * @param workbook the spreadsheet we'll be adding data too + * @param applications the full list of partial applications + * @param csvHeaders the headers and renderers of the export + * @param queryParams the incoming param args + * @param forLottery whether we are getting the lottery results or not + * @returns void but writes the output to a file + */ + async lotteryExportHelper( + workbook: Excel.Workbook, + applications: Application[], + csvHeaders: CsvHeader[], + queryParams: ApplicationCsvQueryParams, + forLottery = false, + ): Promise { + // create raw rank spreadsheet + const rawRankSpreadsheet = workbook.addWorksheet('Raw'); + rawRankSpreadsheet.columns = this.buildExportColumns(csvHeaders); + + // build row data + const promiseArray: Promise[]>[] = []; + for (let i = 0; i < applications.length; i += NUMBER_TO_PAGINATE_BY) { + promiseArray.push( + new Promise(async (resolve) => { + // grab applications NUMBER_TO_PAGINATE_BY at a time + let paginatedApplications = await this.prisma.applications.findMany({ + include: { + ...view.csv, + demographics: queryParams.includeDemographics + ? { + select: { + id: true, + createdAt: true, + updatedAt: true, + ethnicity: true, + gender: true, + sexualOrientation: true, + howDidYouHear: true, + race: true, + }, + } + : false, + applicationLotteryPositions: forLottery + ? { + select: { + ordinal: true, + }, + where: { + multiselectQuestionId: null, + }, + } + : false, + }, + where: { + listingId: queryParams.listingId, + deletedAt: null, + markedAsDuplicate: forLottery ? false : undefined, + id: { + in: applications + .slice(i, i + NUMBER_TO_PAGINATE_BY) + .map((app) => app.id), + }, + }, + }); + if (forLottery) { + paginatedApplications = paginatedApplications.sort( + (a, b) => + a.applicationLotteryPositions[0].ordinal - + b.applicationLotteryPositions[0].ordinal, + ); + } + const rows: Partial[] = []; + paginatedApplications.forEach((app) => { + const row: Partial = {}; + let preferences: ApplicationMultiselectQuestion[]; + let programs: ApplicationMultiselectQuestion[]; + csvHeaders.forEach((header) => { + let multiselectQuestionValue = false; + let parsePreference = false; + let parseProgram = false; + let value = header.path.split('.').reduce((acc, curr) => { + // return preference/program as value for the format function to accept + if (multiselectQuestionValue) { + return acc; + } + + if (parsePreference) { + // curr should equal the preference id we're pulling from + if (!preferences) { + preferences = + (app.preferences as unknown as ApplicationMultiselectQuestion[]) || + []; + } + parsePreference = false; + // there aren't typically many preferences, but if there, then a object map should be created and used + const preference = preferences.find( + (preference) => preference.key === curr, + ); + multiselectQuestionValue = true; + return preference; + } else if (parseProgram) { + // curr should equal the preference id we're pulling from + if (!programs) { + programs = + (app.programs as unknown as ApplicationMultiselectQuestion[]) || + []; + } + parsePreference = false; + // there aren't typically many programs, but if there, then a object map should be created and used + const program = programs.find( + (preference) => preference.key === curr, + ); + multiselectQuestionValue = true; + return program; + } + + // sets parsePreference to true, for the next iteration + if (curr === 'preferences') { + parsePreference = true; + } else if (curr === 'programs') { + parseProgram = true; + } + + if (acc === null || acc === undefined) { + return ''; + } + + // handles working with arrays, e.g. householdMember.0.firstName + if (!isNaN(Number(curr))) { + const index = Number(curr); + return acc[index]; + } + + return acc[curr]; + }, app); + value = value === undefined ? '' : value === null ? '' : value; + if (header.format) { + value = header.format(value); + } + + row[`${header.path}`] = value ? value.toString() : ''; + }); + rows.push(row); + }); + resolve(rows); + }), + ); + } + const res = await Promise.all(promiseArray); + + // add rows to spreadsheet + res.forEach((elem) => { + rawRankSpreadsheet.addRows(elem); + }); + } + + buildExportColumns(csvHeaders: CsvHeader[]): Partial[] { + const res: Partial[] = csvHeaders.map((header) => ({ + key: header.path, + header: header.label, + })); + + return res; + } } diff --git a/api/src/types/CsvExportInterface.ts b/api/src/types/CsvExportInterface.ts index 4167e235a0..0ae7e2ca15 100644 --- a/api/src/types/CsvExportInterface.ts +++ b/api/src/types/CsvExportInterface.ts @@ -11,6 +11,13 @@ export type CsvHeader = { format?: (val: unknown, fullObject?: unknown) => unknown; }; +export interface LotteryHeader { + path: string; + key?: string; + header: string; + format?: (val: unknown, fullObject?: unknown) => unknown; +} + type OneOrMoreArgs = { 0: T } & Array; export interface CsvExporterServiceInterface { diff --git a/api/src/utilities/application-export-helpers.ts b/api/src/utilities/application-export-helpers.ts new file mode 100644 index 0000000000..3f3a178a8b --- /dev/null +++ b/api/src/utilities/application-export-helpers.ts @@ -0,0 +1,585 @@ +import { ApplicationSubmissionTypeEnum } from '@prisma/client'; +import { Address } from '../dtos/addresses/address.dto'; +import { ApplicationFlaggedSet } from '../dtos/application-flagged-sets/application-flagged-set.dto'; +import { ApplicationLotteryPosition } from '../dtos/applications/application-lottery-position.dto'; +import { ApplicationMultiselectQuestion } from '../dtos/applications/application-multiselect-question.dto'; +import { MultiselectQuestion } from '../dtos/multiselect-questions/multiselect-question.dto'; +import { UnitType } from '../dtos/unit-types/unit-type.dto'; +import { CsvHeader } from '../types/CsvExportInterface'; +import { formatLocalDate } from '../utilities/format-local-date'; + +/** + * + * @param maxHouseholdMembers the max number of household members on an application + * @param multiSelectQuestions the set of multiselect questions on the listing + * @param timeZone the timezone to output dates in + * @param includeDemographics whether to include demographic info or not + * @param forLottery whether this is for lottery or not + * @param dateFormat the format to output dates in + * @returns the set of export headers + */ +export const getExportHeaders = ( + maxHouseholdMembers: number, + multiSelectQuestions: MultiselectQuestion[], + timeZone: string, + includeDemographics = false, + forLottery = false, + dateFormat = 'MM-DD-YYYY hh:mm:ssA z', +): CsvHeader[] => { + const headers: CsvHeader[] = [ + { + path: 'id', + label: 'Application Id', + }, + { + path: 'confirmationCode', + label: 'Application Confirmation Code', + }, + ]; + + // if its for the lottery insert the lottery position + if (forLottery) { + headers.push({ + path: 'applicationLotteryPositions', + label: 'Raw Lottery Rank', + format: (val: ApplicationLotteryPosition[]): number => { + if (val?.length) { + return val[0].ordinal; + } + }, + }); + } + + headers.push( + ...[ + { + path: 'submissionType', + label: 'Application Type', + format: (val: string): string => + val === ApplicationSubmissionTypeEnum.electronical + ? 'electronic' + : val, + }, + { + path: 'submissionDate', + label: 'Application Submission Date', + format: (val: string): string => + formatLocalDate(val, dateFormat, timeZone ?? process.env.TIME_ZONE), + }, + { + path: 'applicant.firstName', + label: 'Primary Applicant First Name', + }, + { + path: 'applicant.middleName', + label: 'Primary Applicant Middle Name', + }, + { + path: 'applicant.lastName', + label: 'Primary Applicant Last Name', + }, + { + path: 'applicant.birthDay', + label: 'Primary Applicant Birth Day', + }, + { + path: 'applicant.birthMonth', + label: 'Primary Applicant Birth Month', + }, + { + path: 'applicant.birthYear', + label: 'Primary Applicant Birth Year', + }, + { + path: 'applicant.emailAddress', + label: 'Primary Applicant Email Address', + }, + { + path: 'applicant.phoneNumber', + label: 'Primary Applicant Phone Number', + }, + { + path: 'applicant.phoneNumberType', + label: 'Primary Applicant Phone Type', + }, + { + path: 'additionalPhoneNumber', + label: 'Primary Applicant Additional Phone Number', + }, + { + path: 'contactPreferences', + label: 'Primary Applicant Preferred Contact Type', + }, + { + path: 'applicant.applicantAddress.street', + label: `Primary Applicant Street`, + }, + { + path: 'applicant.applicantAddress.street2', + label: `Primary Applicant Street 2`, + }, + { + path: 'applicant.applicantAddress.city', + label: `Primary Applicant City`, + }, + { + path: 'applicant.applicantAddress.state', + label: `Primary Applicant State`, + }, + { + path: 'applicant.applicantAddress.zipCode', + label: `Primary Applicant Zip Code`, + }, + { + path: 'applicationsMailingAddress.street', + label: `Primary Applicant Mailing Street`, + }, + { + path: 'applicationsMailingAddress.street2', + label: `Primary Applicant Mailing Street 2`, + }, + { + path: 'applicationsMailingAddress.city', + label: `Primary Applicant Mailing City`, + }, + { + path: 'applicationsMailingAddress.state', + label: `Primary Applicant Mailing State`, + }, + { + path: 'applicationsMailingAddress.zipCode', + label: `Primary Applicant Mailing Zip Code`, + }, + { + path: 'applicant.applicantWorkAddress.street', + label: `Primary Applicant Work Street`, + }, + { + path: 'applicant.applicantWorkAddress.street2', + label: `Primary Applicant Work Street 2`, + }, + { + path: 'applicant.applicantWorkAddress.city', + label: `Primary Applicant Work City`, + }, + { + path: 'applicant.applicantWorkAddress.state', + label: `Primary Applicant Work State`, + }, + { + path: 'applicant.applicantWorkAddress.zipCode', + label: `Primary Applicant Work Zip Code`, + }, + { + path: 'alternateContact.firstName', + label: 'Alternate Contact First Name', + }, + { + path: 'alternateContact.lastName', + label: 'Alternate Contact Last Name', + }, + { + path: 'alternateContact.type', + label: 'Alternate Contact Type', + }, + { + path: 'alternateContact.agency', + label: 'Alternate Contact Agency', + }, + { + path: 'alternateContact.otherType', + label: 'Alternate Contact Other Type', + }, + { + path: 'alternateContact.emailAddress', + label: 'Alternate Contact Email Address', + }, + { + path: 'alternateContact.phoneNumber', + label: 'Alternate Contact Phone Number', + }, + { + path: 'alternateContact.address.street', + label: `Alternate Contact Street`, + }, + { + path: 'alternateContact.address.street2', + label: `Alternate Contact Street 2`, + }, + { + path: 'alternateContact.address.city', + label: `Alternate Contact City`, + }, + { + path: 'alternateContact.address.state', + label: `Alternate Contact State`, + }, + { + path: 'alternateContact.address.zipCode', + label: `Alternate Contact Zip Code`, + }, + { + path: 'income', + label: 'Income', + }, + { + path: 'incomePeriod', + label: 'Income Period', + format: (val: string): string => + val === 'perMonth' ? 'per month' : 'per year', + }, + { + path: 'accessibility.mobility', + label: 'Accessibility Mobility', + }, + { + path: 'accessibility.vision', + label: 'Accessibility Vision', + }, + { + path: 'accessibility.hearing', + label: 'Accessibility Hearing', + }, + { + path: 'householdExpectingChanges', + label: 'Expecting Household Changes', + }, + { + path: 'householdStudent', + label: 'Household Includes Student or Member Nearing 18', + }, + { + path: 'incomeVouchers', + label: 'Vouchers or Subsidies', + }, + { + path: 'preferredUnitTypes', + label: 'Requested Unit Types', + format: (val: UnitType[]): string => { + return val.map((unit) => unitTypeToReadable(unit.name)).join(','); + }, + }, + ], + ); + + // add preferences to csv headers + const preferenceHeaders = constructMultiselectQuestionHeaders( + 'preferences', + 'Preference', + multiSelectQuestions, + ); + headers.push(...preferenceHeaders); + + // add programs to csv headers + const programHeaders = constructMultiselectQuestionHeaders( + 'programs', + 'Program', + multiSelectQuestions, + ); + headers.push(...programHeaders); + + headers.push({ + path: 'householdSize', + label: 'Household Size', + }); + + // add household member headers to csv + if (maxHouseholdMembers) { + headers.push(...getHouseholdCsvHeaders(maxHouseholdMembers)); + } + + headers.push( + { + path: 'markedAsDuplicate', + label: 'Marked As Duplicate', + }, + { + path: 'applicationFlaggedSet', + label: 'Flagged As Duplicate', + format: (val: ApplicationFlaggedSet[]): boolean => { + return val.length > 0; + }, + }, + ); + + if (includeDemographics) { + headers.push( + { + path: 'demographics.ethnicity', + label: 'Ethnicity', + }, + { + path: 'demographics.race', + label: 'Race', + format: (val: string[]): string => + val.map((race) => convertDemographicRaceToReadable(race)).join(','), + }, + { + path: 'demographics.howDidYouHear', + label: 'How did you Hear?', + }, + ); + } + return headers; +}; + +/** + * + * @param applicationSection is this for programs or preferences + * @param labelString prefix for header output + * @param multiSelectQuestions the set of multiselect questions on the listing + * @returns the set of headers + */ +export const constructMultiselectQuestionHeaders = ( + applicationSection: string, + labelString: string, + multiSelectQuestions: MultiselectQuestion[], +): CsvHeader[] => { + const headers: CsvHeader[] = []; + + multiSelectQuestions + .filter((question) => question.applicationSection === applicationSection) + .forEach((question) => { + headers.push({ + path: `${applicationSection}.${question.text}.claimed`, + label: `${labelString} ${question.text}`, + format: (val: any): string => { + const claimedString: string[] = []; + val?.options?.forEach((option) => { + if (option.checked) { + claimedString.push(option.key); + } + }); + return claimedString.join(', '); + }, + }); + /** + * there are other input types for extra data besides address + * that are not used on the old backend, but could be added here + */ + question.options + ?.filter((option) => option.collectAddress) + .forEach((option) => { + headers.push({ + path: `${applicationSection}.${question.text}.address`, + label: `${labelString} ${question.text} - ${option.text} - Address`, + format: (val: ApplicationMultiselectQuestion): string => { + return multiselectQuestionFormat(val, option.text, 'address'); + }, + }); + if (option.validationMethod) { + headers.push({ + path: `${applicationSection}.${question.text}.address`, + label: `${labelString} ${question.text} - ${option.text} - Passed Address Check`, + format: (val: ApplicationMultiselectQuestion): string => { + return multiselectQuestionFormat( + val, + option.text, + 'geocodingVerified', + ); + }, + }); + } + if (option.collectName) { + headers.push({ + path: `${applicationSection}.${question.text}.address`, + label: `${labelString} ${question.text} - ${option.text} - Name of Address Holder`, + format: (val: ApplicationMultiselectQuestion): string => { + return multiselectQuestionFormat( + val, + option.text, + 'addressHolderName', + ); + }, + }); + } + if (option.collectRelationship) { + headers.push({ + path: `${applicationSection}.${question.text}.address`, + label: `${labelString} ${question.text} - ${option.text} - Relationship to Address Holder`, + format: (val: ApplicationMultiselectQuestion): string => { + return multiselectQuestionFormat( + val, + option.text, + 'addressHolderRelationship', + ); + }, + }); + } + }); + }); + + return headers; +}; + +/** + * + * @param question the multiselect question to format + * @param optionText the option to consider selected + * @param key additional formatting (address or geocodingVerified) + * @returns the string representation of the multiselect format + */ +export const multiselectQuestionFormat = ( + question: ApplicationMultiselectQuestion, + optionText: string, + key: string, +): string => { + if (!question) return ''; + const selectedOption = question.options.find( + (option) => option.key === optionText, + ); + const extraData = selectedOption?.extraData.find((data) => data.key === key); + if (!extraData) { + return ''; + } + if (key === 'address') { + return addressToString(extraData.value as Address); + } + if (key === 'geocodingVerified') { + return extraData.value === 'unknown' + ? 'Needs Manual Verification' + : extraData.value.toString(); + } + return extraData.value as string; +}; + +/** + * + * @param address the address to convert to a string + * @returns a string representation of the address + */ +export const addressToString = (address: Address): string => { + return `${address.street}${address.street2 ? ' ' + address.street2 : ''} ${ + address.city + }, ${address.state} ${address.zipCode}`; +}; + +/** + * + * @param maxHouseholdMembers the maximum number of household members across all applications + * @returns the headers and formatters for the household member columns + */ +export const getHouseholdCsvHeaders = ( + maxHouseholdMembers: number, +): CsvHeader[] => { + const headers = []; + for (let i = 0; i < maxHouseholdMembers; i++) { + const j = i + 1; + headers.push( + { + path: `householdMember.${i}.firstName`, + label: `Household Member (${j}) First Name`, + }, + { + path: `householdMember.${i}.middleName`, + label: `Household Member (${j}) Middle Name`, + }, + { + path: `householdMember.${i}.lastName`, + label: `Household Member (${j}) Last Name`, + }, + { + path: `householdMember.${i}.firstName`, + label: `Household Member (${j}) First Name`, + }, + { + path: `householdMember.${i}.birthDay`, + label: `Household Member (${j}) Birth Day`, + }, + { + path: `householdMember.${i}.birthMonth`, + label: `Household Member (${j}) Birth Month`, + }, + { + path: `householdMember.${i}.birthYear`, + label: `Household Member (${j}) Birth Year`, + }, + { + path: `householdMember.${i}.sameAddress`, + label: `Household Member (${j}) Same as Primary Applicant`, + }, + { + path: `householdMember.${i}.relationship`, + label: `Household Member (${j}) Relationship`, + }, + { + path: `householdMember.${i}.workInRegion`, + label: `Household Member (${j}) Work in Region`, + }, + { + path: `householdMember.${i}.householdMemberAddress.street`, + label: `Household Member (${j}) Street`, + }, + { + path: `householdMember.${i}.householdMemberAddress.street2`, + label: `Household Member (${j}) Street 2`, + }, + { + path: `householdMember.${i}.householdMemberAddress.city`, + label: `Household Member (${j}) City`, + }, + { + path: `householdMember.${i}.householdMemberAddress.state`, + label: `Household Member (${j}) State`, + }, + { + path: `householdMember.${i}.householdMemberAddress.zipCode`, + label: `Household Member (${j}) Zip Code`, + }, + ); + } + + return headers; +}; + +/** + * + * @param type takes in the demographic string + * @returns outputs the readable version of the string + */ +export const convertDemographicRaceToReadable = (type: string): string => { + const [rootKey, customValue = ''] = type.split(':'); + const typeMap = { + americanIndianAlaskanNative: 'American Indian / Alaskan Native', + asian: 'Asian', + 'asian-asianIndian': 'Asian[Asian Indian]', + 'asian-otherAsian': `Asian[Other Asian:${customValue}]`, + blackAfricanAmerican: 'Black / African American', + 'asian-chinese': 'Asian[Chinese]', + declineToRespond: 'Decline to Respond', + 'asian-filipino': 'Asian[Filipino]', + 'nativeHawaiianOtherPacificIslander-guamanianOrChamorro': + 'Native Hawaiian / Other Pacific Islander[Guamanian or Chamorro]', + 'asian-japanese': 'Asian[Japanese]', + 'asian-korean': 'Asian[Korean]', + 'nativeHawaiianOtherPacificIslander-nativeHawaiian': + 'Native Hawaiian / Other Pacific Islander[Native Hawaiian]', + nativeHawaiianOtherPacificIslander: + 'Native Hawaiian / Other Pacific Islander', + otherMultiracial: `Other / Multiracial:${customValue}`, + 'nativeHawaiianOtherPacificIslander-otherPacificIslander': `Native Hawaiian / Other Pacific Islander[Other Pacific Islander:${customValue}]`, + 'nativeHawaiianOtherPacificIslander-samoan': + 'Native Hawaiian / Other Pacific Islander[Samoan]', + 'asian-vietnamese': 'Asian[Vietnamese]', + white: 'White', + }; + return typeMap[rootKey] ?? rootKey; +}; + +export const typeMap = { + SRO: 'SRO', + studio: 'Studio', + oneBdrm: 'One Bedroom', + twoBdrm: 'Two Bedroom', + threeBdrm: 'Three Bedroom', + fourBdrm: 'Four+ Bedroom', + fiveBdrm: 'Five Bedroom', +}; + +/** + * @param type the unit type we are converting from type to string + * @returns the string representation of that unit type + */ +export const unitTypeToReadable = (type: string): string => { + return typeMap[type] ?? type; +}; diff --git a/api/test/integration/lottery.e2e-spec.ts b/api/test/integration/lottery.e2e-spec.ts index 64057c1406..054fe74d6e 100644 --- a/api/test/integration/lottery.e2e-spec.ts +++ b/api/test/integration/lottery.e2e-spec.ts @@ -8,6 +8,9 @@ import { } from '@prisma/client'; import request from 'supertest'; import cookieParser from 'cookie-parser'; +import { randomUUID } from 'crypto'; +import { Request as ExpressRequest, Response } from 'express'; +import { stringify } from 'qs'; import { AppModule } from '../../src/modules/app.module'; import { PrismaService } from '../../src/services/prisma.service'; import { applicationFactory } from '../../prisma/seed-helpers/application-factory'; @@ -22,10 +25,13 @@ import { userFactory } from '../../prisma/seed-helpers/user-factory'; import { Login } from '../../src/dtos/auth/login.dto'; import { multiselectQuestionFactory } from '../../prisma/seed-helpers/multiselect-question-factory'; import { reservedCommunityTypeFactoryAll } from '../../prisma/seed-helpers/reserved-community-type-factory'; +import { LotteryService } from '../../src/services/lottery.service'; +import { ApplicationCsvQueryParams } from 'src/dtos/applications/application-csv-query-params.dto'; describe('Application Controller Tests', () => { let app: INestApplication; let prisma: PrismaService; + let lotteryService: LotteryService; let cookies = ''; const createMultiselectQuestion = async ( @@ -56,6 +62,7 @@ describe('Application Controller Tests', () => { app = moduleFixture.createNestApplication(); prisma = moduleFixture.get(PrismaService); + lotteryService = moduleFixture.get(LotteryService); app.use(cookieParser()); await app.init(); await unitTypeFactoryAll(prisma); @@ -428,4 +435,83 @@ describe('Application Controller Tests', () => { ); }); }); + + describe('getLotteryResults endpoint', () => { + it('should get a lottery export of application', async () => { + const unitTypeA = await unitTypeFactorySingle( + prisma, + UnitTypeEnum.oneBdrm, + ); + const jurisdiction = await prisma.jurisdictions.create({ + data: jurisdictionFactory(), + }); + await reservedCommunityTypeFactoryAll(jurisdiction.id, prisma); + const listing1 = await listingFactory(jurisdiction.id, prisma, { + status: ListingsStatusEnum.closed, + }); + const listing1Created = await prisma.listings.create({ + data: listing1, + }); + + const appA = await applicationFactory({ + unitTypeId: unitTypeA.id, + listingId: listing1Created.id, + }); + + await prisma.applications.create({ + data: appA, + include: { + applicant: true, + }, + }); + + const appB = await applicationFactory({ + unitTypeId: unitTypeA.id, + listingId: listing1Created.id, + }); + + await prisma.applications.create({ + data: appB, + include: { + applicant: true, + }, + }); + + const appC = await applicationFactory({ + unitTypeId: unitTypeA.id, + listingId: listing1Created.id, + }); + + await prisma.applications.create({ + data: appC, + include: { + applicant: true, + }, + }); + + await lotteryService.lotteryGenerate( + { + user: { + id: randomUUID(), + userRoles: { + isAdmin: true, + }, + }, + } as unknown as ExpressRequest, + {} as Response, + { listingId: listing1Created.id }, + ); + + const queryParams: ApplicationCsvQueryParams = { + listingId: listing1Created.id, + }; + const query = stringify(queryParams as any); + + await request(app.getHttpServer()) + .get(`/lottery/getLotteryResults?${query}`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + }); + }); }); diff --git a/api/test/unit/services/application-csv-export.service.spec.ts b/api/test/unit/services/application-csv-export.service.spec.ts index de1b1a970c..dbfaf7feff 100644 --- a/api/test/unit/services/application-csv-export.service.spec.ts +++ b/api/test/unit/services/application-csv-export.service.spec.ts @@ -1,20 +1,12 @@ import { randomUUID } from 'crypto'; import { PassThrough } from 'stream'; import { Test, TestingModule } from '@nestjs/testing'; -import { - LotteryStatusEnum, - MultiselectQuestionsApplicationSectionEnum, -} from '@prisma/client'; +import { MultiselectQuestionsApplicationSectionEnum } from '@prisma/client'; import { HttpModule } from '@nestjs/axios'; import { Request as ExpressRequest, Response } from 'express'; import { PrismaService } from '../../../src/services/prisma.service'; import { ApplicationCsvExporterService } from '../../../src/services/application-csv-export.service'; import { MultiselectQuestionService } from '../../../src/services/multiselect-question.service'; -import { ApplicationMultiselectQuestion } from '../../../src/dtos/applications/application-multiselect-question.dto'; -import { InputType } from '../../../src/enums/shared/input-type-enum'; -import { UnitType } from 'src/dtos/unit-types/unit-type.dto'; -import { Address } from '../../../src/dtos/addresses/address.dto'; -import { ApplicationFlaggedSet } from '../../../src/dtos/application-flagged-sets/application-flagged-set.dto'; import { User } from '../../../src/dtos/users/user.dto'; import { mockApplicationSet } from './application.service.spec'; import { mockMultiselectQuestion } from './multiselect-question.service.spec'; @@ -27,226 +19,12 @@ import { ConfigService } from '@nestjs/config'; import { Logger } from '@nestjs/common'; import { SchedulerRegistry } from '@nestjs/schedule'; import { GoogleTranslateService } from '../../../src/services/google-translate.service'; -import { CsvHeader } from '../../../src/types/CsvExportInterface'; +import { unitTypeToReadable } from '../../../src/utilities/application-export-helpers'; describe('Testing application CSV export service', () => { let service: ApplicationCsvExporterService; - let address: Address; let prisma: PrismaService; let permissionService: PermissionService; - const csvHeaders: CsvHeader[] = [ - { - path: 'id', - label: 'Application Id', - }, - { - path: 'confirmationCode', - label: 'Application Confirmation Code', - }, - { - path: 'submissionType', - label: 'Application Type', - }, - { - path: 'submissionDate', - label: 'Application Submission Date', - }, - { - path: 'applicant.firstName', - label: 'Primary Applicant First Name', - }, - { - path: 'applicant.middleName', - label: 'Primary Applicant Middle Name', - }, - { - path: 'applicant.lastName', - label: 'Primary Applicant Last Name', - }, - { - path: 'applicant.birthDay', - label: 'Primary Applicant Birth Day', - }, - { - path: 'applicant.birthMonth', - label: 'Primary Applicant Birth Month', - }, - { - path: 'applicant.birthYear', - label: 'Primary Applicant Birth Year', - }, - { - path: 'applicant.emailAddress', - label: 'Primary Applicant Email Address', - }, - { - path: 'applicant.phoneNumber', - label: 'Primary Applicant Phone Number', - }, - { - path: 'applicant.phoneNumberType', - label: 'Primary Applicant Phone Type', - }, - { - path: 'additionalPhoneNumber', - label: 'Primary Applicant Additional Phone Number', - }, - { - path: 'contactPreferences', - label: 'Primary Applicant Preferred Contact Type', - }, - { - path: 'applicant.applicantAddress.street', - label: `Primary Applicant Street`, - }, - { - path: 'applicant.applicantAddress.street2', - label: `Primary Applicant Street 2`, - }, - { - path: 'applicant.applicantAddress.city', - label: `Primary Applicant City`, - }, - { - path: 'applicant.applicantAddress.state', - label: `Primary Applicant State`, - }, - { - path: 'applicant.applicantAddress.zipCode', - label: `Primary Applicant Zip Code`, - }, - { - path: 'applicationsMailingAddress.street', - label: `Primary Applicant Mailing Street`, - }, - { - path: 'applicationsMailingAddress.street2', - label: `Primary Applicant Mailing Street 2`, - }, - { - path: 'applicationsMailingAddress.city', - label: `Primary Applicant Mailing City`, - }, - { - path: 'applicationsMailingAddress.state', - label: `Primary Applicant Mailing State`, - }, - { - path: 'applicationsMailingAddress.zipCode', - label: `Primary Applicant Mailing Zip Code`, - }, - { - path: 'applicant.applicantWorkAddress.street', - label: `Primary Applicant Work Street`, - }, - { - path: 'applicant.applicantWorkAddress.street2', - label: `Primary Applicant Work Street 2`, - }, - { - path: 'applicant.applicantWorkAddress.city', - label: `Primary Applicant Work City`, - }, - { - path: 'applicant.applicantWorkAddress.state', - label: `Primary Applicant Work State`, - }, - { - path: 'applicant.applicantWorkAddress.zipCode', - label: `Primary Applicant Work Zip Code`, - }, - { - path: 'alternateContact.firstName', - label: 'Alternate Contact First Name', - }, - { - path: 'alternateContact.lastName', - label: 'Alternate Contact Last Name', - }, - { - path: 'alternateContact.type', - label: 'Alternate Contact Type', - }, - { - path: 'alternateContact.agency', - label: 'Alternate Contact Agency', - }, - { - path: 'alternateContact.otherType', - label: 'Alternate Contact Other Type', - }, - { - path: 'alternateContact.emailAddress', - label: 'Alternate Contact Email Address', - }, - { - path: 'alternateContact.phoneNumber', - label: 'Alternate Contact Phone Number', - }, - { - path: 'alternateContact.address.street', - label: `Alternate Contact Street`, - }, - { - path: 'alternateContact.address.street2', - label: `Alternate Contact Street 2`, - }, - { - path: 'alternateContact.address.city', - label: `Alternate Contact City`, - }, - { - path: 'alternateContact.address.state', - label: `Alternate Contact State`, - }, - { - path: 'alternateContact.address.zipCode', - label: `Alternate Contact Zip Code`, - }, - { - path: 'income', - label: 'Income', - }, - { - path: 'incomePeriod', - label: 'Income Period', - format: (val: string): string => - val === 'perMonth' ? 'per month' : 'per year', - }, - { - path: 'accessibility.mobility', - label: 'Accessibility Mobility', - }, - { - path: 'accessibility.vision', - label: 'Accessibility Vision', - }, - { - path: 'accessibility.hearing', - label: 'Accessibility Hearing', - }, - { - path: 'householdExpectingChanges', - label: 'Expecting Household Changes', - }, - { - path: 'householdStudent', - label: 'Household Includes Student or Member Nearing 18', - }, - { - path: 'incomeVouchers', - label: 'Vouchers or Subsidies', - }, - { - path: 'preferredUnitTypes', - label: 'Requested Unit Types', - format: (val: UnitType[]): string => { - return val - .map((unit) => service.unitTypeToReadable(unit.name)) - .join(','); - }, - }, - ]; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -279,164 +57,6 @@ describe('Testing application CSV export service', () => { ); prisma = module.get(PrismaService); permissionService = module.get(PermissionService); - - address = { - id: randomUUID(), - createdAt: new Date(), - updatedAt: new Date(), - street: '123 4th St', - street2: '#5', - city: 'City', - state: 'State', - zipCode: '67890', - }; - }); - - it('tests unitTypeToReadable with type in typeMap', () => { - const types = [ - 'SRO', - 'studio', - 'oneBdrm', - 'twoBdrm', - 'threeBdrm', - 'fourBdrm', - ]; - - const readableTypes = [ - 'SRO', - 'Studio', - 'One Bedroom', - 'Two Bedroom', - 'Three Bedroom', - 'Four+ Bedroom', - ]; - - for (let i = 0; i < types.length; i++) { - expect(service.unitTypeToReadable(types[i])).toBe(readableTypes[i]); - } - }); - - it('tests unitTypeToRedable with type not in typeMap', () => { - const custom = 'FiveBdrm'; - expect(service.unitTypeToReadable(custom)).toBe(custom); - }); - - it('tests convertDemographicRaceToReadable with valid type', () => { - const keys = [ - 'americanIndianAlaskanNative', - 'declineToRespond', - 'nativeHawaiianOtherPacificIslander-nativeHawaiian', - ]; - - const values = [ - 'American Indian / Alaskan Native', - 'Decline to Respond', - 'Native Hawaiian / Other Pacific Islander[Native Hawaiian]', - ]; - - for (let i = 0; i < keys.length; i++) { - expect(service.convertDemographicRaceToReadable(keys[i])).toBe(values[i]); - } - }); - - it('tests convertDemographicRaceToReadable with valid type and custom value', () => { - expect( - service.convertDemographicRaceToReadable( - 'nativeHawaiianOtherPacificIslander-otherPacificIslander:Fijian', - ), - ).toBe( - 'Native Hawaiian / Other Pacific Islander[Other Pacific Islander:Fijian]', - ); - }); - - it('tests convertDemographicRaceToReadable with type not in typeMap', () => { - const custom = 'This is a custom value'; - expect(service.convertDemographicRaceToReadable(custom)).toBe(custom); - }); - - it('tests multiselectQuestionFormat with undefined question passed', () => { - expect( - service.multiselectQuestionFormat(undefined, undefined, undefined), - ).toBe(''); - }); - - it('tests multiselectQuestionFormat', () => { - const question: ApplicationMultiselectQuestion = { - multiselectQuestionId: randomUUID(), - key: 'Test Preference 1', - claimed: true, - options: [ - { - key: 'option 1', - checked: true, - extraData: [ - { - key: 'address', - type: InputType.address, - value: address, - }, - ], - }, - ], - }; - - expect( - service.multiselectQuestionFormat(question, 'option 1', 'address'), - ).toBe('123 4th St #5 City, State 67890'); - }); - - it('tests addressToString without street 2', () => { - const testAddress = { ...address, street2: undefined }; - expect(service.addressToString(testAddress)).toBe( - '123 4th St City, State 67890', - ); - }); - - it('tests getCsvHeaders with no houshold members, multiselect questions or demographics', async () => { - const headers = await service.getCsvHeaders(0, [], process.env.TIME_ZONE); - const testHeaders = [ - ...csvHeaders, - { - path: 'householdSize', - label: 'Household Size', - }, - { - path: 'markedAsDuplicate', - label: 'Marked As Duplicate', - }, - { - path: 'applicationFlaggedSet', - label: 'Flagged As Duplicate', - format: (val: ApplicationFlaggedSet[]): boolean => { - return val.length > 0; - }, - }, - ]; - expect(JSON.stringify(headers)).toEqual(JSON.stringify(testHeaders)); - }); - - it('tests getCsvHeaders with household members and no multiselect questions or demographics', async () => { - const headers = await service.getCsvHeaders(3, [], process.env.TIME_ZONE); - const testHeaders = [ - ...csvHeaders, - { - path: 'householdSize', - label: 'Household Size', - }, - ...service.getHouseholdCsvHeaders(3), - { - path: 'markedAsDuplicate', - label: 'Marked As Duplicate', - }, - { - path: 'applicationFlaggedSet', - label: 'Flagged As Duplicate', - format: (val: ApplicationFlaggedSet[]): boolean => { - return val.length > 0; - }, - }, - ]; - expect(JSON.stringify(headers)).toEqual(JSON.stringify(testHeaders)); }); it('should build csv headers without demographics', async () => { @@ -474,7 +94,6 @@ describe('Testing application CSV export service', () => { }, ]); - service.unitTypeToReadable = jest.fn().mockReturnValue('Studio'); const exportResponse = await service.exportFile( { user: requestingUser } as unknown as ExpressRequest, {} as unknown as Response, @@ -487,7 +106,7 @@ describe('Testing application CSV export service', () => { const headerRow = '"Application Id","Application Confirmation Code","Application Type","Application Submission Date","Primary Applicant First Name","Primary Applicant Middle Name","Primary Applicant Last Name","Primary Applicant Birth Day","Primary Applicant Birth Month","Primary Applicant Birth Year","Primary Applicant Email Address","Primary Applicant Phone Number","Primary Applicant Phone Type","Primary Applicant Additional Phone Number","Primary Applicant Preferred Contact Type","Primary Applicant Street","Primary Applicant Street 2","Primary Applicant City","Primary Applicant State","Primary Applicant Zip Code","Primary Applicant Mailing Street","Primary Applicant Mailing Street 2","Primary Applicant Mailing City","Primary Applicant Mailing State","Primary Applicant Mailing Zip Code","Primary Applicant Work Street","Primary Applicant Work Street 2","Primary Applicant Work City","Primary Applicant Work State","Primary Applicant Work Zip Code","Alternate Contact First Name","Alternate Contact Last Name","Alternate Contact Type","Alternate Contact Agency","Alternate Contact Other Type","Alternate Contact Email Address","Alternate Contact Phone Number","Alternate Contact Street","Alternate Contact Street 2","Alternate Contact City","Alternate Contact State","Alternate Contact Zip Code","Income","Income Period","Accessibility Mobility","Accessibility Vision","Accessibility Hearing","Expecting Household Changes","Household Includes Student or Member Nearing 18","Vouchers or Subsidies","Requested Unit Types","Preference text 0","Preference text 0 - text - Address","Program text 1","Household Size","Household Member (1) First Name","Household Member (1) Middle Name","Household Member (1) Last Name","Household Member (1) First Name","Household Member (1) Birth Day","Household Member (1) Birth Month","Household Member (1) Birth Year","Household Member (1) Same as Primary Applicant","Household Member (1) Relationship","Household Member (1) Work in Region","Household Member (1) Street","Household Member (1) Street 2","Household Member (1) City","Household Member (1) State","Household Member (1) Zip Code","Marked As Duplicate","Flagged As Duplicate"'; const firstApp = - '"application 0 firstName","application 0 middleName","application 0 lastName","application 0 birthDay","application 0 birthMonth","application 0 birthYear","application 0 emailaddress","application 0 phoneNumber","application 0 phoneNumberType","additionalPhoneNumber 0",,"application 0 applicantAddress street","application 0 applicantAddress street2","application 0 applicantAddress city","application 0 applicantAddress state","application 0 applicantAddress zipCode",,,,,,"application 0 applicantWorkAddress street","application 0 applicantWorkAddress street2","application 0 applicantWorkAddress city","application 0 applicantWorkAddress state","application 0 applicantWorkAddress zipCode",,,,,,,,,,,,,"income 0","per month",,,,"true","true","true","Studio,Studio",,,,,,,,,,,,,,,,,,,,'; + '"application 0 firstName","application 0 middleName","application 0 lastName","application 0 birthDay","application 0 birthMonth","application 0 birthYear","application 0 emailaddress","application 0 phoneNumber","application 0 phoneNumberType","additionalPhoneNumber 0",,"application 0 applicantAddress street","application 0 applicantAddress street2","application 0 applicantAddress city","application 0 applicantAddress state","application 0 applicantAddress zipCode",,,,,,"application 0 applicantWorkAddress street","application 0 applicantWorkAddress street2","application 0 applicantWorkAddress city","application 0 applicantWorkAddress state","application 0 applicantWorkAddress zipCode",,,,,,,,,,,,,"income 0","per month",,,,"true","true","true","Studio,One Bedroom",,,,,,,,,,,,,,,,,,,,'; const mockedStream = new PassThrough(); exportResponse.getStream().pipe(mockedStream); @@ -534,8 +153,6 @@ describe('Testing application CSV export service', () => { ), ]); - service.unitTypeToReadable = jest.fn().mockReturnValue('Studio'); - const exportResponse = await service.exportFile( { user: requestingUser } as unknown as ExpressRequest, {} as unknown as Response, @@ -545,7 +162,7 @@ describe('Testing application CSV export service', () => { const headerRow = '"Application Id","Application Confirmation Code","Application Type","Application Submission Date","Primary Applicant First Name","Primary Applicant Middle Name","Primary Applicant Last Name","Primary Applicant Birth Day","Primary Applicant Birth Month","Primary Applicant Birth Year","Primary Applicant Email Address","Primary Applicant Phone Number","Primary Applicant Phone Type","Primary Applicant Additional Phone Number","Primary Applicant Preferred Contact Type","Primary Applicant Street","Primary Applicant Street 2","Primary Applicant City","Primary Applicant State","Primary Applicant Zip Code","Primary Applicant Mailing Street","Primary Applicant Mailing Street 2","Primary Applicant Mailing City","Primary Applicant Mailing State","Primary Applicant Mailing Zip Code","Primary Applicant Work Street","Primary Applicant Work Street 2","Primary Applicant Work City","Primary Applicant Work State","Primary Applicant Work Zip Code","Alternate Contact First Name","Alternate Contact Last Name","Alternate Contact Type","Alternate Contact Agency","Alternate Contact Other Type","Alternate Contact Email Address","Alternate Contact Phone Number","Alternate Contact Street","Alternate Contact Street 2","Alternate Contact City","Alternate Contact State","Alternate Contact Zip Code","Income","Income Period","Accessibility Mobility","Accessibility Vision","Accessibility Hearing","Expecting Household Changes","Household Includes Student or Member Nearing 18","Vouchers or Subsidies","Requested Unit Types","Preference text 0","Program text 1","Household Size","Marked As Duplicate","Flagged As Duplicate","Ethnicity","Race","How did you Hear?"'; const firstApp = - '"application 0 firstName","application 0 middleName","application 0 lastName","application 0 birthDay","application 0 birthMonth","application 0 birthYear","application 0 emailaddress","application 0 phoneNumber","application 0 phoneNumberType","additionalPhoneNumber 0",,"application 0 applicantAddress street","application 0 applicantAddress street2","application 0 applicantAddress city","application 0 applicantAddress state","application 0 applicantAddress zipCode",,,,,,"application 0 applicantWorkAddress street","application 0 applicantWorkAddress street2","application 0 applicantWorkAddress city","application 0 applicantWorkAddress state","application 0 applicantWorkAddress zipCode",,,,,,,,,,,,,"income 0","per month",,,,"true","true","true","Studio,Studio",,,,,,,"Decline to Respond"'; + '"application 0 firstName","application 0 middleName","application 0 lastName","application 0 birthDay","application 0 birthMonth","application 0 birthYear","application 0 emailaddress","application 0 phoneNumber","application 0 phoneNumberType","additionalPhoneNumber 0",,"application 0 applicantAddress street","application 0 applicantAddress street2","application 0 applicantAddress city","application 0 applicantAddress state","application 0 applicantAddress zipCode",,,,,,"application 0 applicantWorkAddress street","application 0 applicantWorkAddress street2","application 0 applicantWorkAddress city","application 0 applicantWorkAddress state","application 0 applicantWorkAddress zipCode",,,,,,,,,,,,,"income 0","per month",,,,"true","true","true","Studio,One Bedroom",,,,,,,"Decline to Respond",'; const mockedStream = new PassThrough(); exportResponse.getStream().pipe(mockedStream); @@ -601,7 +218,9 @@ describe('Testing application CSV export service', () => { }, ]); - service.unitTypeToReadable = jest.fn().mockReturnValue('Studio'); + jest + .spyOn({ unitTypeToReadable }, 'unitTypeToReadable') + .mockReturnValue('Studio'); const exportResponse = await service.exportFile( { user: requestingUser } as unknown as ExpressRequest, {} as unknown as Response, @@ -662,7 +281,9 @@ describe('Testing application CSV export service', () => { }, ]); - service.unitTypeToReadable = jest.fn().mockReturnValue('Studio'); + jest + .spyOn({ unitTypeToReadable }, 'unitTypeToReadable') + .mockReturnValue('Studio'); const exportResponse = await service.exportFile( { user: requestingUser } as unknown as ExpressRequest, {} as unknown as Response, @@ -687,74 +308,4 @@ describe('Testing application CSV export service', () => { expect(readable).toContain('EST'); }); - - describe('Testing lotteryExport()', () => { - it('should generate lottery results ', async () => { - process.env.TIME_ZONE = 'America/Los_Angeles'; - jest.useFakeTimers(); - jest.setSystemTime(new Date('2024-01-01')); - - const listingId = randomUUID(); - - const requestingUser = { - firstName: 'requesting fName', - lastName: 'requesting lName', - email: 'requestingUser@email.com', - jurisdictions: [{ id: 'juris id' }], - } as unknown as User; - - const applications = mockApplicationSet(5, new Date(), undefined, true); - prisma.applications.findMany = jest.fn().mockReturnValue(applications); - prisma.listings.findUnique = jest.fn().mockResolvedValue({ - id: listingId, - lotteryLastRunAt: new Date(), - lotteryStatus: LotteryStatusEnum.ran, - }); - permissionService.canOrThrow = jest.fn().mockResolvedValue(true); - - prisma.multiselectQuestions.findMany = jest.fn().mockReturnValue([ - { - ...mockMultiselectQuestion( - 0, - new Date(), - MultiselectQuestionsApplicationSectionEnum.preferences, - ), - options: [ - { id: 1, text: 'text' }, - { id: 2, text: 'text', collectAddress: true }, - ], - }, - { - ...mockMultiselectQuestion( - 1, - new Date(), - MultiselectQuestionsApplicationSectionEnum.programs, - ), - options: [{ id: 1, text: 'text' }], - }, - ]); - - service.unitTypeToReadable = jest.fn().mockReturnValue('Studio'); - const exportResponse = await service.lotteryExport( - { user: requestingUser } as unknown as ExpressRequest, - {} as unknown as Response, - { listingId }, - ); - - const mockedStream = new PassThrough(); - exportResponse.getStream().pipe(mockedStream); - - // In order to make sure the last expect statements are properly hit we need to wrap in a promise and resolve it - const readable = await new Promise((resolve) => { - mockedStream.on('data', async (d) => { - const value = Buffer.from(d).toString(); - mockedStream.end(); - mockedStream.destroy(); - resolve(value); - }); - }); - - expect(readable).toContain('Raw Lottery Rank'); - }); - }); }); diff --git a/api/test/unit/services/application.service.spec.ts b/api/test/unit/services/application.service.spec.ts index 034d2fd9f6..5678d8629e 100644 --- a/api/test/unit/services/application.service.spec.ts +++ b/api/test/unit/services/application.service.spec.ts @@ -60,7 +60,7 @@ export const mockApplication = ( incomePeriod: IncomePeriodEnum.perMonth, preferences: [{ claimed: true, key: 'example key', options: null }], programs: [{ claimed: true, key: 'example key', options: null }], - preferredUnitTypes: ['studio', 'oneBdrm'], + preferredUnitTypes: [{ name: 'studio' }, { name: 'oneBdrm' }], status: ApplicationStatusEnum.submitted, submissionType: ApplicationSubmissionTypeEnum.electronical, acceptedTerms: true, diff --git a/api/test/unit/utilities/application-export-helpers.spec.ts b/api/test/unit/utilities/application-export-helpers.spec.ts new file mode 100644 index 0000000000..7ccb419180 --- /dev/null +++ b/api/test/unit/utilities/application-export-helpers.spec.ts @@ -0,0 +1,394 @@ +import { randomUUID } from 'crypto'; +import { ApplicationFlaggedSet } from '../../../src/dtos/application-flagged-sets/application-flagged-set.dto'; +import { ApplicationMultiselectQuestion } from '../../../src/dtos/applications/application-multiselect-question.dto'; +import { CsvHeader } from '../../../src/types/CsvExportInterface'; +import { UnitType } from '../../../src/dtos/unit-types/unit-type.dto'; +import { InputType } from '../../../src/enums/shared/input-type-enum'; +import { + addressToString, + convertDemographicRaceToReadable, + getExportHeaders, + getHouseholdCsvHeaders, + multiselectQuestionFormat, + unitTypeToReadable, +} from '../../../src/utilities/application-export-helpers'; + +describe('Testing application export helpers', () => { + const address = { + id: randomUUID(), + createdAt: new Date(), + updatedAt: new Date(), + street: '123 4th St', + street2: '#5', + city: 'City', + state: 'State', + zipCode: '67890', + }; + + const csvHeaders: CsvHeader[] = [ + { + path: 'id', + label: 'Application Id', + }, + { + path: 'confirmationCode', + label: 'Application Confirmation Code', + }, + { + path: 'submissionType', + label: 'Application Type', + }, + { + path: 'submissionDate', + label: 'Application Submission Date', + }, + { + path: 'applicant.firstName', + label: 'Primary Applicant First Name', + }, + { + path: 'applicant.middleName', + label: 'Primary Applicant Middle Name', + }, + { + path: 'applicant.lastName', + label: 'Primary Applicant Last Name', + }, + { + path: 'applicant.birthDay', + label: 'Primary Applicant Birth Day', + }, + { + path: 'applicant.birthMonth', + label: 'Primary Applicant Birth Month', + }, + { + path: 'applicant.birthYear', + label: 'Primary Applicant Birth Year', + }, + { + path: 'applicant.emailAddress', + label: 'Primary Applicant Email Address', + }, + { + path: 'applicant.phoneNumber', + label: 'Primary Applicant Phone Number', + }, + { + path: 'applicant.phoneNumberType', + label: 'Primary Applicant Phone Type', + }, + { + path: 'additionalPhoneNumber', + label: 'Primary Applicant Additional Phone Number', + }, + { + path: 'contactPreferences', + label: 'Primary Applicant Preferred Contact Type', + }, + { + path: 'applicant.applicantAddress.street', + label: `Primary Applicant Street`, + }, + { + path: 'applicant.applicantAddress.street2', + label: `Primary Applicant Street 2`, + }, + { + path: 'applicant.applicantAddress.city', + label: `Primary Applicant City`, + }, + { + path: 'applicant.applicantAddress.state', + label: `Primary Applicant State`, + }, + { + path: 'applicant.applicantAddress.zipCode', + label: `Primary Applicant Zip Code`, + }, + { + path: 'applicationsMailingAddress.street', + label: `Primary Applicant Mailing Street`, + }, + { + path: 'applicationsMailingAddress.street2', + label: `Primary Applicant Mailing Street 2`, + }, + { + path: 'applicationsMailingAddress.city', + label: `Primary Applicant Mailing City`, + }, + { + path: 'applicationsMailingAddress.state', + label: `Primary Applicant Mailing State`, + }, + { + path: 'applicationsMailingAddress.zipCode', + label: `Primary Applicant Mailing Zip Code`, + }, + { + path: 'applicant.applicantWorkAddress.street', + label: `Primary Applicant Work Street`, + }, + { + path: 'applicant.applicantWorkAddress.street2', + label: `Primary Applicant Work Street 2`, + }, + { + path: 'applicant.applicantWorkAddress.city', + label: `Primary Applicant Work City`, + }, + { + path: 'applicant.applicantWorkAddress.state', + label: `Primary Applicant Work State`, + }, + { + path: 'applicant.applicantWorkAddress.zipCode', + label: `Primary Applicant Work Zip Code`, + }, + { + path: 'alternateContact.firstName', + label: 'Alternate Contact First Name', + }, + { + path: 'alternateContact.lastName', + label: 'Alternate Contact Last Name', + }, + { + path: 'alternateContact.type', + label: 'Alternate Contact Type', + }, + { + path: 'alternateContact.agency', + label: 'Alternate Contact Agency', + }, + { + path: 'alternateContact.otherType', + label: 'Alternate Contact Other Type', + }, + { + path: 'alternateContact.emailAddress', + label: 'Alternate Contact Email Address', + }, + { + path: 'alternateContact.phoneNumber', + label: 'Alternate Contact Phone Number', + }, + { + path: 'alternateContact.address.street', + label: `Alternate Contact Street`, + }, + { + path: 'alternateContact.address.street2', + label: `Alternate Contact Street 2`, + }, + { + path: 'alternateContact.address.city', + label: `Alternate Contact City`, + }, + { + path: 'alternateContact.address.state', + label: `Alternate Contact State`, + }, + { + path: 'alternateContact.address.zipCode', + label: `Alternate Contact Zip Code`, + }, + { + path: 'income', + label: 'Income', + }, + { + path: 'incomePeriod', + label: 'Income Period', + format: (val: string): string => + val === 'perMonth' ? 'per month' : 'per year', + }, + { + path: 'accessibility.mobility', + label: 'Accessibility Mobility', + }, + { + path: 'accessibility.vision', + label: 'Accessibility Vision', + }, + { + path: 'accessibility.hearing', + label: 'Accessibility Hearing', + }, + { + path: 'householdExpectingChanges', + label: 'Expecting Household Changes', + }, + { + path: 'householdStudent', + label: 'Household Includes Student or Member Nearing 18', + }, + { + path: 'incomeVouchers', + label: 'Vouchers or Subsidies', + }, + { + path: 'preferredUnitTypes', + label: 'Requested Unit Types', + format: (val: UnitType[]): string => { + return val.map((unit) => unitTypeToReadable(unit.name)).join(','); + }, + }, + ]; + + describe('Testing unitTypeToReadable', () => { + it('tests unitTypeToReadable with type in typeMap', () => { + const types = [ + 'SRO', + 'studio', + 'oneBdrm', + 'twoBdrm', + 'threeBdrm', + 'fourBdrm', + ]; + + const readableTypes = [ + 'SRO', + 'Studio', + 'One Bedroom', + 'Two Bedroom', + 'Three Bedroom', + 'Four+ Bedroom', + ]; + + for (let i = 0; i < types.length; i++) { + expect(unitTypeToReadable(types[i])).toBe(readableTypes[i]); + } + }); + + it('tests unitTypeToRedable with type not in typeMap', () => { + const custom = 'FiveBdrm'; + expect(unitTypeToReadable(custom)).toBe(custom); + }); + }); + + describe('Testing convertDemographicRaceToReadable', () => { + it('tests convertDemographicRaceToReadable with valid type', () => { + const keys = [ + 'americanIndianAlaskanNative', + 'declineToRespond', + 'nativeHawaiianOtherPacificIslander-nativeHawaiian', + ]; + + const values = [ + 'American Indian / Alaskan Native', + 'Decline to Respond', + 'Native Hawaiian / Other Pacific Islander[Native Hawaiian]', + ]; + + for (let i = 0; i < keys.length; i++) { + expect(convertDemographicRaceToReadable(keys[i])).toBe(values[i]); + } + }); + + it('tests convertDemographicRaceToReadable with valid type and custom value', () => { + expect( + convertDemographicRaceToReadable( + 'nativeHawaiianOtherPacificIslander-otherPacificIslander:Fijian', + ), + ).toBe( + 'Native Hawaiian / Other Pacific Islander[Other Pacific Islander:Fijian]', + ); + }); + + it('tests convertDemographicRaceToReadable with type not in typeMap', () => { + const custom = 'This is a custom value'; + expect(convertDemographicRaceToReadable(custom)).toBe(custom); + }); + }); + + describe('Testing multiselectQuestionFormat', () => { + it('tests multiselectQuestionFormat with undefined question passed', () => { + expect(multiselectQuestionFormat(undefined, undefined, undefined)).toBe( + '', + ); + }); + + it('tests multiselectQuestionFormat', () => { + const question: ApplicationMultiselectQuestion = { + multiselectQuestionId: randomUUID(), + key: 'Test Preference 1', + claimed: true, + options: [ + { + key: 'option 1', + checked: true, + extraData: [ + { + key: 'address', + type: InputType.address, + value: address, + }, + ], + }, + ], + }; + + expect(multiselectQuestionFormat(question, 'option 1', 'address')).toBe( + '123 4th St #5 City, State 67890', + ); + }); + }); + + describe('Testing addressToString', () => { + it('tests addressToString without street 2', () => { + const testAddress = { ...address, street2: undefined }; + expect(addressToString(testAddress)).toBe('123 4th St City, State 67890'); + }); + }); + + describe('Testing getExportHeaders', () => { + it('tests getCsvHeaders with no houshold members, multiselect questions or demographics', async () => { + const headers = await getExportHeaders(0, [], process.env.TIME_ZONE); + const testHeaders = [ + ...csvHeaders, + { + path: 'householdSize', + label: 'Household Size', + }, + { + path: 'markedAsDuplicate', + label: 'Marked As Duplicate', + }, + { + path: 'applicationFlaggedSet', + label: 'Flagged As Duplicate', + format: (val: ApplicationFlaggedSet[]): boolean => { + return val.length > 0; + }, + }, + ]; + expect(JSON.stringify(headers)).toEqual(JSON.stringify(testHeaders)); + }); + + it('tests getCsvHeaders with household members and no multiselect questions or demographics', async () => { + const headers = await getExportHeaders(3, [], process.env.TIME_ZONE); + const testHeaders = [ + ...csvHeaders, + { + path: 'householdSize', + label: 'Household Size', + }, + ...getHouseholdCsvHeaders(3), + { + path: 'markedAsDuplicate', + label: 'Marked As Duplicate', + }, + { + path: 'applicationFlaggedSet', + label: 'Flagged As Duplicate', + format: (val: ApplicationFlaggedSet[]): boolean => { + return val.length > 0; + }, + }, + ]; + expect(JSON.stringify(headers)).toEqual(JSON.stringify(testHeaders)); + }); + }); +}); diff --git a/api/yarn.lock b/api/yarn.lock index aede65151d..afae53c7e0 100644 --- a/api/yarn.lock +++ b/api/yarn.lock @@ -566,6 +566,31 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.41.0.tgz#080321c3b68253522f7646b55b577dd99d2950b3" integrity sha512-LxcyMGxwmTh2lY9FwHPGWOHmYFCZvbrFCBZL4FzSSsxsRPuhrYUg/49/0KDfW8tnIEaEHtfmn6+NPN+1DqaNmA== +"@fast-csv/format@4.3.5": + version "4.3.5" + resolved "https://registry.yarnpkg.com/@fast-csv/format/-/format-4.3.5.tgz#90d83d1b47b6aaf67be70d6118f84f3e12ee1ff3" + integrity sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A== + dependencies: + "@types/node" "^14.0.1" + lodash.escaperegexp "^4.1.2" + lodash.isboolean "^3.0.3" + lodash.isequal "^4.5.0" + lodash.isfunction "^3.0.9" + lodash.isnil "^4.0.0" + +"@fast-csv/parse@4.3.6": + version "4.3.6" + resolved "https://registry.yarnpkg.com/@fast-csv/parse/-/parse-4.3.6.tgz#ee47d0640ca0291034c7aa94039a744cfb019264" + integrity sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA== + dependencies: + "@types/node" "^14.0.1" + lodash.escaperegexp "^4.1.2" + lodash.groupby "^4.6.0" + lodash.isfunction "^3.0.9" + lodash.isnil "^4.0.0" + lodash.isundefined "^3.0.1" + lodash.uniq "^4.5.0" + "@google-cloud/common@^4.0.0": version "4.0.3" resolved "https://registry.yarnpkg.com/@google-cloud/common/-/common-4.0.3.tgz#d4324ac83087385d727593f7e1b6d81ee66442cf" @@ -1488,6 +1513,13 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== +"@types/exceljs@1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@types/exceljs/-/exceljs-1.3.0.tgz#b7ffa564b898abeeee2d26f8ee26f12615640cf6" + integrity sha512-V3BOkD+eQVEFuYACksKiNEDP3QumTMMrPeXef/F5EAT/MquojQzUwEJt6vwsEwTOZFPfponJMyvdZ0NL71K76Q== + dependencies: + exceljs "*" + "@types/express-serve-static-core@^4.17.33": version "4.17.35" resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.35.tgz#c95dd4424f0d32e525d23812aa8ab8e4d3906c4f" @@ -1649,6 +1681,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-20.3.0.tgz#719498898d5defab83c3560f45d8498f58d11938" integrity sha512-cumHmIAf6On83X7yP+LrsEyUOf/YlociZelmpRYaGFydoaPdxdt80MAbu6vWerQT2COCp2nPvHdsbD7tHn/YlQ== +"@types/node@^14.0.1": + version "14.18.63" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.63.tgz#1788fa8da838dbb5f9ea994b834278205db6ca2b" + integrity sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ== + "@types/node@^18.7.14": version "18.16.18" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.16.18.tgz#85da09bafb66d4bc14f7c899185336d0c1736390" @@ -2130,6 +2167,38 @@ append-field@^1.0.0: resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56" integrity sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw== +archiver-utils@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-2.1.0.tgz#e8a460e94b693c3e3da182a098ca6285ba9249e2" + integrity sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw== + dependencies: + glob "^7.1.4" + graceful-fs "^4.2.0" + lazystream "^1.0.0" + lodash.defaults "^4.2.0" + lodash.difference "^4.5.0" + lodash.flatten "^4.4.0" + lodash.isplainobject "^4.0.6" + lodash.union "^4.6.0" + normalize-path "^3.0.0" + readable-stream "^2.0.0" + +archiver-utils@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-3.0.4.tgz#a0d201f1cf8fce7af3b5a05aea0a337329e96ec7" + integrity sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw== + dependencies: + glob "^7.2.3" + graceful-fs "^4.2.0" + lazystream "^1.0.0" + lodash.defaults "^4.2.0" + lodash.difference "^4.5.0" + lodash.flatten "^4.4.0" + lodash.isplainobject "^4.0.6" + lodash.union "^4.6.0" + normalize-path "^3.0.0" + readable-stream "^3.6.0" + archiver-utils@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-4.0.1.tgz#66ad15256e69589a77f706c90c6dbcc1b2775d2a" @@ -2142,6 +2211,19 @@ archiver-utils@^4.0.1: normalize-path "^3.0.0" readable-stream "^3.6.0" +archiver@^5.0.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/archiver/-/archiver-5.3.2.tgz#99991d5957e53bd0303a392979276ac4ddccf3b0" + integrity sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw== + dependencies: + archiver-utils "^2.1.0" + async "^3.2.4" + buffer-crc32 "^0.2.1" + readable-stream "^3.6.0" + readdir-glob "^1.1.2" + tar-stream "^2.2.0" + zip-stream "^4.1.0" + archiver@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/archiver/-/archiver-6.0.1.tgz#d56968d4c09df309435adb5a1bbfc370dae48133" @@ -2345,6 +2427,11 @@ base64-js@^1.3.0, base64-js@^1.3.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +big-integer@^1.6.17: + version "1.6.52" + resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.52.tgz#60a887f3047614a8e1bffe5d7173490a97dc8c85" + integrity sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg== + bignumber.js@^9.0.0: version "9.1.1" resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.1.1.tgz#c4df7dc496bd849d4c9464344c1aa74228b4dac6" @@ -2355,7 +2442,15 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== -bl@^4.1.0: +binary@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/binary/-/binary-0.3.0.tgz#9f60553bc5ce8c3386f3b553cff47462adecaa79" + integrity sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg== + dependencies: + buffers "~0.1.1" + chainsaw "~0.1.0" + +bl@^4.0.3, bl@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== @@ -2369,6 +2464,11 @@ bluebird@^3.7.2: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== +bluebird@~3.4.1: + version "3.4.7" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.7.tgz#f72d760be09b7f76d08ed8fae98b289a8d05fab3" + integrity sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA== + body-parser@1.20.1: version "1.20.1" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668" @@ -2471,7 +2571,7 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" -buffer-crc32@^0.2.1: +buffer-crc32@^0.2.1, buffer-crc32@^0.2.13: version "0.2.13" resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ== @@ -2486,6 +2586,11 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== +buffer-indexof-polyfill@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz#d2732135c5999c64b277fcf9b1abe3498254729c" + integrity sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A== + buffer@^5.5.0: version "5.7.1" resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" @@ -2502,6 +2607,11 @@ buffer@^6.0.3: base64-js "^1.3.1" ieee754 "^1.2.1" +buffers@~0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/buffers/-/buffers-0.1.1.tgz#b24579c3bed4d6d396aeee6d9a8ae7f5482ab7bb" + integrity sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ== + busboy@^1.0.0: version "1.6.0" resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" @@ -2585,6 +2695,13 @@ catharsis@^0.9.0: dependencies: lodash "^4.17.15" +chainsaw@~0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/chainsaw/-/chainsaw-0.1.0.tgz#5eab50b28afe58074d0d58291388828b5e5fbc98" + integrity sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ== + dependencies: + traverse ">=0.3.0 <0.4" + chalk@4.1.2, chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.1, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" @@ -2803,6 +2920,16 @@ component-emitter@^1.3.0: resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== +compress-commons@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-4.1.2.tgz#6542e59cb63e1f46a8b21b0e06f9a32e4c8b06df" + integrity sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg== + dependencies: + buffer-crc32 "^0.2.13" + crc32-stream "^4.0.2" + normalize-path "^3.0.0" + readable-stream "^3.6.0" + compress-commons@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-5.0.1.tgz#e46723ebbab41b50309b27a0e0f6f3baed2d6590" @@ -2936,6 +3063,14 @@ crc-32@^1.2.0: resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.2.tgz#3cad35a934b8bf71f25ca524b6da51fb7eace2ff" integrity sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ== +crc32-stream@^4.0.2: + version "4.0.3" + resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-4.0.3.tgz#85dd677eb78fa7cad1ba17cc506a597d41fc6f33" + integrity sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw== + dependencies: + crc-32 "^1.2.0" + readable-stream "^3.4.0" + crc32-stream@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-5.0.0.tgz#a97d3a802c8687f101c27cc17ca5253327354720" @@ -3014,6 +3149,11 @@ dayjs@^1.11.9, dayjs@~1.11.9: resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.9.tgz#9ca491933fadd0a60a2c19f6c237c03517d71d1a" integrity sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA== +dayjs@^1.8.34: + version "1.11.12" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.12.tgz#5245226cc7f40a15bf52e0b99fd2a04669ccac1d" + integrity sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg== + debug@2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -3147,6 +3287,13 @@ dotenv@16.3.1: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e" integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ== +duplexer2@~0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1" + integrity sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA== + dependencies: + readable-stream "^2.0.2" + duplexify@^4.0.0, duplexify@^4.1.1: version "4.1.2" resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-4.1.2.tgz#18b4f8d28289132fa0b9573c898d9f903f81c7b0" @@ -3522,6 +3669,21 @@ events@^3.2.0: resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== +exceljs@*, exceljs@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/exceljs/-/exceljs-4.4.0.tgz#cfb1cb8dcc82c760a9fc9faa9e52dadab66b0156" + integrity sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg== + dependencies: + archiver "^5.0.0" + dayjs "^1.8.34" + fast-csv "^4.3.1" + jszip "^3.10.1" + readable-stream "^3.6.0" + saxes "^5.0.1" + tmp "^0.2.0" + unzipper "^0.10.11" + uuid "^8.3.0" + execa@^5.0.0: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" @@ -3623,6 +3785,14 @@ external-editor@^3.0.3, external-editor@^3.1.0: iconv-lite "^0.4.24" tmp "^0.0.33" +fast-csv@^4.3.1: + version "4.3.6" + resolved "https://registry.yarnpkg.com/fast-csv/-/fast-csv-4.3.6.tgz#70349bdd8fe4d66b1130d8c91820b64a21bc4a63" + integrity sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw== + dependencies: + "@fast-csv/format" "4.3.5" + "@fast-csv/parse" "4.3.6" + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -3830,6 +4000,11 @@ fresh@0.5.2: resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== +fs-constants@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" + integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== + fs-extra@^10.0.0: version "10.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" @@ -3854,6 +4029,16 @@ fsevents@^2.3.2, fsevents@~2.3.2: resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== +fstream@^1.0.12: + version "1.0.12" + resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045" + integrity sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg== + dependencies: + graceful-fs "^4.1.2" + inherits "~2.0.0" + mkdirp ">=0.5 0" + rimraf "2" + function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -4004,7 +4189,7 @@ glob@10.3.10: minipass "^5.0.0 || ^6.0.2 || ^7.0.0" path-scurry "^1.10.1" -glob@^7.0.0, glob@^7.1.3, glob@^7.1.4: +glob@^7.0.0, glob@^7.1.3, glob@^7.1.4, glob@^7.2.3: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -4148,7 +4333,7 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" -graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.1.9, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.9: +graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.1.9, graceful-fs@^4.2.0, graceful-fs@^4.2.2, graceful-fs@^4.2.4, graceful-fs@^4.2.9: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== @@ -4344,6 +4529,11 @@ ignore@^5.2.0: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324" integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ== +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== + import-fresh@^3.0.0, import-fresh@^3.2.1, import-fresh@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" @@ -4373,7 +4563,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: +inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -5318,6 +5508,16 @@ jsonwebtoken@~9.0.1: ms "^2.1.1" semver "^7.5.4" +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.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" @@ -5397,6 +5597,13 @@ libphonenumber-js@^1.10.14: resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.10.31.tgz#aab580894c263093a3085a02afcda7a742faeff1" integrity sha512-qYTzElLePmz3X/6I0JPX5n87tu7jVIMtR/yRLi5PGVPvMCMSVTCR+079KmdNK005i4dBjFxY/bMYceI9IBp47w== +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" + lines-and-columns@^1.1.6: version "1.2.4" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" @@ -5409,6 +5616,11 @@ linkify-it@^3.0.1: dependencies: uc.micro "^1.0.1" +listenercount@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/listenercount/-/listenercount-1.0.1.tgz#84c8a72ab59c4725321480c975e6508342e70937" + integrity sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ== + loader-runner@^4.2.0: version "4.3.0" resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1" @@ -5433,6 +5645,31 @@ lodash.camelcase@^4.3.0: resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== +lodash.defaults@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" + integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ== + +lodash.difference@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.difference/-/lodash.difference-4.5.0.tgz#9ccb4e505d486b91651345772885a2df27fd017c" + integrity sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA== + +lodash.escaperegexp@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347" + integrity sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw== + +lodash.flatten@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" + integrity sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g== + +lodash.groupby@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.groupby/-/lodash.groupby-4.6.0.tgz#0b08a1dcf68397c397855c3239783832df7403d1" + integrity sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw== + lodash.includes@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" @@ -5443,11 +5680,26 @@ lodash.isboolean@^3.0.3: resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== +lodash.isequal@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" + integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ== + +lodash.isfunction@^3.0.9: + version "3.0.9" + resolved "https://registry.yarnpkg.com/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz#06de25df4db327ac931981d1bdb067e5af68d051" + integrity sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw== + lodash.isinteger@^4.0.4: version "4.0.4" resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA== +lodash.isnil@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/lodash.isnil/-/lodash.isnil-4.0.0.tgz#49e28cd559013458c814c5479d3c663a21bfaa6c" + integrity sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng== + lodash.isnumber@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" @@ -5463,6 +5715,11 @@ lodash.isstring@^4.0.1: resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== +lodash.isundefined@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz#23ef3d9535565203a66cefd5b830f848911afb48" + integrity sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA== + lodash.memoize@4.x: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" @@ -5478,6 +5735,16 @@ lodash.once@^4.0.0: resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== +lodash.union@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88" + integrity sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw== + +lodash.uniq@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" + integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ== + lodash@4.17.21, lodash@^4.17.15, lodash@^4.17.21, lodash@~4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" @@ -5701,7 +5968,7 @@ minipass@^4.2.4: resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c" integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ== -mkdirp@^0.5.4: +"mkdirp@>=0.5 0", mkdirp@^0.5.4: version "0.5.6" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== @@ -6010,6 +6277,11 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +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.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" @@ -6401,7 +6673,7 @@ react-is@^18.0.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== -readable-stream@^2.0.5, readable-stream@^2.2.2: +readable-stream@^2.0.0, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.2.2, readable-stream@~2.3.6: version "2.3.8" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== @@ -6546,6 +6818,13 @@ reusify@^1.0.4: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== +rimraf@2: + version "2.7.1" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" + integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== + dependencies: + glob "^7.1.3" + rimraf@4.4.1: version "4.4.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-4.4.1.tgz#bd33364f67021c5b79e93d7f4fa0568c7c21b755" @@ -6618,6 +6897,13 @@ safe-regex-test@^1.0.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +saxes@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/saxes/-/saxes-5.0.1.tgz#eebab953fa3b7608dbe94e5dadb15c888fa6696d" + integrity sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw== + dependencies: + xmlchars "^2.2.0" + saxes@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5" @@ -6708,6 +6994,11 @@ set-function-length@^1.2.0: gopd "^1.0.1" has-property-descriptors "^1.0.1" +setimmediate@^1.0.5, setimmediate@~1.0.4: + 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.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" @@ -6839,16 +7130,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -6907,14 +7189,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -7040,6 +7315,17 @@ tapable@^2.1.1, tapable@^2.2.0, tapable@^2.2.1: resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== +tar-stream@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" + integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== + dependencies: + bl "^4.0.3" + end-of-stream "^1.4.1" + fs-constants "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.1.1" + tar-stream@^3.0.0: version "3.1.6" resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.1.6.tgz#6520607b55a06f4a2e2e04db360ba7d338cc5bab" @@ -7118,6 +7404,11 @@ tmp@^0.0.33: dependencies: os-tmpdir "~1.0.2" +tmp@^0.2.0: + version "0.2.3" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.3.tgz#eb783cc22bc1e8bebd0671476d46ea4eb32a79ae" + integrity sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w== + tmp@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" @@ -7169,6 +7460,11 @@ tr46@~0.0.3: resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== +"traverse@>=0.3.0 <0.4": + version "0.3.9" + resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9" + integrity sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ== + tree-kill@1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" @@ -7424,6 +7720,22 @@ unpipe@1.0.0, unpipe@~1.0.0: resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== +unzipper@^0.10.11: + version "0.10.14" + resolved "https://registry.yarnpkg.com/unzipper/-/unzipper-0.10.14.tgz#d2b33c977714da0fbc0f82774ad35470a7c962b1" + integrity sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g== + dependencies: + big-integer "^1.6.17" + binary "~0.3.0" + bluebird "~3.4.1" + buffer-indexof-polyfill "~1.0.0" + duplexer2 "~0.1.4" + fstream "^1.0.12" + graceful-fs "^4.2.2" + listenercount "~1.0.1" + readable-stream "~2.3.6" + setimmediate "~1.0.4" + update-browserslist-db@^1.0.10, update-browserslist-db@^1.0.11: version "1.0.11" resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz#9a2a641ad2907ae7b3616506f4b977851db5b940" @@ -7475,6 +7787,11 @@ uuid@9.0.1, uuid@^9.0.1: resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== +uuid@^8.3.0: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + v8-compile-cache-lib@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" @@ -7652,7 +7969,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -7670,15 +7987,6 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" @@ -7774,6 +8082,15 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== +zip-stream@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-4.1.1.tgz#1337fe974dbaffd2fa9a1ba09662a66932bd7135" + integrity sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ== + dependencies: + archiver-utils "^3.0.4" + compress-commons "^4.1.2" + readable-stream "^3.6.0" + zip-stream@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-5.0.1.tgz#cf3293bba121cad98be2ec7f05991d81d9f18134" diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index 99dd8324c7..8253739b06 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -1443,35 +1443,6 @@ export class ApplicationsService { axios(configs, resolve, reject) }) } - /** - * Get applications lottery results - */ - lotteryResults( - params: { - /** */ - listingId: string - /** */ - includeDemographics?: boolean - /** */ - timeZone?: string - } = {} as any, - options: IRequestOptions = {} - ): Promise { - return new Promise((resolve, reject) => { - let url = basePath + "/applications/getLotteryResults" - - const configs: IRequestConfig = getConfigs("get", "application/json", url, options) - configs.params = { - listingId: params["listingId"], - includeDemographics: params["includeDemographics"], - timeZone: params["timeZone"], - } - - /** 适配ios13,get请求不允许带body */ - - axios(configs, resolve, reject) - }) - } /** * Get applications as csv */ @@ -2189,6 +2160,35 @@ export class LotteryService { configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Get applications lottery results + */ + lotteryResults( + params: { + /** */ + listingId: string + /** */ + includeDemographics?: boolean + /** */ + timeZone?: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/lottery/getLotteryResults" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + configs.params = { + listingId: params["listingId"], + includeDemographics: params["includeDemographics"], + timeZone: params["timeZone"], + } + + /** 适配ios13,get请求不允许带body */ + axios(configs, resolve, reject) }) } diff --git a/sites/partners/src/lib/hooks.ts b/sites/partners/src/lib/hooks.ts index 9281911dd9..c7503ba8b0 100644 --- a/sites/partners/src/lib/hooks.ts +++ b/sites/partners/src/lib/hooks.ts @@ -522,12 +522,39 @@ export const useApplicationsExport = (listingId: string, includeDemographics: bo } export const useLotteryExport = (listingId: string) => { - const { applicationsService } = useContext(AuthContext) + const { lotteryService } = useContext(AuthContext) + const [exportLoading, setExportLoading] = useState(false) + const { addToast } = useContext(MessageContext) - return useCsvExport( - () => applicationsService.lotteryResults({ listingId, timeZone: dayjs.tz.guess() }), - `lottery-${listingId}-${createDateStringFromNow()}.csv` - ) + const onExport = useCallback(async () => { + setExportLoading(true) + try { + const content = await lotteryService.lotteryResults( + { listingId }, + { responseType: "arraybuffer" } + ) + const blob = new Blob([new Uint8Array(content)], { type: "application/zip" }) + const url = window.URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + const now = new Date() + const dateString = dayjs(now).format("YYYY-MM-DD_HH-mm") + link.setAttribute("download", `${listingId}-${dateString}-test.zip`) + document.body.appendChild(link) + link.click() + link.parentNode.removeChild(link) + addToast(t("t.exportSuccess"), { variant: "success" }) + } catch (err) { + console.log(err) + addToast(t("account.settings.alerts.genericError"), { variant: "alert" }) + } + setExportLoading(false) + }, []) + + return { + onExport, + exportLoading, + } } export const useUsersExport = () => { @@ -539,7 +566,11 @@ export const useUsersExport = () => { ) } -const useCsvExport = (endpoint: () => Promise, fileName: string) => { +const useCsvExport = ( + endpoint: () => Promise, + fileName: string, + exportedAsSpreadsheet = false +) => { const [csvExportLoading, setCsvExportLoading] = useState(false) const { addToast } = useContext(MessageContext) @@ -548,7 +579,7 @@ const useCsvExport = (endpoint: () => Promise, fileName: string) => { try { const content = await endpoint() - const blob = new Blob([content], { type: "text/csv" }) + const blob = new Blob([content], { type: exportedAsSpreadsheet ? "text/xlsx" : "text/csv" }) const fileLink = document.createElement("a") fileLink.setAttribute("download", fileName) fileLink.href = URL.createObjectURL(blob) diff --git a/sites/partners/src/pages/api/adapter/[...backendUrl].ts b/sites/partners/src/pages/api/adapter/[...backendUrl].ts index 4cbb2f192f..a15e1c877f 100644 --- a/sites/partners/src/pages/api/adapter/[...backendUrl].ts +++ b/sites/partners/src/pages/api/adapter/[...backendUrl].ts @@ -13,7 +13,7 @@ import { maskAxiosResponse } from "@bloom-housing/shared-helpers" */ // all endpoints that return a zip file -const zipEndpoints = ["listings/csv"] +const zipEndpoints = ["listings/csv", "lottery/getLotteryResults"] export default async (req: NextApiRequest, res: NextApiResponse) => { const jar = new CookieJar() diff --git a/sites/partners/src/pages/listings/[id]/lottery.tsx b/sites/partners/src/pages/listings/[id]/lottery.tsx index 291a94ba3a..32e2dea86a 100644 --- a/sites/partners/src/pages/listings/[id]/lottery.tsx +++ b/sites/partners/src/pages/listings/[id]/lottery.tsx @@ -12,7 +12,6 @@ import { CardHeader, CardSection } from "@bloom-housing/ui-seeds/src/blocks/Card import { AuthContext } from "@bloom-housing/shared-helpers" import { Listing, - ListingUpdate, ListingEventsTypeEnum, ListingsStatusEnum, LotteryStatusEnum, @@ -45,7 +44,7 @@ const Lottery = (props: { listing: Listing }) => { const [loading, setLoading] = useState(false) const { listingsService, lotteryService, profile } = useContext(AuthContext) - const { onExport, csvExportLoading } = useLotteryExport(listing?.id) + const { onExport, exportLoading } = useLotteryExport(listing?.id) const { data } = useFlaggedApplicationsMeta(listing?.id) const duplicatesExist = data?.totalPendingCount > 0 let formattedExpiryDate: string @@ -85,7 +84,7 @@ const Lottery = (props: { listing: Listing }) => {
@@ -391,7 +390,7 @@ const Lottery = (props: { listing: Listing }) => { } }} size="sm" - loadingMessage={loading || csvExportLoading ? t("t.loading") : undefined} + loadingMessage={loading || exportLoading ? t("t.loading") : undefined} > {duplicatesExist ? t("listings.lottery.runLotteryDuplicates") @@ -593,7 +592,7 @@ const Lottery = (props: { listing: Listing }) => { setExportModal(false) }} size="sm" - loadingMessage={loading || csvExportLoading ? t("t.loading") : undefined} + loadingMessage={loading || exportLoading ? t("t.loading") : undefined} > {t("t.export")}