From cec5ff1e2e2201fe3a6369c9dfe09afcb080be8f Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 12 Oct 2026 12:41:55 +0200 Subject: [PATCH] fix(maps): nominatim autocomplete and search fallback keep the location bias On an install without the TREK Places index and without a Google key the suggestions come from Nominatim, and autocompleteNominatim asked it without the box the client had sent, so every suggestion was ranked globally. The search fallback on the same install dropped the bias the same way. Both now pass it on as a viewbox with bounded=0, which ranks results near the trip without hiding anything further away. Refs #2620 --- server/src/nest/maps/maps.service.ts | 4 +- server/src/nest/maps/providers/osm.client.ts | 3 +- server/tests/unit/nest/maps.service.test.ts | 46 ++++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index 4da069975..17140c616 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -393,7 +393,7 @@ export class MapsService { // first call carried a viewbox with bounded=0, which orders results // without changing which ones exist, so a second call can only return the // same empty list a throttle-interval later. - const places = osmAnswer ?? (await this.osm.searchNominatim(query, lang)); + const places = osmAnswer ?? (await this.osm.searchNominatim(query, lang, 'interactive', locationBias)); return { places, source: 'openstreetmap' }; } @@ -532,7 +532,7 @@ export class MapsService { } if (!apiKey) { - return this.osm.autocompleteNominatim(input, lang); + return this.osm.autocompleteNominatim(input, lang, boxCentre); } const google = this.googlePlaces.provider({ key: apiKey, source: keySource, userId }); diff --git a/server/src/nest/maps/providers/osm.client.ts b/server/src/nest/maps/providers/osm.client.ts index a4ad58c78..1c3493f6a 100644 --- a/server/src/nest/maps/providers/osm.client.ts +++ b/server/src/nest/maps/providers/osm.client.ts @@ -267,9 +267,10 @@ export class OsmClient { async autocompleteNominatim( input: string, lang?: string, + bias?: { lat: number; lng: number }, ): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: 'nominatim' }> { try { - const places = await this.searchNominatim(input, lang); + const places = await this.searchNominatim(input, lang, 'interactive', bias); const suggestions = places .filter((p) => p.osm_id && p.osm_id.includes(':') && p.osm_id.split(':')[1] !== '') .slice(0, 5) diff --git a/server/tests/unit/nest/maps.service.test.ts b/server/tests/unit/nest/maps.service.test.ts index c9d0c1b7c..45c86a122 100644 --- a/server/tests/unit/nest/maps.service.test.ts +++ b/server/tests/unit/nest/maps.service.test.ts @@ -1413,6 +1413,20 @@ describe('searchPlaces (fetch stubbed)', () => { expect(Array.isArray(result.places)).toBe(true); }); + // The same gap on the explicit search: with the index off and no key the + // fallback asked Nominatim again and dropped the bias it had been given (#2620). + it('MAPS-038b: the Nominatim fallback keeps the location bias', async () => { + indexOff(); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] }); + vi.stubGlobal('fetch', fetchMock); + + await svc.searchPlaces(999, 'Starbucks', 'en', { lat: 35, lng: 135.75 }); + + const url = new URL(String(fetchMock.mock.calls[0][0])); + expect(url.searchParams.get('viewbox')).toBe('135.25,34.5,136.25,35.5'); + expect(url.searchParams.get('bounded')).toBe('0'); + }); + // Session tokens: the keystrokes of one search and the details lookup that // ends it must reach Google under the same token, or every request is billed // on its own. The body field and the query parameter are what Google's @@ -1625,6 +1639,38 @@ describe('autocompletePlaces (fetch stubbed)', () => { expect(result.suggestions[0].placeId).toBe('node:1'); }); + // Without the index and without a key the suggestions come from Nominatim, + // and the box the client already computed has to reach it (#2620). It ranks, + // it does not restrict: bounded=0, so a far away place still comes back. + it('MAPS-081b: hands the location bias to Nominatim as a viewbox', async () => { + indexOff(); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] }); + vi.stubGlobal('fetch', fetchMock); + + await svc.autocompletePlaces(999, 'Starbucks', 'en', { + low: { lat: 34.9, lng: 135.6 }, + high: { lat: 35.1, lng: 135.9 }, + }); + + const url = new URL(String(fetchMock.mock.calls[0][0])); + expect(url.pathname).toContain('/search'); + expect(url.searchParams.get('bounded')).toBe('0'); + const [west, south, east, north] = String(url.searchParams.get('viewbox')).split(',').map(Number); + expect((west + east) / 2).toBeCloseTo(135.75); + expect((south + north) / 2).toBeCloseTo(35); + }); + + it('MAPS-081c: asks Nominatim without a viewbox when no bias was sent', async () => { + indexOff(); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] }); + vi.stubGlobal('fetch', fetchMock); + + await svc.autocompletePlaces(999, 'Starbucks', 'en'); + + const url = new URL(String(fetchMock.mock.calls[0][0])); + expect(url.searchParams.has('viewbox')).toBe(false); + }); + it('MAPS-082: uses Google when user has an API key', async () => { mockDbGet.mockReturnValueOnce({ maps_api_key: 'ENCRYPTED' }).mockReturnValueOnce(null); vi.stubGlobal(