From a23591612b41915c3a5076e28acd6f4ad23acb2c Mon Sep 17 00:00:00 2001 From: Eric McGarry <46828798+mcgarrye@users.noreply.github.com> Date: Mon, 1 Apr 2024 09:46:41 -0400 Subject: [PATCH] fix: Application Table/Export/Edit/View should display time in local timezone (#3959) * fix: pass timezone to application csv export 3919 * fix: adjust paper apps to submit in local timezone 3919 * fix: match electronic on table and export 3919 * fix: seed a paper application * fix: pacific time migration 3919 * fix: address comments 3919 * fix: cleanup nits 3919 --- api/.env.template | 6 +- .../migration.sql | 19 +++ .../seed-helpers/application-factory.ts | 5 +- api/prisma/seed-staging.ts | 4 + .../application-csv-query-params.dto.ts | 21 +-- .../listings/listing-csv-query-params.dto.ts | 2 +- .../application-csv-export.service.ts | 18 +++ .../services/listing-csv-export.service.ts | 4 +- .../application-csv-export.service.spec.ts | 129 ++++++++++++++++++ shared-helpers/src/types/backend-swagger.ts | 3 + .../applications/ApplicationsColDefs.ts | 5 +- .../sections/DetailsApplicationData.tsx | 6 +- .../lib/applications/formatApplicationData.ts | 5 +- sites/partners/src/lib/helpers.ts | 68 ++++----- sites/partners/src/lib/hooks.ts | 3 +- .../application/[id]/applicationsCols.tsx | 5 +- 16 files changed, 232 insertions(+), 71 deletions(-) create mode 100644 api/prisma/migrations/09_update_submission_date_on_applications/migration.sql diff --git a/api/.env.template b/api/.env.template index 537c1333fb..a4645a2b4d 100644 --- a/api/.env.template +++ b/api/.env.template @@ -44,7 +44,9 @@ CORS_ORIGINS=["http://localhost:3000", "http://localhost:3001"] CORS_REGEX=["test1", "test2"] # controls the repetition of the temp file clearing cron job TEMP_FILE_CLEAR_CRON_STRING=0 * * * +# default time zone for dates in exports +TIME_ZONE=America/Los_Angeles # how long we maintain our request time outs (60 * 60 * 1000 ms) -THROTTLE_TTL=3600000 +THROTTLE_TTL=3600000 # how many requests before we throttle -THROTTLE_LIMIT=100 +THROTTLE_LIMIT=100 diff --git a/api/prisma/migrations/09_update_submission_date_on_applications/migration.sql b/api/prisma/migrations/09_update_submission_date_on_applications/migration.sql new file mode 100644 index 0000000000..25be41e68e --- /dev/null +++ b/api/prisma/migrations/09_update_submission_date_on_applications/migration.sql @@ -0,0 +1,19 @@ +-- Paper applications were originally submitted as though they were in UTC, +-- when they should have been submitted in Pacific (PST/PDT) and converted to UTC. +-- Here we update the times to be in PST or PDT depending on when they were submitted +-- and the yearly cycle of daylight savings. + +UPDATE applications +SET submission_date = submission_date + '7 hours' +WHERE submission_date IS NOT NULL + AND submission_type = 'paper'; + + +UPDATE applications +SET submission_date = submission_date + '1 hours' +WHERE submission_date IS NOT NULL + AND submission_type = 'paper' + AND (submission_date > '2024-03-10 09:00:00.000-00' + OR (submission_date <= '2023-11-05 09:00:00.000-00' AND submission_date > '2023-03-12 09:00:00.000-00') + OR (submission_date <= '2022-11-06 09:00:00.000-00' AND submission_date > '2022-03-13 09:00:00.000-00') + OR (submission_date <= '2021-11-07 09:00:00.000-00' AND submission_date > '2021-03-14 09:00:00.000-00')); diff --git a/api/prisma/seed-helpers/application-factory.ts b/api/prisma/seed-helpers/application-factory.ts index d3e6f8f622..cd8ca056a4 100644 --- a/api/prisma/seed-helpers/application-factory.ts +++ b/api/prisma/seed-helpers/application-factory.ts @@ -28,6 +28,7 @@ export const applicationFactory = async (optionalParams?: { demographics?: Prisma.DemographicsCreateWithoutApplicationsInput; multiselectQuestions?: Partial[]; userId?: string; + submissionType?: ApplicationSubmissionTypeEnum; }): Promise => { let preferredUnitTypes: Prisma.UnitTypesCreateNestedManyWithoutApplicationsInput; if (optionalParams?.unitTypeId) { @@ -45,7 +46,9 @@ export const applicationFactory = async (optionalParams?: { applicant: { create: applicantFactory(optionalParams?.applicant) }, appUrl: '', status: ApplicationStatusEnum.submitted, - submissionType: ApplicationSubmissionTypeEnum.electronical, + submissionType: + optionalParams?.submissionType ?? + ApplicationSubmissionTypeEnum.electronical, submissionDate: new Date(), householdSize: optionalParams?.householdSize ?? 1, income: '40000', diff --git a/api/prisma/seed-staging.ts b/api/prisma/seed-staging.ts index 627b7ceea9..528a0e434d 100644 --- a/api/prisma/seed-staging.ts +++ b/api/prisma/seed-staging.ts @@ -1,6 +1,7 @@ import { ApplicationAddressTypeEnum, ApplicationMethodsTypeEnum, + ApplicationSubmissionTypeEnum, LanguagesEnum, ListingsStatusEnum, MultiselectQuestions, @@ -457,6 +458,9 @@ export const stagingSeed = async ( }), await applicationFactory(), await applicationFactory(), + await applicationFactory({ + submissionType: ApplicationSubmissionTypeEnum.paper, + }), ], }, { diff --git a/api/src/dtos/applications/application-csv-query-params.dto.ts b/api/src/dtos/applications/application-csv-query-params.dto.ts index 106befa4cb..783ab6259c 100644 --- a/api/src/dtos/applications/application-csv-query-params.dto.ts +++ b/api/src/dtos/applications/application-csv-query-params.dto.ts @@ -1,14 +1,9 @@ import { Expose, Transform } from 'class-transformer'; -import { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger'; -import { IsBoolean, IsOptional, IsUUID } from 'class-validator'; -import { AbstractDTO } from '../shared/abstract.dto'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsOptional, IsString, IsUUID } from 'class-validator'; import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; -export class ApplicationCsvQueryParams extends OmitType(AbstractDTO, [ - 'id', - 'createdAt', - 'updatedAt', -]) { +export class ApplicationCsvQueryParams { @Expose() @ApiProperty({ type: String, @@ -32,4 +27,14 @@ export class ApplicationCsvQueryParams extends OmitType(AbstractDTO, [ { toClassOnly: true }, ) includeDemographics?: boolean; + + @Expose() + @ApiPropertyOptional({ + type: String, + example: process.env.TIME_ZONE, + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + timeZone?: string; } diff --git a/api/src/dtos/listings/listing-csv-query-params.dto.ts b/api/src/dtos/listings/listing-csv-query-params.dto.ts index 413a0db122..cb86ae5134 100644 --- a/api/src/dtos/listings/listing-csv-query-params.dto.ts +++ b/api/src/dtos/listings/listing-csv-query-params.dto.ts @@ -7,7 +7,7 @@ export class ListingCsvQueryParams { @Expose() @ApiPropertyOptional({ type: String, - example: 'America/Los_Angeles', + example: process.env.TIME_ZONE, required: false, }) @IsOptional({ groups: [ValidationsGroupsEnum.default] }) diff --git a/api/src/services/application-csv-export.service.ts b/api/src/services/application-csv-export.service.ts index 495dfaad0d..e8ca5a2186 100644 --- a/api/src/services/application-csv-export.service.ts +++ b/api/src/services/application-csv-export.service.ts @@ -4,6 +4,7 @@ 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'; @@ -15,6 +16,7 @@ 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, @@ -45,12 +47,14 @@ export const typeMap = { export class ApplicationCsvExporterService implements CsvExporterServiceInterface { + readonly dateFormat: string = 'MM-DD-YYYY hh:mm:ssA z'; constructor( private prisma: PrismaService, private multiselectQuestionService: MultiselectQuestionService, private listingService: ListingService, private permissionService: PermissionService, ) {} + /** * * @param queryParams @@ -64,12 +68,14 @@ export class ApplicationCsvExporterService ): Promise { const user = mapTo(User, req['user']); await this.authorizeCSVExport(user, queryParams.listingId); + const filename = join( process.cwd(), `src/temp/listing-${queryParams.listingId}-applications-${ user.id }-${new Date().getTime()}.csv`, ); + await this.createCsv(filename, queryParams); const file = createReadStream(filename); return new StreamableFile(file); @@ -109,6 +115,7 @@ export class ApplicationCsvExporterService const csvHeaders = await this.getCsvHeaders( maxHouseholdMembers, multiSelectQuestions, + queryParams.timeZone, queryParams.includeDemographics, ); @@ -330,6 +337,7 @@ export class ApplicationCsvExporterService async getCsvHeaders( maxHouseholdMembers: number, multiSelectQuestions: MultiselectQuestion[], + timeZone: string, includeDemographics = false, ): Promise { const headers: CsvHeader[] = [ @@ -344,10 +352,20 @@ export class ApplicationCsvExporterService { 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', diff --git a/api/src/services/listing-csv-export.service.ts b/api/src/services/listing-csv-export.service.ts index c72d4d5581..f5f08ac9ad 100644 --- a/api/src/services/listing-csv-export.service.ts +++ b/api/src/services/listing-csv-export.service.ts @@ -57,7 +57,7 @@ export const formatCommunityType = { @Injectable() export class ListingCsvExporterService implements CsvExporterServiceInterface { readonly dateFormat: string = 'MM-DD-YYYY hh:mm:ssA z'; - timeZone = 'America/Los_Angeles'; + timeZone = process.env.TIME_ZONE; constructor( private prisma: PrismaService, @Inject(Logger) @@ -319,7 +319,7 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { }, { path: 'createdAt', - label: 'Crated At Date', + label: 'Created At Date', format: (val: string): string => formatLocalDate(val, this.dateFormat, this.timeZone), }, 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 8d4f44c13c..4fd6ea391b 100644 --- a/api/test/unit/services/application-csv-export.service.spec.ts +++ b/api/test/unit/services/application-csv-export.service.spec.ts @@ -559,4 +559,133 @@ describe('Testing application CSV export service', () => { expect(readable).toContain(headerRow); expect(readable).toContain(firstApp); }); + + it('should build csv with submission date defaulted to PST', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-01-01')); + + 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()); + prisma.applications.findMany = jest.fn().mockReturnValue(applications); + prisma.listings.findUnique = jest.fn().mockResolvedValue({}); + permissionService.canOrThrow = jest.fn().mockResolvedValue(true); + + service.maxHouseholdMembers = jest.fn().mockReturnValue(1); + + 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.exportFile( + { user: requestingUser } as unknown as ExpressRequest, + {} as unknown as Response, + { listingId: randomUUID() }, + ); + + 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('PST'); + }); + + it('should build csv with submission date in custom timezone', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-01-01')); + + 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()); + prisma.applications.findMany = jest.fn().mockReturnValue(applications); + prisma.listings.findUnique = jest.fn().mockResolvedValue({}); + permissionService.canOrThrow = jest.fn().mockResolvedValue(true); + + service.maxHouseholdMembers = jest.fn().mockReturnValue(1); + + 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.exportFile( + { user: requestingUser } as unknown as ExpressRequest, + {} as unknown as Response, + { + listingId: randomUUID(), + timeZone: 'America/New_York', + }, + ); + + 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('EST'); + }); }); diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index 35479ac75f..1df662c134 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -1394,6 +1394,8 @@ export class ApplicationsService { listingId: string /** */ includeDemographics?: boolean + /** */ + timeZone?: string } = {} as any, options: IRequestOptions = {} ): Promise { @@ -1404,6 +1406,7 @@ export class ApplicationsService { configs.params = { listingId: params["listingId"], includeDemographics: params["includeDemographics"], + timeZone: params["timeZone"], } /** 适配ios13,get请求不允许带body */ diff --git a/sites/partners/src/components/applications/ApplicationsColDefs.ts b/sites/partners/src/components/applications/ApplicationsColDefs.ts index f3ac63d323..15dc067380 100644 --- a/sites/partners/src/components/applications/ApplicationsColDefs.ts +++ b/sites/partners/src/components/applications/ApplicationsColDefs.ts @@ -53,10 +53,7 @@ export function getColDefs(maxHouseholdSize: number, countyCode: string) { const { submissionDate } = data - const dateTime = convertDataToLocal( - submissionDate, - data?.submissionType || ApplicationSubmissionTypeEnum.electronical - ) + const dateTime = convertDataToLocal(submissionDate) return `${dateTime.date} ${t("t.at")} ${dateTime.time}` }, diff --git a/sites/partners/src/components/applications/PaperApplicationDetails/sections/DetailsApplicationData.tsx b/sites/partners/src/components/applications/PaperApplicationDetails/sections/DetailsApplicationData.tsx index 1575d9fdb6..a3c29b855b 100644 --- a/sites/partners/src/components/applications/PaperApplicationDetails/sections/DetailsApplicationData.tsx +++ b/sites/partners/src/components/applications/PaperApplicationDetails/sections/DetailsApplicationData.tsx @@ -3,7 +3,6 @@ import { t } from "@bloom-housing/ui-components" import { FieldValue, Grid } from "@bloom-housing/ui-seeds" import { ApplicationContext } from "../../ApplicationContext" import { convertDataToLocal } from "../../../../lib/helpers" -import { ApplicationSubmissionTypeEnum } from "@bloom-housing/shared-helpers/src/types/backend-swagger" import SectionWithGrid from "../../../shared/SectionWithGrid" const DetailsApplicationData = () => { @@ -12,10 +11,7 @@ const DetailsApplicationData = () => { const applicationDate = useMemo(() => { if (!application) return null - return convertDataToLocal( - application?.submissionDate, - application?.submissionType || ApplicationSubmissionTypeEnum.electronical - ) + return convertDataToLocal(application?.submissionDate) }, [application]) return ( diff --git a/sites/partners/src/lib/applications/formatApplicationData.ts b/sites/partners/src/lib/applications/formatApplicationData.ts index d3b74a8dea..71f6a9555d 100644 --- a/sites/partners/src/lib/applications/formatApplicationData.ts +++ b/sites/partners/src/lib/applications/formatApplicationData.ts @@ -8,6 +8,7 @@ import { getInputType, } from "@bloom-housing/shared-helpers" import { FormTypes, ApplicationTypes, Address } from "../../lib/applications/FormTypes" +import { convertDataToLocal } from "../../lib/helpers" import dayjs from "dayjs" import utc from "dayjs/plugin/utc" @@ -104,7 +105,7 @@ export const mapFormToApi = ({ "MM/DD/YYYY hh:mm:ss a" ).format(TIME_24H_FORMAT) - const formattedDate = dayjs(dateString, TIME_24H_FORMAT).utc(true).toDate() + const formattedDate = dayjs(dateString, TIME_24H_FORMAT).toDate() return formattedDate })() @@ -270,7 +271,7 @@ export const mapFormToApi = ({ export const mapApiToForm = (applicationData: ApplicationUpdate, listing: Listing) => { const submissionDate = applicationData.submissionDate - ? dayjs(new Date(applicationData.submissionDate)).utc() + ? dayjs(new Date(applicationData.submissionDate)) : null const dateOfBirth = (() => { diff --git a/sites/partners/src/lib/helpers.ts b/sites/partners/src/lib/helpers.ts index e57695bc2a..74727862eb 100644 --- a/sites/partners/src/lib/helpers.ts +++ b/sites/partners/src/lib/helpers.ts @@ -47,7 +47,7 @@ export interface FormOptions { export const emailRegex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ -export const convertDataToLocal = (dateObj: Date, type: ApplicationSubmissionTypeEnum) => { +export const convertDataToLocal = (dateObj: Date) => { if (!dateObj) { return { date: t("t.n/a"), @@ -55,50 +55,36 @@ export const convertDataToLocal = (dateObj: Date, type: ApplicationSubmissionTyp } } - if (type === ApplicationSubmissionTypeEnum.electronical) { - // convert date and time to user's local timezone (electronical applications) - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone - const localFormat = new Intl.DateTimeFormat("en-US", { - timeZone: timeZone, - hour: "numeric", - minute: "numeric", - second: "numeric", - year: "numeric", - day: "numeric", - month: "numeric", - }) - - const originalDate = new Date(dateObj) - const dateParts = localFormat.formatToParts(originalDate) - const timeValues = dateParts.reduce((acc, curr) => { - Object.assign(acc, { - [curr.type]: curr.value, - }) - return acc - }, {} as DateTimeLocal) - - const { month, day, year, hour, minute, second, dayPeriod } = timeValues - const timeZoneFormatted = dayjs().tz(timeZone).format("z") - - const date = `${month}/${day}/${year}` - const time = `${hour}:${minute}:${second} ${dayPeriod} ${timeZoneFormatted}` + // convert date and time to user's local timezone + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone + const localFormat = new Intl.DateTimeFormat("en-US", { + timeZone: timeZone, + hour: "numeric", + minute: "numeric", + second: "numeric", + year: "numeric", + day: "numeric", + month: "numeric", + }) - return { - date, - time, - } - } + const originalDate = new Date(dateObj) + const dateParts = localFormat.formatToParts(originalDate) + const timeValues = dateParts.reduce((acc, curr) => { + Object.assign(acc, { + [curr.type]: curr.value, + }) + return acc + }, {} as DateTimeLocal) - if (type === ApplicationSubmissionTypeEnum.paper) { - const dayjsDate = dayjs(dateObj) + const { month, day, year, hour, minute, second, dayPeriod } = timeValues + const timeZoneFormatted = dayjs().tz(timeZone).format("z") - const date = dayjsDate.utc().format("MM/DD/YYYY") - const time = dayjsDate.utc().format("hh:mm:ss A") + const date = `${month}/${day}/${year}` + const time = `${hour}:${minute}:${second} ${dayPeriod} ${timeZoneFormatted}` - return { - date, - time, - } + return { + date, + time, } } diff --git a/sites/partners/src/lib/hooks.ts b/sites/partners/src/lib/hooks.ts index 40e041e703..e4e9ca2671 100644 --- a/sites/partners/src/lib/hooks.ts +++ b/sites/partners/src/lib/hooks.ts @@ -512,7 +512,8 @@ export const useApplicationsExport = (listingId: string, includeDemographics: bo const { applicationsService } = useContext(AuthContext) return useCsvExport( - () => applicationsService.listAsCsv({ listingId, includeDemographics }), + () => + applicationsService.listAsCsv({ listingId, includeDemographics, timeZone: dayjs.tz.guess() }), `applications-${listingId}-${createDateStringFromNow()}.csv` ) } diff --git a/sites/partners/src/pages/application/[id]/applicationsCols.tsx b/sites/partners/src/pages/application/[id]/applicationsCols.tsx index 98165043cf..0a99daf799 100644 --- a/sites/partners/src/pages/application/[id]/applicationsCols.tsx +++ b/sites/partners/src/pages/application/[id]/applicationsCols.tsx @@ -90,10 +90,7 @@ export const getCols = () => [ const { submissionDate } = data - const dateTime = convertDataToLocal( - submissionDate, - data?.submissionType || ApplicationSubmissionTypeEnum.electronical - ) + const dateTime = convertDataToLocal(submissionDate) return `${dateTime.date} ${t("t.at")} ${dateTime.time}` },