From d669c18bfd90a3f166edeaec37061b7b8d5f4425 Mon Sep 17 00:00:00 2001
From: Brock Anderson <brock@bandersgeo.ca>
Date: Thu, 24 Oct 2024 12:46:56 -0700
Subject: [PATCH 1/3] admin report search and announcement search now both
 convert dates in the filter params into UTC before submitting them to the DB

---
 .../src/components/ReportSearchFilters.vue    |  5 +-
 .../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(+), 14 deletions(-)

diff --git a/admin-frontend/src/components/ReportSearchFilters.vue b/admin-frontend/src/components/ReportSearchFilters.vue
index 44191de85..f5cc5720f 100644
--- a/admin-frontend/src/components/ReportSearchFilters.vue
+++ b/admin-frontend/src/components/ReportSearchFilters.vue
@@ -291,9 +291,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 +304,7 @@ function getReportSearchFilters(): ReportFilterType {
                 .withMinute(59)
                 .withSecond(59)
                 .withNano(999);
+        console.log(`--> ${DateTimeFormatter.ISO_DATE_TIME.format(adjusted)}`);
         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<any> {
     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<PaginatedResult<announcement>> {
+    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,

From 0054b60251bc93e36553022e291f539b6aa09ac5 Mon Sep 17 00:00:00 2001
From: Brock Anderson <brock@bandersgeo.ca>
Date: Thu, 24 Oct 2024 12:57:18 -0700
Subject: [PATCH 2/3] fix code smell

---
 admin-frontend/src/components/ReportSearchFilters.vue | 1 -
 1 file changed, 1 deletion(-)

diff --git a/admin-frontend/src/components/ReportSearchFilters.vue b/admin-frontend/src/components/ReportSearchFilters.vue
index f5cc5720f..a0c343a02 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';

From 871c421e694b46f6aaabe66833af66992087dc97 Mon Sep 17 00:00:00 2001
From: Brock Anderson <brock@bandersgeo.ca>
Date: Thu, 24 Oct 2024 13:26:17 -0700
Subject: [PATCH 3/3] removed unneded debug statement

---
 admin-frontend/src/components/ReportSearchFilters.vue | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/admin-frontend/src/components/ReportSearchFilters.vue b/admin-frontend/src/components/ReportSearchFilters.vue
index a0c343a02..b76cf3238 100644
--- a/admin-frontend/src/components/ReportSearchFilters.vue
+++ b/admin-frontend/src/components/ReportSearchFilters.vue
@@ -303,7 +303,7 @@ function getReportSearchFilters(): ReportFilterType {
                 .withMinute(59)
                 .withSecond(59)
                 .withNano(999);
-        console.log(`--> ${DateTimeFormatter.ISO_DATE_TIME.format(adjusted)}`);
+
         return DateTimeFormatter.ISO_DATE_TIME.format(adjusted);
       }),
     });