mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(maps): nominatim and overpass calls move into an OpenStreetMap client
This commit is contained in:
@@ -101,6 +101,7 @@ export const PUBLIC_FILES = [
|
||||
// The maps domain's outbound clients. MapsModule exports them so enrichment
|
||||
// asks Google and Wikimedia directly instead of through a MapsService facade.
|
||||
'maps/providers/google-places.provider.ts',
|
||||
'maps/providers/osm.client.ts',
|
||||
];
|
||||
|
||||
function walk(dir, files = []) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MapsController } from './maps.controller';
|
||||
import { MapsService } from './maps.service';
|
||||
import { MapsMcp } from './maps.mcp';
|
||||
import { GooglePlacesClient } from './providers/google-places.provider';
|
||||
import { OsmClient } from './providers/osm.client';
|
||||
import { PlacePhotosModule } from '../place-photos/place-photos.module';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { GoogleQuotaModule } from '../google-quota/google-quota.module';
|
||||
@@ -29,7 +30,7 @@ import { Places } from '../../db/entities/Places.entity';
|
||||
@Module({
|
||||
imports: [PlacePhotosModule, StorageModule, GoogleQuotaModule, MikroOrmModule.forFeature([AppSettings, Users, PlaceDetailsCache, Places])],
|
||||
controllers: [MapsController],
|
||||
providers: [MapsService, MapsMcp, GooglePlacesClient],
|
||||
exports: [MapsService, GooglePlacesClient],
|
||||
providers: [MapsService, MapsMcp, GooglePlacesClient, OsmClient],
|
||||
exports: [MapsService, GooglePlacesClient, OsmClient],
|
||||
})
|
||||
export class MapsModule {}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { Places } from '../../db/entities/Places.entity';
|
||||
import type { PlacesRepository } from '../../db/repositories/Places.repository';
|
||||
import { isPlacesProviderChoice, type PlacesProviderChoice } from './providers/places-provider';
|
||||
import { GooglePlacesClient } from './providers/google-places.provider';
|
||||
import { OsmClient, POI_RESULT_CAP, type PoiSearchResult } from './providers/osm.client';
|
||||
import {
|
||||
AMAP_SHORT_HOSTS,
|
||||
AmapPlacesProvider,
|
||||
@@ -35,7 +36,7 @@ import {
|
||||
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
|
||||
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
|
||||
import { GoogleQuotaService } from '../google-quota/google-quota.service';
|
||||
import { nominatimFetch, type GeoLane } from '../geo/nominatim.client';
|
||||
import type { GeoLane } from '../geo/nominatim.client';
|
||||
import {
|
||||
trekPlacesSearch,
|
||||
indexHitsOnly,
|
||||
@@ -55,68 +56,22 @@ import {
|
||||
isGooglePlaceId,
|
||||
googleFtidFromMapsUrl,
|
||||
OSM_PLACE_ID,
|
||||
CATEGORY_OSM_FILTERS,
|
||||
parsePoiCategories,
|
||||
clampPoiBbox,
|
||||
resolveOverpassEndpoints,
|
||||
resolveOverpassTimeoutMs,
|
||||
OVERPASS_QUERY_TIMEOUT_S,
|
||||
stripWikiMarkup,
|
||||
parseWikipediaTag,
|
||||
toWikiLang,
|
||||
haversineMetres,
|
||||
namesOverlap,
|
||||
mergeSearchResults,
|
||||
readChargingInfo,
|
||||
type OverpassPoi,
|
||||
} from './maps.helpers';
|
||||
import {
|
||||
NEARBY_DEFAULT_LIMIT,
|
||||
NEARBY_DEFAULT_RADIUS_M,
|
||||
nearbyCacheKey,
|
||||
nearbyOverpassQuery,
|
||||
nearestFirst,
|
||||
overpassNearbyRecords,
|
||||
} from './maps-nearby.helpers';
|
||||
|
||||
// ── Interfaces ───────────────────────────────────────────────────────────────
|
||||
|
||||
function nominatimCategory(item: { class?: string; type?: string }): string | null {
|
||||
if (!item.class || !item.type || item.type === 'yes') return null;
|
||||
return item.class === 'shop' ? `shop_${item.type}` : item.type;
|
||||
}
|
||||
|
||||
interface NominatimResult {
|
||||
osm_type: string;
|
||||
osm_id: string;
|
||||
/** The main OSM tag, key and value: `class` "tourism" with `type` "hotel". */
|
||||
class?: string;
|
||||
type?: string;
|
||||
name?: string;
|
||||
display_name?: string;
|
||||
lat: string;
|
||||
lon: string;
|
||||
extratags?: Record<string, string> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The keys that say which encyclopaedia entry, Wikidata item and Commons
|
||||
* category describe a place. Everything the enrichment column shows beyond
|
||||
* coordinates hangs off one of these.
|
||||
*
|
||||
* Bare keys only. OSM also carries `brand:wikidata` / `brand:wikipedia`, and
|
||||
* following those means a branch of a chain gets the chain's article and the
|
||||
* chain's logo — for "L'Osteria Rostock" you would confidently describe
|
||||
* L'Osteria the company. That is the exact failure the tag-only rule was
|
||||
* written to avoid, so do not "improve" this by falling back to brand:*.
|
||||
*/
|
||||
const WIKI_IDENTITY_TAGS = ['wikipedia', 'wikidata', 'wikimedia_commons'] as const;
|
||||
|
||||
export interface WikiIdentity {
|
||||
wikipedia: string | null;
|
||||
wikidata: string | null;
|
||||
wikimedia_commons: string | null;
|
||||
}
|
||||
export { readBrandIdentity, readWikiIdentity, type WikiIdentity } from './providers/wiki-identity';
|
||||
|
||||
interface WikidataSnak {
|
||||
mainsnak?: { datavalue?: { value?: string } };
|
||||
@@ -205,44 +160,6 @@ function normalizeCategoryName(raw: string): string | null {
|
||||
return value.replace(/^(category|kategorie|categorie|categoría|categoria)\s*:/i, '').trim() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chain a place belongs to, when it belongs to one.
|
||||
*
|
||||
* Read separately from `readWikiIdentity` and never mixed into it. Following
|
||||
* `brand:wikidata` as if it described the place is how "L'Osteria Rostock"
|
||||
* ends up illustrated with the company logo and described as a franchise
|
||||
* operator — which is why the picture ladder never sees these. For a
|
||||
* description they are still worth something: a branch of a chain has no
|
||||
* article of its own and never will, and "L'Osteria is a German restaurant
|
||||
* chain serving pizza and pasta" beats an empty column, as long as the reader
|
||||
* is told that is what they are looking at.
|
||||
*/
|
||||
export function readBrandIdentity(extratags: Record<string, string> | null | undefined): {
|
||||
wikidata: string | null;
|
||||
wikipedia: string | null;
|
||||
} {
|
||||
const read = (key: string): string | null => {
|
||||
const value = extratags?.[key];
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
};
|
||||
return { wikidata: read('brand:wikidata'), wikipedia: read('brand:wikipedia') };
|
||||
}
|
||||
|
||||
/** Picks the three identity tags out of a Nominatim `extratags` blob. */
|
||||
export function readWikiIdentity(extratags: Record<string, string> | null | undefined): WikiIdentity {
|
||||
const out: WikiIdentity = { wikipedia: null, wikidata: null, wikimedia_commons: null };
|
||||
if (!extratags) return out;
|
||||
for (const tag of WIKI_IDENTITY_TAGS) {
|
||||
const value = extratags[tag];
|
||||
if (typeof value === 'string' && value.trim()) out[tag] = value.trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface OverpassElement {
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface WikiCommonsPage {
|
||||
pageid?: number;
|
||||
title?: string;
|
||||
@@ -424,17 +341,6 @@ async function flattenBrandLogo(bytes: Buffer): Promise<BrandLogo | null> {
|
||||
// day at the provider rather than a slow answer worth waiting for.
|
||||
const IDENTITY_TIMEOUT_MS = 2500;
|
||||
|
||||
// The explicit search asks Nominatim alongside the index, and the pair costs
|
||||
// the slower one. The index gives up after 3.5 s and the browser after 8 s, so a
|
||||
// Nominatim that accepts the connection and then sits on it (the public service
|
||||
// under load, a self-hosted one that hung) has to give up in between: without a
|
||||
// deadline of its own the request rides undici's 300 s default, and the index's
|
||||
// answer is thrown away with the request that timed out waiting for it. The
|
||||
// deadline starts after the throttle wait, so it measures the answer alone:
|
||||
// generous for a slow one, still inside the browser's budget with the wait
|
||||
// added on.
|
||||
const SEARCH_TIMEOUT_MS = 6000;
|
||||
|
||||
const MAX_CONCURRENT_PHOTO_FETCHES = 5;
|
||||
let photoFetchActive = 0;
|
||||
const photoFetchQueue: Array<() => void> = [];
|
||||
@@ -471,49 +377,6 @@ export async function withPhotoFetchSlot<T>(fn: () => Promise<T>): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Overpass POI search state ────────────────────────────────────────────────
|
||||
|
||||
interface OverpassPoiElement {
|
||||
type: string;
|
||||
id: number;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
center?: { lat: number; lon: number };
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface PoiSearchResult {
|
||||
pois: OverpassPoi[];
|
||||
/**
|
||||
* Which index answered. Not decoration: this branch calls naming the sources
|
||||
* a licence obligation rather than a courtesy, and Overture carries more than
|
||||
* OpenStreetMap under more than one licence, so stamping its rows as OSM is a
|
||||
* wrong attribution rather than a rounding of one.
|
||||
*/
|
||||
source: 'openstreetmap' | 'trek-places';
|
||||
truncated: boolean;
|
||||
// True when the requested viewport was too large and got shrunk to a centred
|
||||
// window before querying — the results then cover the middle of the view only.
|
||||
clamped: boolean;
|
||||
}
|
||||
|
||||
// Frozen at module load, same timing as the legacy service ("frozen on purpose",
|
||||
// see src/app-config/README.md).
|
||||
const OVERPASS_MIRRORS = resolveOverpassEndpoints();
|
||||
const OVERPASS_TIMEOUT_MS = resolveOverpassTimeoutMs();
|
||||
// Short-lived cache so panning back over / re-toggling the same area doesn't
|
||||
// re-hit Overpass. Keyed by category + rounded (post-clamp) bbox.
|
||||
const POI_CACHE = new Map<string, { at: number; value: PoiSearchResult }>();
|
||||
const POI_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
// Cap the number of cached areas so panning across the globe can't grow the map
|
||||
// without bound (entries are evicted oldest-first once the cap is reached).
|
||||
const POI_CACHE_MAX = 500;
|
||||
/**
|
||||
* Hard ceiling on one POI answer, however many categories it carries. A corridor search
|
||||
* asks for four kinds at once; without a ceiling a dense city box would return a payload
|
||||
* nobody reads and every mirror pays for.
|
||||
*/
|
||||
const POI_RESULT_CAP = 240;
|
||||
// Places near a point (#976): cached longer than the POI boxes, because the
|
||||
// likely caller is an import asking the same photo location again, and Google
|
||||
// bills every one of those.
|
||||
@@ -521,68 +384,6 @@ const NEARBY_CACHE = new Map<string, { at: number; value: { places: Record<strin
|
||||
const NEARBY_CACHE_TTL_MS = 30 * 60 * 1000;
|
||||
const NEARBY_CACHE_MAX = 500;
|
||||
|
||||
// POST the query to all mirrors at once and return the first one that answers with
|
||||
// valid JSON. Throws {status:502} only if every mirror fails. Racing (rather than
|
||||
// trying one-by-one) keeps latency at the fastest reachable mirror instead of the
|
||||
// sum of every dead mirror's timeout.
|
||||
async function overpassFetch(query: string): Promise<OverpassPoiElement[]> {
|
||||
const body = `data=${encodeURIComponent(query)}`;
|
||||
const controllers: AbortController[] = [];
|
||||
|
||||
const attempt = async (url: string): Promise<OverpassPoiElement[]> => {
|
||||
const ctrl = new AbortController();
|
||||
controllers.push(ctrl);
|
||||
const timer = setTimeout(() => ctrl.abort(), OVERPASS_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Overpass ${res.status} @ ${url}`);
|
||||
const data = (await res.json()) as { elements?: OverpassPoiElement[]; remark?: string };
|
||||
// Overpass signals an internal timeout / runtime error via `remark` while
|
||||
// still answering HTTP 200 — often fast, with an empty or partial element
|
||||
// set. Treat that as a failed attempt so a healthy mirror wins the race
|
||||
// instead of this fast-but-empty answer, and so the all-mirrors-failed path
|
||||
// still surfaces a real error to the client instead of a silent "no places".
|
||||
if (data.remark) throw new Error(`Overpass remark @ ${url}: ${data.remark}`);
|
||||
if (!Array.isArray(data.elements)) throw new Error(`Overpass non-OSM body @ ${url}`);
|
||||
return data.elements;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Promise.any resolves with the first mirror to return valid JSON, and only
|
||||
// rejects (AggregateError) once every mirror has failed.
|
||||
return await Promise.any(OVERPASS_MIRRORS.map(attempt));
|
||||
} catch (err) {
|
||||
// Log WHY every endpoint failed (connection refused, aborted/timed out, non-OSM
|
||||
// body, …) so an operator can tell blocked egress / a firewall from a transiently
|
||||
// overloaded mirror — otherwise this is a bare 502 with no breadcrumb (see #1309).
|
||||
const reasons =
|
||||
err instanceof AggregateError
|
||||
? err.errors.map((e) => (e instanceof Error ? e.message : String(e))).join(' | ')
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
console.error(`[Overpass] all ${OVERPASS_MIRRORS.length} endpoint(s) failed — ${reasons}`);
|
||||
throw Object.assign(new Error('Could not reach any Overpass endpoint'), { status: 502 });
|
||||
} finally {
|
||||
// Cancel the slower/losing requests — we already have (or have given up on) a result.
|
||||
controllers.forEach((c) => {
|
||||
try {
|
||||
c.abort();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type LocationBias = { low: { lat: number; lng: number }; high: { lat: number; lng: number } };
|
||||
|
||||
/**
|
||||
@@ -650,6 +451,7 @@ export class MapsService {
|
||||
@InjectRepository(Places) private readonly placesRepo: PlacesRepository,
|
||||
private readonly googleQuota: GoogleQuotaService,
|
||||
private readonly googlePlaces: GooglePlacesClient,
|
||||
private readonly osm: OsmClient,
|
||||
) {}
|
||||
|
||||
/** Brand id → logo bytes, or null for "asked, has none". Insertion-ordered, so the
|
||||
@@ -950,7 +752,7 @@ export class MapsService {
|
||||
console.warn('TREK Places nearby failed, falling back:', (err as Error).message);
|
||||
}
|
||||
}
|
||||
return this.searchOverpassPois(category, bbox, lang, limit);
|
||||
return this.osm.searchOverpassPois(category, bbox, lang, limit);
|
||||
}
|
||||
|
||||
// ── API key retrieval ──────────────────────────────────────────────────────
|
||||
@@ -1123,357 +925,10 @@ export class MapsService {
|
||||
console.warn('TREK Places geocode failed, falling back:', (err as Error).message);
|
||||
}
|
||||
}
|
||||
const hit = (await this.searchNominatim(query, undefined, 'background'))[0];
|
||||
const hit = (await this.osm.searchNominatim(query, undefined, 'background'))[0];
|
||||
return hit?.lat != null && hit?.lng != null ? { lat: hit.lat, lng: hit.lng } : null;
|
||||
}
|
||||
|
||||
// ── Nominatim search ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `lane` defaults to interactive because most callers are a keystroke.
|
||||
*
|
||||
* Bulk callers must pass 'background': booking-import geocodes every venue and
|
||||
* every uncoordinated endpoint of an import in one request loop, which is up
|
||||
* to thirty sequential calls. On the interactive lane those thirty take the
|
||||
* next slot each time, so somebody typing in the place search waits behind the
|
||||
* whole import. Yielding does not make the import faster, it stops it from
|
||||
* being the only thing the process will do for half a minute.
|
||||
*/
|
||||
async searchNominatim(
|
||||
query: string,
|
||||
lang?: string,
|
||||
lane: GeoLane = 'interactive',
|
||||
bias?: { lat: number; lng: number },
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
format: 'json',
|
||||
addressdetails: '1',
|
||||
// Free, same request: this is where a place's wikidata/wikipedia/commons
|
||||
// tags live. Without them the enrichment column can only fall back to
|
||||
// "photos taken within 300m", which around a city centre is passers-by
|
||||
// and the neighbouring building.
|
||||
extratags: '1',
|
||||
limit: '10',
|
||||
'accept-language': toApiLang(lang),
|
||||
});
|
||||
// Prefer the area the caller is looking at, never restrict to it
|
||||
// (bounded=0). Without this, "Hase-dera" returns the temple of that name in
|
||||
// Nara rather than the one in Kamakura the user is standing next to; with
|
||||
// bounded=1 a search for somewhere genuinely far away would return nothing.
|
||||
if (bias) {
|
||||
const d = 0.5;
|
||||
params.set('viewbox', [bias.lng - d, bias.lat - d, bias.lng + d, bias.lat + d].join(','));
|
||||
params.set('bounded', '0');
|
||||
}
|
||||
// Through the shared client: one throttle for the whole process.
|
||||
const response = await nominatimFetch('search', params, { lane, timeoutMs: SEARCH_TIMEOUT_MS });
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Nominatim API error: ${response.status} ${response.statusText}${text ? ' - ' + text.substring(0, 200) : ''}`,
|
||||
);
|
||||
}
|
||||
const data = (await response.json()) as NominatimResult[];
|
||||
return data.map((item) => {
|
||||
// Number.isFinite, not `|| null`: a place on the equator or prime
|
||||
// meridian has a legitimate 0 coordinate.
|
||||
const lat = Number.parseFloat(item.lat);
|
||||
const lng = Number.parseFloat(item.lon);
|
||||
return {
|
||||
google_place_id: null,
|
||||
google_ftid: null,
|
||||
osm_id: `${item.osm_type}:${item.osm_id}`,
|
||||
name: item.name || item.display_name?.split(',')[0] || '',
|
||||
address: item.display_name || '',
|
||||
lat: Number.isFinite(lat) ? lat : null,
|
||||
lng: Number.isFinite(lng) ? lng : null,
|
||||
rating: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
// What the place is, so the add form can preselect a category (#2282). A shop's
|
||||
// value is the ware ("bakery"), so the key goes first and says it is a shop.
|
||||
category: nominatimCategory(item),
|
||||
source: 'openstreetmap',
|
||||
...readWikiIdentity(item.extratags),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the OpenStreetMap record for a place we only know by name and
|
||||
* coordinate, and hands back its tags.
|
||||
*
|
||||
* This is what gives a Google place a free identity. Google's payload has no
|
||||
* `wikidata`, no `wikipedia` and no `wikimedia_commons` — it never had — so
|
||||
* without this the entire free half of the enrichment column is unreachable
|
||||
* for anyone who configured a Google key, which is the opposite of how this
|
||||
* feature is meant to work. OSM knows these places perfectly well; nobody was
|
||||
* asking it.
|
||||
*
|
||||
* Two gates keep it from describing the wrong building, because a confident
|
||||
* description of somewhere else is worse than none:
|
||||
* - the match has to be within `maxDistanceM` of where we are looking, and
|
||||
* - it has to share a substantial word with the name we are looking for.
|
||||
* Among what survives, Nominatim's own `importance` decides — that is what
|
||||
* separates the Brandenburg Gate from the underground station named after it.
|
||||
*/
|
||||
async resolveOsmIdentity(
|
||||
name: string,
|
||||
lat: number,
|
||||
lng: number,
|
||||
opts: { lang?: string; maxDistanceM?: number; signal?: AbortSignal } = {},
|
||||
): Promise<{ tags: Record<string, string>; osmUrl: string | null; matchedName: string } | null> {
|
||||
const query = (name || '').trim();
|
||||
if (!query || !Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||||
const maxDistanceM = opts.maxDistanceM ?? 2000;
|
||||
|
||||
// ~2km box around the point, so Nominatim ranks locally instead of handing
|
||||
// back the most famous place on earth with this name.
|
||||
const d = 0.02;
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
format: 'jsonv2',
|
||||
extratags: '1',
|
||||
limit: '5',
|
||||
bounded: '1',
|
||||
viewbox: `${lng - d},${lat - d},${lng + d},${lat + d}`,
|
||||
'accept-language': toApiLang(opts.lang),
|
||||
});
|
||||
|
||||
try {
|
||||
// The caller's deadline is handed in rather than built here, so the
|
||||
// throttle wait cannot eat it before the request starts.
|
||||
const res = await nominatimFetch('search', params, {
|
||||
signal: opts.signal,
|
||||
timeoutMs: IDENTITY_TIMEOUT_MS,
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
// Nominatim answers rate limiting in plain text, not JSON.
|
||||
const data = (await res.json()) as (NominatimResult & { importance?: number })[];
|
||||
if (!Array.isArray(data)) return null;
|
||||
|
||||
const best = data
|
||||
.map((item) => ({
|
||||
item,
|
||||
lat: Number.parseFloat(item.lat),
|
||||
lng: Number.parseFloat(item.lon),
|
||||
}))
|
||||
.filter(({ item, lat: hitLat, lng: hitLng }) => {
|
||||
if (!Number.isFinite(hitLat) || !Number.isFinite(hitLng)) return false;
|
||||
if (haversineMetres(lat, lng, hitLat, hitLng) > maxDistanceM) return false;
|
||||
const label = item.name || item.display_name?.split(',')[0] || '';
|
||||
return namesOverlap(query, label);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const byImportance = (b.item.importance ?? 0) - (a.item.importance ?? 0);
|
||||
if (byImportance !== 0) return byImportance;
|
||||
return (
|
||||
haversineMetres(lat, lng, a.lat, a.lng) - haversineMetres(lat, lng, b.lat, b.lng)
|
||||
);
|
||||
})[0];
|
||||
|
||||
if (!best) return null;
|
||||
return {
|
||||
tags: best.item.extratags ?? {},
|
||||
osmUrl:
|
||||
best.item.osm_type && best.item.osm_id
|
||||
? `https://www.openstreetmap.org/${best.item.osm_type}/${best.item.osm_id}`
|
||||
: null,
|
||||
matchedName: best.item.name || best.item.display_name?.split(',')[0] || query,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Nominatim lookup (by OSM ID) ───────────────────────────────────────────
|
||||
|
||||
async lookupNominatim(
|
||||
osmType: string,
|
||||
osmId: string,
|
||||
lang?: string,
|
||||
): Promise<{
|
||||
name: string;
|
||||
address: string;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
extratags: Record<string, string> | null;
|
||||
} | null> {
|
||||
const typePrefix = osmType.charAt(0).toUpperCase(); // N, W, R
|
||||
const params = new URLSearchParams({
|
||||
osm_ids: `${typePrefix}${osmId}`,
|
||||
format: 'json',
|
||||
// Overpass is the richer source but it is also the one that times out;
|
||||
// whatever Nominatim already knows costs nothing extra here.
|
||||
extratags: '1',
|
||||
'accept-language': toApiLang(lang),
|
||||
});
|
||||
try {
|
||||
const res = await nominatimFetch('lookup', params);
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as NominatimResult[];
|
||||
const item = data[0];
|
||||
if (!item) return null;
|
||||
const lat = Number.parseFloat(item.lat);
|
||||
const lng = Number.parseFloat(item.lon);
|
||||
return {
|
||||
name: item.name || item.display_name?.split(',')[0] || '',
|
||||
address: item.display_name || '',
|
||||
lat: Number.isFinite(lat) ? lat : null,
|
||||
lng: Number.isFinite(lng) ? lng : null,
|
||||
extratags: item.extratags ?? null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Overpass API (OSM details) ─────────────────────────────────────────────
|
||||
|
||||
async fetchOverpassDetails(osmType: string, osmId: string): Promise<OverpassElement | null> {
|
||||
const typeMap: Record<string, string> = { node: 'node', way: 'way', relation: 'rel' };
|
||||
const oType = typeMap[osmType];
|
||||
if (!oType) return null;
|
||||
// The id is the one thing written into the query, and the query is a
|
||||
// language. An OSM element id is a number and nothing else; anything with
|
||||
// more in it is a statement of its own, sent under TREK's shared user agent.
|
||||
if (!/^\d+$/.test(osmId)) return null;
|
||||
const query = `[out:json][timeout:5];${oType}(${osmId});out tags;`;
|
||||
try {
|
||||
const res = await fetch('https://overpass-api.de/api/interpreter', {
|
||||
method: 'POST',
|
||||
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `data=${encodeURIComponent(query)}`,
|
||||
// The query asks Overpass for 5 s; a public instance that is overloaded
|
||||
// takes the connection and then sits on it.
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as { elements?: OverpassElement[] };
|
||||
return data.elements?.[0] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Overpass POI search (by category within a viewport bbox) ───────────────
|
||||
// Powers the "explore places on the map" pill. OSM-ONLY by design — this never
|
||||
// calls Google, even when a Google key is configured.
|
||||
|
||||
async searchOverpassPois(
|
||||
category: string,
|
||||
bbox: { south: number; west: number; north: number; east: number },
|
||||
lang?: string,
|
||||
limit = 60,
|
||||
): Promise<PoiSearchResult> {
|
||||
// One or several categories in one query. Each OSM selector belongs to exactly one
|
||||
// category, so a hit can still be labelled with the category it answered.
|
||||
const categories = parsePoiCategories(category);
|
||||
if (!categories.length) throw Object.assign(new Error('Unknown POI category'), { status: 400 });
|
||||
const categoryOfFilter = new Map<string, string>();
|
||||
for (const key of categories) {
|
||||
const own = CATEGORY_OSM_FILTERS[key];
|
||||
if (!own) throw Object.assign(new Error('Unknown POI category'), { status: 400 });
|
||||
for (const f of own) categoryOfFilter.set(f, key);
|
||||
}
|
||||
const filters = [...categoryOfFilter.keys()];
|
||||
// Each category gets its own share of the cap, or a mixed search would spend the
|
||||
// whole budget on whichever kind happens to be densest.
|
||||
const cap = Math.min(limit * categories.length, POI_RESULT_CAP);
|
||||
|
||||
// Clamp an oversized viewport to a centred window so the query stays cheap and
|
||||
// returns fast at any zoom, instead of timing out / 502-ing on a huge area.
|
||||
const searchWindow = clampPoiBbox(bbox);
|
||||
const { south, west, north, east } = searchWindow.bbox;
|
||||
const clamped = searchWindow.clamped;
|
||||
|
||||
// OSM `name:*` tags are keyed by language subtag: prefer the user's language
|
||||
// (the same localization the search/autocomplete path asks the geocoder for)
|
||||
// over the native `name`. `int_name` is OSM's international/romanized name — a
|
||||
// sensible fallback before the native one. Part of the cache key so a cached
|
||||
// area isn't served with another language's titles.
|
||||
const osmLang = toApiLang(lang).split('-')[0].toLowerCase();
|
||||
|
||||
// Serve repeat pans/toggles of the same area straight from the cache.
|
||||
const cacheKey = `${[...categories].sort((a, b) => a.localeCompare(b)).join('+')}|${osmLang}|${south.toFixed(2)},${west.toFixed(2)},${north.toFixed(2)},${east.toFixed(2)}|${cap}`;
|
||||
const cached = POI_CACHE.get(cacheKey);
|
||||
if (cached && Date.now() - cached.at < POI_CACHE_TTL_MS) return cached.value;
|
||||
if (cached) POI_CACHE.delete(cacheKey); // expired — drop it before refetching
|
||||
|
||||
// Overpass wants the box as (south,west,north,east) = (minLat,minLng,maxLat,maxLng).
|
||||
const box = `(${south},${west},${north},${east})`;
|
||||
const selectors = filters
|
||||
.map((f) => {
|
||||
const [k, v] = f.split('=');
|
||||
return ` nwr["${k}"="${v}"]${box};`;
|
||||
})
|
||||
.join('\n');
|
||||
// `out center tags <n>` returns ways/relations with a computed center and caps
|
||||
// the result count in one round-trip.
|
||||
const query = `[out:json][timeout:${OVERPASS_QUERY_TIMEOUT_S}];\n(\n${selectors}\n);\nout center tags ${cap + 25};`;
|
||||
|
||||
const elements = await overpassFetch(query);
|
||||
|
||||
const pois: OverpassPoi[] = [];
|
||||
for (const el of elements) {
|
||||
const tags = el.tags || {};
|
||||
// `operator` comes last but matters for the road categories: petrol stations,
|
||||
// charging points and service areas are routinely mapped with an operator and no
|
||||
// name, and dropping those would empty the road trip corridor over long stretches.
|
||||
const name =
|
||||
tags[`name:${osmLang}`] || tags['int_name'] || tags.name || tags.brand || tags.operator || null;
|
||||
if (!name) continue; // unnamed POIs aren't useful to add to a plan
|
||||
// A shut-down place is not somewhere to plan a visit (#1341). OSM usually
|
||||
// re-tags one with a `disused:`/`abandoned:` prefix, and those never match
|
||||
// the selectors above — but plenty keep their original tag and gain a marker
|
||||
// instead, and those do come back. `opening_hours=closed`/`off` is the same
|
||||
// statement in the hours field.
|
||||
if (tags.disused === 'yes' || tags.abandoned === 'yes') continue;
|
||||
if (tags.opening_hours === 'closed' || tags.opening_hours === 'off') continue;
|
||||
const lat = el.lat ?? el.center?.lat;
|
||||
const lng = el.lon ?? el.center?.lon;
|
||||
if (lat == null || lng == null) continue;
|
||||
const matched =
|
||||
filters.find((f) => {
|
||||
const [k, v] = f.split('=');
|
||||
return tags[k] === v;
|
||||
}) || filters[0];
|
||||
const addr =
|
||||
[tags['addr:street'], tags['addr:housenumber'], tags['addr:postcode'], tags['addr:city']]
|
||||
.filter(Boolean)
|
||||
.join(' ') || null;
|
||||
pois.push({
|
||||
osm_id: `${el.type}:${el.id}`,
|
||||
name,
|
||||
lat,
|
||||
lng,
|
||||
category: categoryOfFilter.get(matched) ?? categories[0],
|
||||
poi_type: matched,
|
||||
address: addr,
|
||||
website: normalizePlaceWebsite(tags.website) ?? normalizePlaceWebsite(tags['contact:website']),
|
||||
phone: tags.phone || tags['contact:phone'] || null,
|
||||
opening_hours: tags.opening_hours || null,
|
||||
cuisine: tags.cuisine || null,
|
||||
brand: tags.brand || tags.operator || null,
|
||||
// Only the plain Q-id form is passed on; anything else would be a lookup we
|
||||
// would have to guess at.
|
||||
brand_wikidata: /^Q[0-9]+$/.test(tags['brand:wikidata'] || '') ? tags['brand:wikidata'] : null,
|
||||
// Only where it means something. Every POI carries `capacity` and `fee` for its
|
||||
// own reasons — a restaurant's capacity is seats — so reading them as charging
|
||||
// data anywhere else would be wrong on most of the map.
|
||||
charging: categoryOfFilter.get(matched) === 'charging' ? readChargingInfo(tags) : null,
|
||||
source: 'openstreetmap',
|
||||
});
|
||||
}
|
||||
const truncated = pois.length > cap;
|
||||
const value: PoiSearchResult = { pois: pois.slice(0, cap), source: 'openstreetmap', truncated, clamped };
|
||||
// FIFO eviction: a Map preserves insertion order, so the first key is the oldest.
|
||||
if (POI_CACHE.size >= POI_CACHE_MAX) POI_CACHE.delete(POI_CACHE.keys().next().value as string);
|
||||
POI_CACHE.set(cacheKey, { at: Date.now(), value });
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── Wikimedia Commons photo lookup ─────────────────────────────────────────
|
||||
|
||||
async fetchWikimediaPhoto(
|
||||
@@ -2026,7 +1481,7 @@ export class MapsService {
|
||||
console.warn('TREK Places search failed, falling back:', (err as Error).message);
|
||||
return [] as TrekPlace[];
|
||||
}),
|
||||
this.searchNominatim(query, lang, 'interactive', locationBias).catch((err: unknown) => {
|
||||
this.osm.searchNominatim(query, lang, 'interactive', locationBias).catch((err: unknown) => {
|
||||
console.warn('OpenStreetMap search failed, index only:', (err as Error).message);
|
||||
return [] as Record<string, unknown>[];
|
||||
}),
|
||||
@@ -2059,7 +1514,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.searchNominatim(query, lang));
|
||||
const places = osmAnswer ?? (await this.osm.searchNominatim(query, lang));
|
||||
return { places, source: 'openstreetmap' };
|
||||
}
|
||||
|
||||
@@ -2124,8 +1579,7 @@ export class MapsService {
|
||||
}
|
||||
|
||||
const osmLang = lang.split('-')[0].toLowerCase();
|
||||
const elements = await overpassFetch(nearbyOverpassQuery(origin.lat, origin.lng, radius, limit));
|
||||
return { places: overpassNearbyRecords(elements, origin, osmLang, limit), source: 'openstreetmap' };
|
||||
return { places: await this.osm.nearby(origin, radius, limit, osmLang), source: 'openstreetmap' };
|
||||
}
|
||||
|
||||
// ── Autocomplete (Google or Nominatim fallback) ────────────────────────────
|
||||
@@ -2236,7 +1690,7 @@ export class MapsService {
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return this.autocompleteNominatim(input, lang);
|
||||
return this.osm.autocompleteNominatim(input, lang);
|
||||
}
|
||||
|
||||
const google = this.googlePlaces.provider({ key: apiKey, source: keySource, userId });
|
||||
@@ -2244,30 +1698,6 @@ export class MapsService {
|
||||
return { suggestions, source: 'google' };
|
||||
}
|
||||
|
||||
private async autocompleteNominatim(
|
||||
input: string,
|
||||
lang?: string,
|
||||
): Promise<MapsAutocompleteResult> {
|
||||
try {
|
||||
const places = await this.searchNominatim(input, lang);
|
||||
const suggestions = places
|
||||
.filter((p) => p.osm_id && p.osm_id.includes(':') && p.osm_id.split(':')[1] !== '')
|
||||
.slice(0, 5)
|
||||
.map((p) => {
|
||||
const parts = (p.address || '').split(',').map((s) => s.trim());
|
||||
return {
|
||||
placeId: p.osm_id,
|
||||
mainText: p.name || parts[0] || '',
|
||||
secondaryText: parts.slice(1).join(', '),
|
||||
};
|
||||
});
|
||||
return { suggestions, source: 'nominatim' };
|
||||
} catch (err) {
|
||||
console.error('Nominatim autocomplete failed:', err);
|
||||
return { suggestions: [], source: 'nominatim' };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Place details (Google or OSM) ──────────────────────────────────────────
|
||||
|
||||
async getPlaceDetails(
|
||||
@@ -2296,7 +1726,7 @@ export class MapsService {
|
||||
// resolveOsmIdentity applies everywhere: within range, and sharing a
|
||||
// substantial word of the name. A confident description of the building
|
||||
// next door is worse than none.
|
||||
const osm = await this.resolveOsmIdentity(found.name, found.lat, found.lng, {
|
||||
const osm = await this.osm.resolveOsmIdentity(found.name, found.lat, found.lng, {
|
||||
lang,
|
||||
maxDistanceM: 150,
|
||||
}).catch(() => null);
|
||||
@@ -2364,8 +1794,8 @@ export class MapsService {
|
||||
// buildOsmDetails never yields name/address/coordinates — Nominatim is
|
||||
// always the source for those (Overpass contributes the tag-derived rest).
|
||||
const [element, nominatim] = await Promise.all([
|
||||
this.fetchOverpassDetails(osmType, osmId),
|
||||
this.lookupNominatim(osmType, osmId, lang),
|
||||
this.osm.fetchOverpassDetails(osmType, osmId),
|
||||
this.osm.lookupNominatim(osmType, osmId, lang),
|
||||
]);
|
||||
// Overpass has the fuller tag set and wins where both answer, but it is
|
||||
// also the one that goes down — overpass-api.de is regularly overloaded.
|
||||
@@ -2658,22 +2088,7 @@ export class MapsService {
|
||||
}
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
lat,
|
||||
lon: lng,
|
||||
format: 'json',
|
||||
addressdetails: '1',
|
||||
zoom: opts?.locality ? '10' : '18',
|
||||
'accept-language': toApiLang(lang),
|
||||
});
|
||||
const response = await nominatimFetch('reverse', params, opts);
|
||||
if (!response.ok) return { name: null, address: null };
|
||||
const data = (await response.json()) as { name?: string; display_name?: string; address?: Record<string, string> };
|
||||
const addr = data.address || {};
|
||||
const name = opts?.locality
|
||||
? addr.city || addr.town || addr.village || addr.municipality || data.name || null
|
||||
: data.name || addr.tourism || addr.amenity || addr.shop || addr.building || addr.road || null;
|
||||
return { name, address: data.display_name || null };
|
||||
return this.osm.reverse(lat, lng, lang, opts);
|
||||
}
|
||||
|
||||
// ── Resolve Google Maps URL ────────────────────────────────────────────────
|
||||
@@ -2783,14 +2198,7 @@ export class MapsService {
|
||||
// Reverse geocode to get address. A non-ok answer (Nominatim 5xx/429) must
|
||||
// not fail the whole resolution — the coordinates are already extracted, so
|
||||
// fall back to the URL-derived name and a null address.
|
||||
const nominatimRes = await nominatimFetch(
|
||||
'reverse',
|
||||
new URLSearchParams({ lat: String(lat), lon: String(lng), format: 'json', addressdetails: '1' }),
|
||||
{ timeoutMs: 8000 },
|
||||
);
|
||||
const nominatim: { display_name?: string; name?: string; address?: Record<string, string> } = nominatimRes.ok
|
||||
? await nominatimRes.json()
|
||||
: {};
|
||||
const nominatim = await this.osm.reverseRaw(lat, lng);
|
||||
|
||||
const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null;
|
||||
const address = nominatim.display_name || null;
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
/**
|
||||
* OpenStreetMap: Nominatim for names and coordinates, Overpass for tags and
|
||||
* points of interest.
|
||||
*
|
||||
* The keyless floor every install falls back to, and deliberately NOT a
|
||||
* PlacesProvider (see places-provider.ts): it has no credential, and its
|
||||
* details path is a Nominatim/Overpass merge rather than one lookup. MapsService
|
||||
* decides when OpenStreetMap is asked; this client only knows how to ask it,
|
||||
* through the shared Nominatim client (one throttle for the whole process) and
|
||||
* the Overpass mirrors the operator configured.
|
||||
*
|
||||
* The POI cache and the mirror list are module state on purpose, frozen at load
|
||||
* the way they were in the service ("frozen on purpose", see
|
||||
* src/app-config/README.md), so every instance shares one cache.
|
||||
*
|
||||
* Never imports MapsService: providers sit below the orchestrator
|
||||
* (lint:boundaries holds that).
|
||||
*/
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { normalizePlaceWebsite } from '@trek/shared';
|
||||
import { nominatimFetch, type GeoLane } from '../../geo/nominatim.client';
|
||||
import {
|
||||
UA,
|
||||
toApiLang,
|
||||
CATEGORY_OSM_FILTERS,
|
||||
parsePoiCategories,
|
||||
clampPoiBbox,
|
||||
resolveOverpassEndpoints,
|
||||
resolveOverpassTimeoutMs,
|
||||
OVERPASS_QUERY_TIMEOUT_S,
|
||||
haversineMetres,
|
||||
namesOverlap,
|
||||
readChargingInfo,
|
||||
type OverpassPoi,
|
||||
} from '../maps.helpers';
|
||||
import { nearbyOverpassQuery, overpassNearbyRecords } from '../maps-nearby.helpers';
|
||||
import { readWikiIdentity } from './wiki-identity';
|
||||
|
||||
function nominatimCategory(item: { class?: string; type?: string }): string | null {
|
||||
if (!item.class || !item.type || item.type === 'yes') return null;
|
||||
return item.class === 'shop' ? `shop_${item.type}` : item.type;
|
||||
}
|
||||
|
||||
interface NominatimResult {
|
||||
osm_type: string;
|
||||
osm_id: string;
|
||||
/** The main OSM tag, key and value: `class` "tourism" with `type` "hotel". */
|
||||
class?: string;
|
||||
type?: string;
|
||||
name?: string;
|
||||
display_name?: string;
|
||||
lat: string;
|
||||
lon: string;
|
||||
extratags?: Record<string, string> | null;
|
||||
}
|
||||
|
||||
export interface OverpassElement {
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface OverpassPoiElement {
|
||||
type: string;
|
||||
id: number;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
center?: { lat: number; lon: number };
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface PoiSearchResult {
|
||||
pois: OverpassPoi[];
|
||||
/**
|
||||
* Which index answered. Not decoration: this branch calls naming the sources
|
||||
* a licence obligation rather than a courtesy, and Overture carries more than
|
||||
* OpenStreetMap under more than one licence, so stamping its rows as OSM is a
|
||||
* wrong attribution rather than a rounding of one.
|
||||
*/
|
||||
source: 'openstreetmap' | 'trek-places';
|
||||
truncated: boolean;
|
||||
// True when the requested viewport was too large and got shrunk to a centred
|
||||
// window before querying — the results then cover the middle of the view only.
|
||||
clamped: boolean;
|
||||
}
|
||||
|
||||
/** A Nominatim reverse answer as it came, for the callers that pick their own fields. */
|
||||
export interface NominatimReverse {
|
||||
display_name?: string;
|
||||
name?: string;
|
||||
address?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Tighter than the wiki calls, because this one sits at the FRONT of a chain:
|
||||
// identity, then sitelinks, then the extract. Nominatim answers a bounded
|
||||
// search in 0.2-0.7s in practice, so anything past a couple of seconds is a bad
|
||||
// day at the provider rather than a slow answer worth waiting for.
|
||||
const IDENTITY_TIMEOUT_MS = 2500;
|
||||
|
||||
// The explicit search asks Nominatim alongside the index, and the pair costs
|
||||
// the slower one. The index gives up after 3.5 s and the browser after 8 s, so a
|
||||
// Nominatim that accepts the connection and then sits on it (the public service
|
||||
// under load, a self-hosted one that hung) has to give up in between: without a
|
||||
// deadline of its own the request rides undici's 300 s default, and the index's
|
||||
// answer is thrown away with the request that timed out waiting for it. The
|
||||
// deadline starts after the throttle wait, so it measures the answer alone:
|
||||
// generous for a slow one, still inside the browser's budget with the wait
|
||||
// added on.
|
||||
const SEARCH_TIMEOUT_MS = 6000;
|
||||
|
||||
// Frozen at module load, same timing as the legacy service ("frozen on purpose",
|
||||
// see src/app-config/README.md).
|
||||
const OVERPASS_MIRRORS = resolveOverpassEndpoints();
|
||||
const OVERPASS_TIMEOUT_MS = resolveOverpassTimeoutMs();
|
||||
// Short-lived cache so panning back over / re-toggling the same area doesn't
|
||||
// re-hit Overpass. Keyed by category + rounded (post-clamp) bbox.
|
||||
const POI_CACHE = new Map<string, { at: number; value: PoiSearchResult }>();
|
||||
const POI_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
// Cap the number of cached areas so panning across the globe can't grow the map
|
||||
// without bound (entries are evicted oldest-first once the cap is reached).
|
||||
const POI_CACHE_MAX = 500;
|
||||
/**
|
||||
* Hard ceiling on one POI answer, however many categories it carries. A corridor search
|
||||
* asks for four kinds at once; without a ceiling a dense city box would return a payload
|
||||
* nobody reads and every mirror pays for.
|
||||
*/
|
||||
export const POI_RESULT_CAP = 240;
|
||||
|
||||
// POST the query to all mirrors at once and return the first one that answers with
|
||||
// valid JSON. Throws {status:502} only if every mirror fails. Racing (rather than
|
||||
// trying one-by-one) keeps latency at the fastest reachable mirror instead of the
|
||||
// sum of every dead mirror's timeout.
|
||||
async function overpassFetch(query: string): Promise<OverpassPoiElement[]> {
|
||||
const body = `data=${encodeURIComponent(query)}`;
|
||||
const controllers: AbortController[] = [];
|
||||
|
||||
const attempt = async (url: string): Promise<OverpassPoiElement[]> => {
|
||||
const ctrl = new AbortController();
|
||||
controllers.push(ctrl);
|
||||
const timer = setTimeout(() => ctrl.abort(), OVERPASS_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Overpass ${res.status} @ ${url}`);
|
||||
const data = (await res.json()) as { elements?: OverpassPoiElement[]; remark?: string };
|
||||
// Overpass signals an internal timeout / runtime error via `remark` while
|
||||
// still answering HTTP 200 — often fast, with an empty or partial element
|
||||
// set. Treat that as a failed attempt so a healthy mirror wins the race
|
||||
// instead of this fast-but-empty answer, and so the all-mirrors-failed path
|
||||
// still surfaces a real error to the client instead of a silent "no places".
|
||||
if (data.remark) throw new Error(`Overpass remark @ ${url}: ${data.remark}`);
|
||||
if (!Array.isArray(data.elements)) throw new Error(`Overpass non-OSM body @ ${url}`);
|
||||
return data.elements;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Promise.any resolves with the first mirror to return valid JSON, and only
|
||||
// rejects (AggregateError) once every mirror has failed.
|
||||
return await Promise.any(OVERPASS_MIRRORS.map(attempt));
|
||||
} catch (err) {
|
||||
// Log WHY every endpoint failed (connection refused, aborted/timed out, non-OSM
|
||||
// body, …) so an operator can tell blocked egress / a firewall from a transiently
|
||||
// overloaded mirror — otherwise this is a bare 502 with no breadcrumb (see #1309).
|
||||
const reasons =
|
||||
err instanceof AggregateError
|
||||
? err.errors.map((e) => (e instanceof Error ? e.message : String(e))).join(' | ')
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
console.error(`[Overpass] all ${OVERPASS_MIRRORS.length} endpoint(s) failed — ${reasons}`);
|
||||
throw Object.assign(new Error('Could not reach any Overpass endpoint'), { status: 502 });
|
||||
} finally {
|
||||
// Cancel the slower/losing requests — we already have (or have given up on) a result.
|
||||
controllers.forEach((c) => {
|
||||
try {
|
||||
c.abort();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OsmClient {
|
||||
// ── Nominatim search ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `lane` defaults to interactive because most callers are a keystroke.
|
||||
*
|
||||
* Bulk callers must pass 'background': booking-import geocodes every venue and
|
||||
* every uncoordinated endpoint of an import in one request loop, which is up
|
||||
* to thirty sequential calls. On the interactive lane those thirty take the
|
||||
* next slot each time, so somebody typing in the place search waits behind the
|
||||
* whole import. Yielding does not make the import faster, it stops it from
|
||||
* being the only thing the process will do for half a minute.
|
||||
*/
|
||||
async searchNominatim(query: string, lang?: string, lane: GeoLane = 'interactive', bias?: { lat: number; lng: number }) {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
format: 'json',
|
||||
addressdetails: '1',
|
||||
// Free, same request: this is where a place's wikidata/wikipedia/commons
|
||||
// tags live. Without them the enrichment column can only fall back to
|
||||
// "photos taken within 300m", which around a city centre is passers-by
|
||||
// and the neighbouring building.
|
||||
extratags: '1',
|
||||
limit: '10',
|
||||
'accept-language': toApiLang(lang),
|
||||
});
|
||||
// Prefer the area the caller is looking at, never restrict to it
|
||||
// (bounded=0). Without this, "Hase-dera" returns the temple of that name in
|
||||
// Nara rather than the one in Kamakura the user is standing next to; with
|
||||
// bounded=1 a search for somewhere genuinely far away would return nothing.
|
||||
if (bias) {
|
||||
const d = 0.5;
|
||||
params.set('viewbox', [bias.lng - d, bias.lat - d, bias.lng + d, bias.lat + d].join(','));
|
||||
params.set('bounded', '0');
|
||||
}
|
||||
// Through the shared client: one throttle for the whole process.
|
||||
const response = await nominatimFetch('search', params, { lane, timeoutMs: SEARCH_TIMEOUT_MS });
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Nominatim API error: ${response.status} ${response.statusText}${text ? ' - ' + text.substring(0, 200) : ''}`,
|
||||
);
|
||||
}
|
||||
const data = (await response.json()) as NominatimResult[];
|
||||
return data.map((item) => {
|
||||
// Number.isFinite, not `|| null`: a place on the equator or prime
|
||||
// meridian has a legitimate 0 coordinate.
|
||||
const lat = Number.parseFloat(item.lat);
|
||||
const lng = Number.parseFloat(item.lon);
|
||||
return {
|
||||
google_place_id: null,
|
||||
google_ftid: null,
|
||||
osm_id: `${item.osm_type}:${item.osm_id}`,
|
||||
name: item.name || item.display_name?.split(',')[0] || '',
|
||||
address: item.display_name || '',
|
||||
lat: Number.isFinite(lat) ? lat : null,
|
||||
lng: Number.isFinite(lng) ? lng : null,
|
||||
rating: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
// What the place is, so the add form can preselect a category (#2282). A shop's
|
||||
// value is the ware ("bakery"), so the key goes first and says it is a shop.
|
||||
category: nominatimCategory(item),
|
||||
source: 'openstreetmap',
|
||||
...readWikiIdentity(item.extratags),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Suggestions out of a Nominatim search, for an install with neither the index nor a key. */
|
||||
async autocompleteNominatim(input: string, lang?: string): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: 'nominatim' }> {
|
||||
try {
|
||||
const places = await this.searchNominatim(input, lang);
|
||||
const suggestions = places
|
||||
.filter((p) => p.osm_id && p.osm_id.includes(':') && p.osm_id.split(':')[1] !== '')
|
||||
.slice(0, 5)
|
||||
.map((p) => {
|
||||
const parts = (p.address || '').split(',').map((s) => s.trim());
|
||||
return {
|
||||
placeId: p.osm_id,
|
||||
mainText: p.name || parts[0] || '',
|
||||
secondaryText: parts.slice(1).join(', '),
|
||||
};
|
||||
});
|
||||
return { suggestions, source: 'nominatim' };
|
||||
} catch (err) {
|
||||
console.error('Nominatim autocomplete failed:', err);
|
||||
return { suggestions: [], source: 'nominatim' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the OpenStreetMap record for a place we only know by name and
|
||||
* coordinate, and hands back its tags.
|
||||
*
|
||||
* This is what gives a Google place a free identity. Google's payload has no
|
||||
* `wikidata`, no `wikipedia` and no `wikimedia_commons` — it never had — so
|
||||
* without this the entire free half of the enrichment column is unreachable
|
||||
* for anyone who configured a Google key, which is the opposite of how this
|
||||
* feature is meant to work. OSM knows these places perfectly well; nobody was
|
||||
* asking it.
|
||||
*
|
||||
* Two gates keep it from describing the wrong building, because a confident
|
||||
* description of somewhere else is worse than none:
|
||||
* - the match has to be within `maxDistanceM` of where we are looking, and
|
||||
* - it has to share a substantial word with the name we are looking for.
|
||||
* Among what survives, Nominatim's own `importance` decides — that is what
|
||||
* separates the Brandenburg Gate from the underground station named after it.
|
||||
*/
|
||||
async resolveOsmIdentity(
|
||||
name: string,
|
||||
lat: number,
|
||||
lng: number,
|
||||
opts: { lang?: string; maxDistanceM?: number; signal?: AbortSignal } = {},
|
||||
): Promise<{ tags: Record<string, string>; osmUrl: string | null; matchedName: string } | null> {
|
||||
const query = (name || '').trim();
|
||||
if (!query || !Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||||
const maxDistanceM = opts.maxDistanceM ?? 2000;
|
||||
|
||||
// ~2km box around the point, so Nominatim ranks locally instead of handing
|
||||
// back the most famous place on earth with this name.
|
||||
const d = 0.02;
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
format: 'jsonv2',
|
||||
extratags: '1',
|
||||
limit: '5',
|
||||
bounded: '1',
|
||||
viewbox: `${lng - d},${lat - d},${lng + d},${lat + d}`,
|
||||
'accept-language': toApiLang(opts.lang),
|
||||
});
|
||||
|
||||
try {
|
||||
// The caller's deadline is handed in rather than built here, so the
|
||||
// throttle wait cannot eat it before the request starts.
|
||||
const res = await nominatimFetch('search', params, {
|
||||
signal: opts.signal,
|
||||
timeoutMs: IDENTITY_TIMEOUT_MS,
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
// Nominatim answers rate limiting in plain text, not JSON.
|
||||
const data = (await res.json()) as (NominatimResult & { importance?: number })[];
|
||||
if (!Array.isArray(data)) return null;
|
||||
|
||||
const best = data
|
||||
.map((item) => ({
|
||||
item,
|
||||
lat: Number.parseFloat(item.lat),
|
||||
lng: Number.parseFloat(item.lon),
|
||||
}))
|
||||
.filter(({ item, lat: hitLat, lng: hitLng }) => {
|
||||
if (!Number.isFinite(hitLat) || !Number.isFinite(hitLng)) return false;
|
||||
if (haversineMetres(lat, lng, hitLat, hitLng) > maxDistanceM) return false;
|
||||
const label = item.name || item.display_name?.split(',')[0] || '';
|
||||
return namesOverlap(query, label);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const byImportance = (b.item.importance ?? 0) - (a.item.importance ?? 0);
|
||||
if (byImportance !== 0) return byImportance;
|
||||
return haversineMetres(lat, lng, a.lat, a.lng) - haversineMetres(lat, lng, b.lat, b.lng);
|
||||
})[0];
|
||||
|
||||
if (!best) return null;
|
||||
return {
|
||||
tags: best.item.extratags ?? {},
|
||||
osmUrl:
|
||||
best.item.osm_type && best.item.osm_id
|
||||
? `https://www.openstreetmap.org/${best.item.osm_type}/${best.item.osm_id}`
|
||||
: null,
|
||||
matchedName: best.item.name || best.item.display_name?.split(',')[0] || query,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Nominatim lookup (by OSM ID) ───────────────────────────────────────────
|
||||
|
||||
async lookupNominatim(
|
||||
osmType: string,
|
||||
osmId: string,
|
||||
lang?: string,
|
||||
): Promise<{
|
||||
name: string;
|
||||
address: string;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
extratags: Record<string, string> | null;
|
||||
} | null> {
|
||||
const typePrefix = osmType.charAt(0).toUpperCase(); // N, W, R
|
||||
const params = new URLSearchParams({
|
||||
osm_ids: `${typePrefix}${osmId}`,
|
||||
format: 'json',
|
||||
// Overpass is the richer source but it is also the one that times out;
|
||||
// whatever Nominatim already knows costs nothing extra here.
|
||||
extratags: '1',
|
||||
'accept-language': toApiLang(lang),
|
||||
});
|
||||
try {
|
||||
const res = await nominatimFetch('lookup', params);
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as NominatimResult[];
|
||||
const item = data[0];
|
||||
if (!item) return null;
|
||||
const lat = Number.parseFloat(item.lat);
|
||||
const lng = Number.parseFloat(item.lon);
|
||||
return {
|
||||
name: item.name || item.display_name?.split(',')[0] || '',
|
||||
address: item.display_name || '',
|
||||
lat: Number.isFinite(lat) ? lat : null,
|
||||
lng: Number.isFinite(lng) ? lng : null,
|
||||
extratags: item.extratags ?? null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Nominatim reverse ──────────────────────────────────────────────────────
|
||||
|
||||
/** The name and address Nominatim gives a point, or nulls when it answers with an error. */
|
||||
async reverse(
|
||||
lat: string,
|
||||
lng: string,
|
||||
lang?: string,
|
||||
opts?: { lane?: GeoLane; timeoutMs?: number; locality?: boolean },
|
||||
): Promise<{ name: string | null; address: string | null }> {
|
||||
const params = new URLSearchParams({
|
||||
lat,
|
||||
lon: lng,
|
||||
format: 'json',
|
||||
addressdetails: '1',
|
||||
zoom: opts?.locality ? '10' : '18',
|
||||
'accept-language': toApiLang(lang),
|
||||
});
|
||||
const response = await nominatimFetch('reverse', params, opts);
|
||||
if (!response.ok) return { name: null, address: null };
|
||||
const data = (await response.json()) as NominatimReverse;
|
||||
const addr = data.address || {};
|
||||
const name = opts?.locality
|
||||
? addr.city || addr.town || addr.village || addr.municipality || data.name || null
|
||||
: data.name || addr.tourism || addr.amenity || addr.shop || addr.building || addr.road || null;
|
||||
return { name, address: data.display_name || null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The raw reverse answer for a point a pasted link carried, in Nominatim's
|
||||
* default language. A non-ok answer (5xx, 429) is an empty object: the
|
||||
* coordinates are already known, so the caller still has something to return.
|
||||
*/
|
||||
async reverseRaw(lat: number, lng: number): Promise<NominatimReverse> {
|
||||
const res = await nominatimFetch(
|
||||
'reverse',
|
||||
new URLSearchParams({ lat: String(lat), lon: String(lng), format: 'json', addressdetails: '1' }),
|
||||
{ timeoutMs: 8000 },
|
||||
);
|
||||
return res.ok ? ((await res.json()) as NominatimReverse) : {};
|
||||
}
|
||||
|
||||
// ── Overpass API (OSM details) ─────────────────────────────────────────────
|
||||
|
||||
async fetchOverpassDetails(osmType: string, osmId: string): Promise<OverpassElement | null> {
|
||||
const typeMap: Record<string, string> = { node: 'node', way: 'way', relation: 'rel' };
|
||||
const oType = typeMap[osmType];
|
||||
if (!oType) return null;
|
||||
// The id is the one thing written into the query, and the query is a
|
||||
// language. An OSM element id is a number and nothing else; anything with
|
||||
// more in it is a statement of its own, sent under TREK's shared user agent.
|
||||
if (!/^\d+$/.test(osmId)) return null;
|
||||
const query = `[out:json][timeout:5];${oType}(${osmId});out tags;`;
|
||||
try {
|
||||
const res = await fetch('https://overpass-api.de/api/interpreter', {
|
||||
method: 'POST',
|
||||
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `data=${encodeURIComponent(query)}`,
|
||||
// The query asks Overpass for 5 s; a public instance that is overloaded
|
||||
// takes the connection and then sits on it.
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as { elements?: OverpassElement[] };
|
||||
return data.elements?.[0] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Places of any kind around a point, from the Overpass mirrors, nearest first (#976). */
|
||||
async nearby(origin: { lat: number; lng: number }, radius: number, limit: number, osmLang: string): Promise<Record<string, unknown>[]> {
|
||||
const elements = await overpassFetch(nearbyOverpassQuery(origin.lat, origin.lng, radius, limit));
|
||||
return overpassNearbyRecords(elements, origin, osmLang, limit);
|
||||
}
|
||||
|
||||
// ── Overpass POI search (by category within a viewport bbox) ───────────────
|
||||
// Powers the "explore places on the map" pill. OSM-ONLY by design — this never
|
||||
// calls Google, even when a Google key is configured.
|
||||
|
||||
async searchOverpassPois(
|
||||
category: string,
|
||||
bbox: { south: number; west: number; north: number; east: number },
|
||||
lang?: string,
|
||||
limit = 60,
|
||||
): Promise<PoiSearchResult> {
|
||||
// One or several categories in one query. Each OSM selector belongs to exactly one
|
||||
// category, so a hit can still be labelled with the category it answered.
|
||||
const categories = parsePoiCategories(category);
|
||||
if (!categories.length) throw Object.assign(new Error('Unknown POI category'), { status: 400 });
|
||||
const categoryOfFilter = new Map<string, string>();
|
||||
for (const key of categories) {
|
||||
const own = CATEGORY_OSM_FILTERS[key];
|
||||
if (!own) throw Object.assign(new Error('Unknown POI category'), { status: 400 });
|
||||
for (const f of own) categoryOfFilter.set(f, key);
|
||||
}
|
||||
const filters = [...categoryOfFilter.keys()];
|
||||
// Each category gets its own share of the cap, or a mixed search would spend the
|
||||
// whole budget on whichever kind happens to be densest.
|
||||
const cap = Math.min(limit * categories.length, POI_RESULT_CAP);
|
||||
|
||||
// Clamp an oversized viewport to a centred window so the query stays cheap and
|
||||
// returns fast at any zoom, instead of timing out / 502-ing on a huge area.
|
||||
const searchWindow = clampPoiBbox(bbox);
|
||||
const { south, west, north, east } = searchWindow.bbox;
|
||||
const clamped = searchWindow.clamped;
|
||||
|
||||
// OSM `name:*` tags are keyed by language subtag: prefer the user's language
|
||||
// (the same localization the search/autocomplete path asks the geocoder for)
|
||||
// over the native `name`. `int_name` is OSM's international/romanized name — a
|
||||
// sensible fallback before the native one. Part of the cache key so a cached
|
||||
// area isn't served with another language's titles.
|
||||
const osmLang = toApiLang(lang).split('-')[0].toLowerCase();
|
||||
|
||||
// Serve repeat pans/toggles of the same area straight from the cache.
|
||||
const cacheKey = `${[...categories].sort((a, b) => a.localeCompare(b)).join('+')}|${osmLang}|${south.toFixed(2)},${west.toFixed(2)},${north.toFixed(2)},${east.toFixed(2)}|${cap}`;
|
||||
const cached = POI_CACHE.get(cacheKey);
|
||||
if (cached && Date.now() - cached.at < POI_CACHE_TTL_MS) return cached.value;
|
||||
if (cached) POI_CACHE.delete(cacheKey); // expired — drop it before refetching
|
||||
|
||||
// Overpass wants the box as (south,west,north,east) = (minLat,minLng,maxLat,maxLng).
|
||||
const box = `(${south},${west},${north},${east})`;
|
||||
const selectors = filters
|
||||
.map((f) => {
|
||||
const [k, v] = f.split('=');
|
||||
return ` nwr["${k}"="${v}"]${box};`;
|
||||
})
|
||||
.join('\n');
|
||||
// `out center tags <n>` returns ways/relations with a computed center and caps
|
||||
// the result count in one round-trip.
|
||||
const query = `[out:json][timeout:${OVERPASS_QUERY_TIMEOUT_S}];\n(\n${selectors}\n);\nout center tags ${cap + 25};`;
|
||||
|
||||
const elements = await overpassFetch(query);
|
||||
|
||||
const pois: OverpassPoi[] = [];
|
||||
for (const el of elements) {
|
||||
const tags = el.tags || {};
|
||||
// `operator` comes last but matters for the road categories: petrol stations,
|
||||
// charging points and service areas are routinely mapped with an operator and no
|
||||
// name, and dropping those would empty the road trip corridor over long stretches.
|
||||
const name =
|
||||
tags[`name:${osmLang}`] || tags['int_name'] || tags.name || tags.brand || tags.operator || null;
|
||||
if (!name) continue; // unnamed POIs aren't useful to add to a plan
|
||||
// A shut-down place is not somewhere to plan a visit (#1341). OSM usually
|
||||
// re-tags one with a `disused:`/`abandoned:` prefix, and those never match
|
||||
// the selectors above — but plenty keep their original tag and gain a marker
|
||||
// instead, and those do come back. `opening_hours=closed`/`off` is the same
|
||||
// statement in the hours field.
|
||||
if (tags.disused === 'yes' || tags.abandoned === 'yes') continue;
|
||||
if (tags.opening_hours === 'closed' || tags.opening_hours === 'off') continue;
|
||||
const lat = el.lat ?? el.center?.lat;
|
||||
const lng = el.lon ?? el.center?.lon;
|
||||
if (lat == null || lng == null) continue;
|
||||
const matched =
|
||||
filters.find((f) => {
|
||||
const [k, v] = f.split('=');
|
||||
return tags[k] === v;
|
||||
}) || filters[0];
|
||||
const addr =
|
||||
[tags['addr:street'], tags['addr:housenumber'], tags['addr:postcode'], tags['addr:city']]
|
||||
.filter(Boolean)
|
||||
.join(' ') || null;
|
||||
pois.push({
|
||||
osm_id: `${el.type}:${el.id}`,
|
||||
name,
|
||||
lat,
|
||||
lng,
|
||||
category: categoryOfFilter.get(matched) ?? categories[0],
|
||||
poi_type: matched,
|
||||
address: addr,
|
||||
website: normalizePlaceWebsite(tags.website) ?? normalizePlaceWebsite(tags['contact:website']),
|
||||
phone: tags.phone || tags['contact:phone'] || null,
|
||||
opening_hours: tags.opening_hours || null,
|
||||
cuisine: tags.cuisine || null,
|
||||
brand: tags.brand || tags.operator || null,
|
||||
// Only the plain Q-id form is passed on; anything else would be a lookup we
|
||||
// would have to guess at.
|
||||
brand_wikidata: /^Q[0-9]+$/.test(tags['brand:wikidata'] || '') ? tags['brand:wikidata'] : null,
|
||||
// Only where it means something. Every POI carries `capacity` and `fee` for its
|
||||
// own reasons — a restaurant's capacity is seats — so reading them as charging
|
||||
// data anywhere else would be wrong on most of the map.
|
||||
charging: categoryOfFilter.get(matched) === 'charging' ? readChargingInfo(tags) : null,
|
||||
source: 'openstreetmap',
|
||||
});
|
||||
}
|
||||
const truncated = pois.length > cap;
|
||||
const value: PoiSearchResult = { pois: pois.slice(0, cap), source: 'openstreetmap', truncated, clamped };
|
||||
// FIFO eviction: a Map preserves insertion order, so the first key is the oldest.
|
||||
if (POI_CACHE.size >= POI_CACHE_MAX) POI_CACHE.delete(POI_CACHE.keys().next().value as string);
|
||||
POI_CACHE.set(cacheKey, { at: Date.now(), value });
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Which encyclopaedia entry, Wikidata item and Commons category describe a
|
||||
* place, read off its OpenStreetMap tags.
|
||||
*
|
||||
* Shared by the OSM client (a Nominatim hit carries the tags in `extratags`)
|
||||
* and the enrichment column (which follows them to Wikimedia), so it lives
|
||||
* apart from both.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The keys that say which encyclopaedia entry, Wikidata item and Commons
|
||||
* category describe a place. Everything the enrichment column shows beyond
|
||||
* coordinates hangs off one of these.
|
||||
*
|
||||
* Bare keys only. OSM also carries `brand:wikidata` / `brand:wikipedia`, and
|
||||
* following those means a branch of a chain gets the chain's article and the
|
||||
* chain's logo — for "L'Osteria Rostock" you would confidently describe
|
||||
* L'Osteria the company. That is the exact failure the tag-only rule was
|
||||
* written to avoid, so do not "improve" this by falling back to brand:*.
|
||||
*/
|
||||
const WIKI_IDENTITY_TAGS = ['wikipedia', 'wikidata', 'wikimedia_commons'] as const;
|
||||
|
||||
export interface WikiIdentity {
|
||||
wikipedia: string | null;
|
||||
wikidata: string | null;
|
||||
wikimedia_commons: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chain a place belongs to, when it belongs to one.
|
||||
*
|
||||
* Read separately from `readWikiIdentity` and never mixed into it. Following
|
||||
* `brand:wikidata` as if it described the place is how "L'Osteria Rostock"
|
||||
* ends up illustrated with the company logo and described as a franchise
|
||||
* operator — which is why the picture ladder never sees these. For a
|
||||
* description they are still worth something: a branch of a chain has no
|
||||
* article of its own and never will, and "L'Osteria is a German restaurant
|
||||
* chain serving pizza and pasta" beats an empty column, as long as the reader
|
||||
* is told that is what they are looking at.
|
||||
*/
|
||||
export function readBrandIdentity(extratags: Record<string, string> | null | undefined): {
|
||||
wikidata: string | null;
|
||||
wikipedia: string | null;
|
||||
} {
|
||||
const read = (key: string): string | null => {
|
||||
const value = extratags?.[key];
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
};
|
||||
return { wikidata: read('brand:wikidata'), wikipedia: read('brand:wikipedia') };
|
||||
}
|
||||
|
||||
/** Picks the three identity tags out of a Nominatim `extratags` blob. */
|
||||
export function readWikiIdentity(extratags: Record<string, string> | null | undefined): WikiIdentity {
|
||||
const out: WikiIdentity = { wikipedia: null, wikidata: null, wikimedia_commons: null };
|
||||
if (!extratags) return out;
|
||||
for (const tag of WIKI_IDENTITY_TAGS) {
|
||||
const value = extratags[tag];
|
||||
if (typeof value === 'string' && value.trim()) out[tag] = value.trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { buildOsmDetails, isGooglePlaceId, parseWikipediaTag, rankCommonsCandidates, toWikiLang } from '../maps/maps.helpers';
|
||||
import { trekPlacesById } from '../maps/trek-places.client';
|
||||
import { GooglePlacesClient } from '../maps/providers/google-places.provider';
|
||||
import { OsmClient } from '../maps/providers/osm.client';
|
||||
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
|
||||
import { readAppSetting } from '../common/app-settings.registry';
|
||||
|
||||
@@ -325,6 +326,7 @@ export class PlaceEnrichmentService {
|
||||
private readonly maps: MapsService,
|
||||
private readonly photoCache: PlacePhotoCacheService,
|
||||
private readonly googlePlaces: GooglePlacesClient,
|
||||
private readonly osm: OsmClient,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -442,7 +444,7 @@ export class PlaceEnrichmentService {
|
||||
};
|
||||
if (carried.wikipedia || carried.wikidata || carried.wikimedia_commons) return carried;
|
||||
|
||||
const resolved = await this.maps.resolveOsmIdentity(req.name, req.lat, req.lng, { lang: req.lang });
|
||||
const resolved = await this.osm.resolveOsmIdentity(req.name, req.lat, req.lng, { lang: req.lang });
|
||||
if (!resolved) return carried;
|
||||
const brand = readBrandIdentity(resolved.tags);
|
||||
return {
|
||||
|
||||
@@ -32,6 +32,7 @@ vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: (
|
||||
import { PlaceEnrichmentModule } from '../../src/nest/place-enrichment/place-enrichment.module';
|
||||
import { candidateKey } from '../../src/nest/place-enrichment/place-enrichment.service';
|
||||
import { MapsService } from '../../src/nest/maps/maps.service';
|
||||
import { OsmClient } from '../../src/nest/maps/providers/osm.client';
|
||||
import { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo-cache.service';
|
||||
import { RateLimitService } from '../../src/nest/common/rate-limit.service';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
@@ -49,6 +50,7 @@ describe('Place enrichment e2e (real auth guard + real validation pipe)', () =>
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
let maps: MapsService;
|
||||
let osm: OsmClient;
|
||||
let orm: TestOrm;
|
||||
|
||||
async function build() {
|
||||
@@ -70,6 +72,7 @@ describe('Place enrichment e2e (real auth guard + real validation pipe)', () =>
|
||||
server = app.getHttpServer();
|
||||
|
||||
maps = app.get(MapsService);
|
||||
osm = app.get(OsmClient);
|
||||
const photoCache = app.get(PlacePhotoCacheService);
|
||||
vi.spyOn(photoCache, 'get').mockReturnValue(null);
|
||||
vi.spyOn(photoCache, 'put').mockImplementation(async (key: string, _b: Buffer, attribution: string | null) => ({
|
||||
@@ -97,7 +100,7 @@ describe('Place enrichment e2e (real auth guard + real validation pipe)', () =>
|
||||
// Every provider the service can reach has to be stubbed here, not just the
|
||||
// ones a given case cares about: anything left open goes out over the real
|
||||
// network from CI, which is both slow and rude to the provider.
|
||||
vi.spyOn(maps, 'resolveOsmIdentity').mockClear().mockResolvedValue(null);
|
||||
vi.spyOn(osm, 'resolveOsmIdentity').mockClear().mockResolvedValue(null);
|
||||
vi.spyOn(maps, 'fetchWikidataSitelinks').mockClear().mockResolvedValue({});
|
||||
vi.spyOn(maps, 'fetchWikiExtractFor').mockClear().mockResolvedValue(null);
|
||||
vi.spyOn(maps, 'fetchWikidataCandidates').mockClear().mockResolvedValue({ candidates: [], commonsCategory: null });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MapsService } from '../../src/nest/maps/maps.service';
|
||||
import { GooglePlacesClient } from '../../src/nest/maps/providers/google-places.provider';
|
||||
import { OsmClient } from '../../src/nest/maps/providers/osm.client';
|
||||
import type { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo-cache.service';
|
||||
import type { AppSettingsRepository } from '../../src/db/repositories/AppSettings.repository';
|
||||
import type { UsersRepository } from '../../src/db/repositories/Users.repository';
|
||||
@@ -12,6 +13,7 @@ import { noGoogleQuota } from './google-quota';
|
||||
export interface MapsParts {
|
||||
svc: MapsService;
|
||||
google: GooglePlacesClient;
|
||||
osm: OsmClient;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,8 +31,9 @@ export function buildMapsParts(
|
||||
googleQuota: GoogleQuotaService = noGoogleQuota,
|
||||
): MapsParts {
|
||||
const google = new GooglePlacesClient(googleQuota);
|
||||
const svc = new MapsService(photoCache, appSettings, users, placeDetailsCache, places, googleQuota, google);
|
||||
return { svc, google };
|
||||
const osm = new OsmClient();
|
||||
const svc = new MapsService(photoCache, appSettings, users, placeDetailsCache, places, googleQuota, google, osm);
|
||||
return { svc, google, osm };
|
||||
}
|
||||
|
||||
export function buildMapsService(
|
||||
|
||||
@@ -50,6 +50,7 @@ import { findRow } from '../../helpers/factories/rows';
|
||||
import { makeTag } from '../../helpers/factories/places';
|
||||
import { Tags } from '../../../src/db/entities/Tags.entity';
|
||||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import { OsmClient } from '../../../src/nest/maps/providers/osm.client';
|
||||
import { getWeather, getDetailedWeather } from '../../../src/nest/weather/weather.impl';
|
||||
|
||||
// The geo tools live on the DI-discovered maps.mcp.ts since the maps fold; the
|
||||
@@ -67,7 +68,7 @@ vi.spyOn(MapsService.prototype, 'getPlaceDetailsExpanded').mockResolvedValue({
|
||||
} as never);
|
||||
// Overpass is stubbed one level below the facade so MapsService.pois() itself
|
||||
// still runs, the way the geo tools reach it.
|
||||
vi.spyOn(MapsService.prototype, 'searchOverpassPois').mockResolvedValue({
|
||||
vi.spyOn(OsmClient.prototype, 'searchOverpassPois').mockResolvedValue({
|
||||
pois: [{ osm_id: 'node:1', name: 'Chez Nous', lat: 48.86, lng: 2.34, category: 'restaurant' }],
|
||||
source: 'openstreetmap',
|
||||
truncated: false,
|
||||
@@ -458,7 +459,7 @@ describe('Tool: search_pois', () => {
|
||||
// back as an empty POI list.
|
||||
it('returns the POIs of a category inside the bbox', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(MapsService.prototype.searchOverpassPois).mockClear();
|
||||
vi.mocked(OsmClient.prototype.searchOverpassPois).mockClear();
|
||||
trekNearbyMock.mockClear();
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
@@ -472,7 +473,7 @@ describe('Tool: search_pois', () => {
|
||||
expect(data.source).toBe('openstreetmap');
|
||||
expect(trekNearbyMock).toHaveBeenCalled();
|
||||
// The caller's per-category budget rides along on the fallback too.
|
||||
expect(MapsService.prototype.searchOverpassPois).toHaveBeenCalledWith('restaurant', BBOX, 'fr', 60);
|
||||
expect(OsmClient.prototype.searchOverpassPois).toHaveBeenCalledWith('restaurant', BBOX, 'fr', 60);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -481,7 +482,7 @@ describe('Tool: search_pois', () => {
|
||||
// places; where they were read is this method's business, not the pill's.
|
||||
it('answers from the TREK Places index and leaves Overpass alone', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(MapsService.prototype.searchOverpassPois).mockClear();
|
||||
vi.mocked(OsmClient.prototype.searchOverpassPois).mockClear();
|
||||
trekNearbyMock.mockClear();
|
||||
trekNearbyMock.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -514,7 +515,7 @@ describe('Tool: search_pois', () => {
|
||||
expect(data.source).toBe('trek-places');
|
||||
expect(data.pois[0].source).toBe('trek-places');
|
||||
expect(data.clamped).toBe(false);
|
||||
expect(MapsService.prototype.searchOverpassPois).not.toHaveBeenCalled();
|
||||
expect(OsmClient.prototype.searchOverpassPois).not.toHaveBeenCalled();
|
||||
|
||||
// The bbox becomes a centre plus half its diagonal, and the category
|
||||
// becomes the Overture terms it maps to.
|
||||
@@ -554,12 +555,12 @@ describe('Tool: search_pois', () => {
|
||||
|
||||
it('passes no language through when none is given', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(MapsService.prototype.searchOverpassPois).mockClear();
|
||||
vi.mocked(OsmClient.prototype.searchOverpassPois).mockClear();
|
||||
trekNearbyMock.mockClear();
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'search_pois', arguments: { category: 'museum', bbox: BBOX } });
|
||||
expect(MapsService.prototype.searchOverpassPois).toHaveBeenCalledWith('museum', BBOX, undefined, 60);
|
||||
expect(OsmClient.prototype.searchOverpassPois).toHaveBeenCalledWith('museum', BBOX, undefined, 60);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -567,7 +568,7 @@ describe('Tool: search_pois', () => {
|
||||
// mapping never reaches Overpass in the first place.
|
||||
it('refuses a category that has no OSM mapping', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(MapsService.prototype.searchOverpassPois).mockClear();
|
||||
vi.mocked(OsmClient.prototype.searchOverpassPois).mockClear();
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
@@ -575,13 +576,13 @@ describe('Tool: search_pois', () => {
|
||||
arguments: { category: 'dentist', bbox: BBOX },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(MapsService.prototype.searchOverpassPois).not.toHaveBeenCalled();
|
||||
expect(OsmClient.prototype.searchOverpassPois).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a bbox edge outside the coordinate range', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(MapsService.prototype.searchOverpassPois).mockClear();
|
||||
vi.mocked(OsmClient.prototype.searchOverpassPois).mockClear();
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
@@ -589,13 +590,13 @@ describe('Tool: search_pois', () => {
|
||||
arguments: { category: 'cafe', bbox: { ...BBOX, north: 118 } },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(MapsService.prototype.searchOverpassPois).not.toHaveBeenCalled();
|
||||
expect(OsmClient.prototype.searchOverpassPois).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('answers isError when every Overpass mirror is unreachable', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(MapsService.prototype.searchOverpassPois).mockRejectedValueOnce(new Error('all mirrors failed'));
|
||||
vi.mocked(OsmClient.prototype.searchOverpassPois).mockRejectedValueOnce(new Error('all mirrors failed'));
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'search_pois', arguments: { category: 'bar', bbox: BBOX } });
|
||||
@@ -611,7 +612,7 @@ describe('Tool: search_pois', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
trekNearbyMock.mockRejectedValueOnce(new Error('index down'));
|
||||
vi.mocked(MapsService.prototype.searchOverpassPois).mockRejectedValueOnce(new Error('all mirrors failed'));
|
||||
vi.mocked(OsmClient.prototype.searchOverpassPois).mockRejectedValueOnce(new Error('all mirrors failed'));
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'search_pois', arguments: { category: 'bar', bbox: BBOX } });
|
||||
|
||||
@@ -62,6 +62,7 @@ vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => (
|
||||
|
||||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import { buildMapsService } from '../../helpers/maps-service';
|
||||
import { OsmClient } from '../../../src/nest/maps/providers/osm.client';
|
||||
import { trekPlacesSearch } from '../../../src/nest/maps/trek-places.client';
|
||||
import {
|
||||
AmapPlacesProvider,
|
||||
@@ -948,7 +949,7 @@ describe('MapsService: Amap first inside China when picked outright (#1636)', ()
|
||||
vi.mocked(trekPlacesSearch).mockClear();
|
||||
});
|
||||
function osmAnswers(places: Record<string, unknown>[]) {
|
||||
const spy = vi.spyOn(svc, 'searchNominatim').mockResolvedValue(places as never);
|
||||
const spy = vi.spyOn(OsmClient.prototype, 'searchNominatim').mockResolvedValue(places as never);
|
||||
spies.push(spy);
|
||||
return spy;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => (
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
|
||||
|
||||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import { buildMapsService } from '../../helpers/maps-service';
|
||||
import { buildMapsParts } from '../../helpers/maps-service';
|
||||
import type { OsmClient } from '../../../src/nest/maps/providers/osm.client';
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import { noGoogleQuota } from '../../helpers/google-quota';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
@@ -45,9 +46,13 @@ const PLACE = {
|
||||
hours: null as { osm: string } | null,
|
||||
};
|
||||
|
||||
/** The OSM client each service was built with, so a case can assert on what it was asked. */
|
||||
const osmOf = new WeakMap<MapsService, OsmClient>();
|
||||
|
||||
function make(osmTags: Record<string, string> | null) {
|
||||
const svc = buildMapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota);
|
||||
vi.spyOn(svc, 'resolveOsmIdentity').mockResolvedValue(
|
||||
const { svc, osm } = buildMapsParts({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota);
|
||||
osmOf.set(svc, osm);
|
||||
vi.spyOn(osm, 'resolveOsmIdentity').mockResolvedValue(
|
||||
osmTags ? { tags: osmTags, osmUrl: 'https://www.openstreetmap.org/node/1', matchedName: "L'Osteria" } : null,
|
||||
);
|
||||
return svc;
|
||||
@@ -111,8 +116,8 @@ describe('MapsService.getPlaceDetails for a gers: id', () => {
|
||||
// only adds what OSM knows about the same building. Letting its failure
|
||||
// through would turn a working answer into an error for the one user whose
|
||||
// details request happened to land while Overpass was unreachable.
|
||||
const svc = buildMapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota);
|
||||
vi.spyOn(svc, 'resolveOsmIdentity').mockRejectedValue(new Error('overpass down'));
|
||||
const { svc, osm } = buildMapsParts({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota);
|
||||
vi.spyOn(osm, 'resolveOsmIdentity').mockRejectedValue(new Error('overpass down'));
|
||||
mockById.mockResolvedValue({ ...PLACE, hours: { osm: 'Mo-Su 12:00-22:00' } });
|
||||
|
||||
const out = await svc.getPlaceDetails(1, 'gers:abc-123');
|
||||
@@ -151,6 +156,6 @@ describe('MapsService.getPlaceDetails for a gers: id', () => {
|
||||
await expect(svc.getPlaceDetails(1, 'gers:abc-123')).resolves.toEqual({ place: null });
|
||||
|
||||
expect(mockById).not.toHaveBeenCalled();
|
||||
expect(svc.resolveOsmIdentity).not.toHaveBeenCalled();
|
||||
expect(osmOf.get(svc)!.resolveOsmIdentity).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('GooglePlacesProvider', () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const { client: c } = client();
|
||||
expect(await c.provider({ key: 'k', source: 'env', userId: 1 }).reverse()).toBeNull();
|
||||
expect(await c.provider({ key: 'k', source: 'instance', userId: 1 }).reverse()).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -19,11 +19,13 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({
|
||||
SsrfBlockedError: class extends Error {},
|
||||
}));
|
||||
|
||||
import { OsmClient } from '../../../src/nest/maps/providers/osm.client';
|
||||
import { buildMapsService } from '../../helpers/maps-service';
|
||||
import { toWikiLang, haversineMetres, namesOverlap } from '../../../src/nest/maps/maps.helpers';
|
||||
import { noGoogleQuota } from '../../helpers/google-quota';
|
||||
import { toWikiLang, haversineMetres, namesOverlap } from '../../../src/nest/maps/maps.helpers';
|
||||
|
||||
const svcOf = () => buildMapsService({} as never, {} as never, {} as never, {} as never, {} as never, noGoogleQuota);
|
||||
const osmOf = () => new OsmClient();
|
||||
|
||||
// The Brandenburg Gate and the underground station named after it, 250m apart.
|
||||
const GATE = { lat: 52.5163, lng: 13.3777 };
|
||||
@@ -120,7 +122,7 @@ describe('resolveOsmIdentity', () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await svcOf().resolveOsmIdentity('Brandenburger Tor', GATE.lat, GATE.lng, { lang: 'de' });
|
||||
await osmOf().resolveOsmIdentity('Brandenburger Tor', GATE.lat, GATE.lng, { lang: 'de' });
|
||||
|
||||
const url = decodeURIComponent(String(fetchMock.mock.calls[0][0]));
|
||||
// Without bounded+viewbox, Nominatim answers with the most famous place on
|
||||
@@ -133,7 +135,7 @@ describe('resolveOsmIdentity', () => {
|
||||
it('MAPS-166: hands back the tags of the best local match', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [hit()] }));
|
||||
|
||||
const out = await svcOf().resolveOsmIdentity('Brandenburger Tor', GATE.lat, GATE.lng);
|
||||
const out = await osmOf().resolveOsmIdentity('Brandenburger Tor', GATE.lat, GATE.lng);
|
||||
|
||||
expect(out?.tags.wikidata).toBe('Q82425');
|
||||
expect(out?.osmUrl).toBe('https://www.openstreetmap.org/way/518071791');
|
||||
@@ -152,7 +154,7 @@ describe('resolveOsmIdentity', () => {
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [station, hit()] }));
|
||||
|
||||
const out = await svcOf().resolveOsmIdentity('Brandenburger Tor', 52.5166, 13.3809);
|
||||
const out = await osmOf().resolveOsmIdentity('Brandenburger Tor', 52.5166, 13.3809);
|
||||
|
||||
expect(out?.tags.wikidata).toBe('Q82425');
|
||||
});
|
||||
@@ -161,7 +163,7 @@ describe('resolveOsmIdentity', () => {
|
||||
const faraway = hit({ lat: '48.8584', lon: '2.2945' }); // Paris
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [faraway] }));
|
||||
|
||||
expect(await svcOf().resolveOsmIdentity('Brandenburger Tor', GATE.lat, GATE.lng)).toBeNull();
|
||||
expect(await osmOf().resolveOsmIdentity('Brandenburger Tor', GATE.lat, GATE.lng)).toBeNull();
|
||||
});
|
||||
|
||||
it('MAPS-169: refuses a match that shares no word with the name', async () => {
|
||||
@@ -170,24 +172,24 @@ describe('resolveOsmIdentity', () => {
|
||||
const neighbour = hit({ name: 'Hotel Adlon', display_name: 'Hotel Adlon, Berlin' });
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [neighbour] }));
|
||||
|
||||
expect(await svcOf().resolveOsmIdentity('Brandenburger Tor', GATE.lat, GATE.lng)).toBeNull();
|
||||
expect(await osmOf().resolveOsmIdentity('Brandenburger Tor', GATE.lat, GATE.lng)).toBeNull();
|
||||
});
|
||||
|
||||
it('MAPS-170: survives an empty name, a bad response, a throw and a non-array body', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
expect(await svcOf().resolveOsmIdentity('', GATE.lat, GATE.lng)).toBeNull();
|
||||
expect(await osmOf().resolveOsmIdentity('', GATE.lat, GATE.lng)).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, json: async () => [] }));
|
||||
expect(await svcOf().resolveOsmIdentity('X Tor', GATE.lat, GATE.lng)).toBeNull();
|
||||
expect(await osmOf().resolveOsmIdentity('X Tor', GATE.lat, GATE.lng)).toBeNull();
|
||||
|
||||
// Nominatim answers rate limiting in plain text, not JSON.
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => 'Bandwidth limit exceeded' }));
|
||||
expect(await svcOf().resolveOsmIdentity('X Tor', GATE.lat, GATE.lng)).toBeNull();
|
||||
expect(await osmOf().resolveOsmIdentity('X Tor', GATE.lat, GATE.lng)).toBeNull();
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')));
|
||||
expect(await svcOf().resolveOsmIdentity('X Tor', GATE.lat, GATE.lng)).toBeNull();
|
||||
expect(await osmOf().resolveOsmIdentity('X Tor', GATE.lat, GATE.lng)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => (
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
|
||||
|
||||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import { buildMapsService } from '../../helpers/maps-service';
|
||||
import { buildMapsParts } from '../../helpers/maps-service';
|
||||
import type { OsmClient } from '../../../src/nest/maps/providers/osm.client';
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import { noGoogleQuota } from '../../helpers/google-quota';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
@@ -117,13 +118,18 @@ afterEach(() => {
|
||||
function make(enabled = true) {
|
||||
if (enabled) delete process.env.TREK_PLACES_ENABLED;
|
||||
else process.env.TREK_PLACES_ENABLED = 'false';
|
||||
return buildMapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota);
|
||||
const { svc, osm } = buildMapsParts({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota);
|
||||
osmOf.set(svc, osm);
|
||||
return svc;
|
||||
}
|
||||
|
||||
/** The OSM client each service was built with: Overpass is asked through it. */
|
||||
const osmOf = new WeakMap<MapsService, OsmClient>();
|
||||
|
||||
// Overpass is the one network call this file must never make; stubbing it is
|
||||
// also what turns "dropped through" into something a case can assert.
|
||||
function stubOverpass(svc: MapsService) {
|
||||
return vi.spyOn(svc, 'searchOverpassPois').mockResolvedValue(OVERPASS_ANSWER);
|
||||
return vi.spyOn(osmOf.get(svc)!, 'searchOverpassPois').mockResolvedValue(OVERPASS_ANSWER);
|
||||
}
|
||||
|
||||
function rows(n: number) {
|
||||
|
||||
@@ -173,6 +173,7 @@ const photoCacheStub = {
|
||||
} as unknown as PlacePhotoCacheService;
|
||||
|
||||
import { MapsService, withPhotoFetchSlot, readWikiIdentity } from '../../../src/nest/maps/maps.service';
|
||||
import { OsmClient } from '../../../src/nest/maps/providers/osm.client';
|
||||
import { buildMapsParts, buildMapsService } from '../../helpers/maps-service';
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
@@ -231,7 +232,7 @@ const placesStub = {
|
||||
// through a repository stub that flows into the SAME mockDbGet/mockDbRun/
|
||||
// mockInstanceGet/mockProviderGet functions, so they keep firing exactly as
|
||||
// they did for the legacy module.
|
||||
const { svc, google } = buildMapsParts(photoCacheStub, appSettingsStub, usersStub, placeDetailsCacheStub, placesStub, noGoogleQuota);
|
||||
const { svc, google, osm } = buildMapsParts(photoCacheStub, appSettingsStub, usersStub, placeDetailsCacheStub, placesStub, noGoogleQuota);
|
||||
|
||||
/**
|
||||
* Switch the TREK Places index off for one case.
|
||||
@@ -882,7 +883,7 @@ describe('searchNominatim (fetch stubbed)', () => {
|
||||
],
|
||||
}),
|
||||
);
|
||||
const results = await svc.searchNominatim('Paris');
|
||||
const results = await osm.searchNominatim('Paris');
|
||||
expect(results).toHaveLength(1);
|
||||
expect((results[0] as any).address).toBe('Paris, France');
|
||||
expect((results[0] as any).source).toBe('openstreetmap');
|
||||
@@ -890,7 +891,7 @@ describe('searchNominatim (fetch stubbed)', () => {
|
||||
|
||||
it('MAPS-030: throws on fetch failure', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
|
||||
await expect(svc.searchNominatim('fail')).rejects.toThrow();
|
||||
await expect(osm.searchNominatim('fail')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('MAPS-030b: throws when nominatim response is not ok', async () => {
|
||||
@@ -903,7 +904,7 @@ describe('searchNominatim (fetch stubbed)', () => {
|
||||
text: async () => '',
|
||||
}),
|
||||
);
|
||||
await expect(svc.searchNominatim('fail')).rejects.toThrow('Nominatim API error');
|
||||
await expect(osm.searchNominatim('fail')).rejects.toThrow('Nominatim API error');
|
||||
});
|
||||
|
||||
it('MAPS-030c: falls back to display_name split when name is absent', async () => {
|
||||
@@ -914,7 +915,7 @@ describe('searchNominatim (fetch stubbed)', () => {
|
||||
json: async () => [{ osm_type: 'node', osm_id: '2', lat: '51.5', lon: '-0.1', display_name: 'London, UK' }],
|
||||
}),
|
||||
);
|
||||
const results = await svc.searchNominatim('London');
|
||||
const results = await osm.searchNominatim('London');
|
||||
expect((results[0] as any).name).toBe('London');
|
||||
});
|
||||
|
||||
@@ -929,7 +930,7 @@ describe('searchNominatim (fetch stubbed)', () => {
|
||||
],
|
||||
}),
|
||||
);
|
||||
const results = await svc.searchNominatim('null island');
|
||||
const results = await osm.searchNominatim('null island');
|
||||
expect((results[0] as any).lat).toBe(0);
|
||||
expect((results[0] as any).lng).toBe(0);
|
||||
expect((results[1] as any).lat).toBeNull();
|
||||
@@ -949,7 +950,7 @@ describe('searchNominatim (fetch stubbed)', () => {
|
||||
],
|
||||
}),
|
||||
);
|
||||
const results = await svc.searchNominatim('x');
|
||||
const results = await osm.searchNominatim('x');
|
||||
expect(results.map((r: any) => r.category)).toEqual(['hotel', 'shop_bakery', null, null]);
|
||||
});
|
||||
});
|
||||
@@ -965,13 +966,13 @@ describe('fetchOverpassDetails (fetch stubbed)', () => {
|
||||
json: async () => ({ elements: [{ tags: { name: 'Eiffel Tower', website: 'https://eiffel.com' } }] }),
|
||||
}),
|
||||
);
|
||||
const result = await svc.fetchOverpassDetails('way', '12345');
|
||||
const result = await osm.fetchOverpassDetails('way', '12345');
|
||||
expect(result).toBeDefined();
|
||||
expect((result as any).tags.name).toBe('Eiffel Tower');
|
||||
});
|
||||
|
||||
it('MAPS-032: returns null for unknown osmType', async () => {
|
||||
const result = await svc.fetchOverpassDetails('unknown', '12345');
|
||||
const result = await osm.fetchOverpassDetails('unknown', '12345');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
@@ -983,20 +984,20 @@ describe('fetchOverpassDetails (fetch stubbed)', () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
for (const bad of ['1);nwr["amenity"](-90,-180,90,180', '12345;out geom', '', ' 1', '1e3', '-5']) {
|
||||
expect(await svc.fetchOverpassDetails('node', bad), bad).toBeNull();
|
||||
expect(await osm.fetchOverpassDetails('node', bad), bad).toBeNull();
|
||||
}
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('MAPS-033: returns null when fetch throws', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
|
||||
const result = await svc.fetchOverpassDetails('node', '99999');
|
||||
const result = await osm.fetchOverpassDetails('node', '99999');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('MAPS-034: returns null when response is not ok', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
||||
const result = await svc.fetchOverpassDetails('node', '99999');
|
||||
const result = await osm.fetchOverpassDetails('node', '99999');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1008,7 +1009,7 @@ describe('fetchOverpassDetails (fetch stubbed)', () => {
|
||||
json: async () => ({ elements: [] }),
|
||||
}),
|
||||
);
|
||||
const result = await svc.fetchOverpassDetails('node', '1');
|
||||
const result = await osm.fetchOverpassDetails('node', '1');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1038,19 +1039,19 @@ describe('searchOverpassPois localized names (#1655)', () => {
|
||||
|
||||
it('prefers name:<lang> for the user language over the native name', async () => {
|
||||
stubOverpass(tags);
|
||||
const { pois } = await svc.searchOverpassPois('sights', bbox(1), 'en-US');
|
||||
const { pois } = await osm.searchOverpassPois('sights', bbox(1), 'en-US');
|
||||
expect(pois[0].name).toBe('Elephant and Obelisk');
|
||||
});
|
||||
|
||||
it('localizes to a non-English language too', async () => {
|
||||
stubOverpass(tags);
|
||||
const { pois } = await svc.searchOverpassPois('sights', bbox(2), 'de-DE');
|
||||
const { pois } = await osm.searchOverpassPois('sights', bbox(2), 'de-DE');
|
||||
expect(pois[0].name).toBe('Minerva-Obelisk');
|
||||
});
|
||||
|
||||
it('falls back to int_name when the language tag is absent', async () => {
|
||||
stubOverpass({ name: tags.name, int_name: tags.int_name, tourism: 'attraction' });
|
||||
const { pois } = await svc.searchOverpassPois('sights', bbox(3), 'fr-FR');
|
||||
const { pois } = await osm.searchOverpassPois('sights', bbox(3), 'fr-FR');
|
||||
expect(pois[0].name).toBe('Elephant Obelisk');
|
||||
});
|
||||
|
||||
@@ -1069,13 +1070,13 @@ describe('searchOverpassPois localized names (#1655)', () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const { pois } = await svc.searchOverpassPois('sights', bbox(5), 'en-US');
|
||||
const { pois } = await osm.searchOverpassPois('sights', bbox(5), 'en-US');
|
||||
expect(pois.map((p: any) => p.name)).toEqual(['Open For Business']);
|
||||
});
|
||||
|
||||
it('falls back to the native name when no localized tag exists', async () => {
|
||||
stubOverpass({ name: tags.name, tourism: 'attraction' });
|
||||
const { pois } = await svc.searchOverpassPois('sights', bbox(4), 'fr-FR');
|
||||
const { pois } = await osm.searchOverpassPois('sights', bbox(4), 'fr-FR');
|
||||
expect(pois[0].name).toBe('Obelisco della Minerva');
|
||||
});
|
||||
});
|
||||
@@ -2680,11 +2681,11 @@ describe('searchOverpassPois multi-category', () => {
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
// One category first, to learn what a single box costs (the service races mirrors).
|
||||
await svc.searchOverpassPois('fuel', bbox(9), 'en-US');
|
||||
await osm.searchOverpassPois('fuel', bbox(9), 'en-US');
|
||||
const single = fetchMock.mock.calls.length;
|
||||
fetchMock.mockClear();
|
||||
|
||||
const { pois } = await svc.searchOverpassPois('fuel,charging,rest_area', bbox(1), 'en-US');
|
||||
const { pois } = await osm.searchOverpassPois('fuel,charging,rest_area', bbox(1), 'en-US');
|
||||
|
||||
// Three categories cost exactly what one does — the whole point of the batching.
|
||||
expect(fetchMock.mock.calls.length).toBe(single);
|
||||
@@ -2704,7 +2705,7 @@ describe('searchOverpassPois multi-category', () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(svc.searchOverpassPois('fuel,unicorns', bbox(2))).rejects.toMatchObject({ status: 400 });
|
||||
await expect(osm.searchOverpassPois('fuel,unicorns', bbox(2))).rejects.toMatchObject({ status: 400 });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -2715,9 +2716,9 @@ describe('searchOverpassPois multi-category', () => {
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await svc.searchOverpassPois('fuel,campsite', bbox(3), 'en-US');
|
||||
await osm.searchOverpassPois('fuel,campsite', bbox(3), 'en-US');
|
||||
const asked = fetchMock.mock.calls.length;
|
||||
await svc.searchOverpassPois('campsite,fuel', bbox(3), 'en-US');
|
||||
await osm.searchOverpassPois('campsite,fuel', bbox(3), 'en-US');
|
||||
|
||||
// Same set, written the other way round: the second ask costs nothing.
|
||||
expect(fetchMock.mock.calls.length).toBe(asked);
|
||||
@@ -2732,7 +2733,7 @@ describe('searchOverpassPois all-endpoints-down', () => {
|
||||
it('MAPS-102: surfaces a 502 with a clear message when every Overpass endpoint fails', async () => {
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED')));
|
||||
await expect(svc.searchOverpassPois('restaurant', bbox)).rejects.toMatchObject({
|
||||
await expect(osm.searchOverpassPois('restaurant', bbox)).rejects.toMatchObject({
|
||||
status: 502,
|
||||
message: 'Could not reach any Overpass endpoint',
|
||||
});
|
||||
@@ -2742,7 +2743,7 @@ describe('searchOverpassPois all-endpoints-down', () => {
|
||||
it('MAPS-103: logs each endpoint failure so an operator can diagnose blocked egress', async () => {
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED')));
|
||||
await expect(svc.searchOverpassPois('bar', bbox)).rejects.toThrow();
|
||||
await expect(osm.searchOverpassPois('bar', bbox)).rejects.toThrow();
|
||||
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('[Overpass] all'));
|
||||
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('ECONNREFUSED'));
|
||||
errSpy.mockRestore();
|
||||
@@ -2815,7 +2816,7 @@ describe('controller-facing wrappers delegate to the folded methods', () => {
|
||||
getPlacePhoto: vi.spyOn(MapsService.prototype, 'getPlacePhoto').mockResolvedValue({ photoUrl: null, attribution: null }),
|
||||
reverseGeocode: vi.spyOn(MapsService.prototype, 'reverseGeocode').mockResolvedValue({ name: null, address: null }),
|
||||
resolveGoogleMapsUrl: vi.spyOn(MapsService.prototype, 'resolveGoogleMapsUrl').mockResolvedValue({ lat: 1, lng: 2, name: null, address: null, google_ftid: null }),
|
||||
searchOverpassPois: vi.spyOn(MapsService.prototype, 'searchOverpassPois').mockResolvedValue({ pois: [], source: 'openstreetmap', truncated: false, clamped: false }),
|
||||
searchOverpassPois: vi.spyOn(OsmClient.prototype, 'searchOverpassPois').mockResolvedValue({ pois: [], source: 'openstreetmap', truncated: false, clamped: false }),
|
||||
};
|
||||
try {
|
||||
const circleBias = { lat: 1, lng: 2, radius: 5 };
|
||||
@@ -3646,7 +3647,7 @@ describe('websites from the map sources (#2483)', () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const { pois } = await svc.searchOverpassPois('sights', { south: 48.0, west: -3.6, north: 48.1, east: -3.4 }, 'fr-FR');
|
||||
const { pois } = await osm.searchOverpassPois('sights', { south: 48.0, west: -3.6, north: 48.1, east: -3.4 }, 'fr-FR');
|
||||
expect(pois.map((p) => p.website)).toEqual([
|
||||
'https://fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët',
|
||||
'https://www.example.fr',
|
||||
|
||||
@@ -33,10 +33,11 @@ import { PlaceEnrichmentService } from '../../../src/nest/place-enrichment/place
|
||||
import { readBrandIdentity } from '../../../src/nest/maps/maps.service';
|
||||
import type { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import type { GooglePlacesClient } from '../../../src/nest/maps/providers/google-places.provider';
|
||||
import type { OsmClient } from '../../../src/nest/maps/providers/osm.client';
|
||||
|
||||
/** Every seam enrichment reaches: the maps orchestrator and the outbound clients it injects beside it. */
|
||||
type Seams<T> = { [K in keyof T]: T[K] };
|
||||
type EnrichmentSeams = Seams<MapsService> & Seams<GooglePlacesClient>;
|
||||
type EnrichmentSeams = Seams<MapsService> & Seams<GooglePlacesClient> & Seams<OsmClient>;
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import type { PlaceDetailsCacheRepository } from '../../../src/db/repositories/PlaceDetailsCache.repository';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
@@ -89,7 +90,7 @@ function cacheRepoStub(): PlaceDetailsCacheRepository {
|
||||
return { findEntry: mockFindEntry, upsertEntry: mockUpsertEntry } as unknown as PlaceDetailsCacheRepository;
|
||||
}
|
||||
|
||||
const make = (maps: EnrichmentSeams) => new PlaceEnrichmentService(cacheRepoStub(), appSettingsStub(), maps as unknown as MapsService, cacheStub(), maps as unknown as GooglePlacesClient);
|
||||
const make = (maps: EnrichmentSeams) => new PlaceEnrichmentService(cacheRepoStub(), appSettingsStub(), maps as unknown as MapsService, cacheStub(), maps as unknown as GooglePlacesClient, maps as unknown as OsmClient);
|
||||
|
||||
/** A place whose own identity is empty but that belongs to a chain. */
|
||||
const branchOfAChain = (over: Partial<Record<keyof EnrichmentSeams, unknown>> = {}) =>
|
||||
|
||||
@@ -37,10 +37,11 @@ vi.mock('../../../src/nest/maps/trek-places.client', () => ({
|
||||
import { PlaceEnrichmentService } from '../../../src/nest/place-enrichment/place-enrichment.service';
|
||||
import type { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import type { GooglePlacesClient } from '../../../src/nest/maps/providers/google-places.provider';
|
||||
import type { OsmClient } from '../../../src/nest/maps/providers/osm.client';
|
||||
|
||||
/** Every seam enrichment reaches: the maps orchestrator and the outbound clients it injects beside it. */
|
||||
type Seams<T> = { [K in keyof T]: T[K] };
|
||||
type EnrichmentSeams = Seams<MapsService> & Seams<GooglePlacesClient>;
|
||||
type EnrichmentSeams = Seams<MapsService> & Seams<GooglePlacesClient> & Seams<OsmClient>;
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import type { PlaceDetailsCacheRepository } from '../../../src/db/repositories/PlaceDetailsCache.repository';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
@@ -88,7 +89,7 @@ function cacheRepoStub(): PlaceDetailsCacheRepository {
|
||||
return { findEntry: mockFindEntry, upsertEntry: mockUpsertEntry } as unknown as PlaceDetailsCacheRepository;
|
||||
}
|
||||
|
||||
const make = (maps: EnrichmentSeams) => new PlaceEnrichmentService(cacheRepoStub(), appSettingsStub(), maps as unknown as MapsService, cacheStub(), maps as unknown as GooglePlacesClient);
|
||||
const make = (maps: EnrichmentSeams) => new PlaceEnrichmentService(cacheRepoStub(), appSettingsStub(), maps as unknown as MapsService, cacheStub(), maps as unknown as GooglePlacesClient, maps as unknown as OsmClient);
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetValue.mockReset();
|
||||
|
||||
@@ -52,10 +52,11 @@ import {
|
||||
} from '../../../src/nest/place-enrichment/place-enrichment.service';
|
||||
import type { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import type { GooglePlacesClient } from '../../../src/nest/maps/providers/google-places.provider';
|
||||
import type { OsmClient } from '../../../src/nest/maps/providers/osm.client';
|
||||
|
||||
/** Every seam enrichment reaches: the maps orchestrator and the outbound clients it injects beside it. */
|
||||
type Seams<T> = { [K in keyof T]: T[K] };
|
||||
type EnrichmentSeams = Seams<MapsService> & Seams<GooglePlacesClient>;
|
||||
type EnrichmentSeams = Seams<MapsService> & Seams<GooglePlacesClient> & Seams<OsmClient>;
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import type { PlaceDetailsCacheRepository } from '../../../src/db/repositories/PlaceDetailsCache.repository';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
@@ -108,7 +109,7 @@ function cacheStub(over: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
function make(maps: EnrichmentSeams, cache: PlacePhotoCacheService) {
|
||||
return new PlaceEnrichmentService(cacheRepoStub(), appSettingsStub(), maps as unknown as MapsService, cache, maps as unknown as GooglePlacesClient);
|
||||
return new PlaceEnrichmentService(cacheRepoStub(), appSettingsStub(), maps as unknown as MapsService, cache, maps as unknown as GooglePlacesClient, maps as unknown as OsmClient);
|
||||
}
|
||||
|
||||
let candidateSeq = 0;
|
||||
|
||||
@@ -35,6 +35,7 @@ import { PermissionsService } from '../../../src/nest/permissions/permissions.se
|
||||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import type { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import type { OsmClient } from '../../../src/nest/maps/providers/osm.client';
|
||||
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
|
||||
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
|
||||
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||||
@@ -61,7 +62,7 @@ const hit = (name: string, lat: number, lng: number) => ({
|
||||
lat, lng, rating: null, website: null, phone: null, source: 'openstreetmap' as const,
|
||||
});
|
||||
|
||||
async function svc(searchNominatim: MapsService['searchNominatim']): Promise<PlacesService> {
|
||||
async function svc(searchNominatim: OsmClient['searchNominatim']): Promise<PlacesService> {
|
||||
return new PlacesService(
|
||||
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
||||
new RealtimeService(),
|
||||
@@ -112,7 +113,7 @@ const geocoder = () => vi.fn(async (query: string) => {
|
||||
};
|
||||
const found = known[query];
|
||||
return found ? [hit(query, found[0], found[1])] : [];
|
||||
}) as unknown as MapsService['searchNominatim'];
|
||||
}) as unknown as OsmClient['searchNominatim'];
|
||||
|
||||
let tripId: string;
|
||||
|
||||
@@ -176,7 +177,7 @@ describe('PlacesService.importGoogleDirections', () => {
|
||||
const search = vi.fn(async (query: string) => {
|
||||
if (query === 'Dresden') throw new Error('Nominatim 429');
|
||||
return [hit(query, 52.52, 13.405)];
|
||||
}) as unknown as MapsService['searchNominatim'];
|
||||
}) as unknown as OsmClient['searchNominatim'];
|
||||
const result = await (await svc(search)).importGoogleDirections(tripId, 'https://www.google.com/maps/dir/Berlin/Dresden/Prague');
|
||||
|
||||
expect('error' in result).toBe(false);
|
||||
@@ -233,7 +234,7 @@ describe('PlacesService.importGoogleDirections', () => {
|
||||
// each Nominatim call taking the next slot on a 1.1 s process-wide throttle,
|
||||
// so on the interactive lane one pasted link made everybody else's place
|
||||
// search queue behind it for half a minute. An index hit costs no slot.
|
||||
const nominatim = vi.fn(async () => [{ lat: 52.52, lng: 13.405 }]) as unknown as MapsService['searchNominatim'];
|
||||
const nominatim = vi.fn(async () => [{ lat: 52.52, lng: 13.405 }]) as unknown as OsmClient['searchNominatim'];
|
||||
const geocode = vi.fn(async () => ({ lat: 52.52, lng: 13.405 }));
|
||||
const service = await svc(nominatim);
|
||||
(service as unknown as { maps: Partial<MapsService> }).maps.geocodeQuery =
|
||||
@@ -270,7 +271,7 @@ describe('PlacesService.importGoogleDirections', () => {
|
||||
});
|
||||
|
||||
it('PLACES-DIR-010: a route where only one stop can be placed is not half an import', async () => {
|
||||
const search = vi.fn(async () => []) as unknown as MapsService['searchNominatim'];
|
||||
const search = vi.fn(async () => []) as unknown as OsmClient['searchNominatim'];
|
||||
const url = 'https://www.google.com/maps/dir/52.52,13.405/Nowhere/Nowhere+Else';
|
||||
const result = await (await svc(search)).importGoogleDirections(tripId, url);
|
||||
expect(result).toEqual({ error: 'None of the stops in that link could be placed on the map.', status: 400 });
|
||||
|
||||
Reference in New Issue
Block a user