diff --git a/app/adapters/geo/GeoApiClient.test.ts b/app/adapters/geo/GeoApiClient.test.ts index 7e94aad366db511a1e2539c9f7bb5cbd56490b82..ffae8a6fa8922732d9c3d3b3ecfd373f0379c0f1 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 ff96973f8f5c5e3fdc35e7d2d64e2a7c71b74f8f..0d2b9334cf9f81f0b445d9655791e0a10c0cbb8f 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 28322905624d198cc6a24f2452b20b3dfde3b0c1..bf470ba204a46a9b39dd8f59714dd3e2c0f8484a 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 d652a33a762e70a1c01df39f1498fe3c9162b1ba..0a90f135ac351121d3fed0657903ad7fa4336f4b 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 86b705c2d3d99214ba1c7d6735c84b5e2e13b185..867d6e112bea47cea7f845b5bce48b3b4b612f01 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 cd38f799d7d65dbb433fe92ec76af4518e9b60a2..67b2630a02b01339d01021dc19d1e1d86eefa1bd 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 7888e773460a72a88928dc7348b4beaa2ff47a33..27f0d5154e6c274ffa9e1784be558cc24a08e8a2 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 fa38fd153cfeea8476e416727215f9dad3da957e..c30750e2a6ffc5367752bbb76cbe0626006b3e0a 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> }