mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user