From 59154b34c94ac28b2829ea768e3234503fc55272 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 30 Oct 2026 20:16:18 +0100 Subject: [PATCH] maps: OSM details carry the values installations published for the element A place found through Photon or Overpass, a relation or a way, is no row of the TREK index, so the community values the index lays over its own rows never reached it. Opening its details now asks /v1/community for the element and lays what is published over the record, with the version, where the TREK index is in use. No answer leaves the details as they were. --- server/src/nest/maps/community-overlay.ts | 44 +++++++++++ .../src/nest/maps/place-details.resolver.ts | 7 +- server/src/nest/maps/trek-places.client.ts | 25 +++++++ .../unit/nest/maps.community-overlay.test.ts | 75 +++++++++++++++++++ .../unit/nest/trek-places.client.test.ts | 20 +++++ 5 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 server/src/nest/maps/community-overlay.ts create mode 100644 server/tests/unit/nest/maps.community-overlay.test.ts diff --git a/server/src/nest/maps/community-overlay.ts b/server/src/nest/maps/community-overlay.ts new file mode 100644 index 000000000..4c70a3c4b --- /dev/null +++ b/server/src/nest/maps/community-overlay.ts @@ -0,0 +1,44 @@ +import { trekCommunityByKeys, type TrekCommunityValues } from './trek-places.client'; +import { normalizePlaceWebsite } from '@trek/shared'; + +/** + * Community values for places TREK did not find in its own index: a relation + * Photon returned, a way from Overpass. The index rows carry them already + * (the service lays them over its own answers); these places get them from + * /v1/community, so a value one installation sent shows on the next open of + * the same place everywhere. + */ + +/** The service's key of an OSM details id: `relation:3389505` is `osm:relation/3389505`. */ +export function communityKeyOf(placeId: string): string | null { + const match = /^(node|way|relation):(\d+)$/.exec(placeId); + return match ? `osm:${match[1]}/${match[2]}` : null; +} + +/** The details record with what the community published laid over it; untouched where nothing was. */ +export function withCommunityValues( + details: Record, + values: TrekCommunityValues | undefined, +): Record { + if (!values?.community) return details; + const out: Record = { ...details }; + const website = normalizePlaceWebsite(values.contact?.website); + if (website) out.website = website; + if (values.contact?.phone) out.phone = values.contact.phone; + if (values.contact?.email) out.email = values.contact.email; + if (values.hours?.osm) out.opening_hours = values.hours.osm; + if (values.description?.text) out.description = values.description.text; + if (typeof values.community.version === 'number') out.community_version = values.community.version; + return out; +} + +/** OSM details as Overpass and Photon gave them, with the community's values for the same element. */ +export async function osmDetailsWithCommunity( + placeId: string, + details: Record | null, +): Promise | null> { + const key = communityKeyOf(placeId); + if (!details || !key) return details; + const published = await trekCommunityByKeys([key]); + return withCommunityValues(details, published[key]); +} diff --git a/server/src/nest/maps/place-details.resolver.ts b/server/src/nest/maps/place-details.resolver.ts index 83099eef7..8694b4ba1 100644 --- a/server/src/nest/maps/place-details.resolver.ts +++ b/server/src/nest/maps/place-details.resolver.ts @@ -9,6 +9,7 @@ */ import { PlaceDetailsCache } from '../../db/entities/PlaceDetailsCache.entity'; import type { PlaceDetailsCacheRepository } from '../../db/repositories/PlaceDetailsCache.repository'; +import { osmDetailsWithCommunity } from './community-overlay'; import { indexPlaceDetails } from './maps-index.helpers'; import { toApiLang, isGooglePlaceId, OSM_PLACE_ID } from './maps.helpers'; import { PlacesProviderSelector } from './places-provider.selector'; @@ -84,7 +85,11 @@ export class PlaceDetailsResolver { if (isAmapPlaceId(placeId)) return this.amapDetails(userId, placeId, lang); // OSM details: placeId is "node:123456" or "way:123456" etc. - if (placeId.includes(':')) return { place: await this.osm.elementDetails(placeId, lang) }; + if (placeId.includes(':')) { + const place = await this.osm.elementDetails(placeId, lang); + // What installations published for this element, like the index rows carry it. + return { place: indexEnabled ? await osmDetailsWithCommunity(placeId, place) : place }; + } // Google details // 'en' default, aligned with search/autocomplete and the MCP tools' ?? 'en' diff --git a/server/src/nest/maps/trek-places.client.ts b/server/src/nest/maps/trek-places.client.ts index 7a38cfcc9..fb50adec0 100644 --- a/server/src/nest/maps/trek-places.client.ts +++ b/server/src/nest/maps/trek-places.client.ts @@ -637,6 +637,31 @@ export async function trekPlacesById(gers: string, opts: { lang?: string } = {}) } } +/** What the service publishes for a place found elsewhere (Photon, Overpass): the overlay of its own rows. */ +export interface TrekCommunityValues { + contact?: { website?: string | null; phone?: string | null; email?: string | null } | null; + hours?: { osm?: string | null } | null; + description?: { text?: string | null } | null; + community?: { version?: number; fields?: string[] } | null; +} + +/** + * The published community values of places that are no rows of the index, by + * key (`osm:relation/3389505`). Nothing published, or the service not + * answering, is an empty map: the details are shown as they are. + */ +export async function trekCommunityByKeys(keys: string[]): Promise> { + if (keys.length === 0) return {}; + try { + const body = await getJson<{ places?: Record }>('/v1/community', { + keys: keys.slice(0, 50).join(','), + }); + return body.places ?? {}; + } catch { + return {}; + } +} + /** * Map a TREK place onto the record shape the client already reads. * diff --git a/server/tests/unit/nest/maps.community-overlay.test.ts b/server/tests/unit/nest/maps.community-overlay.test.ts new file mode 100644 index 000000000..f987028fc --- /dev/null +++ b/server/tests/unit/nest/maps.community-overlay.test.ts @@ -0,0 +1,75 @@ +/** + * Community values for places outside the index: the key a details id maps to, + * what is laid over the record, and that a place without published values, or + * a service that does not answer, leaves the details as they were. + */ +import { communityKeyOf, osmDetailsWithCommunity, withCommunityValues } from '../../../src/nest/maps/community-overlay'; +import { trekCommunityByKeys } from '../../../src/nest/maps/trek-places.client'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/nest/maps/trek-places.client', () => ({ trekCommunityByKeys: vi.fn() })); +const ask = vi.mocked(trekCommunityByKeys); + +const DETAILS = { + name: 'Umeda Sky Building', + website: null, + phone: null, + opening_hours: null, + osm_id: 'relation:3389505', +}; + +beforeEach(() => ask.mockReset()); + +describe('community values on OSM details', () => { + it('COMOV-001: maps an OSM details id onto the service key, and nothing else', () => { + expect(communityKeyOf('relation:3389505')).toBe('osm:relation/3389505'); + expect(communityKeyOf('node:1')).toBe('osm:node/1'); + expect(communityKeyOf('way:22')).toBe('osm:way/22'); + expect(communityKeyOf('gers:abc')).toBeNull(); + expect(communityKeyOf('relation:12;out')).toBeNull(); + }); + + it('COMOV-002: lays the published values over the record, with the version', () => { + const got = withCommunityValues(DETAILS, { + contact: { website: 'www.skybldg.co.jp', phone: '+81 6 6440 3855', email: null }, + hours: { osm: 'Mo-Su 09:30-22:30' }, + description: { text: 'A tower of two linked blocks.' }, + community: { version: 3, fields: ['website', 'phone', 'opening_hours', 'description'] }, + }); + expect(got.website).toMatch(/^https:\/\/www\.skybldg\.co\.jp\/?$/); + expect(got).toMatchObject({ + phone: '+81 6 6440 3855', + opening_hours: 'Mo-Su 09:30-22:30', + description: 'A tower of two linked blocks.', + community_version: 3, + name: 'Umeda Sky Building', + }); + expect(got.email).toBeUndefined(); + }); + + it('COMOV-003: a place without published values keeps its record as it was', () => { + expect(withCommunityValues(DETAILS, undefined)).toBe(DETAILS); + expect(withCommunityValues(DETAILS, { contact: { website: 'https://x.example' } })).toBe(DETAILS); + }); + + it('COMOV-004: asks the service once for the element and merges its answer', async () => { + ask.mockResolvedValueOnce({ + 'osm:relation/3389505': { + contact: { website: 'https://www.skybldg.co.jp/' }, + community: { version: 1, fields: ['website'] }, + }, + }); + const got = await osmDetailsWithCommunity('relation:3389505', DETAILS); + expect(ask).toHaveBeenCalledWith(['osm:relation/3389505']); + expect(got?.website).toBe('https://www.skybldg.co.jp/'); + }); + + it('COMOV-005: no details, an id without a key, or an empty answer ask nothing more', async () => { + expect(await osmDetailsWithCommunity('relation:3389505', null)).toBeNull(); + expect(await osmDetailsWithCommunity('gers:abc', DETAILS)).toBe(DETAILS); + expect(ask).not.toHaveBeenCalled(); + ask.mockResolvedValueOnce({}); + expect(await osmDetailsWithCommunity('node:7', DETAILS)).toEqual(DETAILS); + }); +}); diff --git a/server/tests/unit/nest/trek-places.client.test.ts b/server/tests/unit/nest/trek-places.client.test.ts index 155e216b7..c2ed497b6 100644 --- a/server/tests/unit/nest/trek-places.client.test.ts +++ b/server/tests/unit/nest/trek-places.client.test.ts @@ -13,6 +13,7 @@ import { trekPlacesArea, trekPlacesBaseUrl, trekPlacesById, + trekCommunityByKeys, trekPlacesHighlights, trekPlacesMiss, trekPlacesNearby, @@ -1207,3 +1208,22 @@ describe('the layers breaker', () => { expect(calls).toHaveLength(0); }); }); + +describe('trekCommunityByKeys', () => { + it('asks /v1/community for the keys and answers its places', async () => { + const places = { 'osm:relation/3389505': { contact: { website: 'https://www.skybldg.co.jp/' } } }; + stubFetch({ places }); + expect(await trekCommunityByKeys(['osm:relation/3389505', 'osm:node/1'])).toEqual(places); + const url = new URL(calls[0]?.url ?? 'http://missing'); + expect(url.pathname).toBe('/v1/community'); + expect(url.searchParams.get('keys')).toBe('osm:relation/3389505,osm:node/1'); + }); + + it('asks nothing without keys, and a failing service is an empty map', async () => { + stubFetch({ places: {} }); + expect(await trekCommunityByKeys([])).toEqual({}); + expect(calls).toHaveLength(0); + stubFetch({ error: 'down' }, 503); + expect(await trekCommunityByKeys(['osm:node/1'])).toEqual({}); + }); +});