From 6e2c6fa86c1c764fe8ff3b73b9781ddb60844f69 Mon Sep 17 00:00:00 2001 From: Daniel Bimschas <daniel@bimschas.com> Date: Fri, 28 Feb 2025 09:23:04 +0100 Subject: [PATCH] Revert "Merge branch 'HOLI-9793_HOLI-10970_topics-skills-v2' into 'main'" This reverts commit 8e36280b7d69607d9a08537d0fa1ac0aabf96d30, reversing changes made to f4607e589244d150e6ca496abb53015d01463349. --- .gitlab-ci.yml | 13 +- app/api_types.ts | 35 + app/deps.ts | 1 + app/server.ts | 79 +- app/types.ts | 32 +- app/utils.ts | 38 + app/voltastics.ts | 175 ++- app/voltastics_test.ts | 279 ++++ app/voltastics_test_data.ts | 2429 +++++++++++++++++++++++++++++++++++ app/volunteering_db.ts | 432 ++----- app/volunteering_db_test.ts | 373 ++---- smoketest/main.js | 38 +- 12 files changed, 3279 insertions(+), 645 deletions(-) create mode 100644 app/voltastics_test.ts create mode 100644 app/voltastics_test_data.ts diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e0fb49f..a25638f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -121,13 +121,12 @@ review_deploy: rules: - !reference [.rule_templates, only_review] -# Review environment does not have any tables -# review_smoketest: -# extends: .smoketest -# needs: ['review_deploy'] -# stage: deploy -# rules: -# - !reference [.rule_templates, only_review] +review_smoketest: + extends: .smoketest + needs: ['review_deploy'] + stage: deploy + rules: + - !reference [.rule_templates, only_review] review_destroy: needs: ['review_deploy'] diff --git a/app/api_types.ts b/app/api_types.ts index 5fd15ec..0b71a71 100644 --- a/app/api_types.ts +++ b/app/api_types.ts @@ -1,3 +1,38 @@ +export type ApiSearchEngagement = { + id: number + title: string + description: string + link: string + image?: string + source: string + uses: string[] + hashTags: string[] + orga?: string + imageOrga?: string + location?: string + latitude?: number | null + longitude?: number | null +} + +export type ApiSearchEngagementsResponse = + | { + totalEntries: number + limit: number + offset: number + entries: ApiSearchEngagement[] + } + | { success: boolean } + +export type ApiCategoriesResponse = string[] + export enum ApiRoutes { + SEARCH_ENGAGEMENTS = 'searchengagement', TRACK_VIEW = 'trackview', + USES = 'uses', +} + +export enum ApiDefaults { + CITY = 'ALL_CITIES', + MIN_RADIUS = 5, + MAX_RADIUS = 50, // The largest radius in kilometers that the Voltastics API is capable of handling. } diff --git a/app/deps.ts b/app/deps.ts index 8b0fabf..64af28c 100644 --- a/app/deps.ts +++ b/app/deps.ts @@ -1,6 +1,7 @@ export { createSchema, createYoga } from 'npm:graphql-yoga@5.7.0' export { useResponseCache } from 'npm:@graphql-yoga/plugin-response-cache@3.9.0' export { GraphQLError } from 'npm:graphql@16.8.1' +export * as turf from 'npm:@turf/turf@6.5.0' export { default as postgres } from 'npm:postgres@3.4.5' export { deadline, DeadlineError } from 'https://deno.land/std/async/mod.ts' export * as Sentry from 'https://deno.land/x/sentry/index.mjs' diff --git a/app/server.ts b/app/server.ts index 8e3e628..0ebd1ea 100644 --- a/app/server.ts +++ b/app/server.ts @@ -1,4 +1,6 @@ import { + CategoriesResponse, + EngagementOpportunitiesParameters, EngagementParameters, EngagementRecosParameters, EngagementRecosResponse, @@ -19,7 +21,7 @@ import { Sentry, useResponseCache, } from './deps.ts' -import { trackEngagementView } from './voltastics.ts' +import { fetchCategories, fetchEngagementOpportunities, trackEngagementView } from './voltastics.ts' import { logger } from './logging.ts' import { PostgresVolunteeringDB, VolunteeringDB } from './volunteering_db.ts' import { GeoAPIClientImpl } from './geo_api_client.ts' @@ -42,7 +44,9 @@ const typeDefs = ` description: String url: String imageUrl: String + imageUrlOriginal: String source: String + categories: [String]! @deprecated(reason: "Use topics & skills instead. (since v1.34)") organizer: Organizer location: String latitude: Float @@ -51,22 +55,12 @@ const typeDefs = ` """ An array of topic slugs matching the engagement. Ordered by confidence DESC (typically, may be subject to change). """ - topics: [String] @deprecated(reason: "Use topicsV2 instead. (since v1.51)") + topics: [String] """ An array of skill slugs matching the engagement. Ordered by confidence DESC (typically, may be subject to change). """ - skills: [String] @deprecated(reason: "Use skillsV2 instead. (since v1.51)") - - """ - An array of topic V2 slugs matching the engagement. Ordered by confidence DESC (typically, may be subject to change). - """ - topicsV2: [String] - - """ - An array of skill V2 slugs matching the engagement. Ordered by confidence DESC (typically, may be subject to change). - """ - skillsV2: [String] + skills: [String] """ If this engagement is part of a recommendation query response, matchedTopics contains the slugs of matched topics. The order is defined as follows: 1) topics that were part of the query come first (sorted by matching confidence). 2) other topics the engagement matches (sorted by matching confidence) @@ -84,12 +78,21 @@ const typeDefs = ` matchedGeoLocationDistanceInMeters: Float @deprecated(reason: "Use the identically named field in the EngagementReco type returned by the engagementRecos query instead. (since v1.34)") } + type Category { + name: String! + } + + type CategoriesResponse { + data: [Category!]! + } + type EngagementsResponse { totalResults: Int! data: [Engagement]! } type EngagementReco { + engagement: Engagement! """ @@ -116,21 +119,24 @@ const typeDefs = ` scalar GeoJSON type Query { + # uses offset-based pagination as described in https://www.apollographql.com/docs/react/pagination/offset-based + engagementOpportunities(offset: Int! = 0, limit: Int! = 10, location: GeoJSON, category: String): EngagementsResponse! @deprecated(reason: "Use the engagementRecos query instead. (since v1.34)") + """ - Retrieves a list of recommendations based on the request parameters "topics", "skills" and "geoLocationId" (using deprecated topics & skills V2). "topics" contains slugs of the "Topic" type, "skills" contains slugs of the "Skill" type, "geoLocationId" contains the ID of a location as returned by the "geo_*" queries. + Retrieves a list of recommendations based on the request parameters "topics", "skills" and "geoLocationId". "topics" contains slugs of the "Topic" type, "skills" contains slugs of the "Skill" type, "geoLocationId" contains the ID of a location as returned by the "geo_*" queries. """ - engagementRecos(offset: Int! = 0, limit: Int! = 10, topics: [String!] = [], skills: [String!] = [], geolocationId: String): EngagementRecosResponse! @deprecated(reason: "Use engagementRecosV2 instead. (since v1.51)") + engagementRecommendations(offset: Int! = 0, limit: Int! = 10, topics: [String!] = [], skills: [String!] = [], geolocationId: String): EngagementsResponse! @deprecated(reason: "Use the engagementRecos query instead. (since v1.34)") """ - Retrieves a list of recommendations based on the request parameters "topics", "skills" and "geoLocationId" (using topics & skills V2). "topics" contains slugs of the "Topic" type, "skills" contains slugs of the "Skill" type, "geoLocationId" contains the ID of a location as returned by the "geo_*" queries. + Retrieves a list of recommendations based on the request parameters "topics", "skills" and "geoLocationId". "topics" contains slugs of the "Topic" type, "skills" contains slugs of the "Skill" type, "geoLocationId" contains the ID of a location as returned by the "geo_*" queries. """ - engagementRecosV2(offset: Int! = 0, limit: Int! = 10, topics: [String!] = [], skills: [String!] = [], geolocationId: String): EngagementRecosResponse! + engagementRecos(offset: Int! = 0, limit: Int! = 10, topics: [String!] = [], skills: [String!] = [], geolocationId: String): EngagementRecosResponse! engagement(id: String!): Engagement - filteredEngagements(offset: Int! = 0, limit: Int! = 10, topics: [String!] = [], skills: [String!] = [], geolocationId: String): EngagementsResponse! @deprecated(reason: "Use filteredEngagementsV2 instead. (since v1.51)") - - filteredEngagementsV2(offset: Int! = 0, limit: Int! = 10, topics: [String!] = [], skills: [String!] = [], geolocationId: String): EngagementsResponse! + categories: CategoriesResponse! @deprecated(reason: "Not in use anymore since the introduction of the engagementRecos query (since v1.34)") + + filteredEngagements(offset: Int! = 0, limit: Int! = 10, topics: [String!] = [], skills: [String!] = [], geolocationId: String): EngagementsResponse! similarRecommendations(offset: Int! = 0, limit: Int! = 10, id: String!): EngagementsResponse! } @@ -145,6 +151,16 @@ const createResolvers = ( volunteeringDB: VolunteeringDB, ) => ({ Query: { + engagementOpportunities: ( + // deno-lint-ignore no-explicit-any + _parent: any, + parameters: EngagementOpportunitiesParameters, + ): Promise<EngagementsResponse> => + config.fake ? Promise.resolve({ totalResults: 0, data: [] }) : fetchEngagementOpportunities(config)(parameters), + categories: ( + // deno-lint-ignore no-explicit-any + _parent: any, + ): Promise<CategoriesResponse> => config.fake ? Promise.resolve({ data: [] }) : fetchCategories(config.voltastics), engagement: ( // deno-lint-ignore no-explicit-any _parent: any, @@ -158,15 +174,15 @@ const createResolvers = ( ): Promise<EngagementsResponse> => config.fake ? Promise.resolve({ totalResults: 0, data: [] }) - : Sentry.startNewTrace(() => volunteeringDB.filteredEngagements('v1', parameters)), - filteredEngagementsV2: ( + : Sentry.startNewTrace(() => volunteeringDB.filteredEngagements(parameters)), + engagementRecommendations: ( // deno-lint-ignore no-explicit-any _parent: any, - parameters: FilteredEngagementsParameters, + parameters: EngagementRecosParameters, ): Promise<EngagementsResponse> => config.fake ? Promise.resolve({ totalResults: 0, data: [] }) - : Sentry.startNewTrace(() => volunteeringDB.filteredEngagements('v2', parameters)), + : Sentry.startNewTrace(() => volunteeringDB.engagementRecommendations(parameters)), engagementRecos: ( // deno-lint-ignore no-explicit-any _parent: any, @@ -174,15 +190,7 @@ const createResolvers = ( ): Promise<EngagementRecosResponse> => config.fake ? Promise.resolve({ totalResults: 0, data: [] }) - : Sentry.startNewTrace(() => volunteeringDB.engagementRecos('v1', parameters)), - engagementRecosV2: ( - // deno-lint-ignore no-explicit-any - _parent: any, - parameters: EngagementRecosParameters, - ): Promise<EngagementRecosResponse> => - config.fake - ? Promise.resolve({ totalResults: 0, data: [] }) - : Sentry.startNewTrace(() => volunteeringDB.engagementRecos('v2', parameters)), + : Sentry.startNewTrace(() => volunteeringDB.engagementRecos(parameters)), similarRecommendations: ( // deno-lint-ignore no-explicit-any _parent: any, @@ -222,11 +230,12 @@ export const createGraphQLServer = (config: ServerConfig, volunteeringDB: Volunt useResponseCache({ session: () => null, // global cache, shared by all users ttlPerSchemaCoordinate: { + 'Query.engagementOpportunities': config.cacheTtlMsVoltastics, 'Query.engagement': config.cacheTtlMsVoltastics, + 'Query.categories': config.cacheTtlMsVoltastics, 'Query.filteredEngagements': config.cacheTtlMsDB, - 'Query.filteredEngagementsV2': config.cacheTtlMsDB, + 'Query.engagementRecommendations': config.cacheTtlMsDB, 'Query.engagementRecos': config.cacheTtlMsDB, - 'Query.engagementRecosV2': config.cacheTtlMsDB, 'Query.similarRecommendations': config.cacheTtlMsDB, }, }), diff --git a/app/types.ts b/app/types.ts index 1a57bf8..113cdd1 100644 --- a/app/types.ts +++ b/app/types.ts @@ -8,7 +8,11 @@ export type Engagement = { title: string description: string url: string + // HOLI-4384 imageUrl uses imageProxy-prepended URL for fixing old app versions imageUrl?: string + // HOLI-4384 imageUrlOriginal allows app/web-side implementation of optimized image size fetching + // can be removed once all app versions are updated + imageUrlOriginal?: string source: string categories: string[] organizer?: Organizer @@ -18,14 +22,16 @@ export type Engagement = { topics: string[] skills: string[] - topicsV2: string[] - skillsV2: string[] matchedTopics?: string[] matchedSkills?: string[] matchedGeoLocationDistanceInMeters?: number } +export type Category = { + name: string +} + export type EngagementResponse = Engagement | null export type EngagementsResponse = { @@ -41,10 +47,9 @@ export type SimilarRecommendationsParameters = { export type EngagementReco = { engagement: Engagement + matchedTopics?: string[] matchedSkills?: string[] - matchedTopicsV2?: string[] - matchedSkillsV2?: string[] matchedGeoLocationDistanceInMeters?: number } @@ -53,6 +58,15 @@ export type EngagementRecosResponse = { data: EngagementReco[] } +export type EngagementOpportunitiesParameters = { + limit: number + offset: number + location?: PlaceDetails + category?: string +} + +export type CategoriesResponse = { data: Category[] } + export type EngagementParameters = { id: string } @@ -73,6 +87,11 @@ export type EngagementRecosParameters = { geolocationId?: string } +export type PlaceDetails = { + name: string + geolocation: GeolocationGeoJSON +} + export type GeolocationGeometry = { type: string coordinates: number[] | number[][] | number[][][] | number[][][][] @@ -87,6 +106,11 @@ export type GeolocationProperties = { formatted: string } & GeolocationCoordinates +export type GeolocationGeoJSON = { + properties: GeolocationProperties + geometry: GeolocationGeometry +} + export type FilteredEngagementsParameters = { limit: number offset: number diff --git a/app/utils.ts b/app/utils.ts index 4d069ba..5b06f33 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -1,5 +1,43 @@ +import { GeolocationGeometry } from './types.ts' +import { turf } from './deps.ts' +import { ApiDefaults } from './api_types.ts' import { logger } from './logging.ts' +/** + * Calculates the radius of a given polygon or multi-polygon geometry. + * + * @param {GeolocationGeometry} geometry - The input geometry to calculate the radius for. + * @returns {number} The calculated radius in kilometers. + */ +export function calculateRadius(geometry: GeolocationGeometry) { + if (geometry.type !== 'Polygon' && geometry.type !== 'MultiPolygon') { + return ApiDefaults.MIN_RADIUS + } + + const center = turf.center(geometry) + const vertices = turf.explode(geometry) + + let maxDistance: number = ApiDefaults.MIN_RADIUS + + turf.coordEach(vertices, (vertice) => { + const distance = turf.distance(center, turf.point(vertice), { + units: 'kilometers', + }) + + if (distance > maxDistance) { + maxDistance = distance + } + }) + + const radius = Math.ceil(maxDistance) + + if (radius >= ApiDefaults.MAX_RADIUS) { + return ApiDefaults.MAX_RADIUS + } + + return radius +} + export function isNonEmptyString(str?: string) { return typeof str === 'string' && str.trim().length > 0 } diff --git a/app/voltastics.ts b/app/voltastics.ts index 3e09e23..2866130 100644 --- a/app/voltastics.ts +++ b/app/voltastics.ts @@ -1,7 +1,88 @@ -import { ApiRoutes } from './api_types.ts' +import { + ApiCategoriesResponse, + ApiDefaults, + ApiRoutes, + ApiSearchEngagement, + ApiSearchEngagementsResponse, +} from './api_types.ts' import { logger } from './logging.ts' -import { TrackEngagementViewParameters, TrackEngagementViewResponse } from './types.ts' -import { VoltasticsConfig } from './config.ts' +import { + CategoriesResponse, + Category, + Engagement, + EngagementOpportunitiesParameters, + EngagementsResponse, + Organizer, + TrackEngagementViewParameters, + TrackEngagementViewResponse, +} from './types.ts' +import { calculateRadius } from './utils.ts' +import { ServerConfig, VoltasticsConfig } from './config.ts' + +const transformOrganizer = ( + engagement: ApiSearchEngagement, +): Organizer | undefined => { + if (engagement.orga) { + return { + name: engagement.orga, + imageUrl: engagement.imageOrga, + } + } +} + +export const transformEngagement = (imageProxyBaseUrl: string) => +( + engagement: ApiSearchEngagement, +): Engagement => { + return { + id: engagement.id.toString(), + title: engagement.title, + description: engagement.description, + url: engagement.link, + imageUrl: engagement.image && + `${imageProxyBaseUrl}/crop:0:0/resize:auto:1024/plain/` + + encodeURIComponent(engagement.image), + imageUrlOriginal: engagement.image, + source: engagement.source, + categories: engagement.uses, + organizer: transformOrganizer(engagement), + location: engagement.location, + latitude: engagement.latitude, + longitude: engagement.longitude, + topics: [], + skills: [], + } +} + +const transformEngagementsResponse = (imageProxyBaseUrl: string) => +( + engagementsResponse: ApiSearchEngagementsResponse, +): EngagementsResponse => { + if ('success' in engagementsResponse) { + return { + totalResults: 0, + data: [], + } + } + return { + totalResults: engagementsResponse.totalEntries, + data: engagementsResponse.entries.map( + transformEngagement(imageProxyBaseUrl), + ), + } +} + +const transformCategory = (category: string): Category => { + return { + name: category, + } +} + +const transformCategoriesResponse = ( + categoriesResponse: ApiCategoriesResponse, +): CategoriesResponse => { + return { data: categoriesResponse.filter(String).map(transformCategory) } +} const fetchFromVoltasticsApi = ( config: VoltasticsConfig, @@ -21,6 +102,71 @@ const fetchFromVoltasticsApi = ( }) } +const buildVoltasticsEngagementOpportunitiesSearchParams = ({ + limit = 5, + offset = 0, + location, + category, +}: EngagementOpportunitiesParameters) => { + const params = new URLSearchParams() + params.append('limit', limit.toString()) + params.append('offset', offset.toString()) + + if (location) { + const { geolocation: { geometry, properties: { lat, lon } } } = location + + const radius = calculateRadius(geometry) + + params.append('lat', lat.toString()) + params.append('lon', lon.toString()) + params.append('radius', radius.toString()) + } else { + params.append('city', ApiDefaults.CITY) + } + + if (category) { + params.append('use', category) + } + + return params +} + +export const fetchEngagementOpportunities = (config: ServerConfig) => +( + params: EngagementOpportunitiesParameters, +): Promise<EngagementsResponse> => { + const searchParams = buildVoltasticsEngagementOpportunitiesSearchParams( + params, + ) + const start = Date.now() + logger.info( + `fetching engagement opportunities 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 engagement opportunities 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 trackEngagementView = (voltasticsConfig: VoltasticsConfig) => ( params: TrackEngagementViewParameters, @@ -48,3 +194,26 @@ export const trackEngagementView = (voltasticsConfig: VoltasticsConfig) => // Return immediately and run Voltastics API call asynchronously return Promise.resolve({ id: params.id }) } + +export const fetchCategories = ( + voltasticsConfig: VoltasticsConfig, +): Promise<CategoriesResponse> => { + const start = Date.now() + logger.info(`fetching categories from ${voltasticsConfig.baseUrl}`) + + return fetchFromVoltasticsApi(voltasticsConfig, ApiRoutes.USES) + .then((result) => result.json()) + .then(transformCategoriesResponse) + .then((result) => { + const duration = Date.now() - start + logger.debug(`fetching categories 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}`, + ) + throw e + }) +} diff --git a/app/voltastics_test.ts b/app/voltastics_test.ts new file mode 100644 index 0000000..7ffe1a1 --- /dev/null +++ b/app/voltastics_test.ts @@ -0,0 +1,279 @@ +import { + assertEquals, + assertRejects, + assertSpyCall, + beforeEach, + describe, + it, + returnsNext, + Stub, + stub, +} from './dev_deps.ts' +import { processGqlRequest, stubFetch } from './common_test.ts' + +import { + CategoriesResponse, + EngagementParameters, + EngagementRecosParameters, + EngagementRecosResponse, + EngagementResponse, + EngagementsResponse, + FilteredEngagementsParameters, + SimilarRecommendationsParameters, +} from './types.ts' +import { createGraphQLServer, GraphQLServer } from './server.ts' + +import { + apiCategoriesResponse, + apiSearchEngagementsResponse, + categories, + engagementsResponse, + Munich, +} from './voltastics_test_data.ts' +import { fetchCategories, fetchEngagementOpportunities } from './voltastics.ts' +import { VolunteeringDB } from './volunteering_db.ts' +import { logger, LogSeverity } from './logging.ts' +import { ServerConfig, VoltasticsConfig } from './config.ts' + +logger.setUpLogger( + 'development', + LogSeverity.EMERGENCY, +) + +const emptyResponse = { + success: true, +} + +const validEngagementsResponse = apiSearchEngagementsResponse + +const engagementFragment = ` +id +title +description +url +imageUrl +imageUrlOriginal +source +categories +organizer { + name + imageUrl +} +location +latitude +longitude +topics +skills +` + +const categoryFragment = ` +name +` + +const queryEngagementOpportunities = async ( + graphQLServer: GraphQLServer, +): Promise<EngagementsResponse> => { + const promise = processGqlRequest( + graphQLServer, + ` + query engagementOpportunities($limit:Int!, $offset: Int!, $location: GeoJSON) { + engagementOpportunities(limit: $limit, offset: $offset, location: $location) { + totalResults + data { + ${engagementFragment} + } + } + }`, + { limit: 10, offset: 20, lat: 48.1371079, lon: 11.5753822, radius: 14 }, + ) + + return (await promise)?.engagementOpportunities as EngagementsResponse +} + +const queryCategories = async ( + graphQLServer: GraphQLServer, +): Promise<CategoriesResponse> => { + const response = await processGqlRequest( + graphQLServer, + ` + query categories { + categories { + data { + ${categoryFragment} + } + } + }`, + {}, + ) + + return response?.categories as CategoriesResponse +} + +const voltasticsConfigMock: VoltasticsConfig = { + baseUrl: 'https://test.com/api', + apiToken: 'ABC123', +} + +const serverConfigMock: ServerConfig = { + port: 8080, + cacheEnabled: true, + voltastics: voltasticsConfigMock, + imageProxyBaseUrl: 'https://dev-images.holi.social', + cacheTtlMsVoltastics: 0, + cacheTtlMsDB: 0, + fake: false, + volunteeringDB: { + hostname: 'fakehost', + port: 1234, + database: 'volunteering', + password: 'secret', + username: 'admin', + connectionAttempts: 10, + }, + geoAPIEndpointUrl: 'fakeUrl', +} + +const noCacheServerConfig: ServerConfig = { + cacheEnabled: false, + voltastics: voltasticsConfigMock, + imageProxyBaseUrl: serverConfigMock.imageProxyBaseUrl, + port: 0, + cacheTtlMsVoltastics: 0, + cacheTtlMsDB: 0, + fake: false, + volunteeringDB: { + hostname: 'fakehost', + port: 1234, + database: 'volunteering', + password: 'secret', + username: 'admin', + connectionAttempts: 10, + }, + geoAPIEndpointUrl: 'fakeUrl', +} + +const fakeVolunteeringDB = new (class implements VolunteeringDB { + public engagement(_params: EngagementParameters): Promise<EngagementResponse> { + return Promise.reject(new Error('should not have been called')) + } + engagementRecommendations(_params: EngagementRecosParameters): Promise<EngagementsResponse> { + return Promise.reject(new Error('should not have been called')) + } + engagementRecos(_params: EngagementRecosParameters): Promise<EngagementRecosResponse> { + return Promise.reject(new Error('should not have been called')) + } + filteredEngagements(_params: FilteredEngagementsParameters): Promise<EngagementsResponse> { + return Promise.reject(new Error('should not have been called')) + } + similarRecommendations(_params: SimilarRecommendationsParameters): Promise<EngagementsResponse> { + return Promise.reject(new Error('should not have been called')) + } +})() + +describe('voltastics', () => { + let fetchStub: Stub + + beforeEach(() => { + fetchStub?.restore() + }) + + describe('querying engagement list', () => { + it('calls api with correct parameters', async () => { + fetchStub = stubFetch(emptyResponse) + + await fetchEngagementOpportunities(serverConfigMock)({ + limit: 10, + offset: 20, + location: Munich, + }) + + const expectedUrl = new URL( + 'https://test.com/api/searchengagement?limit=10&offset=20&lat=48.1371079&lon=11.5753822&radius=14', + ) + + const expectedHeaders = { headers: { 'x-voltastics-token': 'ABC123' } } + + assertSpyCall(fetchStub, 0, { + args: [expectedUrl, expectedHeaders], + }) + }) + it('calls api with default location parameter', async () => { + fetchStub = stubFetch(emptyResponse) + + await fetchEngagementOpportunities(serverConfigMock)({ + limit: 10, + offset: 20, + }) + + const expectedUrl = new URL( + 'https://test.com/api/searchengagement?limit=10&offset=20&city=ALL_CITIES', + ) + + const expectedHeaders = { headers: { 'x-voltastics-token': 'ABC123' } } + + assertSpyCall(fetchStub, 0, { + args: [expectedUrl, expectedHeaders], + }) + }) + + it('correctly parses empty response', async () => { + fetchStub = stubFetch(emptyResponse) + + const result = await fetchEngagementOpportunities(serverConfigMock)({ + limit: 0, + offset: 0, + location: Munich, + }) + + assertEquals(result, { totalResults: 0, data: [] }) + }) + + it('correctly parses engagement list', async () => { + fetchStub = stubFetch(validEngagementsResponse) + + const graphQLServer = createGraphQLServer(noCacheServerConfig, fakeVolunteeringDB) + + const result = await queryEngagementOpportunities(graphQLServer) + + assertEquals(result, engagementsResponse) + }) + + it('throws error for invalid engagements response', async () => { + fetchStub = stub( + globalThis, + 'fetch', + returnsNext([Promise.resolve(new Response('foobar'))]), + ) + + await assertRejects(() => + fetchEngagementOpportunities(serverConfigMock)({ + limit: 0, + offset: 0, + location: Munich, + }) + ) + }) + }) + + describe('querying categories', () => { + it('correctly parses category list', async () => { + fetchStub = stubFetch(apiCategoriesResponse) + + const graphQLServer = createGraphQLServer(noCacheServerConfig, fakeVolunteeringDB) + + const result = await queryCategories(graphQLServer) + + assertEquals(result, categories) + }) + + it('throws error for invalid categories response', async () => { + fetchStub = stub( + globalThis, + 'fetch', + returnsNext([Promise.resolve(new Response('foobar'))]), + ) + + await assertRejects(() => fetchCategories(voltasticsConfigMock)) + }) + }) +}) diff --git a/app/voltastics_test_data.ts b/app/voltastics_test_data.ts new file mode 100644 index 0000000..d3c0672 --- /dev/null +++ b/app/voltastics_test_data.ts @@ -0,0 +1,2429 @@ +import { ApiCategoriesResponse, ApiSearchEngagement, ApiSearchEngagementsResponse } from './api_types.ts' +import { CategoriesResponse, EngagementsResponse } from './types.ts' +import { transformEngagement } from './voltastics.ts' + +export const apiSearchEngagement1: ApiSearchEngagement = { + id: 92403, + + image: + 'https://test.com/search/image?id=92403-ehrenamtliche-reisebegleiter-innen-fur-menschen-mit-beeintrachtigungen-gesucht', + latitude: null, + link: + 'https://www.aktion-mensch.de/was-du-tun-kannst/ehrenamt/engagement-plattform#/lokale-angebote/6cf87740-ea8f-4f1d-a725-86afb75053a1?utm_source=voltastics', + orga: 'Aktion Mensch', + description: + '<p>Weitsprung ist ein gemeinnütziger Reiseanbieter, der sich auf die Bedürfnisse von Reisenden mit Handicap eingestellt hat und seinen Kund:innen während der gesamten Urlaubszeit Hilfen in Form von persönlicher Assistenz bietet. Es reisen Reisebegleiter:innen mit, die die persönlichen Assistenzen geben und dort unterstützen, wo Hilfe gewünscht und gebraucht wird.</p>\n<p>Das ganze Jahr über finden Gruppenreisen mit 4-10 Reisegästen statt. Reiseziele sind Nordsee, Ostsee, Lüneburger Heide, aber auch die Balearen, Kanaren sowie USA, Vietnam und Südafrika. Die Reisedauer liegt zwischen 8 und 21 Tagen.</p>\n<p><strong>Für diese Reisen sucht Weitsprung ehrenamtliche Reisebegleiter:innen. </strong>Die Reisebegleiter:innen geben den Reisenden mit Handicap alle Hilfen vom Koffertragen über Mobilität, Orientierung, Kommmunikation bis hin zur grundpflegerischen Versorgung und vieles mehr.</p>\n<p>Eine Reisegruppe besteht in der Regel aus 4-10 Personen und wird von 2-4 Reisebegleiter:innen unterstützt. Sie werden für diese Aufgabe geschult und fachlich angeleitet. Die ehrenamtlichen Reisebegleiter:innen sind unfall- und haftpflichtversichert. Reisekosten entstehen nicht, die Kosten der Fahrt/Flug, Unterbringung ggf. Halbpension übernimmt der zu begleitende Reisende.</p>\n<p>Der Wohnort der Reisebegleiter und -begleiterinnen kann sich im gesamten Bundesgebiet befinden. Die Vorbereitungstreffen zu den Reisen finden online statt. Ein Online-Einsteigerseminar bereitet Sie auf die Reise vor (2 Tage, jeweils von 10-14 Uhr, an einem Wochenende).</p>\n<p>Gesucht werden reisefreudige Freiwillige, die gern im Team arbeiten und Herausforderungen mögen. Außerdem sollten Sie sich vorstellen können, regelmäßig zu unterstützen - also nicht von vornherein nur eine Reise begleiten wollen. Wie häufig Sie das machen, entscheiden Sie selbst, auch eine Reise pro Jahr ist für uns hilfreich.</p>\n<p>Bitte beachten Sie auch, dass Sie für dieses Engagement gegen Corona geimpft sein müssen. Wenn Sie sich für dieses Engagement interessieren, melden Sie sich gerne in der Freiwilligenagentur.</p>', + source: 'Aktion Mensch', + title: 'Ehrenamtliche Reisebegleiter:innen für Menschen mit Beeinträchtigungen gesucht', + hashTags: [ + 'Munich', + 'Aktion Mensch', + ], + location: 'Munich', + uses: [ + 'Chancengleichheit & Vielfalt', + ], + imageOrga: '', + longitude: null, +} + +export const apiSearchEngagement2: ApiSearchEngagement = { + id: 75434, + image: 'https://test.com/search/image?id=75434-ehrenamt-im-ambulanten-kinderhospizdienst-hhansestrolche', + latitude: null, + link: + 'https://www.aktion-mensch.de/was-du-tun-kannst/ehrenamt/engagement-plattform#/lokale-angebote/f55690cd-e982-4f28-afa6-a9f1b87d8c11?utm_source=voltastics', + orga: 'Aktion Mensch', + description: + '<p>Sie können sich vorstellen, als Ehrenamtliche:r eine Familie zu unterstützen, in der ein Kind lebensverkürzend erkrankt ist? Dann engagieren Sie sich im ambulanten Kinderhospizdienst HHanseStrolche.<br /><br /><strong>Was sind die HHanseStrolche?</strong><br />\nHHanseStrolche heißt unser ambulanter Kinderhospizdienst. Der Träger ist das Theodorus Kinder-Tageshospiz gGmbH in Munich-Eidelstedt. Dabei unterstützen ehrenamtliche Helfer:innen betroffene Familien Zuhause, in ihrem Alltag. Die Hilfe durch die „HHanseStrolche“ ist für die Familien kostenlos. <br /><br /><strong>Warum brauchen die Familien Hilfe?</strong><br />\nDie Diagnose einer unheilbaren Erkrankung eines Kindes ist für Familien sehr belastend. Der Alltag muss neu organisiert werden. Meist muss das Kind rund um die Uhr betreut werden. Mit ihren Sorgen fühlen sich Eltern dabei häufig alleingelassen. In dieser belastenden Situation kommen oft auch die Bedürfnisse der gesunden Geschwisterkinder zu kurz. <br /><br /><strong>Wie können Sie helfen?</strong><br />\nIndem Sie uns als „EhrenStrolch“ durch ihre ehrenamtliche Arbeit unterstützen. Sie gehen in die Familien, entlasten sie in ihrem Alltag und geben ihnen die Möglichkeit, Kraftreserven aufzufüllen und neue Energie zu schöpfen.<br /><br /><strong>Was können Sie konkret tun?</strong><br />\nSie helfen Familien, ihren Alltag besser zu bewältigen. Sie schenken den Eltern, dem erkrankten Kind oder dessen Geschwisterkind Ihre Zeit. Sie werden für die Familie ein Freund und Helfer. Dabei orientieren Sie sich an den konkreten Bedürfnissen der Familie oder der Kinder. Vielleicht lesen Sie dem erkrankten Kind etwas vor. Vielleicht spielen Sie mit dem gesunden Geschwisterkind oder machen einen Ausflug. Vielleicht brauchen die Eltern einfach mal zwei Stunden Zeit für ein ungestörtes Gespräch. Die Familie wird Ihnen zeigen, was sie gerade braucht. <br /><br /><strong>Wo unterstützen und begleiten Sie die Familie?</strong><br />\nSie unterstützen die betroffene Familie in Munich in deren eigenen vier Wänden. Idealerweise ist die Familie für Sie innerhalb von 30 Minuten erreichbar.</p>\n<p><strong>Wie viel Zeit sollten Sie als Helfer:in einbringen?</strong><br />\nDamit die Familien von Ihrer Begleitung wirklich profitieren, ist eine regelmäßige Unterstützung notwendig. So kann die Familie ihren Alltag besser planen. Sie sollten deshalb einmal pro Woche mindestens drei Stunden Zeit für die Familie erübrigen können. Außerdem sollten Sie vor Beginn Ihrer ehrenamtlichen Tätigkeit ausreichend Zeit für die Schulung haben.</p>\n<p><strong>Wie lange dauert die Schulung?</strong><br />\nDie Schulung zum Kinderhospizhelfer dauert insgesamt 120 Stunden und schließt auch eine Hospitanz im Kinder-Tageshospiz ein. Sie findet über einen Zeitraum von vier Monaten statt – alle zwei Wochen an Donnerstag- und Freitagnachmittagen, sowie samstags.<br /><br /><strong>Was sind Inhalte dieser Schulung?</strong><br />\nSie lernen die verschiedenen Krankheitsbilder kennen und die Auswirkungen auf das System Familie. Sie sprechen über den Umgang mit Sterben, Tod und Trauer. Sie beschäftigen sich mit Selbstpflege und Selbstfürsorge. </p><p>\n<strong>Wie können Sie die HHanseStrolche noch unterstützen?</strong></p>\n<p>Wir freuen uns, wenn Sie uns als „HHanseStrolch“ Ihre Zeit spenden. Natürlich können Sie sich im Theodorus Kinder-Tageshospiz auch anderweitig engagieren. Wenn Sie sich für dieses Engagement interessieren, können Sie sich jederzeit bei uns melden.</p>', + 'source': 'Aktion Mensch', + + title: 'Ehrenamt im Ambulanten Kinderhospizdienst HHanseStrolche', + hashTags: [ + 'Munich', + 'Aktion Mensch', + ], + location: 'Munich', + uses: [ + 'Familien', + 'Kinder', + ], + imageOrga: '', + longitude: null, +} + +export const apiSearchEngagementsResponse: ApiSearchEngagementsResponse = { + totalEntries: 1186, + limit: 2, + offset: 0, + entries: [apiSearchEngagement1, apiSearchEngagement2], +} + +export const imageProxyBaseUrlMock = 'https://dev-images.holi.social' + +export const engagement1 = transformEngagement(imageProxyBaseUrlMock)( + apiSearchEngagement1, +) +export const engagement2 = transformEngagement(imageProxyBaseUrlMock)( + apiSearchEngagement2, +) + +export const engagementsResponse: EngagementsResponse = { + totalResults: 1186, + data: [engagement1, engagement2], +} + +export const apiCategoriesResponse: ApiCategoriesResponse = [ + 'Einkaufshilfen', + 'Kinder', + 'Kommunikation', + 'Künstlerisches', + 'Chancengleichheit & Vielfalt', +] + +export const categories: CategoriesResponse = { + data: [ + { name: 'Einkaufshilfen' }, + { name: 'Kinder' }, + { name: 'Kommunikation' }, + { name: 'Künstlerisches' }, + { name: 'Chancengleichheit & Vielfalt' }, + ], +} + +export const Munich = { + '__typename': 'Geo_PlaceDetails', + 'name': 'München, Munich, Bavaria, Germany', + 'geolocation': { + 'type': 'Feature', + 'properties': { + 'feature_type': 'details', + 'website': 'https://www.muenchen.de/', + 'website_other': [ + 'https://www.muenchen.de', + ], + 'name': 'München', + 'name_international': { + 'bs': 'Minhen', + 'ca': 'Munic', + 'cs': 'Mnichov', + 'de': 'München', + 'el': 'Μόναχο', + 'en': 'Munich', + 'es': 'Múnich', + 'fa': 'مونیخ', + 'fr': 'Munich', + 'fy': 'München', + 'it': 'Monaco', + 'ko': '뮌헨', + 'mk': 'Минхен', + 'nl': 'München', + 'ru': 'Мюнхен', + 'sk': 'MnÃchov', + 'ta': 'மியூனிகà¯', + 'tr': 'Münih', + 'uk': 'Мюнхен', + 'ur': 'میونخ', + }, + 'contact': { + 'phone': '+49 89 115', + }, + 'wiki_and_media': { + 'wikidata': 'Q1726', + 'wikipedia': 'de:München', + }, + 'categories': [ + 'administrative', + 'administrative.county_level', + ], + 'datasource': { + 'sourcename': 'openstreetmap', + 'attribution': '© OpenStreetMap contributors', + 'license': 'Open Database Licence', + 'url': 'https://www.openstreetmap.org/copyright', + 'raw': { + 'ele': 519, + 'name': 'München', + 'type': 'boundary', + 'phone': '+49 89 115', + 'osm_id': 62428, + 'capital': 4, + 'name:bs': 'Minhen', + 'name:ca': 'Munic', + 'name:cs': 'Mnichov', + 'name:de': 'München', + 'name:el': 'Μόναχο', + 'name:en': 'Munich', + 'name:es': 'Múnich', + 'name:fa': 'مونیخ', + 'name:fr': 'Munich', + 'name:fy': 'München', + 'name:it': 'Monaco', + 'name:ko': '뮌헨', + 'name:mk': 'Минхен', + 'name:nl': 'München', + 'name:ru': 'Мюнхен', + 'name:sk': 'MnÃchov', + 'name:ta': 'மியூனிகà¯', + 'name:tr': 'Münih', + 'name:uk': 'Мюнхен', + 'name:ur': 'میونخ', + 'website': 'https://www.muenchen.de/', + 'boundary': 'administrative', + 'de:place': 'city', + 'name:lld': 'Minca', + 'name:tzl': 'Müntsch', + 'osm_type': 'r', + 'wikidata': 'Q1726', + 'wikipedia': 'de:München', + 'population': 1484226, + 'ref:LOCODE': 'DEMUC', + 'ref:nuts:3': 'DE212', + 'admin_level': 6, + 'attribution': 'Open Data LH München 2017', + 'name:prefix': 'Landeshauptstadt', + 'old_name:sl': 'Monakovo', + 'country_code': 'de', + 'linked_place': 'city', + 'contact:phone': '+49 89 115', + '_place_name:ar': 'ميونخ', + '_place_name:az': 'Münhen', + '_place_name:be': 'Мюнхен', + '_place_name:bg': 'Мюнхен', + '_place_name:br': 'München', + '_place_name:da': 'München', + '_place_name:eo': 'Munkeno', + '_place_name:eu': 'Munich', + '_place_name:fi': 'München', + '_place_name:gl': 'Múnic', + '_place_name:he': '×ž×™× ×›×Ÿ', + '_place_name:hi': 'मà¥à¤¯à¥‚निख', + '_place_name:hr': 'München', + '_place_name:hy': 'Õ„ÕµÕ¸Ö‚Õ¶ÕÕ¥Õ¶', + '_place_name:it': 'Monaco di Baviera', + '_place_name:ja': 'ミュンヘン', + '_place_name:ka': 'მიუნხენი', + '_place_name:kk': 'Мюнхен', + '_place_name:kn': 'ಮà³à²¨à²¿à²•à³', + '_place_name:la': 'Monacum', + '_place_name:lt': 'Miunchenas', + '_place_name:lv': 'Minhene', + '_place_name:oc': 'Munic', + '_place_name:pl': 'Monachium', + '_place_name:pt': 'Munique', + '_place_name:sh': 'München', + '_place_name:sr': 'Минхен', + '_place_name:tt': 'Мүнхен', + '_place_name:uz': 'Munhen', + '_place_name:vi': 'München', + '_place_name:zh': '慕尼黑', + 'openGeoDB:type': 'Landeshauptstadt', + '_place_int_name': 'Munich', + '_place_name:als': 'Münche', + '_place_name:bar': 'Minga', + '_place_name:dsb': 'München', + '_place_name:hsb': 'Mnichow', + 'contact:website': 'https://www.muenchen.de', + 'openGeoDB:loc_id': 212, + '_place_alt_name:la': 'Monachium;Monachum', + 'license_plate_code': 'M', + '_place_name:zh-Hant': '慕尼黑', + '_place_name:be-tarask': 'МюнхÑн', + 'de:regionalschluessel': '091620000000', + 'TMC:cid_58:tabcd_1:Class': 'Area', + 'openGeoDB:license_plate_code': 'M', + 'TMC:cid_58:tabcd_1:LCLversion': 8, + 'openGeoDB:telephone_area_code': '089', + 'TMC:cid_58:tabcd_1:LocationCode': 1956, + 'de:amtlicher_gemeindeschluessel': '09162000', + 'openGeoDB:community_identification_number': '09162', + }, + }, + 'city': 'Munich', + 'state': 'Bavaria', + 'country': 'Germany', + 'country_code': 'de', + 'formatted': 'München, Munich, Bavaria, Germany', + 'address_line1': 'München', + 'address_line2': 'Munich, Bavaria, Germany', + 'lat': 48.1371079, + 'lon': 11.5753822, + 'timezone': { + 'name': 'Europe/Berlin', + 'offset_STD': '+01:00', + 'offset_STD_seconds': 3600, + 'offset_DST': '+02:00', + 'offset_DST_seconds': 7200, + 'abbreviation_STD': 'CET', + 'abbreviation_DST': 'CEST', + }, + 'place_id': '51c69eecf83f18274059dd3ad3f89a134840f00101f901dcf3000000000000c002099203084dc3bc6e6368656e', + }, + 'geometry': { + 'type': 'MultiPolygon', + 'coordinates': [ + [ + [ + [ + 11.5560672, + 48.0799449, + ], + [ + 11.5563954, + 48.0804318, + ], + [ + 11.5577562, + 48.0795803, + ], + [ + 11.5569527, + 48.0793801, + ], + [ + 11.5560672, + 48.0799449, + ], + ], + ], + [ + [ + [ + 11.4902263, + 48.073821, + ], + [ + 11.490724, + 48.0742575, + ], + [ + 11.4909805, + 48.0741023, + ], + [ + 11.4903024, + 48.0737742, + ], + [ + 11.4902263, + 48.073821, + ], + ], + ], + [ + [ + [ + 11.360777, + 48.1580704, + ], + [ + 11.3617953, + 48.1598761, + ], + [ + 11.36277, + 48.163048, + ], + [ + 11.3643432, + 48.1635874, + ], + [ + 11.3654224, + 48.1643455, + ], + [ + 11.3678431, + 48.1684747, + ], + [ + 11.3683799, + 48.1696775, + ], + [ + 11.3688606, + 48.1726736, + ], + [ + 11.3696669, + 48.174332, + ], + [ + 11.369049, + 48.1764125, + ], + [ + 11.3711004, + 48.1782815, + ], + [ + 11.3736819, + 48.1784768, + ], + [ + 11.3750821, + 48.1790691, + ], + [ + 11.3774025, + 48.1789277, + ], + [ + 11.3810841, + 48.1793421, + ], + [ + 11.3831458, + 48.1780236, + ], + [ + 11.3853011, + 48.177571, + ], + [ + 11.38744, + 48.1788823, + ], + [ + 11.3883609, + 48.1801284, + ], + [ + 11.3908722, + 48.181389, + ], + [ + 11.3932585, + 48.1844601, + ], + [ + 11.3912683, + 48.1855524, + ], + [ + 11.3920119, + 48.1867844, + ], + [ + 11.3922842, + 48.1895554, + ], + [ + 11.3905496, + 48.1902715, + ], + [ + 11.3907007, + 48.1905799, + ], + [ + 11.3885404, + 48.1908446, + ], + [ + 11.3908298, + 48.2004624, + ], + [ + 11.4015412, + 48.2009487, + ], + [ + 11.4024401, + 48.2013933, + ], + [ + 11.4028239, + 48.2011354, + ], + [ + 11.4046319, + 48.2013522, + ], + [ + 11.4168667, + 48.2031454, + ], + [ + 11.4188322, + 48.2027723, + ], + [ + 11.4194546, + 48.2035059, + ], + [ + 11.4324192, + 48.2052394, + ], + [ + 11.4323665, + 48.2057585, + ], + [ + 11.4383679, + 48.2058276, + ], + [ + 11.4384262, + 48.2061977, + ], + [ + 11.4435089, + 48.2063668, + ], + [ + 11.4440659, + 48.2069387, + ], + [ + 11.4576008, + 48.2087996, + ], + [ + 11.4587546, + 48.2104817, + ], + [ + 11.4590604, + 48.2115776, + ], + [ + 11.4605009, + 48.2120246, + ], + [ + 11.4604103, + 48.2131961, + ], + [ + 11.4617916, + 48.214465, + ], + [ + 11.4634522, + 48.215499, + ], + [ + 11.4702641, + 48.2184986, + ], + [ + 11.473032, + 48.2193732, + ], + [ + 11.4770563, + 48.2195538, + ], + [ + 11.4774679, + 48.2197313, + ], + [ + 11.4774271, + 48.2199989, + ], + [ + 11.4910806, + 48.2230962, + ], + [ + 11.4888895, + 48.2234665, + ], + [ + 11.4889809, + 48.2240063, + ], + [ + 11.488066, + 48.226256, + ], + [ + 11.4885583, + 48.229337, + ], + [ + 11.4877521, + 48.2320807, + ], + [ + 11.4886374, + 48.2334869, + ], + [ + 11.4900386, + 48.234826, + ], + [ + 11.4902024, + 48.2354487, + ], + [ + 11.4895372, + 48.2379692, + ], + [ + 11.490802, + 48.2379814, + ], + [ + 11.4915039, + 48.2382047, + ], + [ + 11.4919203, + 48.2397346, + ], + [ + 11.4908786, + 48.240872, + ], + [ + 11.4915749, + 48.2418609, + ], + [ + 11.4906009, + 48.2434475, + ], + [ + 11.4906903, + 48.2454237, + ], + [ + 11.490981, + 48.2457752, + ], + [ + 11.491617, + 48.2459324, + ], + [ + 11.4983881, + 48.247344, + ], + [ + 11.4999247, + 48.2474575, + ], + [ + 11.4998422, + 48.2480984, + ], + [ + 11.5011968, + 48.2481162, + ], + [ + 11.5061828, + 48.2473326, + ], + [ + 11.5132717, + 48.2457287, + ], + [ + 11.5253855, + 48.2413177, + ], + [ + 11.5296105, + 48.2328517, + ], + [ + 11.5311184, + 48.2313437, + ], + [ + 11.5340096, + 48.2314164, + ], + [ + 11.5339469, + 48.2304904, + ], + [ + 11.5377331, + 48.2299955, + ], + [ + 11.5417649, + 48.2291812, + ], + [ + 11.5416825, + 48.2288405, + ], + [ + 11.5432765, + 48.2287567, + ], + [ + 11.5435745, + 48.2294385, + ], + [ + 11.5437386, + 48.2291371, + ], + [ + 11.5442711, + 48.229276, + ], + [ + 11.5441915, + 48.2287072, + ], + [ + 11.5450185, + 48.2278923, + ], + [ + 11.5468314, + 48.227114, + ], + [ + 11.5493764, + 48.2268791, + ], + [ + 11.5605832, + 48.2270424, + ], + [ + 11.570792, + 48.2275558, + ], + [ + 11.5823007, + 48.2285636, + ], + [ + 11.583046, + 48.2276449, + ], + [ + 11.5866848, + 48.2247135, + ], + [ + 11.587159, + 48.2240011, + ], + [ + 11.5874962, + 48.222137, + ], + [ + 11.5874666, + 48.2134767, + ], + [ + 11.5921987, + 48.2135653, + ], + [ + 11.6018342, + 48.2133525, + ], + [ + 11.6015143, + 48.2149446, + ], + [ + 11.6020336, + 48.215108, + ], + [ + 11.6018313, + 48.2168632, + ], + [ + 11.6049988, + 48.2174771, + ], + [ + 11.604813, + 48.2183323, + ], + [ + 11.606269, + 48.218402, + ], + [ + 11.6061897, + 48.2191897, + ], + [ + 11.6108532, + 48.2189434, + ], + [ + 11.6109421, + 48.2192164, + ], + [ + 11.6115803, + 48.2191392, + ], + [ + 11.6092356, + 48.2225798, + ], + [ + 11.606306, + 48.2263477, + ], + [ + 11.6057154, + 48.2267704, + ], + [ + 11.6075009, + 48.2273276, + ], + [ + 11.6070763, + 48.2281597, + ], + [ + 11.6088775, + 48.228416, + ], + [ + 11.6090824, + 48.2276135, + ], + [ + 11.6075414, + 48.2273, + ], + [ + 11.6081407, + 48.2256627, + ], + [ + 11.609395, + 48.2255504, + ], + [ + 11.6129485, + 48.2255761, + ], + [ + 11.6141709, + 48.2279305, + ], + [ + 11.6181431, + 48.2275868, + ], + [ + 11.6224909, + 48.2275567, + ], + [ + 11.6223607, + 48.2287395, + ], + [ + 11.6241083, + 48.2287003, + ], + [ + 11.624613, + 48.2292444, + ], + [ + 11.6329046, + 48.2270696, + ], + [ + 11.633034, + 48.2272244, + ], + [ + 11.6366599, + 48.2262756, + ], + [ + 11.6389922, + 48.2259624, + ], + [ + 11.6394583, + 48.225636, + ], + [ + 11.6383027, + 48.2247514, + ], + [ + 11.63826, + 48.2241538, + ], + [ + 11.6391535, + 48.221753, + ], + [ + 11.6430175, + 48.2206437, + ], + [ + 11.6424356, + 48.2202328, + ], + [ + 11.6428052, + 48.219879, + ], + [ + 11.6505051, + 48.2170076, + ], + [ + 11.6507277, + 48.2151315, + ], + [ + 11.6504917, + 48.2133803, + ], + [ + 11.6497562, + 48.2117561, + ], + [ + 11.6480642, + 48.2097556, + ], + [ + 11.645815, + 48.2083158, + ], + [ + 11.64129, + 48.2060798, + ], + [ + 11.6398525, + 48.2047959, + ], + [ + 11.6391181, + 48.2037657, + ], + [ + 11.6385425, + 48.2020515, + ], + [ + 11.6383474, + 48.1992745, + ], + [ + 11.6377992, + 48.1978661, + ], + [ + 11.6327349, + 48.1890134, + ], + [ + 11.6292562, + 48.1843291, + ], + [ + 11.6295999, + 48.1842016, + ], + [ + 11.6294552, + 48.1840213, + ], + [ + 11.6312303, + 48.1834969, + ], + [ + 11.6310359, + 48.1833379, + ], + [ + 11.6315335, + 48.1830639, + ], + [ + 11.630186, + 48.1820887, + ], + [ + 11.6290368, + 48.1807207, + ], + [ + 11.6280539, + 48.1787193, + ], + [ + 11.627706, + 48.1773044, + ], + [ + 11.6304297, + 48.1771078, + ], + [ + 11.6307066, + 48.1776555, + ], + [ + 11.6313289, + 48.1775033, + ], + [ + 11.6314677, + 48.1778549, + ], + [ + 11.6372115, + 48.1764448, + ], + [ + 11.6364458, + 48.1751439, + ], + [ + 11.6358903, + 48.174917, + ], + [ + 11.6375913, + 48.1742731, + ], + [ + 11.6410058, + 48.1733964, + ], + [ + 11.6413408, + 48.1739388, + ], + [ + 11.6458544, + 48.1736881, + ], + [ + 11.6457021, + 48.1739785, + ], + [ + 11.6482684, + 48.1741321, + ], + [ + 11.6482604, + 48.1745236, + ], + [ + 11.6509888, + 48.1745453, + ], + [ + 11.6509408, + 48.1749964, + ], + [ + 11.6655252, + 48.1761816, + ], + [ + 11.6657165, + 48.1767065, + ], + [ + 11.6655143, + 48.1770181, + ], + [ + 11.6649376, + 48.177376, + ], + [ + 11.6644839, + 48.1773975, + ], + [ + 11.6610541, + 48.1797005, + ], + [ + 11.6610123, + 48.1816876, + ], + [ + 11.6612058, + 48.1819001, + ], + [ + 11.6616288, + 48.1818785, + ], + [ + 11.6622045, + 48.1828884, + ], + [ + 11.6785744, + 48.1815952, + ], + [ + 11.6868297, + 48.180373, + ], + [ + 11.6890044, + 48.1818191, + ], + [ + 11.6889679, + 48.1827884, + ], + [ + 11.6919782, + 48.1825288, + ], + [ + 11.6955543, + 48.1799813, + ], + [ + 11.6907035, + 48.1781596, + ], + [ + 11.6935138, + 48.1765041, + ], + [ + 11.6932409, + 48.1764234, + ], + [ + 11.6960995, + 48.1742858, + ], + [ + 11.6932605, + 48.1705663, + ], + [ + 11.6887835, + 48.1715231, + ], + [ + 11.6857938, + 48.1717565, + ], + [ + 11.6859487, + 48.170847, + ], + [ + 11.6867422, + 48.1697065, + ], + [ + 11.6864455, + 48.1688082, + ], + [ + 11.685728, + 48.1683237, + ], + [ + 11.6856558, + 48.1674893, + ], + [ + 11.6784996, + 48.1604538, + ], + [ + 11.673925, + 48.15647, + ], + [ + 11.6798009, + 48.1532533, + ], + [ + 11.6781624, + 48.144285, + ], + [ + 11.6830616, + 48.1449132, + ], + [ + 11.6837275, + 48.1447925, + ], + [ + 11.6915437, + 48.1457975, + ], + [ + 11.6914785, + 48.1459701, + ], + [ + 11.6916723, + 48.1458121, + ], + [ + 11.6984962, + 48.1466823, + ], + [ + 11.698403, + 48.1468928, + ], + [ + 11.6986205, + 48.1466975, + ], + [ + 11.7027845, + 48.1472332, + ], + [ + 11.702654, + 48.1474726, + ], + [ + 11.7029382, + 48.147252, + ], + [ + 11.710117, + 48.14818, + ], + [ + 11.7134632, + 48.1455237, + ], + [ + 11.7109481, + 48.1446123, + ], + [ + 11.7127796, + 48.1431936, + ], + [ + 11.7101299, + 48.141854, + ], + [ + 11.7141843, + 48.1386774, + ], + [ + 11.7179981, + 48.1377062, + ], + [ + 11.720463, + 48.1390675, + ], + [ + 11.722903, + 48.1370622, + ], + [ + 11.7196799, + 48.1355406, + ], + [ + 11.7204373, + 48.134335, + ], + [ + 11.7152706, + 48.1327982, + ], + [ + 11.7144568, + 48.1340328, + ], + [ + 11.7122674, + 48.1333845, + ], + [ + 11.7143702, + 48.1313657, + ], + [ + 11.7117067, + 48.1300689, + ], + [ + 11.7133971, + 48.1291073, + ], + [ + 11.7145197, + 48.1296081, + ], + [ + 11.7153862, + 48.1289385, + ], + [ + 11.7151701, + 48.1287789, + ], + [ + 11.7161672, + 48.1281741, + ], + [ + 11.7139429, + 48.124264, + ], + [ + 11.7119885, + 48.1229767, + ], + [ + 11.7107359, + 48.1240348, + ], + [ + 11.70885, + 48.1226348, + ], + [ + 11.7086252, + 48.1229634, + ], + [ + 11.7074155, + 48.1223731, + ], + [ + 11.7070999, + 48.1225171, + ], + [ + 11.7060649, + 48.1223646, + ], + [ + 11.7042695, + 48.1214437, + ], + [ + 11.6977398, + 48.1253464, + ], + [ + 11.6964272, + 48.1243881, + ], + [ + 11.6959275, + 48.1243863, + ], + [ + 11.6941881, + 48.1231033, + ], + [ + 11.6975662, + 48.12118, + ], + [ + 11.6960577, + 48.1198625, + ], + [ + 11.6951246, + 48.1203408, + ], + [ + 11.6940418, + 48.1193471, + ], + [ + 11.6984629, + 48.1179705, + ], + [ + 11.6997477, + 48.1179185, + ], + [ + 11.710668, + 48.1155572, + ], + [ + 11.708916, + 48.1142371, + ], + [ + 11.7129604, + 48.1115152, + ], + [ + 11.7139366, + 48.1110192, + ], + [ + 11.714605, + 48.111119, + ], + [ + 11.7145946, + 48.1108471, + ], + [ + 11.7127047, + 48.1048692, + ], + [ + 11.7093428, + 48.1025744, + ], + [ + 11.7043694, + 48.1004612, + ], + [ + 11.7033248, + 48.1003102, + ], + [ + 11.7001631, + 48.0991648, + ], + [ + 11.6966424, + 48.0974342, + ], + [ + 11.6942302, + 48.0971188, + ], + [ + 11.6888833, + 48.0949278, + ], + [ + 11.6890564, + 48.0940088, + ], + [ + 11.6871126, + 48.0935434, + ], + [ + 11.6887349, + 48.0922119, + ], + [ + 11.6878519, + 48.0921369, + ], + [ + 11.6859668, + 48.0924856, + ], + [ + 11.684497, + 48.0924806, + ], + [ + 11.6833458, + 48.0922506, + ], + [ + 11.6827716, + 48.0918035, + ], + [ + 11.6824127, + 48.0884471, + ], + [ + 11.6809575, + 48.0838042, + ], + [ + 11.681313, + 48.0829658, + ], + [ + 11.6835162, + 48.0814904, + ], + [ + 11.6835818, + 48.0802574, + ], + [ + 11.6840698, + 48.079155, + ], + [ + 11.6847024, + 48.0781947, + ], + [ + 11.685651, + 48.0774915, + ], + [ + 11.6793467, + 48.0781985, + ], + [ + 11.6793833, + 48.0783844, + ], + [ + 11.6786688, + 48.0784527, + ], + [ + 11.67862, + 48.0782739, + ], + [ + 11.6737148, + 48.0787812, + ], + [ + 11.6734504, + 48.0782127, + ], + [ + 11.6717534, + 48.0780549, + ], + [ + 11.6678188, + 48.0779646, + ], + [ + 11.6655716, + 48.0782116, + ], + [ + 11.665644, + 48.0784041, + ], + [ + 11.664332, + 48.0786148, + ], + [ + 11.6634758, + 48.0789937, + ], + [ + 11.6633645, + 48.0788111, + ], + [ + 11.6468164, + 48.0820207, + ], + [ + 11.6373747, + 48.0836363, + ], + [ + 11.6382475, + 48.0836693, + ], + [ + 11.6330367, + 48.0842769, + ], + [ + 11.633041, + 48.0859672, + ], + [ + 11.629625, + 48.0862576, + ], + [ + 11.6296264, + 48.086547, + ], + [ + 11.6265793, + 48.0868019, + ], + [ + 11.6270317, + 48.0870844, + ], + [ + 11.6237095, + 48.0873266, + ], + [ + 11.6237531, + 48.08811, + ], + [ + 11.6167533, + 48.0886253, + ], + [ + 11.616744, + 48.0884262, + ], + [ + 11.615151, + 48.0884248, + ], + [ + 11.6151817, + 48.0880168, + ], + [ + 11.6116855, + 48.0881107, + ], + [ + 11.6119796, + 48.0870427, + ], + [ + 11.6064974, + 48.086808, + ], + [ + 11.6035253, + 48.0850642, + ], + [ + 11.6017741, + 48.0873456, + ], + [ + 11.5983569, + 48.0865858, + ], + [ + 11.5976965, + 48.0858711, + ], + [ + 11.5964285, + 48.0857868, + ], + [ + 11.5950305, + 48.0854296, + ], + [ + 11.5947192, + 48.0858191, + ], + [ + 11.5943263, + 48.0855192, + ], + [ + 11.5945095, + 48.0852642, + ], + [ + 11.5936151, + 48.0849751, + ], + [ + 11.5926218, + 48.0850713, + ], + [ + 11.5918167, + 48.0875241, + ], + [ + 11.588558, + 48.0934318, + ], + [ + 11.5875022, + 48.0935005, + ], + [ + 11.5869189, + 48.0938795, + ], + [ + 11.5860873, + 48.0934961, + ], + [ + 11.5854243, + 48.0935285, + ], + [ + 11.5744301, + 48.0893368, + ], + [ + 11.5743819, + 48.0888153, + ], + [ + 11.5732319, + 48.0888993, + ], + [ + 11.5629144, + 48.0851799, + ], + [ + 11.5624875, + 48.0836047, + ], + [ + 11.5605688, + 48.0839426, + ], + [ + 11.5605373, + 48.0841634, + ], + [ + 11.5539743, + 48.0850231, + ], + [ + 11.5491556, + 48.0748689, + ], + [ + 11.5499136, + 48.0746572, + ], + [ + 11.549757, + 48.0744612, + ], + [ + 11.5490322, + 48.0746059, + ], + [ + 11.5454241, + 48.0681278, + ], + [ + 11.5440667, + 48.0683123, + ], + [ + 11.5440071, + 48.0696331, + ], + [ + 11.5442887, + 48.0702448, + ], + [ + 11.5439683, + 48.0719453, + ], + [ + 11.5431318, + 48.0730499, + ], + [ + 11.5436028, + 48.0735282, + ], + [ + 11.5426646, + 48.0745128, + ], + [ + 11.5417552, + 48.0745914, + ], + [ + 11.541585, + 48.0755009, + ], + [ + 11.5422018, + 48.0767898, + ], + [ + 11.5423719, + 48.0778567, + ], + [ + 11.53686, + 48.0780039, + ], + [ + 11.5334768, + 48.0776817, + ], + [ + 11.5321329, + 48.076834, + ], + [ + 11.5315304, + 48.0761553, + ], + [ + 11.5310844, + 48.0749435, + ], + [ + 11.5290508, + 48.07194, + ], + [ + 11.5282499, + 48.0689844, + ], + [ + 11.5262944, + 48.0689891, + ], + [ + 11.5262498, + 48.0683357, + ], + [ + 11.5238869, + 48.0683743, + ], + [ + 11.5230971, + 48.0676607, + ], + [ + 11.5229583, + 48.0664605, + ], + [ + 11.5221, + 48.0665384, + ], + [ + 11.5221162, + 48.0673467, + ], + [ + 11.5188177, + 48.0673262, + ], + [ + 11.5190033, + 48.0670298, + ], + [ + 11.5182103, + 48.0669313, + ], + [ + 11.5182312, + 48.0666233, + ], + [ + 11.5176967, + 48.0666003, + ], + [ + 11.517575, + 48.066269, + ], + [ + 11.5170141, + 48.0663671, + ], + [ + 11.5166094, + 48.065377, + ], + [ + 11.5163096, + 48.0654374, + ], + [ + 11.5156035, + 48.0633629, + ], + [ + 11.5115038, + 48.0643994, + ], + [ + 11.5087578, + 48.0616244, + ], + [ + 11.5042105, + 48.0623947, + ], + [ + 11.5036726, + 48.0654734, + ], + [ + 11.5030423, + 48.065703, + ], + [ + 11.5025769, + 48.0669437, + ], + [ + 11.5030212, + 48.0669886, + ], + [ + 11.5027329, + 48.0681078, + ], + [ + 11.5028817, + 48.0686153, + ], + [ + 11.503558, + 48.0692942, + ], + [ + 11.4872351, + 48.0762576, + ], + [ + 11.4866484, + 48.0753802, + ], + [ + 11.488005, + 48.074964, + ], + [ + 11.4871308, + 48.0737735, + ], + [ + 11.4857966, + 48.0742165, + ], + [ + 11.4870344, + 48.0763511, + ], + [ + 11.4733659, + 48.0821506, + ], + [ + 11.4730197, + 48.0818192, + ], + [ + 11.4733979, + 48.081628, + ], + [ + 11.4729244, + 48.0812229, + ], + [ + 11.4719421, + 48.0818083, + ], + [ + 11.472374, + 48.0822666, + ], + [ + 11.4729241, + 48.0819643, + ], + [ + 11.4732555, + 48.0821942, + ], + [ + 11.4709217, + 48.0831868, + ], + [ + 11.4736262, + 48.0866661, + ], + [ + 11.4747255, + 48.0869866, + ], + [ + 11.476342, + 48.0887657, + ], + [ + 11.4747322, + 48.0888762, + ], + [ + 11.4720742, + 48.0901146, + ], + [ + 11.4734694, + 48.092711, + ], + [ + 11.4741079, + 48.0953762, + ], + [ + 11.470851, + 48.0993754, + ], + [ + 11.4746327, + 48.1038646, + ], + [ + 11.4748504, + 48.1046058, + ], + [ + 11.4720881, + 48.1050848, + ], + [ + 11.4696902, + 48.1058319, + ], + [ + 11.4696285, + 48.1052066, + ], + [ + 11.4629691, + 48.1052549, + ], + [ + 11.4636007, + 48.1086455, + ], + [ + 11.4631838, + 48.1111136, + ], + [ + 11.4635178, + 48.1123065, + ], + [ + 11.4632473, + 48.1135427, + ], + [ + 11.4633954, + 48.1145011, + ], + [ + 11.4650608, + 48.1147058, + ], + [ + 11.4656651, + 48.1185145, + ], + [ + 11.4655645, + 48.120325, + ], + [ + 11.4647654, + 48.1230888, + ], + [ + 11.4647395, + 48.1248029, + ], + [ + 11.4651283, + 48.126992, + ], + [ + 11.4649715, + 48.1282282, + ], + [ + 11.4645429, + 48.129238, + ], + [ + 11.4637916, + 48.1294828, + ], + [ + 11.4636931, + 48.129926, + ], + [ + 11.4620216, + 48.1300002, + ], + [ + 11.4621018, + 48.1302968, + ], + [ + 11.4594724, + 48.1308994, + ], + [ + 11.4601384, + 48.1320449, + ], + [ + 11.4578847, + 48.1324797, + ], + [ + 11.4578446, + 48.1322561, + ], + [ + 11.4547026, + 48.1326279, + ], + [ + 11.4548798, + 48.1329687, + ], + [ + 11.4533677, + 48.1331361, + ], + [ + 11.4513653, + 48.1332754, + ], + [ + 11.4511498, + 48.1324851, + ], + [ + 11.4497754, + 48.1329213, + ], + [ + 11.4481179, + 48.1311538, + ], + [ + 11.4479757, + 48.1317725, + ], + [ + 11.447041, + 48.1330838, + ], + [ + 11.4476451, + 48.1334827, + ], + [ + 11.4474667, + 48.1335873, + ], + [ + 11.4473328, + 48.1334886, + ], + [ + 11.4450237, + 48.1340895, + ], + [ + 11.44229, + 48.1339226, + ], + [ + 11.4401533, + 48.1344226, + ], + [ + 11.4381326, + 48.1346137, + ], + [ + 11.4385662, + 48.135373, + ], + [ + 11.4363741, + 48.1366581, + ], + [ + 11.4360395, + 48.1361104, + ], + [ + 11.4346193, + 48.1368894, + ], + [ + 11.4330624, + 48.1369342, + ], + [ + 11.4299801, + 48.1375559, + ], + [ + 11.4262618, + 48.136538, + ], + [ + 11.4247077, + 48.1353412, + ], + [ + 11.4240461, + 48.1345771, + ], + [ + 11.4233438, + 48.1330177, + ], + [ + 11.4226466, + 48.1324989, + ], + [ + 11.4188182, + 48.1306139, + ], + [ + 11.4155118, + 48.1286784, + ], + [ + 11.4128816, + 48.127491, + ], + [ + 11.4120802, + 48.1273715, + ], + [ + 11.4089641, + 48.1261967, + ], + [ + 11.4038308, + 48.1256149, + ], + [ + 11.4022976, + 48.1253264, + ], + [ + 11.4022722, + 48.1251677, + ], + [ + 11.3944236, + 48.1255105, + ], + [ + 11.3930053, + 48.1285263, + ], + [ + 11.3896186, + 48.1278656, + ], + [ + 11.388724, + 48.1297424, + ], + [ + 11.3894088, + 48.1316766, + ], + [ + 11.388975, + 48.1329001, + ], + [ + 11.3887205, + 48.1328491, + ], + [ + 11.3881907, + 48.1345772, + ], + [ + 11.3901684, + 48.1354416, + ], + [ + 11.3899751, + 48.1359786, + ], + [ + 11.3921849, + 48.1366638, + ], + [ + 11.3895295, + 48.1412227, + ], + [ + 11.3912725, + 48.1418913, + ], + [ + 11.390152, + 48.143654, + ], + [ + 11.3906327, + 48.1438677, + ], + [ + 11.3894055, + 48.1457461, + ], + [ + 11.3902615, + 48.1459832, + ], + [ + 11.389144, + 48.1479327, + ], + [ + 11.3872366, + 48.1473301, + ], + [ + 11.3855294, + 48.1496533, + ], + [ + 11.3840216, + 48.1490191, + ], + [ + 11.3840156, + 48.1497672, + ], + [ + 11.3831771, + 48.1496722, + ], + [ + 11.3828857, + 48.1530762, + ], + [ + 11.3795025, + 48.1535352, + ], + [ + 11.3795223, + 48.1538312, + ], + [ + 11.3780394, + 48.1538371, + ], + [ + 11.3780223, + 48.1532215, + ], + [ + 11.3739109, + 48.1535523, + ], + [ + 11.3735834, + 48.1541884, + ], + [ + 11.3744215, + 48.1540883, + ], + [ + 11.3738626, + 48.1555401, + ], + [ + 11.3751552, + 48.1554886, + ], + [ + 11.3749147, + 48.155983, + ], + [ + 11.3719407, + 48.1562949, + ], + [ + 11.3698222, + 48.15683, + ], + [ + 11.3702468, + 48.157973, + ], + [ + 11.3711086, + 48.1581782, + ], + [ + 11.3711951, + 48.1584195, + ], + [ + 11.3666368, + 48.1586617, + ], + [ + 11.3636336, + 48.1584867, + ], + [ + 11.360777, + 48.1580704, + ], + ], + ], + ], + }, + }, + 'label': 'München, Munich, Bavaria, Germany', +} diff --git a/app/volunteering_db.ts b/app/volunteering_db.ts index 359bded..66d1baa 100644 --- a/app/volunteering_db.ts +++ b/app/volunteering_db.ts @@ -1,4 +1,5 @@ -import { GraphQLError, postgres, Sentry } from './deps.ts' +import { postgres } from './deps.ts' +import { GraphQLError, Sentry } from './deps.ts' import { GeoAPIClient } from './geo_api_client.ts' import { Engagement, @@ -40,7 +41,6 @@ export type VolunteeringDBRow = { latitude?: number longitude?: number embedding_array: string // JSON containing `number[]` - embedding_array_v2: string // JSON containing `number[]` cosine_similarity: number distance_in_meters: number total_results: number @@ -136,65 +136,6 @@ const SKILLS_VECTOR_INDICES = new Map<string, number>([ ['writing-translation', 49], ]) -const TOPICS_V2_VECTOR_INDICES = new Map<string, number>([ - ['animal-welfare', 0], - ['arts-culture', 1], - ['climate-environment', 2], - ['democracy-politics', 3], - ['disability-inclusion', 4], - ['disaster-relief', 5], - ['elderly-care', 6], - ['family-kids', 7], - ['food-hunger-relief', 8], - ['gender-equality', 9], - ['housing', 10], - ['human-rights', 11], - ['lgbtqia-plus', 12], - ['lifestyle-consumption', 13], - ['literacy-education', 14], - ['mental-physical-health', 15], - ['migration-refugees', 16], - ['mobility-transport', 17], - ['other', 18], - ['peace-security', 19], - ['poverty-homelessness', 20], - ['sports-recreation', 21], - ['technology-innovation', 22], - ['youth-development', 23], -]) - -const SKILLS_V2_VECTOR_INDICES = new Map<string, number>([ - ['administration', 0], - ['animal-care', 1], - ['arts-crafts', 2], - ['building-handicraft', 3], - ['childcare', 4], - ['community-organizing', 5], - ['creativity-design', 6], - ['driving', 7], - ['emergency-care', 8], - ['event-planning', 9], - ['finances-taxes', 10], - ['food-service', 11], - ['fundraising', 12], - ['gardening-conservation', 13], - ['intercultural-competence', 14], - ['it-technology', 15], - ['legal-assistance', 16], - ['management-strategy', 17], - ['marketing-communications', 18], - ['moderation-facilitation', 19], - ['music-performance', 20], - ['nursing-caregiving', 21], - ['other', 22], - ['religious-spiritual-care', 23], - ['research-analysis', 24], - ['sales-distribution', 25], - ['team-leadership', 26], - ['translation', 27], - ['tutoring-coaching', 28], -]) - const TOPICS_VECTOR_INDICES_REVERSE = new Map<number, string>( Array.from(TOPICS_VECTOR_INDICES.entries()).map(( [key, index], @@ -210,21 +151,6 @@ const SKILLS_VECTOR_INDICES_REVERSE = new Map<number, string>( const EMBEDDING_DIMENSIONS = TOPICS_VECTOR_INDICES.size + SKILLS_VECTOR_INDICES.size -const TOPICS_V2_VECTOR_INDICES_REVERSE = new Map<number, string>( - Array.from(TOPICS_V2_VECTOR_INDICES.entries()).map(( - [key, index], - ) => [index, key]), -) - -const SKILLS_V2_VECTOR_INDICES_REVERSE = new Map<number, string>( - Array.from(SKILLS_V2_VECTOR_INDICES.entries()).map(( - [key, index], - ) => [index, key]), -) - -const EMBEDDING_V2_DIMENSIONS = TOPICS_V2_VECTOR_INDICES.size + - SKILLS_V2_VECTOR_INDICES.size - /** When annotating search results with matches, embedding values below these thresholds are not considered a match. * Should be aligned with the precomputed binary_embedding. */ @@ -233,15 +159,6 @@ const MATCH_SKILL_CONFIDENCE_THRESHOLD = 0.8 const MAX_LIMIT = 100 const DEFAULT_LIMIT = 10 -const DEFAULT_RECO_TOPICS_V2 = [ - 'human-rights', - 'animal-welfare', - 'arts-culture', - 'literacy-education', - 'mental-physical-health', - 'democracy-politics', -] - const queryEmbeddingVector = (topics: string[], skills: string[]): number[] => { const queryVector: number[] = Array(EMBEDDING_DIMENSIONS).fill(0.0) for (const topic of topics) { @@ -259,23 +176,6 @@ const queryEmbeddingVector = (topics: string[], skills: string[]): number[] => { return queryVector } -const queryEmbeddingVectorV2 = (topics: string[], skills: string[]): number[] => { - const queryVector: number[] = Array(EMBEDDING_V2_DIMENSIONS).fill(0.0) - for (const topic of topics) { - if (TOPICS_V2_VECTOR_INDICES.has(topic)) { - queryVector[TOPICS_V2_VECTOR_INDICES.get(topic)!] = 1.0 - } - } - for (const skill of skills) { - if (SKILLS_V2_VECTOR_INDICES.has(skill)) { - queryVector[ - TOPICS_V2_VECTOR_INDICES.size + SKILLS_V2_VECTOR_INDICES.get(skill)! - ] = 1.0 - } - } - return queryVector -} - /** * Sorts matches with confidence so that * @@ -304,33 +204,27 @@ const sortMatches = ( * @param embedding * @param queriedTopics * @param queriedSkills - * @param version */ const decodeMatchesFromEmbedding = ( embedding: number[], queriedTopics: string[], queriedSkills: string[], - version: TopicsSkillsVersion, ): { topics: string[]; skills: string[] } => { - const skillsOffset = version == 'v1' ? TOPICS_VECTOR_INDICES.size : TOPICS_V2_VECTOR_INDICES.size - const topics_indices_reverse = version == 'v1' ? TOPICS_VECTOR_INDICES_REVERSE : TOPICS_V2_VECTOR_INDICES_REVERSE - const skills_indices_reverse = version == 'v1' ? SKILLS_VECTOR_INDICES_REVERSE : SKILLS_V2_VECTOR_INDICES_REVERSE - const expectedEmbeddingDimension = version == 'v1' ? EMBEDDING_DIMENSIONS : EMBEDDING_V2_DIMENSIONS - - if (embedding.length != expectedEmbeddingDimension) { + if (embedding.length != EMBEDDING_DIMENSIONS) { logger.warn( - `Received embedding of dimension ${embedding.length}, expected ${expectedEmbeddingDimension}`, + `Received embedding of dimension ${embedding.length}, expected ${EMBEDDING_DIMENSIONS}`, ) } + const skillsOffset = TOPICS_VECTOR_INDICES.size const matchedTopicsWithConfidence = embedding .map((confidence, index): [string, number] | null => { - return index < skillsOffset ? [topics_indices_reverse.get(index)!, confidence] : null + return index < skillsOffset ? [TOPICS_VECTOR_INDICES_REVERSE.get(index)!, confidence] : null }) .filter((matchWithConfidence) => matchWithConfidence != null) .filter((matchWithConfidence) => matchWithConfidence[1] >= MATCH_TOPIC_CONFIDENCE_THRESHOLD) const matchedSkillsWithConfidence = embedding .map((confidence, index): [string, number] | null => { - return index >= skillsOffset ? [skills_indices_reverse.get(index - skillsOffset)!, confidence] : null + return index >= skillsOffset ? [SKILLS_VECTOR_INDICES_REVERSE.get(index - skillsOffset)!, confidence] : null }) .filter((matchWithConfidence) => matchWithConfidence != null) .filter((matchWithConfidence) => matchWithConfidence[1] >= MATCH_SKILL_CONFIDENCE_THRESHOLD) @@ -348,13 +242,26 @@ const decodeMatchesFromEmbedding = ( export const exportedForTesting = { queryEmbeddingVector, - queryEmbeddingVectorV2, decodeMatchesFromEmbedding, sortMatches, TOPICS_VECTOR_INDICES, SKILLS_VECTOR_INDICES, - TOPICS_V2_VECTOR_INDICES, - SKILLS_V2_VECTOR_INDICES, +} + +const addQueryMatchInfo = ( + row: VolunteeringDBRow, + engagement: Engagement, + queriedTopics: string[], + queriedSkills: string[], +) => { + const embedding = JSON.parse(row.embedding_array) as number[] + const { topics, skills } = decodeMatchesFromEmbedding(embedding, queriedTopics, queriedSkills) + return { + ...engagement, + matchedTopics: topics, + matchedSkills: skills, + matchedGeoLocationDistanceInMeters: row.distance_in_meters, + } } const toEngagementRecommendation = ( @@ -363,41 +270,19 @@ const toEngagementRecommendation = ( queriedTopics: string[], queriedSkills: string[], ): EngagementReco => { - const { topics, skills } = decodeMatchesFromEmbedding( - JSON.parse(row.embedding_array) as number[], - queriedTopics, - queriedSkills, - 'v1', - ) - const { topics: topicsV2, skills: skillsV2 } = decodeMatchesFromEmbedding( - JSON.parse(row.embedding_array_v2) as number[], - queriedTopics, - queriedSkills, - 'v2', - ) + const embedding = JSON.parse(row.embedding_array) as number[] + const { topics, skills } = decodeMatchesFromEmbedding(embedding, queriedTopics, queriedSkills) return { engagement: toEngagement(imageProxyBaseUrl, row), matchedTopics: topics, matchedSkills: skills, - matchedTopicsV2: topicsV2, - matchedSkillsV2: skillsV2, matchedGeoLocationDistanceInMeters: row.distance_in_meters, } } const toEngagement = (imageProxyBaseUrl: string, row: VolunteeringDBRow): Engagement => { - const { topics, skills } = decodeMatchesFromEmbedding( - JSON.parse(row.embedding_array) as number[], - [], - [], - 'v1', - ) - const { topics: topicsV2, skills: skillsV2 } = decodeMatchesFromEmbedding( - JSON.parse(row.embedding_array_v2) as number[], - [], - [], - 'v2', - ) + const embedding = JSON.parse(row.embedding_array) as number[] + const { topics, skills } = decodeMatchesFromEmbedding(embedding, [], []) return { id: '' + row.id, title: row.title, @@ -406,6 +291,7 @@ const toEngagement = (imageProxyBaseUrl: string, row: VolunteeringDBRow): Engage imageUrl: row.image && `${imageProxyBaseUrl}/crop:0:0/resize:auto:1024/plain/` + encodeURIComponent(row.image), + imageUrlOriginal: row.image, source: row.source, categories: safeJsonParse( row.uses, @@ -422,8 +308,6 @@ const toEngagement = (imageProxyBaseUrl: string, row: VolunteeringDBRow): Engage longitude: row.longitude, topics: topics, skills: skills, - topicsV2, - skillsV2, } } @@ -435,12 +319,11 @@ const extractTotalResultCount = ( const ERROR_CODE_NOT_FOUND = 'NOT_FOUND' -export type TopicsSkillsVersion = 'v1' | 'v2' - export interface VolunteeringDB { engagement(params: EngagementParameters): Promise<EngagementResponse> - engagementRecos(version: TopicsSkillsVersion, params: EngagementRecosParameters): Promise<EngagementRecosResponse> - filteredEngagements(version: TopicsSkillsVersion, params: FilteredEngagementsParameters): Promise<EngagementsResponse> + engagementRecommendations(params: EngagementRecosParameters): Promise<EngagementsResponse> + engagementRecos(params: EngagementRecosParameters): Promise<EngagementRecosResponse> + filteredEngagements(params: FilteredEngagementsParameters): Promise<EngagementsResponse> similarRecommendations(params: SimilarRecommendationsParameters): Promise<EngagementsResponse> } @@ -480,19 +363,46 @@ export class PostgresVolunteeringDB implements VolunteeringDB { ) } + async engagementRecommendations( + { offset, limit, topics, skills, geolocationId }: EngagementRecosParameters, + ): Promise<EngagementsResponse> { + return await Sentry.startSpan( + { name: 'VolunteeringDB.engagementRecommendations' }, + async () => { + const result = await this.queryRecos( + offset < 0 ? 0 : offset, + limit > MAX_LIMIT || limit < 1 ? DEFAULT_LIMIT : limit, + topics, + skills, + geolocationId, + ).catch((reason) => { + if (!BadRequestError.isBadRequest(reason)) { + logger.error('Error retrieving recommendations. Reason: ', reason) + } + throw reason + }) + const recos = result.map((row) => + addQueryMatchInfo(row, toEngagement(this.imageProxyBaseUrl, row), topics, skills) + ) + return Promise.resolve({ + totalResults: extractTotalResultCount(result), + data: recos, + }) + }, + ) + } + async engagementRecos( - version: TopicsSkillsVersion, { offset, limit, topics, skills, geolocationId }: EngagementRecosParameters, ): Promise<EngagementRecosResponse> { return await Sentry.startSpan( - { name: version == 'v1' ? 'VolunteeringDB.engagementRecos' : 'VolunteeringDB.engagementRecosV2' }, + { name: 'VolunteeringDB.engagementRecos' }, async () => { const result = await this.queryRecos( offset < 0 ? 0 : offset, limit > MAX_LIMIT || limit < 1 ? DEFAULT_LIMIT : limit, topics, skills, - version, geolocationId, ).catch((reason: Error) => { if (!BadRequestError.isBadRequest(reason)) { @@ -514,7 +424,6 @@ export class PostgresVolunteeringDB implements VolunteeringDB { limit: number, topics: string[], skills: string[], - version: TopicsSkillsVersion, geolocationId?: string, ): Promise<VolunteeringDBRow[]> { const geolocationCoordinates = geolocationId @@ -538,40 +447,19 @@ export class PostgresVolunteeringDB implements VolunteeringDB { topics, skills, geolocationCoordinates, - version, ) } else if (geolocationCoordinates) { - if (version === 'v2') { - return this.queryRecosBasedOnTopicsSkillsAndLocation( - offset, - limit, - DEFAULT_RECO_TOPICS_V2, - skills, - geolocationCoordinates, - version, - ) - } else { - return this.queryRecosBasedOnLocation( - offset, - limit, - geolocationCoordinates, - ) - } - } else if (topics.length > 0 || skills.length > 0) { - return this.queryRecosBasedOnTopicsAndSkills( + return this.queryRecosBasedOnLocation( offset, limit, - topics, - skills, - version, + geolocationCoordinates, ) - } else if (version === 'v2') { + } else if (topics.length > 0 || skills.length > 0) { return this.queryRecosBasedOnTopicsAndSkills( offset, limit, - DEFAULT_RECO_TOPICS_V2, + topics, skills, - version, ) } else { return Promise.reject( @@ -587,7 +475,6 @@ export class PostgresVolunteeringDB implements VolunteeringDB { limit: number, topics: string[], skills: string[], - version: TopicsSkillsVersion, ): Promise<VolunteeringDBRow[]> { return await Sentry.startSpan( { name: 'VolunteeringDB.queryRecosBasedOnTopicsAndSkills', op: 'db.query' }, @@ -595,26 +482,13 @@ export class PostgresVolunteeringDB implements VolunteeringDB { logger.debug( `queryRecosBasedOnTopicsAndSkills ${JSON.stringify({ offset, limit, topics, skills })}`, ) - const queryVector = version == 'v1' - ? queryEmbeddingVector(topics, skills) - : queryEmbeddingVectorV2(topics, skills) - const queryVectorJson = JSON.stringify(queryVector) - switch (version) { - case 'v1': - return await this.sql<VolunteeringDBRow[]>` - SELECT *, 1 - (embedding_array <=> ${queryVectorJson}) AS cosine_similarity, count(*) OVER () as total_results - FROM volunteering_voltastics_with_classification - ORDER BY cosine_similarity DESC - OFFSET ${offset} LIMIT ${limit} - ` - case 'v2': - return await this.sql<VolunteeringDBRow[]>` - SELECT *, 1 - (embedding_array_v2 <=> ${queryVectorJson}) AS cosine_similarity, count(*) OVER () as total_results - FROM volunteering_voltastics_with_classification - ORDER BY cosine_similarity DESC - OFFSET ${offset} LIMIT ${limit} - ` - } + const queryVector = JSON.stringify(queryEmbeddingVector(topics, skills)) + return await this.sql<VolunteeringDBRow[]>` + SELECT *, 1 - (embedding_array <=> ${queryVector}) AS cosine_similarity, count(*) OVER () as total_results + FROM volunteering_voltastics_with_classification + ORDER BY cosine_similarity DESC + OFFSET ${offset} LIMIT ${limit} + ` }, ) } @@ -625,7 +499,6 @@ export class PostgresVolunteeringDB implements VolunteeringDB { topics: string[], skills: string[], geolocationCoordinates: GeolocationCoordinates, - version: TopicsSkillsVersion, ): Promise<VolunteeringDBRow[]> { return await Sentry.startSpan( { name: 'VolunteeringDB.queryRecosBasedOnTopicsSkillsAndLocation', op: 'db.query' }, @@ -638,59 +511,34 @@ export class PostgresVolunteeringDB implements VolunteeringDB { const rankingWeightCosineSimilarity = 0.7 // Weight for cosine similarity const rankingWeightDistance = 0.3 // Weight for proximity const maxDistanceInMeters = 50_000 // in meters (e.g., 50 km) - const queryVector = version == 'v1' - ? queryEmbeddingVector(topics, skills) - : queryEmbeddingVectorV2(topics, skills) - const queryVectorJson = JSON.stringify(queryVector) + const queryVector = JSON.stringify(queryEmbeddingVector(topics, skills)) const { lat, lon } = geolocationCoordinates + // Useful knowledge // - PostGIS uses lon, lat (NOT lat, lon) // - <#> uses index scans if used in an ORDER BY clause, ST_Distance does not // - Database stores in GPS-Coordinates (SRID 4326) // - In order to calculate a distance in meters, a geometry needs to be projected by transforming using // ST_Transform(geom, 3857) where we use the SRID 3857 for Pseudo-Mercator - switch (version) { - case 'v1': - return await this.sql<VolunteeringDBRow[]>` - WITH calculations AS ( - SELECT *, - 1 - (embedding_array <=> ${queryVectorJson}) AS cosine_similarity, - ST_Transform(location_gps, 3857) <#> ST_Transform(ST_SetSRID(ST_MakePoint(${lon.toString()}, ${lat.toString()}), 4326), 3857) AS distance_in_meters - FROM volunteering_voltastics_with_classification - WHERE longitude IS NOT NULL - AND latitude IS NOT NULL - ), scored AS ( - SELECT *, - (${rankingWeightCosineSimilarity} * cosine_similarity) + - (${rankingWeightDistance} * (1 - LEAST(distance_in_meters, ${maxDistanceInMeters})) / ${maxDistanceInMeters}) AS weighted_score - FROM calculations - ) - SELECT *, count(*) OVER () AS total_results - FROM scored - ORDER BY weighted_score DESC - OFFSET ${offset} LIMIT ${limit}; - ` - case 'v2': - return await this.sql<VolunteeringDBRow[]>` - WITH calculations AS ( - SELECT *, - 1 - (embedding_array_v2 <=> ${queryVectorJson}) AS cosine_similarity, - ST_Transform(location_gps, 3857) <#> ST_Transform(ST_SetSRID(ST_MakePoint(${lon.toString()}, ${lat.toString()}), 4326), 3857) AS distance_in_meters - FROM volunteering_voltastics_with_classification - WHERE longitude IS NOT NULL - AND latitude IS NOT NULL - ), scored AS ( - SELECT *, - (${rankingWeightCosineSimilarity} * cosine_similarity) + - (${rankingWeightDistance} * (1 - LEAST(distance_in_meters, ${maxDistanceInMeters})) / ${maxDistanceInMeters}) AS weighted_score - FROM calculations - ) - SELECT *, count(*) OVER () AS total_results - FROM scored - ORDER BY weighted_score DESC - OFFSET ${offset} LIMIT ${limit}; - ` - } + return await this.sql<VolunteeringDBRow[]>` + WITH calculations AS ( + SELECT *, + 1 - (embedding_array <=> ${queryVector}) AS cosine_similarity, + ST_Transform(location_gps, 3857) <#> ST_Transform(ST_SetSRID(ST_MakePoint(${lon.toString()}, ${lat.toString()}), 4326), 3857) AS distance_in_meters + FROM volunteering_voltastics_with_classification + WHERE longitude IS NOT NULL AND latitude IS NOT NULL + ), scored AS ( + SELECT *, + (${rankingWeightCosineSimilarity} * cosine_similarity) + + (${rankingWeightDistance} * (1 - LEAST(distance_in_meters, ${maxDistanceInMeters})) / ${maxDistanceInMeters}) AS weighted_score + FROM calculations + ) + SELECT *, count(*) OVER() AS total_results + FROM scored + ORDER BY weighted_score DESC + OFFSET ${offset} + LIMIT ${limit}; + ` }, ) } @@ -721,7 +569,6 @@ export class PostgresVolunteeringDB implements VolunteeringDB { } async filteredEngagements( - version: TopicsSkillsVersion, { offset, limit, topics, skills, geolocationId }: FilteredEngagementsParameters, ): Promise<EngagementsResponse> { const geometry: GeolocationGeometry | undefined = geolocationId @@ -746,7 +593,6 @@ export class PostgresVolunteeringDB implements VolunteeringDB { skills, offset < 0 ? 0 : offset, limit > MAX_LIMIT || limit < 1 ? DEFAULT_LIMIT : limit, - version, geometry, ) } @@ -875,83 +721,49 @@ export class PostgresVolunteeringDB implements VolunteeringDB { skills: string[], offset: number, limit: number, - version: TopicsSkillsVersion, geometry?: GeolocationGeometry, ): Promise<EngagementsResponse> { return await Sentry.startSpan( { name: 'VolunteeringDB.queryFilteredEngagements', op: 'db.query' }, async () => { - const queryVector = version == 'v1' - ? queryEmbeddingVector(topics, skills) - : queryEmbeddingVectorV2(topics, skills) - const queryVectorJson = JSON.stringify(queryVector) + const queryVector = queryEmbeddingVector(topics, skills) + const queryVectorString = JSON.stringify(queryVector) const nonEmptyQueryVector = topics.length + skills.length > 0 const binaryQueryVector = queryVector.map((value) => value > 0 ? '1' : '0') .join('') - const emptyBinaryQueryVector = '0'.repeat(version == 'v1' ? EMBEDDING_DIMENSIONS : EMBEDDING_V2_DIMENSIONS) + const emptyBinaryQueryVector = '0'.repeat(EMBEDDING_DIMENSIONS) // Filtering engagements: // - Precomputed `binary_embedding` is a bit string marking matching topics and skills // - Unset bits in `binary_embedding` for terms that are not part of the query using bitwise AND => If at least one set bit remains, it matches the query (checked by comparing to empty bit string, as it's too long for integer conversion) // - Filter by geolocation by checking if coordinates are inside the given geolocation's GeoJSON geometry // - Sort results using cosine_similarity - let result: VolunteeringDBRow[] - switch (version) { - case 'v1': - result = await this.sql<VolunteeringDBRow[]>` - WITH vector_matches AS ( - SELECT - *, - 1 - (embedding_array <=> ${queryVectorJson}) AS cosine_similarity, - CASE - WHEN ${nonEmptyQueryVector} - THEN (binary_embedding & ${binaryQueryVector}) > ${emptyBinaryQueryVector} - ELSE true - END AS matches_query, - CASE - WHEN ${!!geometry} - THEN ST_Contains(ST_GeomFromGeoJSON(${geometry ? JSON.stringify(geometry) : ''}), location_gps) - ELSE true - END AS matches_location - FROM volunteering_voltastics_with_classification - ORDER BY cosine_similarity DESC - ) - SELECT *, count(*) OVER() AS total_results - FROM vector_matches - WHERE matches_query - AND matches_location - OFFSET ${offset} - LIMIT ${limit}; - ` - break - case 'v2': - result = await this.sql<VolunteeringDBRow[]>` - WITH vector_matches AS ( - SELECT - *, - 1 - (embedding_array_v2 <=> ${queryVectorJson}) AS cosine_similarity, - CASE - WHEN ${nonEmptyQueryVector} - THEN (binary_embedding_v2 & ${binaryQueryVector}) > ${emptyBinaryQueryVector} - ELSE true - END AS matches_query, - CASE - WHEN ${!!geometry} - THEN ST_Contains(ST_GeomFromGeoJSON(${geometry ? JSON.stringify(geometry) : ''}), location_gps) - ELSE true - END AS matches_location - FROM volunteering_voltastics_with_classification - ORDER BY cosine_similarity DESC - ) - SELECT *, count(*) OVER() AS total_results - FROM vector_matches - WHERE matches_query - AND matches_location - OFFSET ${offset} - LIMIT ${limit}; - ` - break - } + const result: VolunteeringDBRow[] = await this.sql<VolunteeringDBRow[]>` + WITH vector_matches AS ( + SELECT + *, + 1 - (embedding_array <=> ${queryVectorString}) AS cosine_similarity, + CASE + WHEN ${nonEmptyQueryVector} + THEN (binary_embedding & ${binaryQueryVector}) > ${emptyBinaryQueryVector} + ELSE true + END AS matches_query, + CASE + WHEN ${!!geometry} + THEN ST_Contains(ST_GeomFromGeoJSON(${geometry ? JSON.stringify(geometry) : ''}), location_gps) + ELSE true + END AS matches_location + FROM volunteering_voltastics_with_classification + ORDER BY cosine_similarity DESC + ) + SELECT *, count(*) OVER() AS total_results + FROM vector_matches + WHERE matches_query + AND matches_location + OFFSET ${offset} + LIMIT ${limit}; + ` + return { totalResults: extractTotalResultCount(result), data: result.map((row) => toEngagement(this.imageProxyBaseUrl, row)), diff --git a/app/volunteering_db_test.ts b/app/volunteering_db_test.ts index 357c9d5..a5b5378 100644 --- a/app/volunteering_db_test.ts +++ b/app/volunteering_db_test.ts @@ -9,13 +9,7 @@ import { Stub, stub, } from './dev_deps.ts' -import { - exportedForTesting, - PostgresVolunteeringDB, - TopicsSkillsVersion, - VolunteeringDB, - VolunteeringDBRow, -} from './volunteering_db.ts' +import { exportedForTesting, PostgresVolunteeringDB, VolunteeringDB, VolunteeringDBRow } from './volunteering_db.ts' import { GraphQLError, postgres } from './deps.ts' import { GeoAPIClient } from './geo_api_client.ts' import { Engagement } from './types.ts' @@ -77,7 +71,7 @@ const withMockedDependencies = (queryObjectResult: VolunteeringDBRow[] = []) => test(volunteeringDB, mockedGeoApiClient) } -const embeddingV1Fixtures = { +const embeddingFixtures = { // deno-fmt-ignore embedding_array: [ 1,0,0,0,0,0,0,0,0,1, @@ -91,20 +85,6 @@ const embeddingV1Fixtures = { ], } -const embeddingV2Fixtures = { - // deno-fmt-ignore - embedding_array_v2: [ - // topics V2 - 1,0,0,0,0,0,0,0,0,1, // animal-welfare, ..., gender-equality - 0,0,0,0,0,0,0,0,0,0, - 0,0,0,1, // youth-development - // skills V2 - 1,0,0,0,0,0,0,0,0,1, // administration, ..., event-planning - 0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,1, // tutoring-coaching - ], -} - const dbFixtures = { row1: { id: 1, @@ -119,15 +99,14 @@ const dbFixtures = { location: 'Blibhausen', latitude: 12.3456, longitude: 65.4321, - embedding_array: JSON.stringify(embeddingV1Fixtures.embedding_array), - embedding_array_v2: JSON.stringify(embeddingV2Fixtures.embedding_array_v2), + embedding_array: JSON.stringify(embeddingFixtures.embedding_array), cosine_similarity: 0, distance_in_meters: 0, total_results: 1, } as VolunteeringDBRow, } -const assertEngagementMatchesRow = (version: TopicsSkillsVersion, row: VolunteeringDBRow, engagement: Engagement) => { +const assertEngagementMatchesRow = (row: VolunteeringDBRow, engagement: Engagement) => { assertEquals(engagement.id, `${row.id}`) assertEquals(engagement.title, row.title) assertEquals(engagement.description, row.description) @@ -136,6 +115,7 @@ const assertEngagementMatchesRow = (version: TopicsSkillsVersion, row: Volunteer engagement.imageUrl, 'mock-image-proxy-base-url/crop:0:0/resize:auto:1024/plain/' + encodeURIComponent(row.image!), ) + assertEquals(engagement.imageUrlOriginal, row.image) assertEquals(engagement.source, row.source) assertEquals(engagement.categories, undefined) assertEquals(engagement.organizer, { @@ -145,32 +125,16 @@ const assertEngagementMatchesRow = (version: TopicsSkillsVersion, row: Volunteer assertEquals(engagement.location, row.location) assertEquals(engagement.latitude, row.latitude) assertEquals(engagement.longitude, row.longitude) - switch (version) { - case 'v1': - assertEquals(engagement.topics, [ - 'agriculture-food', // first item (index 0) - 'disabilities-inclusion', // tenth item (index 9) - 'water-ocean', // last item (index 27) - ]) - assertEquals(engagement.skills, [ - 'adaptability', // first item (index 0) - 'conflict-management', // tenth item (index 9) - 'writing-translation', // last item (index 49) - ]) - break - case 'v2': - assertEquals(engagement.topicsV2, [ - 'animal-welfare', // first item (index 0) - 'gender-equality', // tenth item (index 9) - 'youth-development', // last item (index 23) - ]) - assertEquals(engagement.skillsV2, [ - 'administration', // first item (index 0) - 'event-planning', // tenth item (index 9) - 'tutoring-coaching', // last item (index 28) - ]) - break - } + assertEquals(engagement.topics, [ + 'agriculture-food', // first item (index 0) + 'disabilities-inclusion', // tenth item (index 9) + 'water-ocean', // last item (index 27) + ]) + assertEquals(engagement.skills, [ + 'adaptability', // first item (index 0) + 'conflict-management', // tenth item (index 9) + 'writing-translation', // last item (index 49) + ]) assertEquals(engagement.matchedTopics, undefined) assertEquals(engagement.matchedSkills, undefined) assertEquals(engagement.matchedGeoLocationDistanceInMeters, undefined) @@ -181,7 +145,16 @@ describe('VolunteeringDB', () => { it('correctly constructs a query embedding vector', () => { const expectedLength = 28 /* #topics */ + 50 /* #skills */ // deno-fmt-ignore - const expectedVector: number[] = embeddingV1Fixtures.embedding_array + const expectedVector: number[] = [ + 1,0,0,0,0,0,0,0,0,1, + 0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,1, + 1,0,0,0,0,0,0,0,0,1, + 0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,1, + ] // deno-fmt-ignore const actualVector = exportedForTesting.queryEmbeddingVector([ "agriculture-food", // first item (index 0) @@ -196,25 +169,8 @@ describe('VolunteeringDB', () => { assertEquals(actualVector, expectedVector) }) }) - describe('queryEmbeddingVectorV2', () => { - it('correctly constructs a query embedding vector', () => { - const expectedVector: number[] = embeddingV2Fixtures.embedding_array_v2 - // deno-fmt-ignore - const actualVector = exportedForTesting.queryEmbeddingVectorV2([ - 'animal-welfare', // first item (index 0) - 'gender-equality', // tenth item (index 9) - 'youth-development', // last item (index 23) - ], [ - 'administration', // first item (index 0) - 'event-planning', // tenth item (index 9) - 'tutoring-coaching', // last item (index 28) - ]); - assertEquals(actualVector.length, expectedVector.length) - assertEquals(actualVector, expectedVector) - }) - }) describe('decodeMatchesFromEmbedding', () => { - it('correctly reverse-decodes topics and skills matches from V1 embeddings', () => { + it('correctly reverse-decodes topics and skills matches from embeddings', () => { // deno-fmt-ignore const embedding: number[] = [ 0.9,0,0,0,0,0,0,0,0,0.8, @@ -248,52 +204,11 @@ describe('VolunteeringDB', () => { embedding, allTopics, allSkills, - 'v1', - ) - assertEquals(matches.topics, expectedMatchedTopics) - assertEquals(matches.skills, expectedMatchedSkills) - }) - it('correctly reverse-decodes topics and skills matches from V2 embeddings', () => { - // deno-fmt-ignore - const embedding: number[] = [ - // topics V2 - 0.9,0,0,0,0,0,0,0,0,0.8, - 0,0,0,0,0,0,0,0,0,0, - 0,0,0,0.85, - // skills V2 - 0.85,0,0,-0.5,0,0,0,0,0,0.99, - 0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,1.0, - ] - - // ordered by confidence DESC with queried for terms first - const expectedMatchedTopics = [ - 'animal-welfare', // first item (index 0) - 'youth-development', // last item (index 23) - 'gender-equality', // tenth item (index 9) - ] - // ordered by confidence DESC with queried for terms first - const expectedMatchedSkills = [ - 'tutoring-coaching', // last item (index 28) - 'event-planning', // tenth item (index 9) - 'administration', // first item (index 0) - ] - const allTopics = Array.from( - exportedForTesting.TOPICS_V2_VECTOR_INDICES.keys(), - ) - const allSkills = Array.from( - exportedForTesting.SKILLS_V2_VECTOR_INDICES.keys(), - ) - const matches = exportedForTesting.decodeMatchesFromEmbedding( - embedding, - allTopics, - allSkills, - 'v2', ) assertEquals(matches.topics, expectedMatchedTopics) assertEquals(matches.skills, expectedMatchedSkills) }) - it('filters out V1 topics and skills matches with confidence below MATCH_CONFIDENCE_THRESHOLD', () => { + it('filters out topics and skills matches with confidence below MATCH_CONFIDENCE_THRESHOLD', () => { // deno-fmt-ignore const embedding: number[] = [ 0.9,0,0,0,0,0,0,0,0,0.3, @@ -325,49 +240,11 @@ describe('VolunteeringDB', () => { embedding, allTopics, allSkills, - 'v1', ) assertEquals(matches.topics, expectedMatchedTopics) assertEquals(matches.skills, expectedMatchedSkills) }) - it('filters out V2 topics and skills matches with confidence below MATCH_CONFIDENCE_THRESHOLD', () => { - // deno-fmt-ignore - const embedding: number[] = [ - // topics V2 - 0.9,0,0,0,0,0,0,0,0,0.3, - 0,0,0,0,0,0,0,0,0,0, - 0,0,0,0.85, - // skills V2 - 0.3,0,0,-0.5,0,0,0,0,0,0.99, - 0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,1.0, - ] - // ordered by confidence DESC with queried for terms first - const expectedMatchedTopics = [ - 'animal-welfare', // first item (index 0) - 'youth-development', // last item (index 23) - ] - // ordered by confidence DESC with queried for terms first - const expectedMatchedSkills = [ - 'tutoring-coaching', // last item (index 28) - 'event-planning', // tenth item (index 9) - ] - const allTopics = Array.from( - exportedForTesting.TOPICS_V2_VECTOR_INDICES.keys(), - ) - const allSkills = Array.from( - exportedForTesting.SKILLS_V2_VECTOR_INDICES.keys(), - ) - const matches = exportedForTesting.decodeMatchesFromEmbedding( - embedding, - allTopics, - allSkills, - 'v2', - ) - assertEquals(matches.topics, expectedMatchedTopics) - assertEquals(matches.skills, expectedMatchedSkills) - }) - it('ranks V1 topics and skills matches queried for higher', () => { + it('ranks topics and skills matches queried for higher', () => { // deno-fmt-ignore const embedding: number[] = [ 0.85,0,0,0,0,0,0,0,0,0.9, @@ -398,43 +275,6 @@ describe('VolunteeringDB', () => { embedding, queriedTopics, queriedSkills, - 'v1', - ) - assertEquals(matches.topics, expectedMatchedTopics) - assertEquals(matches.skills, expectedMatchedSkills) - }) - it('ranks V2 topics and skills matches queried for higher', () => { - // deno-fmt-ignore - const embedding: number[] = [ - // topics V2 - 0.85,0,0,0,0,0,0,0,0,0.9, - 0,0,0,0,0,0,0,0,0,0, - 0,0.81,0,0.9, - // skills V2 - 0.85,0,0,-0.5,0,0,0,0,0,0.99, - 0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,1.0, - ] - // ordered by confidence DESC with queried for terms first - const expectedMatchedTopics = [ - 'gender-equality', // tenth item (index 9) - 'sports-recreation', - 'youth-development', // last item (index 23) - 'animal-welfare', // first item (index 0) - ] - // ordered by confidence DESC with queried for terms first - const expectedMatchedSkills = [ - 'tutoring-coaching', // last item (index 28) - 'event-planning', // tenth item (index 9) - 'administration', // first item (index 0) - ] - const queriedTopics = ['sports-recreation', 'gender-equality'] - const queriedSkills = ['tutoring-coaching'] - const matches = exportedForTesting.decodeMatchesFromEmbedding( - embedding, - queriedTopics, - queriedSkills, - 'v2', ) assertEquals(matches.topics, expectedMatchedTopics) assertEquals(matches.skills, expectedMatchedSkills) @@ -464,8 +304,7 @@ describe('VolunteeringDB', () => { it('correctly parses engagement', () => { withMockedDependencies([dbFixtures.row1])(async (volunteeringDB) => { const response = await volunteeringDB.engagement({ id: '1' }) - assertEngagementMatchesRow('v1', dbFixtures.row1, response!) - assertEngagementMatchesRow('v2', dbFixtures.row1, response!) + assertEngagementMatchesRow(dbFixtures.row1, response!) }) }) @@ -476,10 +315,86 @@ describe('VolunteeringDB', () => { }) }) }) + // TODO findRecommendations is deprecated, can be removed after clients are migrated to findRecommendationsV2 + describe('findRecommendations', () => { + it('resolves latitude/longitude using the GeoAPI client when geolocationId is given', () => { + withMockedDependencies()( + (volunteeringDB, geoAPIClient) => { + volunteeringDB.engagementRecommendations({ + limit: 10, + offset: 0, + topics: [], + skills: [], + geolocationId: 'mock-geolocation-id', + }) + assertSpyCall(geoAPIClient.resolveCoordinates as Stub, 0, { + args: ['mock-geolocation-id'], + }) + }, + ) + }) + it('does not resolve latitude/longitude when geolocationId is not given', () => { + withMockedDependencies()( + (volunteeringDB, geoAPIClient) => { + volunteeringDB.engagementRecommendations({ + limit: 10, + offset: 0, + topics: ['agriculture-food'], + skills: [], + }) + assertSpyCalls(geoAPIClient.resolveCoordinates as Stub, 0) + }, + ) + }) + it('falls back to recommend based only on topics and skills if given and geo location resolution fails', () => { + withMockedDependencies()( + async (volunteeringDB, geoAPIClient) => { + stub( + geoAPIClient, + 'resolveCoordinates', + returnsNext([Promise.reject('boom!')]), + ) + + const result = await volunteeringDB.engagementRecommendations({ + limit: 10, + offset: 0, + topics: ['agriculture-food'], + skills: [], + geolocationId: 'mock-geolocation-id', + }) + assertEquals(result.totalResults, 0) + assertEquals(result.data, []) + }, + ) + }) + it('fails with error message if no topics and skills are given and geo location resolution fails', () => { + withMockedDependencies()( + async (volunteeringDB, geoAPIClient) => { + stub( + geoAPIClient, + 'resolveCoordinates', + returnsNext([Promise.reject('boom!')]), + ) + await assertRejects( + () => + volunteeringDB.engagementRecommendations({ + limit: 10, + offset: 0, + topics: [], + skills: [], + geolocationId: 'mock-geolocation-id', + }), + Error, + 'geo location resolution failed and no topics or skills given', + ) + }, + ) + }) + }) describe('findRecos', () => { it('wraps search results with matchedTopics, matchedSkills and matchedGeoLocationDistanceInMeters', () => { withMockedDependencies([dbFixtures.row1])(async (volunteeringDB, _geoApiClient) => { - const response = await volunteeringDB.engagementRecos('v1', { + const response = await volunteeringDB.engagementRecos({ limit: 10, offset: 0, topics: ['disabilities-inclusion', 'water-ocean'], @@ -490,7 +405,7 @@ describe('VolunteeringDB', () => { assertEquals(response.data.length, 1) const reco = response.data[0] - assertEngagementMatchesRow('v1', dbFixtures.row1, reco.engagement) + assertEngagementMatchesRow(dbFixtures.row1, reco.engagement) assertEquals(reco.matchedTopics, [ 'disabilities-inclusion', // tenth item (index 9) @@ -505,38 +420,10 @@ describe('VolunteeringDB', () => { assertEquals(reco.matchedGeoLocationDistanceInMeters, 0) }) }) - it('wraps search results with matchedTopicsV2, matchedSkillsV2 and matchedGeoLocationDistanceInMeters', () => { - withMockedDependencies([dbFixtures.row1])(async (volunteeringDB, _geoApiClient) => { - const response = await volunteeringDB.engagementRecos('v2', { - limit: 10, - offset: 0, - topics: ['animal-welfare', 'youth-development'], - skills: ['event-planning', 'tutoring-coaching'], - geolocationId: 'mock-geolocation-id', - }) - assertEquals(response.totalResults, 1) - assertEquals(response.data.length, 1) - - const reco = response.data[0] - assertEngagementMatchesRow('v2', dbFixtures.row1, reco.engagement) - - assertEquals(reco.matchedTopicsV2, [ - 'animal-welfare', - 'youth-development', - 'gender-equality', - ]) - assertEquals(reco.matchedSkillsV2, [ - 'event-planning', - 'tutoring-coaching', - 'administration', - ]) - assertEquals(reco.matchedGeoLocationDistanceInMeters, 0) - }) - }) it('resolves latitude/longitude using the GeoAPI client when geolocationId is given', () => { withMockedDependencies()( (volunteeringDB, geoAPIClient) => { - volunteeringDB.engagementRecos('v2', { + volunteeringDB.engagementRecos({ limit: 10, offset: 0, topics: [], @@ -552,7 +439,7 @@ describe('VolunteeringDB', () => { it('does not resolve latitude/longitude when geolocationId is not given', () => { withMockedDependencies()( (volunteeringDB, geoAPIClient) => { - volunteeringDB.engagementRecos('v2', { + volunteeringDB.engagementRecos({ limit: 10, offset: 0, topics: ['agriculture-food'], @@ -571,7 +458,7 @@ describe('VolunteeringDB', () => { returnsNext([Promise.reject('boom!')]), ) - const result = await volunteeringDB.engagementRecos('v2', { + const result = await volunteeringDB.engagementRecos({ limit: 10, offset: 0, topics: ['agriculture-food'], @@ -593,7 +480,7 @@ describe('VolunteeringDB', () => { ) await assertRejects( () => - volunteeringDB.engagementRecos('v2', { + volunteeringDB.engagementRecos({ limit: 10, offset: 0, topics: [], @@ -606,43 +493,13 @@ describe('VolunteeringDB', () => { }, ) }) - it('falls back to default topics if only a geolocation is given in V2', () => { - withMockedDependencies([dbFixtures.row1])( - async (volunteeringDB, _geoAPIClient) => { - const result = await volunteeringDB.engagementRecos('v2', { - limit: 10, - offset: 0, - topics: [], - skills: [], - geolocationId: 'mock-geolocation-id', - }) - assertEquals(result.totalResults, 1) - assertEquals(result.data.length, 1) - }, - ) - }) - it('falls back to default topics if no topics, skills or geolocation is given in V2', () => { - withMockedDependencies([dbFixtures.row1])( - async (volunteeringDB, _geoAPIClient) => { - const result = await volunteeringDB.engagementRecos('v2', { - limit: 10, - offset: 0, - topics: [], - skills: [], - geolocationId: 'mock-geolocation-id', - }) - assertEquals(result.totalResults, 1) - assertEquals(result.data.length, 1) - }, - ) - }) }) describe('filterEngagements', () => { it('resolves geolocation geometry using the GeoAPI client when geolocationId is given', () => { withMockedDependencies()( (volunteeringDB, geoAPIClient) => { - volunteeringDB.filteredEngagements('v2', { + volunteeringDB.filteredEngagements({ limit: 10, offset: 0, topics: [], @@ -658,7 +515,7 @@ describe('VolunteeringDB', () => { it('does not resolve geolocation geometry when geolocationId is not given', () => { withMockedDependencies()( (volunteeringDB, geoAPIClient) => { - volunteeringDB.filteredEngagements('v2', { + volunteeringDB.filteredEngagements({ limit: 10, offset: 0, topics: ['agriculture-food'], @@ -678,7 +535,7 @@ describe('VolunteeringDB', () => { ) await assertRejects( () => - volunteeringDB.filteredEngagements('v2', { + volunteeringDB.filteredEngagements({ limit: 10, offset: 0, topics: ['agriculture-food'], @@ -696,7 +553,7 @@ describe('VolunteeringDB', () => { async (volunteeringDB, _geoAPIClient) => { await assertRejects( () => - volunteeringDB.filteredEngagements('v2', { + volunteeringDB.filteredEngagements({ limit: 10, offset: 0, topics: [], diff --git a/smoketest/main.js b/smoketest/main.js index 1ecded4..919c8a1 100644 --- a/smoketest/main.js +++ b/smoketest/main.js @@ -27,32 +27,14 @@ function forQuery(query, checkFunction) { // Define your smoketest(s) here. export default () => { - forQuery( - `{ - engagementRecosV2(offset:0, limit:10) { - data { - engagement { - id - title - description - topicsV2 - skillsV2 - } - matchedTopics - matchedSkills - } - } - }`, - (response) => { - check(response, { - 'is status 200': (r) => r.status === 200, - }) - check(JSON.parse(response.body), { - // there can be multiple tests here, e.g. - //"contains topics object": (r) => typeof r.data.topics != null, - 'contains recos': (r) => - Array.isArray(r.data.engagementRecosV2.data) && r.data.engagementRecosV2.data.length > 0, - }) - }, - ) + forQuery(`{categories{data{name}}}`, (response) => { + check(response, { + 'is status 200': (r) => r.status === 200, + }) + check(JSON.parse(response.body), { + // there can be multiple tests here, e.g. + //"contains topics object": (r) => typeof r.data.topics != null, + 'contains categories': (r) => Array.isArray(r.data.categories.data) && r.data.categories.data.length > 0, + }) + }) } -- GitLab