diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a25638f90fe19445a5377cc8dca7449fa1730bde..e0fb49f638f8d4e9ebf09ce0fca07c93b0097744 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -121,12 +121,13 @@ review_deploy: rules: - !reference [.rule_templates, only_review] -review_smoketest: - extends: .smoketest - needs: ['review_deploy'] - stage: 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_destroy: needs: ['review_deploy'] diff --git a/app/api_types.ts b/app/api_types.ts index 0b71a711eab4cd02295fa2ead44f148cb449d092..5fd15ec40ec3ff07be7a5f255984ca368d020e4c 100644 --- a/app/api_types.ts +++ b/app/api_types.ts @@ -1,38 +1,3 @@ -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 64af28c463c3160b5f697923c3f55f37b1bb233f..8b0fabfe4cc92b0e970c81a244e9aac2b033405b 100644 --- a/app/deps.ts +++ b/app/deps.ts @@ -1,7 +1,6 @@ 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 0ebd1ea2c3060ee0dfd7fad34630e0d7a8736ed2..8e3e6282421fb05e4efc213bff501afb744ba387 100644 --- a/app/server.ts +++ b/app/server.ts @@ -1,6 +1,4 @@ import { - CategoriesResponse, - EngagementOpportunitiesParameters, EngagementParameters, EngagementRecosParameters, EngagementRecosResponse, @@ -21,7 +19,7 @@ import { Sentry, useResponseCache, } from './deps.ts' -import { fetchCategories, fetchEngagementOpportunities, trackEngagementView } from './voltastics.ts' +import { trackEngagementView } from './voltastics.ts' import { logger } from './logging.ts' import { PostgresVolunteeringDB, VolunteeringDB } from './volunteering_db.ts' import { GeoAPIClientImpl } from './geo_api_client.ts' @@ -44,9 +42,7 @@ 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 @@ -55,12 +51,22 @@ const typeDefs = ` """ An array of topic slugs matching the engagement. Ordered by confidence DESC (typically, may be subject to change). """ - topics: [String] + topics: [String] @deprecated(reason: "Use topicsV2 instead. (since v1.51)") """ An array of skill slugs matching the engagement. Ordered by confidence DESC (typically, may be subject to change). """ - skills: [String] + 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] """ 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) @@ -78,21 +84,12 @@ 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! """ @@ -119,24 +116,21 @@ 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". "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" (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. """ - engagementRecommendations(offset: Int! = 0, limit: Int! = 10, topics: [String!] = [], skills: [String!] = [], geolocationId: String): EngagementsResponse! @deprecated(reason: "Use the engagementRecos query instead. (since v1.34)") + engagementRecos(offset: Int! = 0, limit: Int! = 10, topics: [String!] = [], skills: [String!] = [], geolocationId: String): EngagementRecosResponse! @deprecated(reason: "Use engagementRecosV2 instead. (since v1.51)") """ - 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. + 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. """ - engagementRecos(offset: Int! = 0, limit: Int! = 10, topics: [String!] = [], skills: [String!] = [], geolocationId: String): EngagementRecosResponse! + engagementRecosV2(offset: Int! = 0, limit: Int! = 10, topics: [String!] = [], skills: [String!] = [], geolocationId: String): EngagementRecosResponse! engagement(id: String!): Engagement - 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! + 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! similarRecommendations(offset: Int! = 0, limit: Int! = 10, id: String!): EngagementsResponse! } @@ -151,16 +145,6 @@ 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, @@ -174,15 +158,15 @@ const createResolvers = ( ): Promise<EngagementsResponse> => config.fake ? Promise.resolve({ totalResults: 0, data: [] }) - : Sentry.startNewTrace(() => volunteeringDB.filteredEngagements(parameters)), - engagementRecommendations: ( + : Sentry.startNewTrace(() => volunteeringDB.filteredEngagements('v1', parameters)), + filteredEngagementsV2: ( // deno-lint-ignore no-explicit-any _parent: any, - parameters: EngagementRecosParameters, + parameters: FilteredEngagementsParameters, ): Promise<EngagementsResponse> => config.fake ? Promise.resolve({ totalResults: 0, data: [] }) - : Sentry.startNewTrace(() => volunteeringDB.engagementRecommendations(parameters)), + : Sentry.startNewTrace(() => volunteeringDB.filteredEngagements('v2', parameters)), engagementRecos: ( // deno-lint-ignore no-explicit-any _parent: any, @@ -190,7 +174,15 @@ const createResolvers = ( ): Promise<EngagementRecosResponse> => config.fake ? Promise.resolve({ totalResults: 0, data: [] }) - : Sentry.startNewTrace(() => volunteeringDB.engagementRecos(parameters)), + : 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)), similarRecommendations: ( // deno-lint-ignore no-explicit-any _parent: any, @@ -230,12 +222,11 @@ 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.engagementRecommendations': config.cacheTtlMsDB, + 'Query.filteredEngagementsV2': 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 113cdd145c886da0be3a292ecfa46de73908eeb7..1a57bf85fd7b01daea917bf7bfdf2d8d83e96e48 100644 --- a/app/types.ts +++ b/app/types.ts @@ -8,11 +8,7 @@ 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 @@ -22,16 +18,14 @@ 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 = { @@ -47,9 +41,10 @@ export type SimilarRecommendationsParameters = { export type EngagementReco = { engagement: Engagement - matchedTopics?: string[] matchedSkills?: string[] + matchedTopicsV2?: string[] + matchedSkillsV2?: string[] matchedGeoLocationDistanceInMeters?: number } @@ -58,15 +53,6 @@ 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 } @@ -87,11 +73,6 @@ export type EngagementRecosParameters = { geolocationId?: string } -export type PlaceDetails = { - name: string - geolocation: GeolocationGeoJSON -} - export type GeolocationGeometry = { type: string coordinates: number[] | number[][] | number[][][] | number[][][][] @@ -106,11 +87,6 @@ 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 5b06f33d3c8110703a94ac7b38d2751d22a697f8..4d069bab6a47c35f6756fd7507a45035118149ae 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -1,43 +1,5 @@ -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 28661307ab9dd35827b558f9f6aba3f6b7c06416..3e09e2383d2e508dbbfef2156ef2d74f139abc70 100644 --- a/app/voltastics.ts +++ b/app/voltastics.ts @@ -1,88 +1,7 @@ -import { - ApiCategoriesResponse, - ApiDefaults, - ApiRoutes, - ApiSearchEngagement, - ApiSearchEngagementsResponse, -} from './api_types.ts' +import { ApiRoutes } from './api_types.ts' import { logger } from './logging.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) } -} +import { TrackEngagementViewParameters, TrackEngagementViewResponse } from './types.ts' +import { VoltasticsConfig } from './config.ts' const fetchFromVoltasticsApi = ( config: VoltasticsConfig, @@ -102,71 +21,6 @@ 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, @@ -194,26 +48,3 @@ 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 deleted file mode 100644 index 7ffe1a146f5a4f5cac35cf05deb5cd798c46ba2b..0000000000000000000000000000000000000000 --- a/app/voltastics_test.ts +++ /dev/null @@ -1,279 +0,0 @@ -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 deleted file mode 100644 index d3c0672cf12fc5f60f341d767ccfb539f9c5de34..0000000000000000000000000000000000000000 --- a/app/voltastics_test_data.ts +++ /dev/null @@ -1,2429 +0,0 @@ -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 66d1baa96a3888955c79cbf56ce928f2627abebd..359bdedb83329b2b91cc81634348fbfca244d73c 100644 --- a/app/volunteering_db.ts +++ b/app/volunteering_db.ts @@ -1,5 +1,4 @@ -import { postgres } from './deps.ts' -import { GraphQLError, Sentry } from './deps.ts' +import { GraphQLError, postgres, Sentry } from './deps.ts' import { GeoAPIClient } from './geo_api_client.ts' import { Engagement, @@ -41,6 +40,7 @@ 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,6 +136,65 @@ 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], @@ -151,6 +210,21 @@ 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. */ @@ -159,6 +233,15 @@ 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) { @@ -176,6 +259,23 @@ 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 * @@ -204,27 +304,33 @@ const sortMatches = ( * @param embedding * @param queriedTopics * @param queriedSkills + * @param version */ const decodeMatchesFromEmbedding = ( embedding: number[], queriedTopics: string[], queriedSkills: string[], + version: TopicsSkillsVersion, ): { topics: string[]; skills: string[] } => { - if (embedding.length != EMBEDDING_DIMENSIONS) { + 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) { logger.warn( - `Received embedding of dimension ${embedding.length}, expected ${EMBEDDING_DIMENSIONS}`, + `Received embedding of dimension ${embedding.length}, expected ${expectedEmbeddingDimension}`, ) } - const skillsOffset = TOPICS_VECTOR_INDICES.size const matchedTopicsWithConfidence = embedding .map((confidence, index): [string, number] | null => { - return index < skillsOffset ? [TOPICS_VECTOR_INDICES_REVERSE.get(index)!, confidence] : null + return index < skillsOffset ? [topics_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_VECTOR_INDICES_REVERSE.get(index - skillsOffset)!, confidence] : null + return index >= skillsOffset ? [skills_indices_reverse.get(index - skillsOffset)!, confidence] : null }) .filter((matchWithConfidence) => matchWithConfidence != null) .filter((matchWithConfidence) => matchWithConfidence[1] >= MATCH_SKILL_CONFIDENCE_THRESHOLD) @@ -242,26 +348,13 @@ const decodeMatchesFromEmbedding = ( export const exportedForTesting = { queryEmbeddingVector, + queryEmbeddingVectorV2, decodeMatchesFromEmbedding, sortMatches, TOPICS_VECTOR_INDICES, SKILLS_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, - } + TOPICS_V2_VECTOR_INDICES, + SKILLS_V2_VECTOR_INDICES, } const toEngagementRecommendation = ( @@ -270,19 +363,41 @@ const toEngagementRecommendation = ( queriedTopics: string[], queriedSkills: string[], ): EngagementReco => { - const embedding = JSON.parse(row.embedding_array) as number[] - const { topics, skills } = decodeMatchesFromEmbedding(embedding, queriedTopics, queriedSkills) + 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', + ) 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 embedding = JSON.parse(row.embedding_array) as number[] - const { topics, skills } = decodeMatchesFromEmbedding(embedding, [], []) + 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', + ) return { id: '' + row.id, title: row.title, @@ -291,7 +406,6 @@ 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, @@ -308,6 +422,8 @@ const toEngagement = (imageProxyBaseUrl: string, row: VolunteeringDBRow): Engage longitude: row.longitude, topics: topics, skills: skills, + topicsV2, + skillsV2, } } @@ -319,11 +435,12 @@ const extractTotalResultCount = ( const ERROR_CODE_NOT_FOUND = 'NOT_FOUND' +export type TopicsSkillsVersion = 'v1' | 'v2' + export interface VolunteeringDB { engagement(params: EngagementParameters): Promise<EngagementResponse> - engagementRecommendations(params: EngagementRecosParameters): Promise<EngagementsResponse> - engagementRecos(params: EngagementRecosParameters): Promise<EngagementRecosResponse> - filteredEngagements(params: FilteredEngagementsParameters): Promise<EngagementsResponse> + engagementRecos(version: TopicsSkillsVersion, params: EngagementRecosParameters): Promise<EngagementRecosResponse> + filteredEngagements(version: TopicsSkillsVersion, params: FilteredEngagementsParameters): Promise<EngagementsResponse> similarRecommendations(params: SimilarRecommendationsParameters): Promise<EngagementsResponse> } @@ -363,46 +480,19 @@ 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: 'VolunteeringDB.engagementRecos' }, + { name: version == 'v1' ? 'VolunteeringDB.engagementRecos' : 'VolunteeringDB.engagementRecosV2' }, 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)) { @@ -424,6 +514,7 @@ export class PostgresVolunteeringDB implements VolunteeringDB { limit: number, topics: string[], skills: string[], + version: TopicsSkillsVersion, geolocationId?: string, ): Promise<VolunteeringDBRow[]> { const geolocationCoordinates = geolocationId @@ -447,19 +538,40 @@ export class PostgresVolunteeringDB implements VolunteeringDB { topics, skills, geolocationCoordinates, + version, ) } else if (geolocationCoordinates) { - return this.queryRecosBasedOnLocation( + 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( offset, limit, - geolocationCoordinates, + topics, + skills, + version, ) - } else if (topics.length > 0 || skills.length > 0) { + } else if (version === 'v2') { return this.queryRecosBasedOnTopicsAndSkills( offset, limit, - topics, + DEFAULT_RECO_TOPICS_V2, skills, + version, ) } else { return Promise.reject( @@ -475,6 +587,7 @@ 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' }, @@ -482,13 +595,26 @@ export class PostgresVolunteeringDB implements VolunteeringDB { logger.debug( `queryRecosBasedOnTopicsAndSkills ${JSON.stringify({ offset, limit, topics, skills })}`, ) - 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} - ` + 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} + ` + } }, ) } @@ -499,6 +625,7 @@ 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' }, @@ -511,34 +638,59 @@ 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 = JSON.stringify(queryEmbeddingVector(topics, skills)) + const queryVector = version == 'v1' + ? queryEmbeddingVector(topics, skills) + : queryEmbeddingVectorV2(topics, skills) + const queryVectorJson = JSON.stringify(queryVector) 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 - 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}; - ` + 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}; + ` + } }, ) } @@ -569,6 +721,7 @@ export class PostgresVolunteeringDB implements VolunteeringDB { } async filteredEngagements( + version: TopicsSkillsVersion, { offset, limit, topics, skills, geolocationId }: FilteredEngagementsParameters, ): Promise<EngagementsResponse> { const geometry: GeolocationGeometry | undefined = geolocationId @@ -593,6 +746,7 @@ export class PostgresVolunteeringDB implements VolunteeringDB { skills, offset < 0 ? 0 : offset, limit > MAX_LIMIT || limit < 1 ? DEFAULT_LIMIT : limit, + version, geometry, ) } @@ -721,49 +875,83 @@ 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 = queryEmbeddingVector(topics, skills) - const queryVectorString = JSON.stringify(queryVector) + const queryVector = version == 'v1' + ? queryEmbeddingVector(topics, skills) + : queryEmbeddingVectorV2(topics, skills) + const queryVectorJson = JSON.stringify(queryVector) const nonEmptyQueryVector = topics.length + skills.length > 0 const binaryQueryVector = queryVector.map((value) => value > 0 ? '1' : '0') .join('') - const emptyBinaryQueryVector = '0'.repeat(EMBEDDING_DIMENSIONS) + const emptyBinaryQueryVector = '0'.repeat(version == 'v1' ? EMBEDDING_DIMENSIONS : EMBEDDING_V2_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 - 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}; - ` - + 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 + } 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 a5b5378fce148cf2a99661ae6b223fb104a3814d..357c9d570bccf5f20dd6fbfbf54d85df6d0738d9 100644 --- a/app/volunteering_db_test.ts +++ b/app/volunteering_db_test.ts @@ -9,7 +9,13 @@ import { Stub, stub, } from './dev_deps.ts' -import { exportedForTesting, PostgresVolunteeringDB, VolunteeringDB, VolunteeringDBRow } from './volunteering_db.ts' +import { + exportedForTesting, + PostgresVolunteeringDB, + TopicsSkillsVersion, + VolunteeringDB, + VolunteeringDBRow, +} from './volunteering_db.ts' import { GraphQLError, postgres } from './deps.ts' import { GeoAPIClient } from './geo_api_client.ts' import { Engagement } from './types.ts' @@ -71,7 +77,7 @@ const withMockedDependencies = (queryObjectResult: VolunteeringDBRow[] = []) => test(volunteeringDB, mockedGeoApiClient) } -const embeddingFixtures = { +const embeddingV1Fixtures = { // deno-fmt-ignore embedding_array: [ 1,0,0,0,0,0,0,0,0,1, @@ -85,6 +91,20 @@ const embeddingFixtures = { ], } +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, @@ -99,14 +119,15 @@ const dbFixtures = { location: 'Blibhausen', latitude: 12.3456, longitude: 65.4321, - embedding_array: JSON.stringify(embeddingFixtures.embedding_array), + embedding_array: JSON.stringify(embeddingV1Fixtures.embedding_array), + embedding_array_v2: JSON.stringify(embeddingV2Fixtures.embedding_array_v2), cosine_similarity: 0, distance_in_meters: 0, total_results: 1, } as VolunteeringDBRow, } -const assertEngagementMatchesRow = (row: VolunteeringDBRow, engagement: Engagement) => { +const assertEngagementMatchesRow = (version: TopicsSkillsVersion, row: VolunteeringDBRow, engagement: Engagement) => { assertEquals(engagement.id, `${row.id}`) assertEquals(engagement.title, row.title) assertEquals(engagement.description, row.description) @@ -115,7 +136,6 @@ const assertEngagementMatchesRow = (row: VolunteeringDBRow, engagement: Engageme 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, { @@ -125,16 +145,32 @@ const assertEngagementMatchesRow = (row: VolunteeringDBRow, engagement: Engageme assertEquals(engagement.location, row.location) assertEquals(engagement.latitude, row.latitude) assertEquals(engagement.longitude, row.longitude) - 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) - ]) + 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.matchedTopics, undefined) assertEquals(engagement.matchedSkills, undefined) assertEquals(engagement.matchedGeoLocationDistanceInMeters, undefined) @@ -145,16 +181,7 @@ describe('VolunteeringDB', () => { it('correctly constructs a query embedding vector', () => { const expectedLength = 28 /* #topics */ + 50 /* #skills */ // deno-fmt-ignore - 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, - ] + const expectedVector: number[] = embeddingV1Fixtures.embedding_array // deno-fmt-ignore const actualVector = exportedForTesting.queryEmbeddingVector([ "agriculture-food", // first item (index 0) @@ -169,8 +196,25 @@ 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 embeddings', () => { + it('correctly reverse-decodes topics and skills matches from V1 embeddings', () => { // deno-fmt-ignore const embedding: number[] = [ 0.9,0,0,0,0,0,0,0,0,0.8, @@ -204,11 +248,52 @@ 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 topics and skills matches with confidence below MATCH_CONFIDENCE_THRESHOLD', () => { + it('filters out V1 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, @@ -240,11 +325,49 @@ describe('VolunteeringDB', () => { embedding, allTopics, allSkills, + 'v1', ) assertEquals(matches.topics, expectedMatchedTopics) assertEquals(matches.skills, expectedMatchedSkills) }) - it('ranks topics and skills matches queried for higher', () => { + 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', () => { // deno-fmt-ignore const embedding: number[] = [ 0.85,0,0,0,0,0,0,0,0,0.9, @@ -275,6 +398,43 @@ 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) @@ -304,7 +464,8 @@ describe('VolunteeringDB', () => { it('correctly parses engagement', () => { withMockedDependencies([dbFixtures.row1])(async (volunteeringDB) => { const response = await volunteeringDB.engagement({ id: '1' }) - assertEngagementMatchesRow(dbFixtures.row1, response!) + assertEngagementMatchesRow('v1', dbFixtures.row1, response!) + assertEngagementMatchesRow('v2', dbFixtures.row1, response!) }) }) @@ -315,86 +476,10 @@ 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({ + const response = await volunteeringDB.engagementRecos('v1', { limit: 10, offset: 0, topics: ['disabilities-inclusion', 'water-ocean'], @@ -405,7 +490,7 @@ describe('VolunteeringDB', () => { assertEquals(response.data.length, 1) const reco = response.data[0] - assertEngagementMatchesRow(dbFixtures.row1, reco.engagement) + assertEngagementMatchesRow('v1', dbFixtures.row1, reco.engagement) assertEquals(reco.matchedTopics, [ 'disabilities-inclusion', // tenth item (index 9) @@ -420,10 +505,38 @@ 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({ + volunteeringDB.engagementRecos('v2', { limit: 10, offset: 0, topics: [], @@ -439,7 +552,7 @@ describe('VolunteeringDB', () => { it('does not resolve latitude/longitude when geolocationId is not given', () => { withMockedDependencies()( (volunteeringDB, geoAPIClient) => { - volunteeringDB.engagementRecos({ + volunteeringDB.engagementRecos('v2', { limit: 10, offset: 0, topics: ['agriculture-food'], @@ -458,7 +571,7 @@ describe('VolunteeringDB', () => { returnsNext([Promise.reject('boom!')]), ) - const result = await volunteeringDB.engagementRecos({ + const result = await volunteeringDB.engagementRecos('v2', { limit: 10, offset: 0, topics: ['agriculture-food'], @@ -480,7 +593,7 @@ describe('VolunteeringDB', () => { ) await assertRejects( () => - volunteeringDB.engagementRecos({ + volunteeringDB.engagementRecos('v2', { limit: 10, offset: 0, topics: [], @@ -493,13 +606,43 @@ 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({ + volunteeringDB.filteredEngagements('v2', { limit: 10, offset: 0, topics: [], @@ -515,7 +658,7 @@ describe('VolunteeringDB', () => { it('does not resolve geolocation geometry when geolocationId is not given', () => { withMockedDependencies()( (volunteeringDB, geoAPIClient) => { - volunteeringDB.filteredEngagements({ + volunteeringDB.filteredEngagements('v2', { limit: 10, offset: 0, topics: ['agriculture-food'], @@ -535,7 +678,7 @@ describe('VolunteeringDB', () => { ) await assertRejects( () => - volunteeringDB.filteredEngagements({ + volunteeringDB.filteredEngagements('v2', { limit: 10, offset: 0, topics: ['agriculture-food'], @@ -553,7 +696,7 @@ describe('VolunteeringDB', () => { async (volunteeringDB, _geoAPIClient) => { await assertRejects( () => - volunteeringDB.filteredEngagements({ + volunteeringDB.filteredEngagements('v2', { limit: 10, offset: 0, topics: [], diff --git a/smoketest/main.js b/smoketest/main.js index 919c8a156d5102cce203590c7d9e24f46d72fce4..1ecded4651c7297cea450f7ac589698bb38d4816 100644 --- a/smoketest/main.js +++ b/smoketest/main.js @@ -27,14 +27,32 @@ function forQuery(query, checkFunction) { // Define your smoketest(s) here. export default () => { - 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, - }) - }) + 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, + }) + }, + ) }