mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
d152f9d02b
* fix(shared-view): render each leg of multi-leg flights correctly The read-only shared view showed the overall trip start/end airports and the first leg's flight number on every leg of a multi-leg flight. The Day Plan already expands legs (each carries __leg), but the renderer ignored it and read flat top-level metadata; the Bookings tab had the same bug. - Day Plan: use __leg for per-leg airline/flight number/route, plus dep-arr time - Bookings tab: list each leg via getFlightLegs() - unique React keys for multi-leg rows Closes #1219 * feat(pdf): add legs to pdf export * fix(demo): skip first-run admin seed in demo mode When DEMO_MODE is on, the demo seeder creates its own admin (admin@trek.app, username "admin") right after the generic seeds run. The first-run admin bootstrap was grabbing username "admin" first, so the demo seeder hit the UNIQUE(username) constraint and aborted before the demo user was ever created - which surfaced as a 500 "Demo user not found" on demo-login. Skip the generic admin bootstrap when demo mode owns the admin account. * fix(docker): ship the encryption-key migration script in the image The production image only copied server/dist, so the documented rotation command `node --import tsx scripts/migrate-encryption.ts` failed inside the container with a module-not-found error - the raw .ts was never present. The script runs via tsx straight from source and only pulls node builtins plus better-sqlite3 (both prod deps), so copying the single file into /app/server/scripts is enough to make the rotation work again. * fix(vacay): keep the mode toolbar above the mobile bottom nav The floating Vacation/Company toolbar was pinned at bottom-3 with z-30, so on mobile it landed in the same band as the fixed bottom nav (z-60) and got hidden behind it - and could scroll out of reach entirely. Pin it above the nav with the shared --bottom-nav-h variable (0px on desktop, so nothing changes there) and reserve matching space below the calendar grid so it never gets swallowed. * fix(dashboard): show the correct reservation date regardless of timezone The upcoming-reservations widget built the date with new Date(reservation_time) .toISOString(), which reinterprets the stored naive local time as UTC and can roll the displayed day forward in non-UTC timezones (e.g. a 23:30 reservation showing the next day). Read the date and time straight from the stored string parts via splitReservationDateTime, and format the time with the shared formatTime helper so it also honours the user's 12h/24h preference. * fix(atlas): cursor-following tooltips and removing countries from search Two related Atlas fixes: - Country tooltips were bound with sticky:false, which anchors them at the feature's bounds centre. For countries with overseas territories (e.g. France) that centre sits far out in the ocean, so the tooltip popped up nowhere near the area being hovered. Make them sticky so they track the cursor. - Selecting an already-visited country from the search bar always opened the "Mark / Bucket" dialog, with no way to remove it. Tiny countries like Vatican City or Singapore are hard to hit on the map, so search was the only way in. Mirror the map-click behaviour: a manually-marked country opens the Remove confirmation, a trip/place-backed one opens its detail. * fix(oidc): keep dots in generated usernames The OIDC username sanitizer stripped dots because they were missing from the allowed character class, so a name claim like "first.last" became "firstlast". Dots are valid usernames (the profile validator already allows ^[a-zA-Z0-9_.-]+$), so add the dot to the sanitizer. * fix(collab): show poll option labels in the UI The poll API formatted each option as { label, voters }, but the React poll component renders opt.text - so every option button came out blank. Emit text alongside label (kept for any other consumer) so options render again. * feat(backup): make the upload size limit configurable The restore upload was capped at a hard-coded 500 MB, so instances whose backup archive (uploads/ included) grew past that got a 413 "File too large" with no way to raise it. Add a BACKUP_UPLOAD_LIMIT_MB env var (default 500, invalid values warn and fall back), documented in .env.example. * feat(costs): create an expense from a booking, fix editing total-only items Replace the inline price + budget-category fields in the Transport and Reservation booking modals with a "Create expense" flow: the modal saves the booking, then opens the full Costs editor prefilled (name + category mapped from the booking type) and linked to the reservation. A booking with a linked expense shows it inline with edit / remove. Also fix the Costs editor so an expense with a recorded total but no payers (transport-derived or pre-rework items) opens with its amount, lets you set the currency, and saves - it previously showed 0 everywhere and could not be saved. Legacy / localized categories now map to the fixed keys, and changing a booking's type keeps its linked expense category in sync (unless it was manually set). - shared: reservation_id on budget create, typeToCostCategory helper, i18n keys - server: createBudgetItem stores reservation_id; keep total_price for payerless items; a booking update no longer wipes its linked expense and syncs the category on type change - client: shared BookingCostsSection, exported ExpenseModal with prefill and an editable total, page-level save-then-open wiring * test(reservations): align syncBudgetOnUpdate unit tests with no-wipe + type-sync The service now leaves a linked expense alone when no budget entry is on the payload (only an explicit total_price 0 deletes it) and syncs the category on a booking type change. Update the unit tests accordingly - the old "price cleared" case passed entry: undefined, which is now a no-op and left a mocked return queued that leaked into the next test. * fix(planner): keep a reservation on its day when edited (#1237) Editing a booking forced its day_id to the globally selected day, which is null when editing from the Book tab - so the booking lost its day and vanished from the Plan. Preserve the reservation own day_id on edit instead. * fix(planner): derive a booking day from its date when none is set (#1237) The client always sends day_id on a reservation update, so the server only derived it from reservation_time when the field was absent. A non-transport booking saved without a selected day (Book tab) therefore got day_id null and vanished from the Plan, even though its date matched a day. Derive the day from reservation_time whenever day_id is null, mirroring create. * fix(planner): let a booking's day follow its date when edited (#1237) Preserving the old day_id on edit left a re-dated booking on its previous start day while end_day_id followed the new date, so it spanned both. Stop sending day_id from the edit modal entirely - the server derives both ends from the booking's date (and keeps the current day when there is no date), so a re-dated booking moves cleanly to the matching day. * fix(atlas): keep the continent breakdown in sync on mark/unmark (#1225) The optimistic mark/unmark updates bumped the country total but never the per-continent counts, so the continent column froze until a full reload. Move the country to continent map into @trek/shared (single source for server and client) and adjust the matching continent count at every optimistic site: the country confirm flow plus the choose / region mark and region unmark handlers. * feat(admin): let admins set a default currency for new users Adds a currency picker to Admin > User Defaults. Stored as the default_currency user-default, so users who have not picked their own currency inherit it in Costs. * fix(atlas): give every sub-national region a distinct code (#1217) geoBoundaries fills shapeISO with the bare country code for some countries (every Spanish region got "ESP", every Chinese "CHN", also Chile/Oman), so marking one region lit up the whole country. build-atlas-geo.mjs now keeps shapeISO only when it is a real "XX-..." subdivision code and otherwise synthesizes a unique per-country id from the region name. Regenerated admin1.geojson.gz: Spain/China/ Chile/Oman now carry distinct region codes (countries with real codes, e.g. Germany, are unchanged). * fix(dashboard): never crash on a malformed reservation date A reservation with an invalid date blanked the whole My Trips page: the old Upcoming widget did new Date(value).toISOString(), which throws "Invalid time value" (fixed in #1222 by reading the string parts). Also guard splitDate so a bad date renders a dash instead of "Invalid Date" or throwing. * fix(airtrail): gate airtrail update behind a user setting, on airtrail update: rebuild payload from fresh data to prevent any data loss * fix(airtrail): add back missing tests * fix(costs): rework the cost panel UX wise and apply prettier on the shared package * chore(prettier) prettier this file * fix(airtrail): don't use cabin class as seat on import When an AirTrail flight has a cabin class but no seat number, the mapper fell back to the class for metadata.seat, so reservations showed e.g. "economy" as the seat. Use only the seat number; leave the seat blank otherwise. The class is still surfaced separately in the import picker. Closes #1246 * fix(airtrail): import scheduled flight times instead of actual AirTrail exposes both scheduled (departureScheduled/arrivalScheduled) and actual (departure/arrival) times. TREK read the actual times, so a delayed or early flight imported the wrong time for planning. Read the scheduled times on import and on poll-sync (both go through mapFlightToReservation); when a flight has no scheduled time, leave the clock blank (date preserved) rather than fabricating 00:00 or falling back to actual. The change-detection hash now tracks the scheduled values, so existing linked reservations re-sync once on the next poll. The opt-in writeback mirrors the read, pushing TREK edits to the scheduled fields so they round-trip. * fix(planner): hydrate per-assignment times when editing a place from the pool Times live per day-assignment, not on the pool place, so reopening a place from the Places panel / inspector showed empty Start/End fields (#1247). The editor now resolves a place's lone assignment when no day is in context and hydrates the fields from it; ambiguous (0 or 2+ days) edits hide the fields instead of showing non-persisting inputs. * fix(mcp): make write tools return client-valid, hydrated entities Audit of all write tools under server/src/mcp/tools (issue #1244 anchor). S1 (broken): - create_budget_item / create_budget_item_with_members now default the split to all trip members when member_ids omitted, so the entry passes the client save-gate instead of being member-less (#1244). - create_transport / update_transport backfill lat/lng/timezone for code-only flight endpoints (NOT NULL columns) and return a clean error for unresolvable endpoints instead of crashing. S2 (under-hydration): set_budget_item_members, create_journey, create_journey_entry, create_packing_bag, bulk_import_packing and update_vacay_plan now return the hydrated shape the matching read/REST route returns; bulk_import widened to accept bag/weight_grams/checked. S3 (parity): check_in_end added to accommodation tools; atlas mark_region_visited echoes the client shape; update_journey_entry/ update_journey_preferences, set_bag_members, set_packing_category_assignees, apply_packing_template return hydrated payloads; set_vacay_color echoes the color. Auth: save_packing_template now requires admin, matching the REST gate. Also refactors server/src/config.ts (JWT-secret handling). Adds getBudgetItem hydrated getter, exports EndpointInput, and MCP regression tests (incl. new tools-transports and tools-journey suites). * fix(mcp): fix ICS/maps/accommodation bugs, add settlement & template tools Bugs: - export_trip_ics: include flights that store times per-endpoint (local_date/local_time) instead of a top-level reservation_time - resolve_maps_url: follow redirects for cid=/share links and fall back to parsing the page body, all SSRF-guarded - link_hotel_accommodation: normalize accommodation_id (TEXT column) to an integer in the reservation read paths so it no longer returns "14.0" Gaps: - packing: save_packing_template returns the new template id; add list_packing_templates (read) and delete_packing_template (admin) - budget: update_budget_item accepts payers/member_ids; clarify create/ update/members descriptions to ask which members share the expense and who paid - budget: add settlement tools — get_settlement_summary, list_settlements, create/update/delete_settlement (budget_edit, mirrors REST + WS events) * chore: bump nodemailer * chore: bump multer --------- Co-authored-by: Maurice <mauriceboe@icloud.com>
1069 lines
44 KiB
TypeScript
1069 lines
44 KiB
TypeScript
import { db } from '../db/database';
|
|
import { decrypt_api_key } from './apiKeyCrypto';
|
|
import { safeFetchFollow, SsrfBlockedError } from '../utils/ssrfGuard';
|
|
import { getAppUrl } from './notifications';
|
|
|
|
// ── Google API call counter ───────────────────────────────────────────────────
|
|
|
|
let googleApiCallCount = 0;
|
|
|
|
function googleFetch(endpoint: string, label: string, init?: RequestInit): Promise<Response> {
|
|
googleApiCallCount++;
|
|
console.debug(`[Google API] #${googleApiCallCount} ${label} → ${endpoint}`);
|
|
const referer = process.env.APP_URL ? getAppUrl() : undefined;
|
|
return fetch(endpoint, {
|
|
...init,
|
|
headers: { ...(referer ? { Referer: referer } : {}), ...(init?.headers as Record<string, string> ?? {}) },
|
|
});
|
|
}
|
|
|
|
// ── Interfaces ───────────────────────────────────────────────────────────────
|
|
|
|
interface NominatimResult {
|
|
osm_type: string;
|
|
osm_id: string;
|
|
name?: string;
|
|
display_name?: string;
|
|
lat: string;
|
|
lon: string;
|
|
}
|
|
|
|
interface OverpassElement {
|
|
tags?: Record<string, string>;
|
|
}
|
|
|
|
interface WikiCommonsPage {
|
|
imageinfo?: { url?: string; thumburl?: string; extmetadata?: { Artist?: { value?: string } } }[];
|
|
}
|
|
|
|
interface GooglePlaceResult {
|
|
id: string;
|
|
displayName?: { text: string };
|
|
formattedAddress?: string;
|
|
location?: { latitude: number; longitude: number };
|
|
rating?: number;
|
|
websiteUri?: string;
|
|
nationalPhoneNumber?: string;
|
|
types?: string[];
|
|
}
|
|
|
|
interface GoogleAutocompleteSuggestion {
|
|
placePrediction?: {
|
|
placeId: string;
|
|
structuredFormat?: {
|
|
mainText?: { text: string };
|
|
secondaryText?: { text: string };
|
|
};
|
|
};
|
|
}
|
|
|
|
interface GooglePlaceDetails extends GooglePlaceResult {
|
|
userRatingCount?: number;
|
|
regularOpeningHours?: { weekdayDescriptions?: string[]; openNow?: boolean };
|
|
googleMapsUri?: string;
|
|
editorialSummary?: { text: string };
|
|
reviews?: { authorAttribution?: { displayName?: string; photoUri?: string }; rating?: number; text?: { text?: string }; relativePublishTimeDescription?: string }[];
|
|
photos?: { name: string; authorAttributions?: { displayName?: string }[] }[];
|
|
}
|
|
|
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
|
|
const UA = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)';
|
|
|
|
// TREK's internal language codes mostly coincide with valid BCP-47 codes, but a
|
|
// couple don't: 'br' is Brazilian Portuguese here (BCP-47 'pt-BR'; bare 'br' is
|
|
// Breton) and 'gr' is Greek (BCP-47 'el'). Outbound geo APIs (Google Places,
|
|
// Nominatim) expect BCP-47, so normalise before sending — otherwise names and
|
|
// opening hours come back in the wrong language. Codes not listed here pass
|
|
// through unchanged (they are already valid), as do locale forms the client
|
|
// sometimes sends (e.g. 'pt-BR').
|
|
const API_LANG_OVERRIDES: Record<string, string> = {
|
|
br: 'pt-BR',
|
|
gr: 'el',
|
|
'el-GR': 'el',
|
|
};
|
|
function toApiLang(lang: string | undefined, fallback = 'en'): string {
|
|
const code = (lang || '').trim();
|
|
if (!code) return fallback;
|
|
return API_LANG_OVERRIDES[code] ?? code;
|
|
}
|
|
|
|
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
|
|
import * as placePhotoCache from './placePhotoCache';
|
|
|
|
// ── Concurrency limiter for outbound photo fetches ───────────────────────────
|
|
// Caps simultaneous Wikimedia/Google photo requests so a bulk import of hundreds
|
|
// of places cannot monopolise the event loop or trigger external API rate limits.
|
|
const MAX_CONCURRENT_PHOTO_FETCHES = 5;
|
|
let photoFetchActive = 0;
|
|
const photoFetchQueue: Array<() => void> = [];
|
|
|
|
function acquirePhotoFetchSlot(): Promise<void> {
|
|
if (photoFetchActive < MAX_CONCURRENT_PHOTO_FETCHES) {
|
|
photoFetchActive++;
|
|
return Promise.resolve();
|
|
}
|
|
return new Promise(resolve => photoFetchQueue.push(resolve));
|
|
}
|
|
|
|
function releasePhotoFetchSlot(): void {
|
|
const next = photoFetchQueue.shift();
|
|
if (next) {
|
|
next();
|
|
} else {
|
|
photoFetchActive--;
|
|
}
|
|
}
|
|
|
|
// ── API key retrieval ────────────────────────────────────────────────────────
|
|
|
|
export function getMapsKey(userId: number): string | null {
|
|
const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(userId) as { maps_api_key: string | null } | undefined;
|
|
const user_key = decrypt_api_key(user?.maps_api_key);
|
|
if (user_key) return user_key;
|
|
const admin = db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get() as { maps_api_key: string } | undefined;
|
|
return decrypt_api_key(admin?.maps_api_key) || null;
|
|
}
|
|
|
|
// ── Nominatim search ─────────────────────────────────────────────────────────
|
|
|
|
export async function searchNominatim(query: string, lang?: string) {
|
|
const params = new URLSearchParams({
|
|
q: query,
|
|
format: 'json',
|
|
addressdetails: '1',
|
|
limit: '10',
|
|
'accept-language': toApiLang(lang),
|
|
});
|
|
const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, {
|
|
headers: { 'User-Agent': UA },
|
|
});
|
|
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 => ({
|
|
google_place_id: null,
|
|
osm_id: `${item.osm_type}:${item.osm_id}`,
|
|
name: item.name || item.display_name?.split(',')[0] || '',
|
|
address: item.display_name || '',
|
|
lat: parseFloat(item.lat) || null,
|
|
lng: parseFloat(item.lon) || null,
|
|
rating: null,
|
|
website: null,
|
|
phone: null,
|
|
source: 'openstreetmap',
|
|
}));
|
|
}
|
|
|
|
// ── Nominatim lookup (by OSM ID) ────────────────────────────────────────────
|
|
|
|
export async function lookupNominatim(osmType: string, osmId: string, lang?: string): Promise<{
|
|
name: string; address: string; lat: number | null; lng: number | null;
|
|
} | null> {
|
|
const typePrefix = osmType.charAt(0).toUpperCase(); // N, W, R
|
|
const params = new URLSearchParams({
|
|
osm_ids: `${typePrefix}${osmId}`,
|
|
format: 'json',
|
|
'accept-language': toApiLang(lang),
|
|
});
|
|
try {
|
|
const res = await fetch(`https://nominatim.openstreetmap.org/lookup?${params}`, {
|
|
headers: { 'User-Agent': UA },
|
|
});
|
|
if (!res.ok) return null;
|
|
const data = await res.json() as NominatimResult[];
|
|
const item = data[0];
|
|
if (!item) return null;
|
|
return {
|
|
name: item.name || item.display_name?.split(',')[0] || '',
|
|
address: item.display_name || '',
|
|
lat: parseFloat(item.lat) || null,
|
|
lng: parseFloat(item.lon) || null,
|
|
};
|
|
} catch { return null; }
|
|
}
|
|
|
|
// ── Overpass API (OSM details) ───────────────────────────────────────────────
|
|
|
|
export async function 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;
|
|
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)}`,
|
|
});
|
|
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.
|
|
|
|
export interface OverpassPoi {
|
|
osm_id: string; // 'node:123' | 'way:123' | 'relation:123' (matches the placeId format elsewhere)
|
|
name: string;
|
|
lat: number;
|
|
lng: number;
|
|
category: string; // the requested pill category key, e.g. 'restaurant'
|
|
poi_type: string; // the raw OSM tag that matched, e.g. 'amenity=restaurant'
|
|
address: string | null;
|
|
website: string | null;
|
|
phone: string | null;
|
|
opening_hours: string | null;
|
|
cuisine: string | null;
|
|
source: 'openstreetmap';
|
|
}
|
|
|
|
// Each pill category → the OSM tag selectors it searches. Keys here are the
|
|
// contract with the client's POI_CATEGORIES (same keys, label/icon/colour live
|
|
// client-side).
|
|
const CATEGORY_OSM_FILTERS: Record<string, string[]> = {
|
|
restaurant: ['amenity=restaurant', 'amenity=fast_food'],
|
|
cafe: ['amenity=cafe'],
|
|
bar: ['amenity=bar', 'amenity=pub', 'amenity=nightclub'],
|
|
hotel: ['tourism=hotel', 'tourism=hostel', 'tourism=guest_house', 'tourism=apartment', 'tourism=motel'],
|
|
sights: ['tourism=attraction', 'tourism=viewpoint', 'historic=monument', 'historic=castle', 'historic=memorial', 'historic=ruins'],
|
|
museum: ['tourism=museum', 'tourism=gallery', 'tourism=artwork', 'amenity=theatre'],
|
|
nature: ['leisure=park', 'leisure=garden', 'natural=beach', 'natural=peak'],
|
|
activity: ['tourism=theme_park', 'tourism=zoo', 'tourism=aquarium', 'leisure=water_park'],
|
|
shopping: ['shop=mall', 'shop=department_store', 'amenity=marketplace'],
|
|
supermarket: ['shop=supermarket', 'shop=convenience'],
|
|
};
|
|
|
|
export const POI_CATEGORY_KEYS = Object.keys(CATEGORY_OSM_FILTERS);
|
|
|
|
interface OverpassPoiElement {
|
|
type: string;
|
|
id: number;
|
|
lat?: number;
|
|
lon?: number;
|
|
center?: { lat: number; lon: number };
|
|
tags?: Record<string, string>;
|
|
}
|
|
|
|
interface PoiSearchResult {
|
|
pois: OverpassPoi[];
|
|
source: 'openstreetmap';
|
|
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;
|
|
}
|
|
|
|
// Public Overpass mirrors, queried in PARALLEL (first valid response wins).
|
|
// Reachability and load vary a lot by network/region — the canonical instance is
|
|
// frequently overloaded (504s) and some community mirrors are unreachable from
|
|
// certain networks. Racing them means whichever mirror is fastest-reachable for
|
|
// this user answers, and an overloaded or blocked one never blocks the others.
|
|
const OVERPASS_MIRRORS = [
|
|
'https://overpass-api.de/api/interpreter',
|
|
'https://maps.mail.ru/osm/tools/overpass/api/interpreter',
|
|
'https://overpass.kumi.systems/api/interpreter',
|
|
'https://overpass.private.coffee/api/interpreter',
|
|
];
|
|
// Per-mirror cap. Because mirrors race in parallel this is also the worst-case
|
|
// total wait before every mirror is given up on and a 502 is returned.
|
|
const OVERPASS_TIMEOUT_MS = 12000;
|
|
// Largest viewport side we send to Overpass. A country/continent-sized bbox makes
|
|
// Overpass scan millions of elements and time out; clamping to a centred window
|
|
// keeps the query cheap so the explore pill returns fast at ANY zoom level.
|
|
const MAX_BBOX_SPAN_DEG = 0.5;
|
|
|
|
// 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;
|
|
|
|
// 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 {
|
|
throw Object.assign(new Error('Overpass request failed'), { 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 */ } });
|
|
}
|
|
}
|
|
|
|
export async function searchOverpassPois(
|
|
category: string,
|
|
bbox: { south: number; west: number; north: number; east: number },
|
|
limit = 60,
|
|
): Promise<PoiSearchResult> {
|
|
const filters = CATEGORY_OSM_FILTERS[category];
|
|
if (!filters) throw Object.assign(new Error('Unknown POI category'), { status: 400 });
|
|
|
|
// 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.
|
|
let { south, west, north, east } = bbox;
|
|
let clamped = false;
|
|
if (north - south > MAX_BBOX_SPAN_DEG) {
|
|
const c = (north + south) / 2;
|
|
south = c - MAX_BBOX_SPAN_DEG / 2;
|
|
north = c + MAX_BBOX_SPAN_DEG / 2;
|
|
clamped = true;
|
|
}
|
|
if (east - west > MAX_BBOX_SPAN_DEG) {
|
|
const c = (east + west) / 2;
|
|
west = c - MAX_BBOX_SPAN_DEG / 2;
|
|
east = c + MAX_BBOX_SPAN_DEG / 2;
|
|
clamped = true;
|
|
}
|
|
|
|
// Serve repeat pans/toggles of the same area straight from the cache.
|
|
const cacheKey = `${category}|${south.toFixed(2)},${west.toFixed(2)},${north.toFixed(2)},${east.toFixed(2)}|${limit}`;
|
|
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:20];\n(\n${selectors}\n);\nout center tags ${limit + 25};`;
|
|
|
|
const elements = await overpassFetch(query);
|
|
|
|
const pois: OverpassPoi[] = [];
|
|
for (const el of elements) {
|
|
const tags = el.tags || {};
|
|
const name = tags.name || tags['name:en'] || tags.brand || null;
|
|
if (!name) continue; // unnamed POIs aren't useful to add to a plan
|
|
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,
|
|
poi_type: matched,
|
|
address: addr,
|
|
website: tags.website || tags['contact:website'] || null,
|
|
phone: tags.phone || tags['contact:phone'] || null,
|
|
opening_hours: tags.opening_hours || null,
|
|
cuisine: tags.cuisine || null,
|
|
source: 'openstreetmap',
|
|
});
|
|
}
|
|
const truncated = pois.length > limit;
|
|
const value: PoiSearchResult = { pois: pois.slice(0, limit), 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;
|
|
}
|
|
|
|
// ── Opening hours parsing ────────────────────────────────────────────────────
|
|
|
|
export function parseOpeningHours(ohString: string): { weekdayDescriptions: string[]; openNow: boolean | null } {
|
|
const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
|
|
const LONG = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
|
const result: string[] = LONG.map(d => `${d}: ?`);
|
|
|
|
// Parse segments like "Mo-Fr 09:00-18:00; Sa 10:00-14:00"
|
|
for (const segment of ohString.split(';')) {
|
|
const trimmed = segment.trim();
|
|
if (!trimmed) continue;
|
|
const match = trimmed.match(/^((?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?(?:\s*,\s*(?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?)*)\s+(.+)$/i);
|
|
if (!match) continue;
|
|
const [, daysPart, timePart] = match;
|
|
const dayIndices = new Set<number>();
|
|
for (const range of daysPart.split(',')) {
|
|
const parts = range.trim().split('-').map(d => DAYS.indexOf(d.trim()));
|
|
if (parts.length === 2 && parts[0] >= 0 && parts[1] >= 0) {
|
|
for (let i = parts[0]; i !== (parts[1] + 1) % 7; i = (i + 1) % 7) dayIndices.add(i);
|
|
dayIndices.add(parts[1]);
|
|
} else if (parts[0] >= 0) {
|
|
dayIndices.add(parts[0]);
|
|
}
|
|
}
|
|
for (const idx of dayIndices) {
|
|
result[idx] = `${LONG[idx]}: ${timePart.trim()}`;
|
|
}
|
|
}
|
|
|
|
// Compute openNow
|
|
let openNow: boolean | null = null;
|
|
try {
|
|
const now = new Date();
|
|
const jsDay = now.getDay();
|
|
const dayIdx = jsDay === 0 ? 6 : jsDay - 1;
|
|
const todayLine = result[dayIdx];
|
|
const timeRanges = [...todayLine.matchAll(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g)];
|
|
if (timeRanges.length > 0) {
|
|
const nowMins = now.getHours() * 60 + now.getMinutes();
|
|
openNow = timeRanges.some(m => {
|
|
const start = parseInt(m[1]) * 60 + parseInt(m[2]);
|
|
const end = parseInt(m[3]) * 60 + parseInt(m[4]);
|
|
return end > start ? nowMins >= start && nowMins < end : nowMins >= start || nowMins < end;
|
|
});
|
|
}
|
|
} catch { /* best effort */ }
|
|
|
|
return { weekdayDescriptions: result, openNow };
|
|
}
|
|
|
|
// ── Build standardized OSM details ───────────────────────────────────────────
|
|
|
|
export function buildOsmDetails(tags: Record<string, string>, osmType: string, osmId: string) {
|
|
let opening_hours: string[] | null = null;
|
|
let open_now: boolean | null = null;
|
|
if (tags.opening_hours) {
|
|
const parsed = parseOpeningHours(tags.opening_hours);
|
|
const hasData = parsed.weekdayDescriptions.some(line => !line.endsWith('?'));
|
|
if (hasData) {
|
|
opening_hours = parsed.weekdayDescriptions;
|
|
open_now = parsed.openNow;
|
|
}
|
|
}
|
|
return {
|
|
website: tags['contact:website'] || tags.website || null,
|
|
phone: tags['contact:phone'] || tags.phone || null,
|
|
opening_hours,
|
|
open_now,
|
|
osm_url: `https://www.openstreetmap.org/${osmType}/${osmId}`,
|
|
summary: tags.description || null,
|
|
source: 'openstreetmap' as const,
|
|
};
|
|
}
|
|
|
|
// ── Wikimedia Commons photo lookup ───────────────────────────────────────────
|
|
|
|
export async function fetchWikimediaPhoto(lat: number, lng: number, name?: string): Promise<{ photoUrl: string; attribution: string | null } | null> {
|
|
// Strategy 1: Search Wikipedia for the place name -> get the article image
|
|
if (name) {
|
|
try {
|
|
const searchParams = new URLSearchParams({
|
|
action: 'query', format: 'json',
|
|
titles: name,
|
|
prop: 'pageimages',
|
|
piprop: 'thumbnail',
|
|
pithumbsize: '400',
|
|
pilimit: '1',
|
|
redirects: '1',
|
|
});
|
|
const res = await fetch(`https://en.wikipedia.org/w/api.php?${searchParams}`, { headers: { 'User-Agent': UA } });
|
|
if (res.ok) {
|
|
const data = await res.json() as { query?: { pages?: Record<string, { thumbnail?: { source?: string } }> } };
|
|
const pages = data.query?.pages;
|
|
if (pages) {
|
|
for (const page of Object.values(pages)) {
|
|
if (page.thumbnail?.source) {
|
|
return { photoUrl: page.thumbnail.source, attribution: 'Wikipedia' };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch { /* fall through to geosearch */ }
|
|
}
|
|
|
|
// Strategy 2: Wikimedia Commons geosearch by coordinates
|
|
const params = new URLSearchParams({
|
|
action: 'query', format: 'json',
|
|
generator: 'geosearch',
|
|
ggsprimary: 'all',
|
|
ggsnamespace: '6',
|
|
ggsradius: '300',
|
|
ggscoord: `${lat}|${lng}`,
|
|
ggslimit: '5',
|
|
prop: 'imageinfo',
|
|
iiprop: 'url|extmetadata|mime',
|
|
iiurlwidth: '400',
|
|
});
|
|
try {
|
|
const res = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`, { headers: { 'User-Agent': UA } });
|
|
if (!res.ok) return null;
|
|
const data = await res.json() as { query?: { pages?: Record<string, WikiCommonsPage & { imageinfo?: { mime?: string }[] }> } };
|
|
const pages = data.query?.pages;
|
|
if (!pages) return null;
|
|
for (const page of Object.values(pages)) {
|
|
const info = page.imageinfo?.[0];
|
|
// Only use actual photos (JPEG/PNG), skip SVGs and PDFs
|
|
const mime = (info as { mime?: string })?.mime || '';
|
|
if (info?.url && (mime.startsWith('image/jpeg') || mime.startsWith('image/png'))) {
|
|
const attribution = info.extmetadata?.Artist?.value?.replace(/<[^>]+>/g, '').trim() || null;
|
|
// iiurlwidth=400 makes Commons also return a scaled thumburl. Prefer it —
|
|
// info.url is the full-resolution original (multi-megapixel camera exports).
|
|
return { photoUrl: info.thumburl ?? info.url, attribution };
|
|
}
|
|
}
|
|
return null;
|
|
} catch { return null; }
|
|
}
|
|
|
|
// ── Search places (Google or Nominatim fallback) ─────────────────────────────
|
|
|
|
export async function searchPlaces(userId: number, query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }): Promise<{ places: Record<string, unknown>[]; source: string }> {
|
|
const apiKey = getMapsKey(userId);
|
|
|
|
if (!apiKey) {
|
|
const places = await searchNominatim(query, lang);
|
|
return { places, source: 'openstreetmap' };
|
|
}
|
|
|
|
const searchBody: Record<string, unknown> = { textQuery: query, languageCode: toApiLang(lang) };
|
|
// Bias results toward the caller's area when supplied — without it Google Text
|
|
// Search falls back to the API key's billing region, which skews foreign-region queries.
|
|
if (locationBias) {
|
|
searchBody.locationBias = {
|
|
circle: {
|
|
center: { latitude: locationBias.lat, longitude: locationBias.lng },
|
|
radius: locationBias.radius ?? 50000,
|
|
},
|
|
};
|
|
}
|
|
|
|
const response = await googleFetch('https://places.googleapis.com/v1/places:searchText', 'searchText', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Goog-Api-Key': apiKey,
|
|
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types',
|
|
},
|
|
body: JSON.stringify(searchBody),
|
|
});
|
|
|
|
const data = await response.json() as { places?: GooglePlaceResult[]; error?: { message?: string } };
|
|
|
|
if (!response.ok) {
|
|
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
|
|
err.status = response.status;
|
|
throw err;
|
|
}
|
|
|
|
const places = (data.places || []).map((p: GooglePlaceResult) => ({
|
|
google_place_id: p.id,
|
|
name: p.displayName?.text || '',
|
|
address: p.formattedAddress || '',
|
|
lat: p.location?.latitude || null,
|
|
lng: p.location?.longitude || null,
|
|
rating: p.rating || null,
|
|
website: p.websiteUri || null,
|
|
phone: p.nationalPhoneNumber || null,
|
|
types: p.types || [],
|
|
source: 'google',
|
|
}));
|
|
|
|
return { places, source: 'google' };
|
|
}
|
|
|
|
// ── Autocomplete (Google or Nominatim fallback) ─────────────────────────────
|
|
|
|
export async function autocompletePlaces(
|
|
userId: number,
|
|
input: string,
|
|
lang?: string,
|
|
locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } },
|
|
): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> {
|
|
const apiKey = getMapsKey(userId);
|
|
|
|
if (!apiKey) {
|
|
return autocompleteNominatim(input, lang);
|
|
}
|
|
|
|
const body: Record<string, unknown> = {
|
|
input,
|
|
languageCode: toApiLang(lang),
|
|
};
|
|
if (locationBias) {
|
|
body.locationBias = {
|
|
rectangle: {
|
|
low: { latitude: locationBias.low.lat, longitude: locationBias.low.lng },
|
|
high: { latitude: locationBias.high.lat, longitude: locationBias.high.lng },
|
|
},
|
|
};
|
|
}
|
|
|
|
const response = await googleFetch('https://places.googleapis.com/v1/places:autocomplete', 'autocomplete', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Goog-Api-Key': apiKey,
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
const data = await response.json() as { suggestions?: GoogleAutocompleteSuggestion[]; error?: { message?: string } };
|
|
|
|
if (!response.ok) {
|
|
const err = new Error(data.error?.message || 'Google Places Autocomplete error') as Error & { status: number };
|
|
err.status = response.status;
|
|
throw err;
|
|
}
|
|
|
|
const suggestions = (data.suggestions || [])
|
|
.filter((s) => s.placePrediction)
|
|
.slice(0, 5)
|
|
.map((s) => ({
|
|
placeId: s.placePrediction!.placeId,
|
|
mainText: s.placePrediction!.structuredFormat?.mainText?.text || '',
|
|
secondaryText: s.placePrediction!.structuredFormat?.secondaryText?.text || '',
|
|
}));
|
|
|
|
return { suggestions, source: 'google' };
|
|
}
|
|
|
|
async function autocompleteNominatim(
|
|
input: string,
|
|
lang?: string,
|
|
): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> {
|
|
try {
|
|
const places = await 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) ────────────────────────────────────────────
|
|
|
|
export async function getPlaceDetails(userId: number, placeId: string, lang?: string): Promise<{ place: Record<string, unknown> }> {
|
|
// OSM details: placeId is "node:123456" or "way:123456" etc.
|
|
if (placeId.includes(':')) {
|
|
const [osmType, osmId] = placeId.split(':');
|
|
const element = await fetchOverpassDetails(osmType, osmId);
|
|
const details = buildOsmDetails(element?.tags || {}, osmType, osmId);
|
|
|
|
// Fetch Nominatim only when Overpass lacks coordinates or address
|
|
const d = details as Record<string, unknown>;
|
|
const needsNominatim = !d.lat || !d.lng || !d.address;
|
|
const nominatim = needsNominatim ? await lookupNominatim(osmType, osmId, lang) : null;
|
|
|
|
return {
|
|
place: {
|
|
...details,
|
|
name: (d.name as string) || nominatim?.name || element?.tags?.name || '',
|
|
address: (d.address as string) || nominatim?.address || '',
|
|
lat: d.lat ?? nominatim?.lat ?? null,
|
|
lng: d.lng ?? nominatim?.lng ?? null,
|
|
osm_id: placeId,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Google details
|
|
const langKey = toApiLang(lang, 'de');
|
|
const apiKey = getMapsKey(userId);
|
|
if (!apiKey) {
|
|
throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
|
|
}
|
|
|
|
// Check DB cache first (lean mask, expanded=0) — 7-day TTL
|
|
const DETAILS_TTL = 7 * 24 * 60 * 60 * 1000;
|
|
const cached = db.prepare(
|
|
'SELECT payload_json, fetched_at FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 0'
|
|
).get(placeId, langKey) as { payload_json: string; fetched_at: number } | undefined;
|
|
if (cached && Date.now() - cached.fetched_at < DETAILS_TTL) return { place: JSON.parse(cached.payload_json) };
|
|
|
|
const response = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, `getPlaceDetails(${placeId})`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'X-Goog-Api-Key': apiKey,
|
|
'X-Goog-FieldMask': 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri',
|
|
},
|
|
});
|
|
|
|
const data = await response.json() as GooglePlaceDetails & { error?: { message?: string } };
|
|
|
|
if (!response.ok) {
|
|
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
|
|
err.status = response.status;
|
|
throw err;
|
|
}
|
|
|
|
const place = {
|
|
google_place_id: data.id,
|
|
name: data.displayName?.text || '',
|
|
address: data.formattedAddress || '',
|
|
lat: data.location?.latitude || null,
|
|
lng: data.location?.longitude || null,
|
|
rating: data.rating || null,
|
|
rating_count: data.userRatingCount || null,
|
|
website: data.websiteUri || null,
|
|
phone: data.nationalPhoneNumber || null,
|
|
opening_hours: data.regularOpeningHours?.weekdayDescriptions || null,
|
|
open_now: data.regularOpeningHours?.openNow ?? null,
|
|
google_maps_url: data.googleMapsUri || null,
|
|
summary: null,
|
|
reviews: [],
|
|
source: 'google' as const,
|
|
cached_at: Date.now(),
|
|
};
|
|
|
|
try {
|
|
db.prepare(
|
|
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 0, ?, ?)'
|
|
).run(placeId, langKey, JSON.stringify(place), Date.now());
|
|
} catch (dbErr) {
|
|
console.error('Failed to cache place details:', dbErr);
|
|
}
|
|
|
|
return { place };
|
|
}
|
|
|
|
export async function getPlaceDetailsExpanded(userId: number, placeId: string, lang?: string, refresh = false): Promise<{ place: Record<string, unknown> }> {
|
|
const langKey = toApiLang(lang, 'de');
|
|
const apiKey = getMapsKey(userId);
|
|
if (!apiKey) throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
|
|
|
|
// Check DB cache for expanded result
|
|
if (!refresh) {
|
|
const cached = db.prepare(
|
|
'SELECT payload_json FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 1'
|
|
).get(placeId, langKey) as { payload_json: string } | undefined;
|
|
if (cached) return { place: JSON.parse(cached.payload_json) };
|
|
}
|
|
|
|
const response = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, `getPlaceDetailsExpanded(${placeId})`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'X-Goog-Api-Key': apiKey,
|
|
'X-Goog-FieldMask': 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri,reviews,editorialSummary',
|
|
},
|
|
});
|
|
|
|
const data = await response.json() as GooglePlaceDetails & { error?: { message?: string } };
|
|
|
|
if (!response.ok) {
|
|
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
|
|
err.status = response.status;
|
|
throw err;
|
|
}
|
|
|
|
const place = {
|
|
google_place_id: data.id,
|
|
name: data.displayName?.text || '',
|
|
address: data.formattedAddress || '',
|
|
lat: data.location?.latitude || null,
|
|
lng: data.location?.longitude || null,
|
|
rating: data.rating || null,
|
|
rating_count: data.userRatingCount || null,
|
|
website: data.websiteUri || null,
|
|
phone: data.nationalPhoneNumber || null,
|
|
opening_hours: data.regularOpeningHours?.weekdayDescriptions || null,
|
|
open_now: data.regularOpeningHours?.openNow ?? null,
|
|
google_maps_url: data.googleMapsUri || null,
|
|
summary: data.editorialSummary?.text || null,
|
|
reviews: (data.reviews || []).slice(0, 5).map((r: NonNullable<GooglePlaceDetails['reviews']>[number]) => ({
|
|
author: r.authorAttribution?.displayName || null,
|
|
rating: r.rating || null,
|
|
text: r.text?.text || null,
|
|
time: r.relativePublishTimeDescription || null,
|
|
photo: r.authorAttribution?.photoUri || null,
|
|
})),
|
|
source: 'google' as const,
|
|
cached_at: Date.now(),
|
|
};
|
|
|
|
try {
|
|
db.prepare(
|
|
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 1, ?, ?)'
|
|
).run(placeId, langKey, JSON.stringify(place), Date.now());
|
|
} catch (dbErr) {
|
|
console.error('Failed to cache expanded place details:', dbErr);
|
|
}
|
|
|
|
return { place };
|
|
}
|
|
|
|
// ── Place photo (Google or Wikimedia, disk-cached) ────────────────────────────
|
|
|
|
export async function getPlacePhoto(
|
|
userId: number,
|
|
placeId: string,
|
|
lat: number,
|
|
lng: number,
|
|
name?: string,
|
|
): Promise<{ photoUrl: string; attribution: string | null }> {
|
|
// Disk cache hit — serve immediately, no Google call
|
|
const diskHit = placePhotoCache.get(placeId);
|
|
if (diskHit) return { photoUrl: diskHit.photoUrl, attribution: diskHit.attribution };
|
|
|
|
// Recent error — don't hammer the API
|
|
if (placePhotoCache.getErrored(placeId)) {
|
|
throw Object.assign(new Error('(Cache) No photo available'), { status: 404 });
|
|
}
|
|
|
|
// Deduplicate concurrent requests for the same placeId
|
|
const existing = placePhotoCache.getInFlight(placeId);
|
|
if (existing) {
|
|
const result = await existing;
|
|
if (!result) throw Object.assign(new Error('(Cache) No photo available'), { status: 404 });
|
|
return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution };
|
|
}
|
|
|
|
const fetchPromise = (async (): Promise<{ filePath: string; attribution: string | null } | null> => {
|
|
await acquirePhotoFetchSlot();
|
|
try {
|
|
const apiKey = getMapsKey(userId);
|
|
const isCoordLookup = placeId.startsWith('coords:');
|
|
|
|
// Coordinate-based Wikipedia/Wikimedia lookup. Used for coordinate-only
|
|
// (right-click) places and as a fallback when a Google place yields no photo,
|
|
// so a place added via search still gets a marker image when Google returns
|
|
// nothing. Returns null (without marking an error) so the caller decides.
|
|
const fetchWikimediaFallback = async (): Promise<{ filePath: string; attribution: string | null } | null> => {
|
|
if (isNaN(lat) || isNaN(lng)) return null;
|
|
try {
|
|
const wiki = await fetchWikimediaPhoto(lat, lng, name);
|
|
if (!wiki) return null;
|
|
// Follow redirects manually so each hop (the image URL can 3xx to a CDN
|
|
// host) is re-validated against the SSRF guard, not just the first URL.
|
|
const imgRes = await safeFetchFollow(wiki.photoUrl, undefined, { bypassInternalIpAllowed: true });
|
|
if (!imgRes.ok) return null;
|
|
const bytes = Buffer.from(await imgRes.arrayBuffer());
|
|
const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution);
|
|
return { filePath: cached.filePath, attribution: cached.attribution };
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Google Places photo for a Google place_id. Returns null (without marking an
|
|
// error) on any miss — no key, URL-shaped id, request rejected, no photos, or
|
|
// a failed media download — so the caller can fall back to Wikimedia.
|
|
const fetchGooglePhoto = async (): Promise<{ filePath: string; attribution: string | null } | null> => {
|
|
// URL-shaped placeIds aren't Google IDs — legacy DBs may store raw photo URLs in image_url
|
|
if (!apiKey || /^https?:\/\//i.test(placeId)) return null;
|
|
|
|
// Fetch details to get the photo name
|
|
const detailsRes = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, {
|
|
headers: {
|
|
'X-Goog-Api-Key': apiKey,
|
|
'X-Goog-FieldMask': 'photos',
|
|
},
|
|
});
|
|
const body = await detailsRes.text();
|
|
if (!detailsRes.ok) {
|
|
console.error('Google Places photo details error:', detailsRes.status, body.slice(0, 200));
|
|
return null;
|
|
}
|
|
let details: GooglePlaceDetails & { error?: { message?: string } };
|
|
try { details = body ? JSON.parse(body) : { photos: [] }; }
|
|
catch { return null; }
|
|
if (!details.photos?.length) return null;
|
|
|
|
const photo = details.photos[0];
|
|
const photoName = photo.name;
|
|
const attribution = photo.authorAttributions?.[0]?.displayName || null;
|
|
|
|
// Fetch actual image bytes
|
|
const mediaRes = await googleFetch(
|
|
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`,
|
|
`getPlacePhoto/media(${placeId})`,
|
|
{ headers: { 'X-Goog-Api-Key': apiKey } }
|
|
);
|
|
if (!mediaRes.ok) return null;
|
|
|
|
const bytes = Buffer.from(await mediaRes.arrayBuffer());
|
|
if (!bytes.length) return null;
|
|
|
|
const cached = await placePhotoCache.put(placeId, bytes, attribution);
|
|
|
|
// Persist stable proxy URL to database
|
|
try {
|
|
db.prepare(
|
|
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = \'\')'
|
|
).run(cached.photoUrl, placeId);
|
|
} catch (dbErr) {
|
|
console.error('Failed to persist photo URL to database:', dbErr);
|
|
}
|
|
|
|
return { filePath: cached.filePath, attribution };
|
|
};
|
|
|
|
// Prefer the Google photo (higher quality); if Google yields nothing, fall
|
|
// back to the same coordinate-based Wikipedia/OSM lookup that right-click
|
|
// places use. Coordinate-only ids skip Google entirely.
|
|
if (!isCoordLookup) {
|
|
const googlePhoto = await fetchGooglePhoto();
|
|
if (googlePhoto) return googlePhoto;
|
|
}
|
|
|
|
const fallback = await fetchWikimediaFallback();
|
|
if (fallback) return fallback;
|
|
|
|
placePhotoCache.markError(placeId);
|
|
return null;
|
|
} finally {
|
|
releasePhotoFetchSlot();
|
|
}
|
|
})();
|
|
|
|
placePhotoCache.setInFlight(placeId, fetchPromise);
|
|
|
|
const result = await fetchPromise;
|
|
if (!result) throw Object.assign(new Error('No photo available'), { status: 404 });
|
|
return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution };
|
|
}
|
|
|
|
// ── Reverse geocoding ────────────────────────────────────────────────────────
|
|
|
|
export async function reverseGeocode(lat: string, lng: string, lang?: string): Promise<{ name: string | null; address: string | null }> {
|
|
const params = new URLSearchParams({
|
|
lat, lon: lng, format: 'json', addressdetails: '1', zoom: '18',
|
|
'accept-language': toApiLang(lang),
|
|
});
|
|
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, {
|
|
headers: { 'User-Agent': UA },
|
|
});
|
|
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 = data.name || addr.tourism || addr.amenity || addr.shop || addr.building || addr.road || null;
|
|
return { name, address: data.display_name || null };
|
|
}
|
|
|
|
// ── Resolve Google Maps URL ──────────────────────────────────────────────────
|
|
|
|
export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number; lng: number; name: string | null; address: string | null }> {
|
|
let resolvedUrl = url;
|
|
|
|
// Extract coordinates from a string (URL or page body). Google Maps encodes
|
|
// them several ways: /@lat,lng,zoom · !3dlat!4dlng (map data param) · ?q=/?ll=.
|
|
const extractCoords = (s: string): { lat: number; lng: number } | null => {
|
|
const at = s.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
|
|
if (at) return { lat: parseFloat(at[1]), lng: parseFloat(at[2]) };
|
|
const data = s.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/);
|
|
if (data) return { lat: parseFloat(data[1]), lng: parseFloat(data[2]) };
|
|
const q = s.match(/[?&](?:q|ll)=(-?\d+\.\d+),(-?\d+\.\d+)/);
|
|
if (q) return { lat: parseFloat(q[1]), lng: parseFloat(q[2]) };
|
|
return null;
|
|
};
|
|
|
|
const followRedirects = async (target: string, init?: RequestInit): Promise<Response> => {
|
|
try {
|
|
return await safeFetchFollow(
|
|
target,
|
|
{ signal: AbortSignal.timeout(10000), ...init },
|
|
{ bypassInternalIpAllowed: true },
|
|
);
|
|
} catch (err) {
|
|
if (err instanceof SsrfBlockedError) {
|
|
throw Object.assign(new Error('URL blocked by SSRF check'), { status: 403 });
|
|
}
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
// Follow redirects for short URLs (goo.gl, maps.app.goo.gl) and for Google Maps
|
|
// URLs that carry no inline coordinates — e.g. ?cid= links (the format
|
|
// get_place_details returns) and "Share"-button links. The redirect target
|
|
// usually carries the !3d!4d data param we can then parse. Redirects are
|
|
// followed manually so every hop is SSRF-re-checked.
|
|
const parsed = new URL(url);
|
|
const GOOGLE_MAPS_HOSTS = ['goo.gl', 'maps.app.goo.gl', 'google.com', 'www.google.com', 'maps.google.com'];
|
|
const isShort = ['goo.gl', 'maps.app.goo.gl'].includes(parsed.hostname);
|
|
const isGoogleMaps = GOOGLE_MAPS_HOSTS.includes(parsed.hostname);
|
|
if (isShort || (isGoogleMaps && !extractCoords(url))) {
|
|
resolvedUrl = (await followRedirects(url)).url || resolvedUrl;
|
|
}
|
|
|
|
let coords = extractCoords(resolvedUrl);
|
|
|
|
// Still nothing (e.g. a cid page whose final URL lacks coordinates): fetch the
|
|
// page body once and parse the coordinates out of the embedded map data.
|
|
if (!coords) {
|
|
try {
|
|
const pageRes = await followRedirects(resolvedUrl, {
|
|
headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' },
|
|
});
|
|
coords = extractCoords(await pageRes.text());
|
|
} catch (err) {
|
|
if ((err as { status?: number })?.status === 403) throw err; // SSRF block — surface it
|
|
// Otherwise fall through to the not-found error below.
|
|
}
|
|
}
|
|
|
|
// Extract place name from URL path: /place/Place+Name/@...
|
|
let placeName: string | null = null;
|
|
const placeMatch = resolvedUrl.match(/\/place\/([^/@]+)/);
|
|
if (placeMatch) {
|
|
placeName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
|
|
}
|
|
|
|
if (!coords || isNaN(coords.lat) || isNaN(coords.lng)) {
|
|
throw Object.assign(new Error('Could not extract coordinates from URL'), { status: 400 });
|
|
}
|
|
const { lat, lng } = coords;
|
|
|
|
// Reverse geocode to get address
|
|
const nominatimRes = await fetch(
|
|
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`,
|
|
{ headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' }, signal: AbortSignal.timeout(8000) }
|
|
);
|
|
const nominatim = await nominatimRes.json() as { display_name?: string; name?: string; address?: Record<string, string> };
|
|
|
|
const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null;
|
|
const address = nominatim.display_name || null;
|
|
|
|
return { lat, lng, name, address };
|
|
}
|