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(