feat(maps): bill a place search as one session instead of per keystroke

Google charges autocomplete per request unless the requests carry a session
token. With one, the keystrokes of a single search and the details lookup that
ends it are charged together, which is also how a user experiences it: they
looked up one place.

TREK sent no token at all, so every debounced keystroke was its own billable
request. On a busy trip that is the largest line on the Places bill, and it is
paid by whoever owns the key, which for a self-hosted instance is the person
running it.

A session opens with the first suggestion request and closes when the user picks
a result. It has to close on the abandoned paths too: a token carried into the
next search would merge two searches into one billed session and scope the
suggestions wrongly. So the search box clearing it, the text-search fallback and
the error paths all end it.

The token is validated on the way in. The autocomplete body goes through the Zod
pipe, the details query does not, so the controller shape-checks it there.
Anything that does not match Google's URL-safe 36-character form is dropped
rather than forwarded, which falls back to per-request billing instead of
breaking a lookup.

A cached details hit never reaches Google and so never closes its session. That
is billing-neutral: an unclosed session is charged as a plain autocomplete
session, which is still cheaper than the per-request behaviour it replaces.
This commit is contained in:
Maurice
2026-08-14 23:18:05 +02:00
committed by Maurice
parent f23de88b74
commit f02a4b5654
10 changed files with 159 additions and 18 deletions
+3 -3
View File
@@ -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
@@ -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<ReturnType<typeof setTimeout> | null>(null)
const acAbortRef = useRef<AbortController | null>(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<string, unknown> | 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()
}
}
@@ -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<ReturnType<typeof setTimeout> | null>(null)
const abortRef = useRef<AbortController | null>(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()
}
}
+47
View File
@@ -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;
}
}
+11 -2
View File
@@ -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<MapsPlaceDetailsResult> {
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);
+16 -5
View File
@@ -492,12 +492,12 @@ export class MapsService {
return this.searchPlaces(userId, query, lang, locationBias) as Promise<MapsSearchResult>;
}
autocomplete(userId: number, input: string, lang?: string, locationBias?: LocationBias): Promise<MapsAutocompleteResult> {
return this.autocompletePlaces(userId, input, lang, locationBias) as Promise<MapsAutocompleteResult>;
autocomplete(userId: number, input: string, lang?: string, locationBias?: LocationBias, sessionToken?: string): Promise<MapsAutocompleteResult> {
return this.autocompletePlaces(userId, input, lang, locationBias, sessionToken) as Promise<MapsAutocompleteResult>;
}
details(userId: number, placeId: string, lang?: string): Promise<MapsPlaceDetailsResult> {
return this.getPlaceDetails(userId, placeId, lang) as Promise<MapsPlaceDetailsResult>;
details(userId: number, placeId: string, lang?: string, sessionToken?: string): Promise<MapsPlaceDetailsResult> {
return this.getPlaceDetails(userId, placeId, lang, sessionToken) as Promise<MapsPlaceDetailsResult>;
}
detailsExpanded(userId: number, placeId: string, lang: string | undefined, refresh: boolean): Promise<MapsPlaceDetailsResult> {
@@ -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<string, unknown> }> {
// 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',
+1
View File
@@ -402,6 +402,7 @@ describe('Maps autocomplete', () => {
'test',
'fr',
{ low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } },
undefined,
);
});
+27 -2
View File
@@ -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 () => {
+29 -2
View File
@@ -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);
+7
View File
@@ -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<typeof mapsAutocompleteRequestSchema>;