From 938204ee0d693ba202c9ad010ec6132396d30607 Mon Sep 17 00:00:00 2001
From: gregor <gregor.schulz@holi.social>
Date: Mon, 24 Mar 2025 16:14:08 +0100
Subject: [PATCH] respect request limit in location matching

---
 app/adapters/geo/GeoApiClient.test.ts       |   4 +-
 app/adapters/geo/GeoApiClient.ts            |  17 +--
 app/adapters/geo/GeoApiClient.types.ts      |   4 +
 app/adapters/jasd/tests/fixtures.ts         |  69 +++++++++++
 app/adapters/jasd/types/appapi.dto.types.ts |   6 +-
 app/usecases/QueryEvents.test.ts            | 131 +++++++++++++++++++-
 app/usecases/QueryEvents.ts                 |  53 ++++++--
 app/usecases/dependencies/ResolvesCity.ts   |   6 +-
 8 files changed, 261 insertions(+), 29 deletions(-)

diff --git a/app/adapters/geo/GeoApiClient.test.ts b/app/adapters/geo/GeoApiClient.test.ts
index 7e94aad..ffae8a6 100644
--- a/app/adapters/geo/GeoApiClient.test.ts
+++ b/app/adapters/geo/GeoApiClient.test.ts
@@ -44,8 +44,8 @@ describe('GeoAPIClient', () => {
   it('fetches city name of place id', async () => {
     using _ = fakeFetch(aPlaceDetailsReponse)
 
-    const response = await client.resolveCityName('somePlaceId')
+    const response = await client.resolvePlace('somePlaceId')
 
-    assertEquals(response, 'Hamburg')
+    assertEquals(response.city, 'Hamburg')
   })
 })
diff --git a/app/adapters/geo/GeoApiClient.ts b/app/adapters/geo/GeoApiClient.ts
index ff96973..0d2b933 100644
--- a/app/adapters/geo/GeoApiClient.ts
+++ b/app/adapters/geo/GeoApiClient.ts
@@ -1,10 +1,10 @@
-import { GeoAPIResponse, GeolocationCoordinates } from './GeoApiClient.types.ts'
-import { ResolvesCity } from '../../usecases/dependencies/ResolvesCity.ts'
+import { GeoAPIResponse, GeolocationCoordinates, GeolocationProperties } from './GeoApiClient.types.ts'
+import { Place, ResolvesPlace } from '../../usecases/dependencies/ResolvesCity.ts'
 import { ResolvesCoordinates } from '../../usecases/dependencies/ResolvesCoordinates.ts'
 import { Logs } from '../../usecases/dependencies/Logs.ts'
 import { GraphQLError } from '../../deps.ts'
 
