diff --git a/client/src/api/client.ts b/client/src/api/client.ts index b498c5adc..05843d54a 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -930,9 +930,9 @@ export const journeyApi = { export const mapsApi = { search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')), - autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) => - apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')), - details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')), + autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) => + apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')), + details: (placeId: string, lang?: string, sessionToken?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')), // Pictures and a description for a place that is being looked at but not yet // saved. Fans out to several providers server-side, so it takes a signal and // the caller is expected to abort it when the selection changes, and a longer diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 1648f8aaa..50f615f58 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -20,6 +20,7 @@ import { BookingCostsSection } from './BookingCostsSection' import type { BookingExpenseRequest } from './BookingCostsSection.types' import type { Place, Category, Assignment, BudgetItem } from '../../types' import { NumericInput } from '../shared/NumericInput' +import { PlacesSession } from '../../utils/placesSession' // The submit payload mirrors the form, but lat/lng are parsed to numbers and // category_id is normalised, plus any files chosen before the place existed. @@ -106,6 +107,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) { const [acHighlight, setAcHighlight] = useState(-1) const acDebounceRef = useRef | null>(null) const acAbortRef = useRef(null) + // Ties one search's keystrokes and its details lookup into a single Google + // billing session (see utils/placesSession). + const placesSessionRef = useRef(new PlacesSession()) const toast = useToast() const { t, language, locale } = useTranslation() const { hasMapsKey, placesEnrichEnabled } = useAuthStore() @@ -244,7 +248,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) { const controller = new AbortController() acAbortRef.current = controller try { - const result = await mapsApi.autocomplete(query, language, locationBias, controller.signal) + const result = await mapsApi.autocomplete(query, language, locationBias, controller.signal, placesSessionRef.current.current()) setAcSuggestions(result.suggestions || []) setAcHighlight(-1) } catch (err: unknown) { @@ -263,6 +267,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) { if (trimmed.length < 2 || isGoogleMapsUrl(trimmed)) { setAcSuggestions([]) setAcHighlight(-1) + placesSessionRef.current.end() return } @@ -349,7 +354,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) { // clickable instead of dead-ending on "Place search failed". (#1192) let place: Record | null = null try { - const result = await mapsApi.details(suggestion.placeId, language) + // Spends the session the suggestions opened, so Google bills the search + // once rather than per keystroke. + const result = await mapsApi.details(suggestion.placeId, language, placesSessionRef.current.peek()) if (result.place && result.place.lat != null && result.place.lng != null) { place = result.place } @@ -373,6 +380,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) { toast.error(getApiErrorMessage(err, t('places.mapsSearchError'))) } finally { setIsSearchingMaps(false) + placesSessionRef.current.end() } } diff --git a/client/src/mobile/screens/trip/sheets/PlPlaceSearch.tsx b/client/src/mobile/screens/trip/sheets/PlPlaceSearch.tsx index 28529f955..2467e987f 100644 --- a/client/src/mobile/screens/trip/sheets/PlPlaceSearch.tsx +++ b/client/src/mobile/screens/trip/sheets/PlPlaceSearch.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Loader2, Search } from 'lucide-react' import { mapsApi } from '../../../../api/client' +import { PlacesSession } from '../../../../utils/placesSession' import { isGoogleMapsUrl } from '../../../../components/Planner/PlaceFormModal.helpers' import { getApiErrorMessage } from '../../../../utils/apiError' import { FIELD_CLS } from './PlSheetChrome' @@ -67,6 +68,8 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi const [searching, setSearching] = useState(false) const debounceRef = useRef | null>(null) const abortRef = useRef(null) + // One Google billing session per search (see utils/placesSession). + const placesSessionRef = useRef(new PlacesSession()) const setResolving = useCallback( (v: boolean) => { @@ -82,7 +85,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi const controller = new AbortController() abortRef.current = controller try { - const result = await mapsApi.autocomplete(input, language, locationBias, controller.signal) + const result = await mapsApi.autocomplete(input, language, locationBias, controller.signal, placesSessionRef.current.current()) setSuggestions(result.suggestions || []) } catch (err: unknown) { // Superseded request — axios rejects an aborted call with CanceledError. @@ -150,6 +153,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi toast.error(getApiErrorMessage(err, t('places.mapsSearchError'))) } finally { setResolving(false) + placesSessionRef.current.end() } } @@ -164,7 +168,8 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi // back to the text-search path so suggestions never dead-end. (#1192) let place: MapsPlace | null = null try { - const result = await mapsApi.details(suggestion.placeId, language) + // Spends the session the suggestions opened. + const result = await mapsApi.details(suggestion.placeId, language, placesSessionRef.current.peek()) if (result.place && result.place.lat != null && result.place.lng != null) place = result.place } catch { // fall through to text search @@ -185,6 +190,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi toast.error(getApiErrorMessage(err, t('places.mapsSearchError'))) } finally { setResolving(false) + placesSessionRef.current.end() } } diff --git a/client/src/utils/placesSession.ts b/client/src/utils/placesSession.ts new file mode 100644 index 000000000..ee3a76e98 --- /dev/null +++ b/client/src/utils/placesSession.ts @@ -0,0 +1,47 @@ +/** + * Google bills place autocomplete per keystroke unless the requests carry a + * session token. With one, the keystrokes and the details lookup that ends the + * search are charged as a single session, which is what a user experiences as + * "I looked up one place". + * + * A session starts with the first suggestion request and ends when the user + * picks a result (or gives up). Reusing a token across two searches would merge + * them into one billed session and return stale-scoped suggestions, so `end()` + * has to run on both paths. + * + * The token has to be URL-safe ASCII and at most 36 characters; a UUID is + * exactly 36 and is what Google's own clients use. `crypto.randomUUID` is + * missing on http:// origins in some browsers, hence the fallback. + */ +export function newSessionToken(): string { + const c = globalThis.crypto; + if (c && typeof c.randomUUID === 'function') return c.randomUUID(); + if (c && typeof c.getRandomValues === 'function') { + const bytes = new Uint8Array(16); + c.getRandomValues(bytes); + return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); + } + return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`; +} + +/** One search's worth of token, created lazily and cleared when it is spent. */ +export class PlacesSession { + private token: string | null = null; + + /** The token for the running search, starting one if none is open. */ + current(): string { + if (!this.token) this.token = newSessionToken(); + return this.token; + } + + /** The token without starting a session: a details lookup that no search led + * to must not open one, or it would be billed as an empty session. */ + peek(): string | undefined { + return this.token ?? undefined; + } + + /** Call when the search is over, whether it ended in a pick or not. */ + end(): void { + this.token = null; + } +} diff --git a/server/src/nest/maps/maps.controller.ts b/server/src/nest/maps/maps.controller.ts index 4ba8b1fe6..88d7096a8 100644 --- a/server/src/nest/maps/maps.controller.ts +++ b/server/src/nest/maps/maps.controller.ts @@ -26,6 +26,11 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { MapsSearchDto, MapsAutocompleteDto, MapsResolveUrlDto } from './maps.dto'; +/** Google's session-token shape: URL-safe ASCII, at most 36 characters. The + * autocomplete body is validated by the Zod pipe; the details query is not, + * so it is checked here rather than forwarded blindly. */ +const SESSION_TOKEN = /^[A-Za-z0-9_-]{1,36}$/; + /** Maps a thrown service error to the same status + { error } body Express sent. */ function toHttpException(err: unknown, fallbackMessage: string, defaultStatus: number): HttpException { const status = (err as { status?: number }).status || defaultStatus; @@ -104,7 +109,7 @@ export class MapsController { return { suggestions: [], source: 'disabled' }; } try { - return await this.maps.autocomplete(user.id, body.input, body.lang, body.locationBias); + return await this.maps.autocomplete(user.id, body.input, body.lang, body.locationBias, body.sessionToken); } catch (err: unknown) { console.error('Maps autocomplete error:', err); throw toHttpException(err, 'Autocomplete error', 500); @@ -118,6 +123,10 @@ export class MapsController { @Query('expand') expand?: string, @Query('lang') lang?: string, @Query('refresh') refresh?: string, + // Closes the autocomplete session this lookup came from. Only forwarded when + // it matches Google's shape, so a junk value bills per request instead of + // breaking the lookup. + @Query('sessionToken') sessionToken?: string, ): Promise { if (this.maps.detailsDisabled()) { return { place: null, disabled: true }; @@ -125,7 +134,7 @@ export class MapsController { try { return expand ? await this.maps.detailsExpanded(user.id, placeId, lang, refresh === '1') - : await this.maps.details(user.id, placeId, lang); + : await this.maps.details(user.id, placeId, lang, SESSION_TOKEN.test(sessionToken ?? '') ? sessionToken : undefined); } catch (err: unknown) { console.error('Maps details error:', err); throw toHttpException(err, 'Error fetching place details', 500); diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index abe08c8fc..dc889cea9 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -492,12 +492,12 @@ export class MapsService { return this.searchPlaces(userId, query, lang, locationBias) as Promise; } - autocomplete(userId: number, input: string, lang?: string, locationBias?: LocationBias): Promise { - return this.autocompletePlaces(userId, input, lang, locationBias) as Promise; + autocomplete(userId: number, input: string, lang?: string, locationBias?: LocationBias, sessionToken?: string): Promise { + return this.autocompletePlaces(userId, input, lang, locationBias, sessionToken) as Promise; } - details(userId: number, placeId: string, lang?: string): Promise { - return this.getPlaceDetails(userId, placeId, lang) as Promise; + details(userId: number, placeId: string, lang?: string, sessionToken?: string): Promise { + return this.getPlaceDetails(userId, placeId, lang, sessionToken) as Promise; } detailsExpanded(userId: number, placeId: string, lang: string | undefined, refresh: boolean): Promise { @@ -1457,6 +1457,7 @@ export class MapsService { input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, + sessionToken?: string, ): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> { const apiKey = this.getMapsKey(userId); @@ -1468,6 +1469,10 @@ export class MapsService { input, languageCode: toApiLang(lang), }; + // With a session token Google bills the whole search as one autocomplete + // session instead of charging each keystroke; the details call that closes + // the session carries the same token. + if (sessionToken) body.sessionToken = sessionToken; if (locationBias) { body.locationBias = { rectangle: { @@ -1539,6 +1544,7 @@ export class MapsService { userId: number, placeId: string, lang?: string, + sessionToken?: string, ): Promise<{ place: Record }> { // OSM details: placeId is "node:123456" or "way:123456" etc. if (placeId.includes(':')) { @@ -1591,8 +1597,13 @@ export class MapsService { ); if (cached && Date.now() - cached.fetched_at < DETAILS_TTL) return { place: JSON.parse(cached.payload_json) }; + // Closes the autocomplete session this lookup belongs to, so Google bills + // the search once instead of per keystroke. A cache hit above never reaches + // here, which is billing-neutral: an unclosed session is charged as a plain + // autocomplete session. + const sessionParam = sessionToken ? `&sessionToken=${encodeURIComponent(sessionToken)}` : ''; const response = await googleFetch( - `https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, + `https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}${sessionParam}`, `getPlaceDetails(${placeId})`, { method: 'GET', diff --git a/server/tests/integration/maps.test.ts b/server/tests/integration/maps.test.ts index 01c9127d0..e5597b5de 100644 --- a/server/tests/integration/maps.test.ts +++ b/server/tests/integration/maps.test.ts @@ -402,6 +402,7 @@ describe('Maps autocomplete', () => { 'test', 'fr', { low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } }, + undefined, ); }); diff --git a/server/tests/unit/nest/maps.controller.test.ts b/server/tests/unit/nest/maps.controller.test.ts index b0cb1acf7..a058db614 100644 --- a/server/tests/unit/nest/maps.controller.test.ts +++ b/server/tests/unit/nest/maps.controller.test.ts @@ -115,7 +115,16 @@ describe('MapsController (parity with the legacy /api/maps route)', () => { const autocomplete = vi.fn().mockResolvedValue({ suggestions: [], source: 'osm' }); const bias = { low: { lat: 1, lng: 2 }, high: { lat: 3, lng: 4 } }; await makeController({ autocompleteDisabled: () => false, autocomplete }).autocomplete(user, { input: 'be', lang: 'en', locationBias: bias }); - expect(autocomplete).toHaveBeenCalledWith(3, 'be', 'en', bias); + expect(autocomplete).toHaveBeenCalledWith(3, 'be', 'en', bias, undefined); + }); + + // Session tokens tie a search's keystrokes and its details lookup into one + // Google billing session instead of charging each request. + it('passes a session token through to the service', async () => { + const autocomplete = vi.fn().mockResolvedValue({ suggestions: [], source: 'google' }); + await makeController({ autocompleteDisabled: () => false, autocomplete }) + .autocomplete(user, { input: 'be', sessionToken: 'abc123' }); + expect(autocomplete).toHaveBeenCalledWith(3, 'be', undefined, undefined, 'abc123'); }); it('maps a service error', async () => { @@ -145,7 +154,23 @@ describe('MapsController (parity with the legacy /api/maps route)', () => { it('uses the plain lookup without expand', async () => { const details = vi.fn().mockResolvedValue({ place: { id: 'p1' } }); await makeController({ detailsDisabled: () => false, details }).details(user, 'p1', undefined, 'de'); - expect(details).toHaveBeenCalledWith(3, 'p1', 'de'); + expect(details).toHaveBeenCalledWith(3, 'p1', 'de', undefined); + }); + + // The details query is not Zod-validated, so the token is shape-checked here + // and a junk value degrades to per-request billing instead of reaching Google. + it('forwards a well-formed session token and drops a malformed one', async () => { + const details = vi.fn().mockResolvedValue({ place: { id: 'p1' } }); + const c = makeController({ detailsDisabled: () => false, details }); + + await c.details(user, 'p1', undefined, 'de', undefined, 'a-b_C9'); + expect(details).toHaveBeenLastCalledWith(3, 'p1', 'de', 'a-b_C9'); + + await c.details(user, 'p1', undefined, 'de', undefined, 'has spaces & symbols!'); + expect(details).toHaveBeenLastCalledWith(3, 'p1', 'de', undefined); + + await c.details(user, 'p1', undefined, 'de', undefined, 'x'.repeat(37)); + expect(details).toHaveBeenLastCalledWith(3, 'p1', 'de', undefined); }); it('maps a service error', async () => { diff --git a/server/tests/unit/nest/maps.service.test.ts b/server/tests/unit/nest/maps.service.test.ts index 3f7a5bb2b..63b309a5a 100644 --- a/server/tests/unit/nest/maps.service.test.ts +++ b/server/tests/unit/nest/maps.service.test.ts @@ -1084,6 +1084,33 @@ describe('searchPlaces (fetch stubbed)', () => { expect(Array.isArray(result.places)).toBe(true); }); + // 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 + // reference specifies for each half. + it('MAPS-039f: sends the session token in the autocomplete body', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'ENCRYPTED' }).mockReturnValueOnce(null); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ suggestions: [] }) }); + vi.stubGlobal('fetch', fetchMock); + + await svc.autocompletePlaces(1, 'eiff', 'en', undefined, 'sess-123'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toContain('places:autocomplete'); + expect(JSON.parse((init as { body: string }).body).sessionToken).toBe('sess-123'); + }); + + it('MAPS-039g: omits the field entirely when there is no session token', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'ENCRYPTED' }).mockReturnValueOnce(null); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ suggestions: [] }) }); + vi.stubGlobal('fetch', fetchMock); + + await svc.autocompletePlaces(1, 'eiff', 'en'); + + const body = JSON.parse((fetchMock.mock.calls[0][1] as { body: string }).body); + expect(body).not.toHaveProperty('sessionToken'); + }); + it('MAPS-039: uses Google when user has an API key', async () => { mockDbGet.mockReturnValueOnce({ maps_api_key: 'ENCRYPTED' }).mockReturnValueOnce(null); vi.stubGlobal( @@ -2323,10 +2350,10 @@ describe('controller-facing wrappers delegate to the folded methods', () => { const rectBias = { low: { lat: 1, lng: 2 }, high: { lat: 3, lng: 4 } }; await svc.autocomplete(3, 'be', 'en', rectBias); - expect(spies.autocompletePlaces).toHaveBeenCalledWith(3, 'be', 'en', rectBias); + expect(spies.autocompletePlaces).toHaveBeenCalledWith(3, 'be', 'en', rectBias, undefined); await svc.details(3, 'p1', 'de'); - expect(spies.getPlaceDetails).toHaveBeenCalledWith(3, 'p1', 'de'); + expect(spies.getPlaceDetails).toHaveBeenCalledWith(3, 'p1', 'de', undefined); await svc.detailsExpanded(3, 'p1', 'de', true); expect(spies.getPlaceDetailsExpanded).toHaveBeenCalledWith(3, 'p1', 'de', true); diff --git a/shared/src/maps/maps.schema.ts b/shared/src/maps/maps.schema.ts index 12de6bd5a..37375b22e 100644 --- a/shared/src/maps/maps.schema.ts +++ b/shared/src/maps/maps.schema.ts @@ -33,6 +33,13 @@ export const mapsAutocompleteRequestSchema = z.object({ input: z.string().min(1).max(200), lang: z.string().optional(), locationBias: z.object({ low: latLng, high: latLng }).optional(), + /** + * Ties the keystrokes of one search, and the details call that ends it, into a + * single Google billing session. Google caps it at 36 URL-safe ASCII + * characters; anything else is dropped rather than forwarded, so a bad token + * degrades to per-request billing instead of failing the search. + */ + sessionToken: z.string().regex(/^[A-Za-z0-9_-]{1,36}$/).optional(), }); export type MapsAutocompleteRequest = z.infer;