diff --git a/app/server.ts b/app/server.ts index e35c5f2c8282a642facfe3d240243309b952efd1..11bcc28a4cb11c9d3b5d94c5dbba3705dfad96ec 100644 --- a/app/server.ts +++ b/app/server.ts @@ -1,9 +1,10 @@ import { logger } from './adapters/Logger.ts' import { GeoApiClient } from './adapters/geo/GeoApiClient.ts' -import { createSchema, createYoga } from './deps.ts' +import { createSchema, createYoga, GraphQLError } from './deps.ts' import { QueryEvents, QueryEventsInput, QueryEventsOutput } from './usecases/QueryEvents.ts' import { JasdGateway } from './adapters/jasd/JasdGateway.ts' import { QueryEvent, QueryEventInput, QueryEventOutput } from './usecases/QueryEvent.ts' +import { QueryNearbyEvents, QueryNearbyEventsInput, QueryNearbyEventsOutput } from './usecases/QueryNearbyEvents.ts' const SCHEMA = ` type Location { @@ -36,13 +37,18 @@ const SCHEMA = ` data: [Event] } + type NearbyEventsResponse { + data: [Event] + } + type EventResponse { data: Event } type Query { - # uses offset-based pagination as described in https://www.apollographql.com/docs/react/pagination/offset-based - events(offset: Int = 0, limit: Int = 5, geolocationId: ID, localOnly: Boolean): EventsResponse! + # offset-based pagination https://www.apollographql.com/docs/react/pagination/offset-based + events(offset: Int = 0, limit: Int = 5, localOnly: Boolean): EventsResponse! + nearbyEvents(offset: Int = 0, limit: Int = 5, geolocationId: ID!): NearbyEventsResponse! event(id: String!): EventResponse! } ` @@ -54,6 +60,12 @@ export type EventsRequest = { localOnly?: boolean } +export type NearbyEventsRequest = { + limit?: number + offset?: number + geolocationId: string +} + export type EventRequest = { id: string } @@ -74,6 +86,23 @@ const validateQueryEventsInput = (request: EventsRequest): QueryEventsInput => { } } +const validateQueryNearbyEventsInput = (request: NearbyEventsRequest): QueryNearbyEventsInput => { + logger.debug('validating request: ' + JSON.stringify(request)) + + let limit = request.limit ?? 5 + if (limit > 50) { + limit = 50 + } + + if (!request.geolocationId) throw new GraphQLError('GeolocationId not present') + + return { + limit: limit, + offset: request.offset ?? 0, + geolocationId: request.geolocationId, + } +} + const validateQueryEventInput = (request: EventRequest): QueryEventInput => { logger.debug('validating request: ' + JSON.stringify(request)) return { @@ -113,10 +142,25 @@ const createResolvers = (config: ServerConfig, useCases: UseCases) => ({ return useCases.QueryEvents.useCase.execute(input) } }, + nearbyEvents: ( + // deno-lint-ignore no-explicit-any + _parent: any, + parameters: EventsRequest, + // next line is required for the resolver to work + // deno-lint-ignore no-unused-vars + context: GraphQLContext, + ): Promise<QueryNearbyEventsOutput> => { + if (config.fake) { + return Promise.resolve({ data: [] }) + } else { + const input = useCases.QueryEvents.validateInput(parameters) + return useCases.QueryEvents.useCase.execute(input) + } + }, }, }) -type UseCaseNames = 'QueryEvents' | 'QueryEvent' +type UseCaseNames = 'QueryEvents' | 'QueryEvent' | 'QueryNearbyEvents' type UseCases = Record<UseCaseNames, { useCase: UseCase; validateInput: ValidateInputFn }> // deno-lint-ignore no-explicit-any type UseCase = { execute: (input: any) => any } @@ -127,14 +171,18 @@ export const startServer = (config: ServerConfig): Deno.HttpServer<Deno.NetAddr> const geoApi = new GeoApiClient(config.geoAPIEndpointUrl, logger) const jasdEventsGateway = new JasdGateway(logger) - const useCases = { + const useCases: UseCases = { QueryEvent: { validateInput: validateQueryEventInput, useCase: new QueryEvent(jasdEventsGateway, logger), }, QueryEvents: { validateInput: validateQueryEventsInput, - useCase: new QueryEvents(jasdEventsGateway, geoApi, logger), + useCase: new QueryEvents(jasdEventsGateway, logger), + }, + QueryNearbyEvents: { + validateInput: validateQueryNearbyEventsInput, + useCase: new QueryNearbyEvents(jasdEventsGateway, geoApi, logger), }, } diff --git a/app/usecases/QueryEvents.test.ts b/app/usecases/QueryEvents.test.ts index 67b2630a02b01339d01021dc19d1e1d86eefa1bd..ad4ee6b07265224002bbbdb148652064fd36cb34 100644 --- a/app/usecases/QueryEvents.test.ts +++ b/app/usecases/QueryEvents.test.ts @@ -1,19 +1,11 @@ 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, 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' +import { ActivityResult } from '../adapters/jasd/types/appapi.dto.types.ts' +import { LocalTimedEvent } from '../adapters/jasd/tests/fixtures.ts' describe('QueryEvents', () => { - const geoApiAMock = { - resolvePlace() { - return Promise.resolve({ name: 'Village', city: 'Municipality', state: 'State' }) - }, - } - it('can make sense of an empty response', async () => { const jasdGatewayMock = { fetchActivities() { @@ -21,7 +13,7 @@ describe('QueryEvents', () => { }, } - const sut = new QueryEvents(jasdGatewayMock, geoApiAMock, console) + const sut = new QueryEvents(jasdGatewayMock, console) const result = await sut.execute({ offset: 0, limit: 1, localOnly: false }) @@ -41,7 +33,7 @@ describe('QueryEvents', () => { }, } - const sut = new QueryEvents(jasdGatewayMock, geoApiAMock, console) + const sut = new QueryEvents(jasdGatewayMock, console) const result = await sut.execute({ offset: 0, limit: 1, localOnly: false }) @@ -72,123 +64,4 @@ 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 27f0d5154e6c274ffa9e1784be558cc24a08e8a2..afcc988215e8dff67e0c454b2e363ceb28102df7 100644 --- a/app/usecases/QueryEvents.ts +++ b/app/usecases/QueryEvents.ts @@ -1,5 +1,4 @@ import { FetchesJasdActivities } from './dependencies/FetchesJasdActivities.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 +20,6 @@ const APP_FILES_BASE_URL = 'https://gemeinschaftswerk-nachhaltigkeit.de/app/api/ export class QueryEvents { constructor( private readonly jasdApi: FetchesJasdActivities, - private readonly geoApi: ResolvesPlace, private readonly logger: Logs, ) {} @@ -30,55 +28,17 @@ export class QueryEvents { const startTime = Date.now() try { - const placeDetails = input.geolocationId ? await this.geoApi.resolvePlace(input.geolocationId) : undefined - - 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, + const jasdActivities = await this.jasdApi.fetchActivities( + input.limit, input.offset, input.localOnly, ) - holiEvents.push(...(nationalOnlineAndLocalEvents.events.map(this.toDomainEvent))) + const holiEvents = jasdActivities.events.map(this.toDomainEvent) return { data: holiEvents, - totalResults: holiEvents.length, + totalResults: jasdActivities.totalCount, } } catch (error) { this.logger.error('Error executing QueryEvents use case', error) diff --git a/app/usecases/QueryNearbyEvents.test.ts b/app/usecases/QueryNearbyEvents.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc022c33fba08ff88032c1ba4b4a62d7246943df --- /dev/null +++ b/app/usecases/QueryNearbyEvents.test.ts @@ -0,0 +1,134 @@ +import { assertEquals } from '@std/assert' +import { describe, it } from '@std/testing/bdd' +import { returnsNext, stub } from '@std/testing/mock' + +import { Address, Location } from '../adapters/jasd/types/appapi.dto.types.ts' +import { LocalTimedActivity, OnlineTimedActivity } from '../adapters/jasd/tests/fixtures.ts' +import { JasdGateway } from '../adapters/jasd/JasdGateway.ts' +import { QueryNearbyEvents } from './QueryNearbyEvents.ts' + +describe('QueryNearbyEvents', () => { + const geoApiAMock = { + resolvePlace() { + return Promise.resolve({ name: 'Village', city: 'Municipality', state: 'State' }) + }, + } + + 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 QueryNearbyEvents( + { + fetchActivities: fetchActivitiesSpy, + }, + geoApiAMock, + console, + ) + + const result = await sut.execute({ + limit: 5, + offset: 0, + geolocationId: 'someGeolocationId', + }) + + assertEquals(result.data.length, 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/QueryNearbyEvents.ts b/app/usecases/QueryNearbyEvents.ts new file mode 100644 index 0000000000000000000000000000000000000000..c875e072597a17f3a838ce9f8cfc19357101eefc --- /dev/null +++ b/app/usecases/QueryNearbyEvents.ts @@ -0,0 +1,107 @@ +import { FetchesJasdActivities } from './dependencies/FetchesJasdActivities.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' + +export interface QueryNearbyEventsInput { + limit: number + offset: number + geolocationId?: string +} + +export interface QueryNearbyEventsOutput { + data: HoliEvent[] +} + +const APP_FILES_BASE_URL = 'https://gemeinschaftswerk-nachhaltigkeit.de/app/api/v1/files' + +export class QueryNearbyEvents { + constructor( + private readonly jasdApi: FetchesJasdActivities, + private readonly geoApi: ResolvesPlace, + private readonly logger: Logs, + ) {} + + async execute(input: QueryNearbyEventsInput): Promise<QueryNearbyEventsOutput> { + this.logger.debug(`Executing QueryEvents use case with input: ${JSON.stringify(input)}`) + const startTime = Date.now() + + try { + const placeDetails = input.geolocationId ? await this.geoApi.resolvePlace(input.geolocationId) : undefined + + const eventsToFind = input.limit + const holiEvents: HoliEvent[] = [] + const cityActivities = await this.jasdApi.fetchActivities( + input.limit, + input.offset, + false, + placeDetails?.city, + ) + const holiCityEvents = cityActivities.events.map(this.toDomainEvent) + holiEvents.push(...holiCityEvents) + + if (holiEvents.length >= eventsToFind) { + return { + data: holiEvents, + } + } + + const stateActivities = await this.jasdApi.fetchActivities( + input.limit, + input.offset, + false, + placeDetails?.state, + ) + const holiStateEvents = stateActivities.events.map(this.toDomainEvent) + holiEvents.push(...holiStateEvents) + + if (holiEvents.length >= eventsToFind) { + return { + data: holiEvents, + } + } + + const nationalOnlineAndLocalEvents = await this.jasdApi.fetchActivities( + input.limit - holiEvents.length, + input.offset, + false, + ) + + holiEvents.push(...(nationalOnlineAndLocalEvents.events.map(this.toDomainEvent))) + + return { + data: holiEvents, + } + } catch (error) { + this.logger.error('Error executing QueryEvents use case', error) + throw error + } finally { + const duration = Date.now() - startTime + this.logger.info(`QueryEvents use case executed in ${duration}ms`) + } + } + + private toDomainEvent(event: ActivityResult): HoliEvent { + return { + id: event.activity.id.toString(), + title: event.name, + organisationName: event.activity.organisation.name, + startDate: event.activity.period.start, + endDate: event.activity.period.end, + location: { + city: event.location.address.city, + street: event.location.address.street, + streetNo: event.location.address.streetNo, + zipCode: event.location.address.zipCode, + }, + isRemote: event.activity.location.online, + imageUrl: `${APP_FILES_BASE_URL}/${event.activity.image}`, + eventDetails: { + description: event.description, + externalLink: event.activity.location.url, + meetingLink: event.activity.registerUrl ?? undefined, + }, + } + } +} diff --git a/smoketest/main.js b/smoketest/main.js index 41a4b5bc18df6822ffa686b27d0778e8f2ffe4ab..9f9951fc5efe25a15faa985b378195906bb64e92 100644 --- a/smoketest/main.js +++ b/smoketest/main.js @@ -29,6 +29,16 @@ export default () => { }) }) + forQuery( + `{ nearbyEvents(geolocationId: "511b707c992900284059c4dd733cc1874a40f00101f9019a4a930000000000c002059203084b72616d70666572") { data { id } } }`, + (response) => { + check(JSON.parse(response.body), { + 'returns 5 nearbyEvents': (r) => + Array.isArray(r.data.nearbyEvents.data) && r.data.nearbyEvents.data.length === 5, + }) + }, + ) + forQuery(`{ event(id: "1234") { data { id } } }`, (response) => { check(JSON.parse(response.body), { 'returns event': (r) => r.data.event.data.id === '1234',