-export class GeoApiClient implements ResolvesCoordinates, ResolvesCity {
+export class GeoApiClient implements ResolvesCoordinates, ResolvesPlace {
   constructor(private readonly endpointUrl: string, private readonly logger: Logs) {
   }
 
@@ -22,13 +22,13 @@ export class GeoApiClient implements ResolvesCoordinates, ResolvesCity {
     }
   }
 
-  async resolveCityName(
+  async resolvePlace(
     geolocationId: string,
-  ): Promise<string> {
+  ): Promise<Place> {
     const response = await this.fetchPlaceDetails(geolocationId)
-    const { city } = response.data?.placeDetails?.geolocation?.properties || {}
-    if (city) {
-      return city
+    const placeDetailsProperties = response.data?.placeDetails?.geolocation?.properties || {}
+    if (placeDetailsProperties) {
+      return placeDetailsProperties as GeolocationProperties
     } else {
       throw new GraphQLError(
         `Resolution of city name failed (no data in response for geolocationId=${geolocationId})`,
@@ -45,6 +45,7 @@ export class GeoApiClient implements ResolvesCoordinates, ResolvesCity {
       'headers': {
         'Accept': 'application/graphql-response+json, application/json',
         'Content-Type': 'application/json',
+        'Accept-Language': 'de',
       },
       'method': 'POST',
     })
diff --git a/app/adapters/geo/GeoApiClient.types.ts b/app/adapters/geo/GeoApiClient.types.ts
index 2832290..bf470ba 100644
--- a/app/adapters/geo/GeoApiClient.types.ts
+++ b/app/adapters/geo/GeoApiClient.types.ts
@@ -10,7 +10,11 @@ export type GeolocationCoordinates = {
 
 export type GeolocationProperties = {
   formatted: string
+  name: string
   city: string
+  county: string
+  state: string
+  country: string
 } & GeolocationCoordinates
 
 export type GeoAPIResponse = {
diff --git a/app/adapters/jasd/tests/fixtures.ts b/app/adapters/jasd/tests/fixtures.ts
index d652a33..0a90f13 100644
--- a/app/adapters/jasd/tests/fixtures.ts
+++ b/app/adapters/jasd/tests/fixtures.ts
@@ -490,6 +490,75 @@ export const LocalTimedActivity: Activity = {
   },
 }
 
+export const OnlineTimedActivity: Activity = {
+  'id': 1234,
+  'createdBy': 'f5186f93-5a19-4643-b019-927232e45665',
+  'lastModifiedBy': 'f5186f93-5a19-4643-b019-927232e45665',
+  'createdAt': '2023-09-01T08:29:54.704843+02:00',
+  'modifiedAt': '2023-09-01T08:29:54.704843+02:00',
+  'name': 'Einstiegskurs Löten',
+  'description':
+    '<p>Beim Reparieren elektrischer Geräte kommt früher oder später oft der Punkt, an dem gelötet werden muss. In diesem Workshop können Sie lernen und üben, richtig mit Lötkolben und Zinn umzugehen. Es ist kein Vorwissen nötig. Wenn die Grundlagen geklärt sind, können wir uns gemeinsam zu verschiedenen Anwendungsfeldern vortasten - je nachdem wofür Sie sich besonders interessiert. Dafür können Sie auch eigene Lötprojekte mitbringen (Beispiele: Kabel mit Wackelkontakt, z.B. von Kopfhörern oder Audiokabeln, kleine Geräte diverser Art wie Akku-Fahrradbeleuchtung, Lichterketten oder auch Stirnlampen/Taschenlampen).</p>',
+  'sustainableDevelopmentGoals': [
+    12,
+    13,
+  ],
+  'impactArea': 'LOCAL',
+  'contact': {
+    'lastName': 'Binternagel',
+    'firstName': 'Manuel',
+    'position': '',
+    'email': 'manuel.binternagel@leipzig.de',
+    'phone': '03411236000',
+    'image': '99197284-a2e5-45fc-b8a2-8f21debb22a5.png',
+  },
+  'location': {
+    'address': {
+      'name': '',
+      'street': '',
+      'streetNo': '',
+      'supplement': '',
+      'zipCode': '',
+      'city': '',
+      'state': null,
+      'country': null,
+    },
+    'online': true,
+    'privateLocation': null,
+    'url':
+      'https://www.vhs-leipzig.de/p/normale-kurse/kunst-kultur-kreativitaet/kreatives-gestalten/metall-und-schmuckgestaltung/einstiegskurs-loeten-495-C-C2A1001K',
+    'coordinate': {
+      'type': 'Point',
+      'coordinates': [
+        51.3470085,
+        12.3732656,
+      ],
+    },
+  },
+  'thematicFocus': [
+    'SUSTAINABLE_PROCUREMENT',
+    'SUSTAINABLE_LIFESTYLE',
+  ],
+  'activityType': 'EVENT',
+  'externalId': null,
+  'registerUrl': null,
+  'approvedUntil': null,
+  'period': {
+    'permanent': false,
+    'start': '2023-09-25T00:00:00+02:00',
+    'end': '2023-09-25T00:00:00+02:00',
+  },
+  'logo': '8bf39e2d-ef0e-465f-a81c-5bc7516eb90b.png',
+  'image': 'activities/act6.jpg',
+  'bannerImageMode': null,
+  'organisation': {
+    'id': 1517,
+    'name': 'Volkshochschule Leipzig',
+    'logo': '75240286-d282-4fa4-9dfe-5b822eec0dee.png',
+    'image': '496d800f-15f4-4aac-bd36-33f85b85ea85.png',
+  },
+}
+
 export const emptyPageResponse = {
   'content': [],
   'pageable': {
diff --git a/app/adapters/jasd/types/appapi.dto.types.ts b/app/adapters/jasd/types/appapi.dto.types.ts
index 86b705c..867d6e1 100644
--- a/app/adapters/jasd/types/appapi.dto.types.ts
+++ b/app/adapters/jasd/types/appapi.dto.types.ts
@@ -3,18 +3,18 @@ type Coordinate = {
   coordinates: [number, number] // [latitude, longitude]
 }
 
-type Address = {
+export type Address = {
   name: string
   street: string
   streetNo: string
   supplement: string | null
   zipCode: string
   city: string
-  state: string
+  state: string | null
   country: null | string
 }
 
-type Location = {
+export type Location = {
   address: Address
   online: boolean
   privateLocation: boolean | null
diff --git a/app/usecases/QueryEvents.test.ts b/app/usecases/QueryEvents.test.ts
index cd38f79..67b2630 100644
--- a/app/usecases/QueryEvents.test.ts
+++ b/app/usecases/QueryEvents.test.ts
@@ -1,16 +1,16 @@
 import { assertEquals } from '@std/assert'
 import { describe, it } from '@std/testing/bdd'
+import { returnsNext, stub } from '@std/testing/mock'
 
 import { QueryEvents } from './QueryEvents.ts'
-import { ActivityResult } from '../adapters/jasd/types/appapi.dto.types.ts'
-import { LocalTimedEvent } from '../adapters/jasd/tests/fixtures.ts'
-
-export type ResponsePayload = Record<string, unknown> | Error
+import { ActivityResult, Address, Location } from '../adapters/jasd/types/appapi.dto.types.ts'
+import { LocalTimedActivity, LocalTimedEvent, OnlineTimedActivity } from '../adapters/jasd/tests/fixtures.ts'
+import { JasdGateway } from '../adapters/jasd/JasdGateway.ts'
 
 describe('QueryEvents', () => {
   const geoApiAMock = {
-    resolveCityName() {
-      return Promise.resolve('Hamburg')
+    resolvePlace() {
+      return Promise.resolve({ name: 'Village', city: 'Municipality', state: 'State' })
     },
   }
 
@@ -72,4 +72,123 @@ describe('QueryEvents', () => {
       totalResults: 1,
     })
   })
+
+  describe('when requesting 5 events', () => {
+    it('it puts a events in the users city first in the response', async () => {
+      const activitiesForCityResponse = {
+        events: [
+          {
+            name: 'a',
+            description: 'description',
+            resultType: 'ACTIVITY',
+            activity: LocalTimedActivity,
+            location: aLocation({ address: anAddress({ city: 'Municipality' }) }),
+          },
+          {
+            name: 'b',
+            description: 'description',
+            resultType: 'ACTIVITY',
+            activity: LocalTimedActivity,
+            location: aLocation({ address: anAddress({ city: 'Municipality' }) }),
+          },
+        ],
+        totalCount: 2,
+      }
+
+      const activitiesForStateResponse = {
+        events: [
+          {
+            name: 'c',
+            description: 'description',
+            resultType: 'ACTIVITY',
+            activity: LocalTimedActivity,
+            location: aLocation({ address: anAddress({ state: 'State' }) }),
+          },
+        ],
+        totalCount: 1,
+      }
+      const activitiesUnfilteredResponse = {
+        events: [
+          {
+            name: 'd',
+            description: 'description',
+            resultType: 'ACTIVITY',
+            activity: LocalTimedActivity,
+            location: LocalTimedActivity.location,
+          },
+          {
+            name: 'e',
+            description: 'description',
+            resultType: 'ACTIVITY',
+            activity: OnlineTimedActivity,
+            location: OnlineTimedActivity.location,
+          },
+        ],
+        totalCount: 2,
+      }
+
+      const fetchActivitiesSpy = stub(
+        {} as JasdGateway,
+        'fetchActivities',
+        returnsNext([
+          Promise.resolve(activitiesForCityResponse),
+          Promise.resolve(activitiesForStateResponse),
+          Promise.resolve(activitiesUnfilteredResponse),
+        ]),
+      )
+
+      const sut = new QueryEvents(
+        {
+          fetchActivities: fetchActivitiesSpy,
+        },
+        geoApiAMock,
+        console,
+      )
+
+      const result = await sut.execute({
+        limit: 5,
+        offset: 0,
+        localOnly: false,
+        geolocationId: 'someGeolocationId',
+      })
+
+      assertEquals(result.totalResults, 5)
+      assertEquals(result.data[0].title, 'a')
+      assertEquals(result.data[1].title, 'b')
+      assertEquals(result.data[2].title, 'c')
+      assertEquals(result.data[3].title, 'd')
+      assertEquals(result.data[4].title, 'e')
+    })
+  })
 })
+
+// todo gregor: test remote events in the location-based set are ignored
+
+const aLocation = (override?: Partial<Location>): Location => {
+  const fallback: Location = {
+    address: anAddress(),
+    coordinate: {
+      coordinates: [0, 0],
+      type: 'Point',
+    },
+    online: false,
+    privateLocation: false,
+    url: '',
+  }
+
+  return { ...fallback, ...override }
+}
+
+const anAddress = (override?: Partial<Address>): Address => {
+  const fallback = {
+    city: '',
+    country: null,
+    name: '',
+    state: null,
+    street: '',
+    streetNo: '',
+    supplement: null,
+    zipCode: '',
+  }
+  return { ...fallback, ...override }
+}
diff --git a/app/usecases/QueryEvents.ts b/app/usecases/QueryEvents.ts
index 7888e77..27f0d51 100644
--- a/app/usecases/QueryEvents.ts
+++ b/app/usecases/QueryEvents.ts
@@ -1,5 +1,5 @@
 import { FetchesJasdActivities } from './dependencies/FetchesJasdActivities.ts'
-import { ResolvesCity } from './dependencies/ResolvesCity.ts'
+import { ResolvesPlace } from './dependencies/ResolvesCity.ts'
 import { HoliEvent } from '../domain/HoliEvent.ts'
 import { Logs } from './dependencies/Logs.ts'
 import { ActivityResult } from '../adapters/jasd/types/appapi.dto.types.ts'
@@ -21,7 +21,7 @@ const APP_FILES_BASE_URL = 'https://gemeinschaftswerk-nachhaltigkeit.de/app/api/
 export class QueryEvents {
   constructor(
     private readonly jasdApi: FetchesJasdActivities,
-    private readonly geoApi: ResolvesCity,
+    private readonly geoApi: ResolvesPlace,
     private readonly logger: Logs,
   ) {}
 
@@ -30,18 +30,55 @@ export class QueryEvents {
     const startTime = Date.now()
 
     try {
-      const location = input.geolocationId ? await this.geoApi.resolveCityName(input.geolocationId) : undefined
+      const placeDetails = input.geolocationId ? await this.geoApi.resolvePlace(input.geolocationId) : undefined
 
-      const jasdEvents = await this.jasdApi.fetchActivities(
-        input.limit,
+      const eventsToFind = input.limit
+      const holiEvents: HoliEvent[] = []
+      if (input.geolocationId) {
+        const cityActivities = await this.jasdApi.fetchActivities(
+          input.limit,
+          input.offset,
+          input.localOnly,
+          placeDetails?.city,
+        )
+        const holiCityEvents = cityActivities.events.map(this.toDomainEvent)
+        holiEvents.push(...holiCityEvents)
+
+        if (holiEvents.length >= eventsToFind) {
+          return {
+            data: holiEvents,
+            totalResults: holiEvents.length,
+          }
+        }
+
+        const stateActivities = await this.jasdApi.fetchActivities(
+          input.limit,
+          input.offset,
+          input.localOnly,
+          placeDetails?.state,
+        )
+        const holiStateEvents = stateActivities.events.map(this.toDomainEvent)
+        holiEvents.push(...holiStateEvents)
+
+        if (holiEvents.length >= eventsToFind) {
+          return {
+            data: holiEvents,
+            totalResults: holiEvents.length,
+          }
+        }
+      }
+
+      const nationalOnlineAndLocalEvents = await this.jasdApi.fetchActivities(
+        input.limit - holiEvents.length,
         input.offset,
         input.localOnly,
-        location,
       )
-      const holiEvents = jasdEvents.events.map(this.toDomainEvent)
+
+      holiEvents.push(...(nationalOnlineAndLocalEvents.events.map(this.toDomainEvent)))
+
       return {
         data: holiEvents,
-        totalResults: jasdEvents.totalCount,
+        totalResults: holiEvents.length,
       }
     } catch (error) {
       this.logger.error('Error executing QueryEvents use case', error)
diff --git a/app/usecases/dependencies/ResolvesCity.ts b/app/usecases/dependencies/ResolvesCity.ts
index fa38fd1..c30750e 100644
--- a/app/usecases/dependencies/ResolvesCity.ts
+++ b/app/usecases/dependencies/ResolvesCity.ts
@@ -1,3 +1,5 @@
-export interface ResolvesCity {
-  resolveCityName(geolocationId?: string): Promise<string | undefined>
+export type Place = { name: string; city: string; state: string }
+
+export interface ResolvesPlace {
+  resolvePlace(geolocationId?: string): Promise<Place>
 }
-- 
GitLab