diff --git a/app/api_types.ts b/app/api_types.ts index 5c4d1b0787d0e891701767979301ad232983d1bd..315a01245d3f21a83d4596a356950733b887febc 100644 --- a/app/api_types.ts +++ b/app/api_types.ts @@ -40,3 +40,6 @@ export enum ApiDefaults { MIN_RADIUS = 5, MAX_RADIUS = 50, // The largest radius in kilometers that the Voltastics API is capable of handling. } + +// TODO: Delete the following when app version 1.5.1 is no longer supported. +export type ApiCitiesResponse = string[]; diff --git a/app/server.ts b/app/server.ts index 0a8cb04b9db6f9e91935516203a3e1a0e79f5a27..bb82b37a644aeb828e1370509cedb5c98a8fbc07 100644 --- a/app/server.ts +++ b/app/server.ts @@ -1,4 +1,5 @@ import { + EngagementOpportunitiesParameters, EngagementParameters, EngagementsParameters, TrackEngagementViewParameters, @@ -6,7 +7,9 @@ import { import { createSchema, createYoga, serve, useResponseCache } from "./deps.ts"; import { fetchCategories, + fetchCities, fetchEngagement, + fetchEngagementOpportunities, fetchEngagements, trackEngagementView, } from "./voltastics.ts"; @@ -53,11 +56,22 @@ const typeDefs = ` scalar GeoJSON + type City { + name: String! + } + + type CitiesResponse { + data: [City]! + } + + type Query { # uses offset-based pagination as described in https://www.apollographql.com/docs/react/pagination/offset-based - engagements(offset: Int!, limit: Int!, location: GeoJSON, category: String): EngagementsResponse! + engagementOpportunities(offset: Int!, limit: Int!, location: GeoJSON, category: String): EngagementsResponse! engagement(id: String!): Engagement categories: CategoriesResponse! + engagements(offset: Int!, limit: Int!, location: String, category: String, longitude: Float, latitude: Float, radiusKm: Float): EngagementsResponse! @deprecated(reason: "Use engagementOpportunities instead, as its support will be discontinued after 1.5.1.") + cities: CitiesResponse! @deprecated(reason: "It will no longer be supported after 1.5.1.") } type Mutation { @@ -67,11 +81,11 @@ const typeDefs = ` const createResolvers = (config: ServerConfig) => ({ Query: { - engagements: ( + engagementOpportunities: ( // deno-lint-ignore no-explicit-any _parent: any, - parameters: EngagementsParameters, - ) => fetchEngagements(config)(parameters), + parameters: EngagementOpportunitiesParameters, + ) => fetchEngagementOpportunities(config)(parameters), engagement: ( // deno-lint-ignore no-explicit-any _parent: any, @@ -81,6 +95,15 @@ const createResolvers = (config: ServerConfig) => ({ // deno-lint-ignore no-explicit-any _parent: any, ) => fetchCategories(config.voltastics), + engagements: ( + // deno-lint-ignore no-explicit-any + _parent: any, + parameters: EngagementsParameters, + ) => fetchEngagements(config)(parameters), + cities: ( + // deno-lint-ignore no-explicit-any + _parent: any, + ) => fetchCities(config.voltastics), }, Mutation: { trackEngagementView: ( @@ -113,12 +136,16 @@ export const createGraphQLServer = (config: ServerConfig): GraphQLServer => { useResponseCache({ session: () => null, // global cache, shared by all users ttlPerSchemaCoordinate: { - "Query.engagements": config.cacheTtlMsVoltastics || + "Query.engagementOpportunities": config.cacheTtlMsVoltastics || DEFAULT_CACHE_TTL_MS_VOLTASTICS, "Query.engagement": config.cacheTtlMsVoltastics || DEFAULT_CACHE_TTL_MS_VOLTASTICS, "Query.categories": config.cacheTtlMsVoltastics || DEFAULT_CACHE_TTL_MS_VOLTASTICS, + "Query.engagements": config.cacheTtlMsVoltastics || + DEFAULT_CACHE_TTL_MS_VOLTASTICS, + "Query.cities": config.cacheTtlMsVoltastics || + DEFAULT_CACHE_TTL_MS_VOLTASTICS, }, }), ] diff --git a/app/types.ts b/app/types.ts index 908dc41b968fa381d9a05acd03e1b757dc26d934..c160bacc98dee2d0875ae626ef68dbbd2ec7d3cd 100644 --- a/app/types.ts +++ b/app/types.ts @@ -32,7 +32,7 @@ export type EngagementsResponse = { data: Engagement[]; }; -export type EngagementsParameters = { +export type EngagementOpportunitiesParameters = { limit: number; offset: number; location?: PlaceDetails; @@ -72,3 +72,20 @@ export type GeolocationGeoJSON = { properties: Place; geometry: GeolocationGeometry; }; + +// TODO: Delete the following when app version 1.5.1 is no longer supported. +export type City = { + name: string; +}; + +export type CitiesResponse = { data: City[] }; + +export type EngagementsParameters = { + limit: number; + offset: number; + latitude?: number; + longitude?: number; + radiusKm?: number; + location?: string; + category?: string; +}; diff --git a/app/voltastics.ts b/app/voltastics.ts index e225900a0e5c63b6dc503d32b4787208540ad577..8bc99e90d30e989357cf9d1de456f87fc7bdbfa7 100644 --- a/app/voltastics.ts +++ b/app/voltastics.ts @@ -1,17 +1,22 @@ import { ApiCategoriesResponse, + ApiCitiesResponse, ApiDefaults, ApiEngagementResponse, ApiRoutes, ApiSearchEngagement, ApiSearchEngagementsResponse, } from "./api_types.ts"; +import { sortCitiesAlphabetically } from "./helpers.ts"; import { logger } from "./logging.ts"; import { ServerConfig, VoltasticsConfig } from "./server.ts"; import { CategoriesResponse, Category, + CitiesResponse, + City, Engagement, + EngagementOpportunitiesParameters, EngagementParameters, EngagementResponse, EngagementsParameters, @@ -113,12 +118,12 @@ const fetchFromVoltasticsApi = ( }); }; -const buildVoltasticsEngagementsSearchParams = ({ +const buildVoltasticsEngagementOpportunitiesSearchParams = ({ limit = 5, offset = 0, location, category, -}: EngagementsParameters) => { +}: EngagementOpportunitiesParameters) => { const params = new URLSearchParams(); params.append("limit", limit.toString()); params.append("offset", offset.toString()); @@ -142,12 +147,16 @@ const buildVoltasticsEngagementsSearchParams = ({ return params; }; -export const fetchEngagements = +export const fetchEngagementOpportunities = (config: ServerConfig) => - (params: EngagementsParameters): Promise<EngagementsResponse> => { - const searchParams = buildVoltasticsEngagementsSearchParams(params); + (params: EngagementOpportunitiesParameters): Promise<EngagementsResponse> => { + const searchParams = buildVoltasticsEngagementOpportunitiesSearchParams( + params, + ); const start = Date.now(); - logger.info(`fetching engagements from ${config.voltastics.baseUrl}`); + logger.info( + `fetching engagement opportunities from ${config.voltastics.baseUrl}`, + ); return fetchFromVoltasticsApi( config.voltastics, @@ -158,7 +167,7 @@ export const fetchEngagements = .then(transformEngagementsResponse(config.imageProxyBaseUrl)) .then((result) => { const duration = Date.now() - start; - logger.debug(`fetching engagements took ${duration} ms`); + logger.debug(`fetching engagement opportunities took ${duration} ms`); return result; }) .catch((e) => { @@ -251,3 +260,104 @@ export const fetchCategories = ( throw e; }); }; + +// TODO: Delete the following when app version 1.5.1 is no longer supported. +const transformCity = (city: string): City => { + return { + name: city, + }; +}; + +const transformCitiesResponse = ( + citiesResponse: ApiCitiesResponse, +): CitiesResponse => { + return { + data: citiesResponse.sort(sortCitiesAlphabetically).map(transformCity), + }; +}; + +const buildVoltasticsEngagementsSearchParams = ({ + limit = 5, + offset = 0, + location = ApiDefaults.CITY, + category, + latitude, + longitude, + radiusKm, +}: EngagementsParameters) => { + const params = new URLSearchParams(); + params.append("limit", limit.toString()); + params.append("offset", offset.toString()); + params.append("city", location); + + if (latitude) { + params.append("lat", latitude.toString()); + } + + if (longitude) { + params.append("lon", longitude.toString()); + } + + if (radiusKm) { + params.append("radius", radiusKm.toString()); + } + + if (category) { + params.append("use", category); + } + + return params; +}; + +export const fetchEngagements = + (config: ServerConfig) => + (params: EngagementsParameters): Promise<EngagementsResponse> => { + const searchParams = buildVoltasticsEngagementsSearchParams(params); + const start = Date.now(); + logger.info(`fetching engagements from ${config.voltastics.baseUrl}`); + + return fetchFromVoltasticsApi( + config.voltastics, + ApiRoutes.SEARCH_ENGAGEMENTS, + searchParams, + ) + .then((result) => result.json()) + .then(transformEngagementsResponse(config.imageProxyBaseUrl)) + .then((result) => { + const duration = Date.now() - start; + logger.debug(`fetching engagements took ${duration} ms`); + return result; + }) + .catch((e) => { + const duration = Date.now() - start; + logger.error( + `Error performing request to ${config.voltastics.baseUrl} after ${duration} ms: ${e.message}`, + { duration }, + ); + throw e; + }); + }; + +export const fetchCities = ( + voltasticsConfig: VoltasticsConfig, +): Promise<CitiesResponse> => { + const start = Date.now(); + logger.info(`fetching cities from ${voltasticsConfig.baseUrl}`); + + return fetchFromVoltasticsApi(voltasticsConfig, ApiRoutes.CITIES) + .then((result) => result.json()) + .then(transformCitiesResponse) + .then((result) => { + const duration = Date.now() - start; + logger.debug(`fetching cities took ${duration} ms`); + return result; + }) + .catch((e) => { + const duration = Date.now() - start; + logger.error( + `Error performing request to ${voltasticsConfig.baseUrl} after ${duration} ms: ${e.message}`, + { duration }, + ); + throw e; + }); +}; diff --git a/app/voltastics_test.ts b/app/voltastics_test.ts index ef22f169f6431652a52fa89de041832a41da82a5..cae725f622da8a52f00197f6751f10fe17a8900b 100644 --- a/app/voltastics_test.ts +++ b/app/voltastics_test.ts @@ -35,7 +35,7 @@ import { import { fetchCategories, fetchEngagement, - fetchEngagements, + fetchEngagementOpportunities, } from "./voltastics.ts"; const emptyResponse = { @@ -66,14 +66,14 @@ const categoryFragment = ` name `; -const queryEngagements = async ( +const queryEngagementOpportunities = async ( graphQLServer: GraphQLServer, ): Promise<EngagementsResponse> => { const promise = processGqlRequest( graphQLServer, ` - query engagements($limit:Int!, $offset: Int!, $location: GeoJSON) { - engagements(limit: $limit, offset: $offset, location: $location) { + query engagementOpportunities($limit:Int!, $offset: Int!, $location: GeoJSON) { + engagementOpportunities(limit: $limit, offset: $offset, location: $location) { totalResults data { ${engagementFragment} @@ -83,7 +83,7 @@ const queryEngagements = async ( { limit: 10, offset: 20, lat: 48.1371079, lon: 11.5753822, radius: 14 }, ); - return (await promise)?.engagements as EngagementsResponse; + return (await promise)?.engagementOpportunities as EngagementsResponse; }; const queryEngagement = async ( @@ -145,7 +145,7 @@ describe("voltastics", () => { it("calls api with correct parameters", async () => { fetchStub = stubFetch(emptyResponse); - await fetchEngagements(serverConfigMock)({ + await fetchEngagementOpportunities(serverConfigMock)({ limit: 10, offset: 20, location: Munich, @@ -164,7 +164,10 @@ describe("voltastics", () => { it("calls api with default location parameter", async () => { fetchStub = stubFetch(emptyResponse); - await fetchEngagements(serverConfigMock)({ limit: 10, offset: 20 }); + await fetchEngagementOpportunities(serverConfigMock)({ + limit: 10, + offset: 20, + }); const expectedUrl = new URL( "https://test.com/api/searchengagement?limit=10&offset=20&city=ALL_CITIES", @@ -180,7 +183,7 @@ describe("voltastics", () => { it("correctly parses empty response", async () => { fetchStub = stubFetch(emptyResponse); - const result = await fetchEngagements(serverConfigMock)({ + const result = await fetchEngagementOpportunities(serverConfigMock)({ limit: 0, offset: 0, location: Munich, @@ -198,7 +201,7 @@ describe("voltastics", () => { imageProxyBaseUrl: serverConfigMock.imageProxyBaseUrl, }); - const result = await queryEngagements(graphQLServer); + const result = await queryEngagementOpportunities(graphQLServer); assertEquals(result, engagementsResponse); }); @@ -211,7 +214,7 @@ describe("voltastics", () => { ); await assertRejects(() => - fetchEngagements(serverConfigMock)({ + fetchEngagementOpportunities(serverConfigMock)({ limit: 0, offset: 0, location: Munich,