From cb81bbd42793a1fc939a4b5c29ad8d26ff8e2300 Mon Sep 17 00:00:00 2001 From: Brock Anderson Date: Thu, 24 Oct 2024 15:35:27 -0700 Subject: [PATCH] fix: GEO-1198 report and announcement searches convert filter dates into UTC timezone (#822) --- .../src/components/ReportSearchFilters.vue | 6 +- .../v1/services/admin-report-service.spec.ts | 15 +++++ .../src/v1/services/admin-report-service.ts | 11 ++-- .../v1/services/announcements-service.spec.ts | 32 +++++++++- .../src/v1/services/announcements-service.ts | 6 +- backend/src/v1/services/utils-service.spec.ts | 33 ++++++++++- backend/src/v1/services/utils-service.ts | 58 ++++++++++++++++++- 7 files changed, 146 insertions(+), 15 deletions(-) diff --git a/admin-frontend/src/components/ReportSearchFilters.vue b/admin-frontend/src/components/ReportSearchFilters.vue index 44191de85..b76cf3238 100644 --- a/admin-frontend/src/components/ReportSearchFilters.vue +++ b/admin-frontend/src/components/ReportSearchFilters.vue @@ -244,7 +244,6 @@ import { ZonedDateTime, nativeJs, DateTimeFormatter, - ZoneId, LocalDate, } from '@js-joda/core'; import { Locale } from '@js-joda/locale_en'; @@ -291,9 +290,7 @@ function getReportSearchFilters(): ReportFilterType { key: 'create_date', operation: 'between', value: submissionDateRange.value.map((d, i) => { - const jodaZonedDateTime = ZonedDateTime.from( - nativeJs(d), - ).withZoneSameLocal(ZoneId.of('UTC')); + const jodaZonedDateTime = ZonedDateTime.from(nativeJs(d)); const adjusted = i == 0 ? jodaZonedDateTime //start of day @@ -306,6 +303,7 @@ function getReportSearchFilters(): ReportFilterType { .withMinute(59) .withSecond(59) .withNano(999); + return DateTimeFormatter.ISO_DATE_TIME.format(adjusted); }), }); diff --git a/backend/src/v1/services/admin-report-service.spec.ts b/backend/src/v1/services/admin-report-service.spec.ts index df573fa12..a3abae6c6 100644 --- a/backend/src/v1/services/admin-report-service.spec.ts +++ b/backend/src/v1/services/admin-report-service.spec.ts @@ -180,6 +180,21 @@ describe('admin-report-service', () => { }); describe('filtering', () => { + describe('when there are dates in the filter', () => { + it('dates in the filters are converted to UTC', async () => { + await adminReportService.searchReport( + 0, + 10, + '[]', + '[{"key": "create_date", "operation": "between", "value": ["2024-10-02T00:00:00-07:00", "2024-10-02T23:59:59-07:00"] }]', + ); + const { where } = mockFindMany.mock.calls[0][0]; + expect(where.create_date).toEqual({ + gte: '2024-10-02T07:00:00Z', + lt: '2024-10-03T06:59:59Z', + }); + }); + }); describe('when filter is valid', () => { describe('reporting year', () => { it('eq', async () => { diff --git a/backend/src/v1/services/admin-report-service.ts b/backend/src/v1/services/admin-report-service.ts index 7998ec86d..276fcf052 100644 --- a/backend/src/v1/services/admin-report-service.ts +++ b/backend/src/v1/services/admin-report-service.ts @@ -12,6 +12,7 @@ import { } from '../types/report-search'; import { PayTransparencyUserError } from './file-upload-service'; import { reportService } from './report-service'; +import { utils } from './utils-service'; interface IGetReportMetricsInput { reportingYear: number; @@ -34,7 +35,7 @@ const adminReportService = { ): Promise { offset = offset || 0; let sortObj: ReportSortType = []; - let filterObj: ReportFilterType[] = []; + let filters: ReportFilterType[] = []; if (limit < 0) { throw new PayTransparencyUserError('Invalid limit'); } @@ -43,14 +44,16 @@ const adminReportService = { } try { sortObj = JSON.parse(sort); - filterObj = JSON.parse(filter); + filters = JSON.parse(filter); } catch (e) { throw new PayTransparencyUserError('Invalid query parameters'); } - await FilterValidationSchema.parseAsync(filterObj); + await FilterValidationSchema.parseAsync(filters); - const where = this.convertFiltersToPrismaFormat(filterObj); + filters = utils.convertIsoDateStringsToUtc(filters, 'value'); + + const where = this.convertFiltersToPrismaFormat(filters); const orderBy = adminReportServicePrivate.convertSortToPrismaFormat(sortObj); diff --git a/backend/src/v1/services/announcements-service.spec.ts b/backend/src/v1/services/announcements-service.spec.ts index 402ed46b7..6bd9c2a7d 100644 --- a/backend/src/v1/services/announcements-service.spec.ts +++ b/backend/src/v1/services/announcements-service.spec.ts @@ -1,4 +1,5 @@ import { faker } from '@faker-js/faker'; +import { LocalDateTime, ZonedDateTime, ZoneId } from '@js-joda/core'; import omit from 'lodash/omit'; import { AnnouncementDataType, @@ -7,7 +8,6 @@ import { import { UserInputError } from '../types/errors'; import { announcementService } from './announcements-service'; import { utils } from './utils-service'; -import { LocalDateTime, ZonedDateTime, ZoneId } from '@js-joda/core'; const mockFindMany = jest.fn().mockResolvedValue([ { @@ -116,6 +116,36 @@ describe('AnnouncementsService', () => { describe('when query is provided', () => { describe('when filters are provided', () => { + describe('when there are dates in the filter', () => { + it('dates in the filters are converted to UTC', async () => { + await announcementService.getAnnouncements({ + filters: [ + { + key: 'active_on', + operation: 'between', + value: [ + '2024-10-02T00:00:00-07:00', //time in Pacific daylight time (PDT) + '2024-10-02T23:59:59-07:00', + ], + }, + ], + }); + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: [ + { + active_on: { + gte: '2024-10-02T07:00:00Z', //time in UTC + lt: '2024-10-03T06:59:59Z', + }, + }, + ], + }), + }), + ); + }); + }); describe('when title is provided', () => { it('should return announcements', async () => { await announcementService.getAnnouncements({ diff --git a/backend/src/v1/services/announcements-service.ts b/backend/src/v1/services/announcements-service.ts index 802436a91..61321fd54 100644 --- a/backend/src/v1/services/announcements-service.ts +++ b/backend/src/v1/services/announcements-service.ts @@ -5,6 +5,7 @@ import { ZonedDateTime, ZoneId, } from '@js-joda/core'; +import '@js-joda/timezone'; import { announcement, announcement_resource, @@ -13,6 +14,7 @@ import { } from '@prisma/client'; import { DefaultArgs } from '@prisma/client/runtime/library'; import isEmpty from 'lodash/isEmpty'; +import { config } from '../../config'; import { logger } from '../../logger'; import prisma from '../prisma/prisma-client'; import { PaginatedResult } from '../types'; @@ -24,8 +26,6 @@ import { } from '../types/announcements'; import { UserInputError } from '../types/errors'; import { utils } from './utils-service'; -import { config } from '../../config'; -import '@js-joda/timezone'; const saveHistory = async ( tx: Omit< @@ -132,6 +132,8 @@ export const announcementService = { async getAnnouncements( query: AnnouncementQueryType = {}, ): Promise> { + query.filters = utils.convertIsoDateStringsToUtc(query.filters, 'value'); + const where = buildAnnouncementWhereInput(query); const orderBy = buildAnnouncementSortInput(query); const items = await prisma.announcement.findMany({ diff --git a/backend/src/v1/services/utils-service.spec.ts b/backend/src/v1/services/utils-service.spec.ts index e02379cd5..adf1f4953 100644 --- a/backend/src/v1/services/utils-service.spec.ts +++ b/backend/src/v1/services/utils-service.spec.ts @@ -80,7 +80,7 @@ describe('utils-service', () => { throw new Error('test'); }); - expect(utils.parseJwt('test')).toBe(null); + expect(utils.parseJwt('test')).toBeNull(); }); }); @@ -158,7 +158,7 @@ describe('utils-service', () => { }); }); describe('when typeHints are provided', () => { - it('the executed SQL includes casts to te specified hints', () => { + it('the executed SQL includes casts to te specified hints', async () => { const mockTx = { $executeRawUnsafe: jest.fn(), }; @@ -169,7 +169,7 @@ describe('utils-service', () => { const mockTableName = 'mock_table'; const primaryKeyCol = 'mock_table_id'; - utils.updateManyUnsafe( + await utils.updateManyUnsafe( mockTx, updates, typeHints, @@ -199,4 +199,31 @@ describe('utils-service', () => { }); }); }); + + describe('convertIsoDateStringsToUtc', () => { + describe('given an array of objects with the wrong form', () => { + it('throws an error', () => { + const items = [{}]; + expect(() => + utils.convertIsoDateStringsToUtc(items, 'some_attribute'), + ).toThrow( + "All objects in the given array are expected to have a property called 'some_attribute'", + ); + }); + }); + describe('given an array of objects, some of which have dates, and some which do not', () => { + it('returns a copy of the array, with modified copies of those items that have dates, and unmodified copies of the other items', () => { + const items = [ + { value: '2024-10-02T00:00:00-07:00' }, + { value: 'not a date' }, + ]; + const modifiedCopies = utils.convertIsoDateStringsToUtc(items, 'value'); + const expected = [ + { value: '2024-10-02T07:00:00Z' }, //date string converted to UTC + { value: 'not a date' }, //not modified + ]; + expect(modifiedCopies).toStrictEqual(expected); + }); + }); + }); }); diff --git a/backend/src/v1/services/utils-service.ts b/backend/src/v1/services/utils-service.ts index 9daa25601..5544db064 100644 --- a/backend/src/v1/services/utils-service.ts +++ b/backend/src/v1/services/utils-service.ts @@ -1,4 +1,9 @@ -import { DateTimeFormatter, nativeJs } from '@js-joda/core'; +import { + DateTimeFormatter, + nativeJs, + ZonedDateTime, + ZoneId, +} from '@js-joda/core'; import axios from 'axios'; import { NextFunction, Request, Response } from 'express'; import jsonwebtoken from 'jsonwebtoken'; @@ -222,8 +227,59 @@ async function updateManyUnsafe( await tx.$executeRawUnsafe(sql); } +/* + Loops through all objects in the given array, and for any object with a + attribute of the given name whose value is an ISO date time string (with timezone), + convert the value into a date time string in the UTC timezone. Any object with + such an atttribute whose value that is not an ISO date string will not be touched. + The function does not modify any items in the given array parameter. It returns a + deep copy of the array, which may have some items which are modified copies of the + originals. + This function is designed to work with objects of type ReportFilterType and + AnnouncementFilterType, but has been generalized so it can potentially be useful for + other scenarios as well. + sample usage: + const items: ReportFilterType[] = [...] + const modifiedItems = convertIsoDateStringsToUtc(items, 'value'); + */ +const convertIsoDateStringsToUtc = (items: any[], attrName: string): any[] => { + return items?.map((item: any) => { + if (!Object.hasOwn(item, attrName)) { + throw new Error( + `All objects in the given array are expected to have a property called '${attrName}'`, + ); + } + let value = item[attrName]; + try { + if (Array.isArray(value)) { + value = value.map((v) => { + return ZonedDateTime.parse(v, DateTimeFormatter.ISO_OFFSET_DATE_TIME) + .withZoneSameInstant(ZoneId.of('UTC')) + .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + }); + } else { + value = ZonedDateTime.parse( + value, + DateTimeFormatter.ISO_OFFSET_DATE_TIME, + ) + .withZoneSameInstant(ZoneId.of('UTC')) + .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + } + const modifiedItem = { ...item }; + modifiedItem[attrName] = value; + + return modifiedItem; + } catch (e) { + // The item's value isn't a date string (or an array of date strings), so + // return a copy of the original, unmodified item + return { ...item }; + } + }); +}; + const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const utils = { + convertIsoDateStringsToUtc, getOidcDiscovery, prettyStringify: (obj, indent = 2) => JSON.stringify(obj, null, indent), getSessionUser,