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
+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',