Spaces:
Sleeping
Sleeping
| import { db, getPlaceWithTags } from '../db/database'; | |
| import { broadcast } from '../websocket'; | |
| import { getMapsKey, searchPlaces, getPlacePhoto } from './mapsService'; | |
| /** | |
| * Background enrichment for list-imported places (#886). | |
| * | |
| * Google/Naver list imports only carry name + coordinates, so the imported | |
| * places open as bare pins (the Maps tab jumps to coordinates, no photo, no | |
| * open/closed). When the importer opts in and a Google Maps key is configured, | |
| * we re-resolve each place by name β biased to and validated against the | |
| * imported coordinates β to a real Google place, then fill in the empty fields | |
| * and persist the resolved `google_place_id` (which is what powers on-demand | |
| * opening hours / the proper Maps link going forward). | |
| * | |
| * This runs detached from the import request (fire-and-forget) so a long list | |
| * never blocks the response, and pushes each enriched row over the websocket so | |
| * the sidebar fills in progressively. It only ever fills EMPTY columns, so it | |
| * can never clobber data the import already captured (e.g. a Naver address). | |
| */ | |
| /** A place the import produced β only the fields enrichment reads/writes. */ | |
| export interface EnrichablePlace { | |
| id: number; | |
| name: string; | |
| lat: number; | |
| lng: number; | |
| google_place_id?: string | null; | |
| address?: string | null; | |
| website?: string | null; | |
| phone?: string | null; | |
| image_url?: string | null; | |
| } | |
| /** How close a search hit must be to the imported coordinates to be trusted. */ | |
| const MATCH_RADIUS_METERS = 250; | |
| /** Bias the text search to roughly the imported area. */ | |
| const SEARCH_BIAS_RADIUS_METERS = 2000; | |
| /** Concurrent enrichment lookups β small, to stay friendly to the Maps quota. */ | |
| const ENRICH_CONCURRENCY = 3; | |
| function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number { | |
| const R = 6371000; | |
| const toRad = (d: number) => (d * Math.PI) / 180; | |
| const dLat = toRad(b.lat - a.lat); | |
| const dLng = toRad(b.lng - a.lng); | |
| const lat1 = toRad(a.lat); | |
| const lat2 = toRad(b.lat); | |
| const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2; | |
| return 2 * R * Math.asin(Math.sqrt(h)); | |
| } | |
| /** | |
| * Pick the search result that is the same place as the import: it must be a | |
| * Google result (have a google_place_id) with coordinates within | |
| * MATCH_RADIUS_METERS of the imported point. Returns the closest such hit, or | |
| * null when nothing is close enough β in which case the place is left as | |
| * imported rather than risking a wrong-place overwrite (common-name / romanized | |
| * lists). Exported for unit testing. | |
| */ | |
| export function pickEnrichmentMatch( | |
| candidates: Record<string, unknown>[], | |
| target: { lat: number; lng: number }, | |
| maxMeters: number = MATCH_RADIUS_METERS, | |
| ): Record<string, unknown> | null { | |
| let best: { c: Record<string, unknown>; dist: number } | null = null; | |
| for (const c of candidates || []) { | |
| const gpid = c.google_place_id; | |
| const lat = c.lat; | |
| const lng = c.lng; | |
| if (typeof gpid !== 'string' || !gpid) continue; | |
| if (typeof lat !== 'number' || typeof lng !== 'number') continue; | |
| const dist = haversineMeters(target, { lat, lng }); | |
| if (dist > maxMeters) continue; | |
| if (!best || dist < best.dist) best = { c, dist }; | |
| } | |
| return best?.c ?? null; | |
| } | |
| async function mapWithConcurrency<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> { | |
| let cursor = 0; | |
| const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { | |
| while (cursor < items.length) { | |
| const item = items[cursor++]; | |
| await fn(item); | |
| } | |
| }); | |
| await Promise.all(workers); | |
| } | |
| const str = (v: unknown): string | null => (typeof v === 'string' && v.trim() ? v.trim() : null); | |
| async function enrichOne(tripId: string, userId: number, place: EnrichablePlace, lang?: string): Promise<void> { | |
| // Already linked (shouldn't happen for list imports) β nothing to resolve. | |
| if (place.google_place_id) return; | |
| if (typeof place.lat !== 'number' || typeof place.lng !== 'number') return; | |
| const { places: results } = await searchPlaces(userId, place.name, lang, { | |
| lat: place.lat, | |
| lng: place.lng, | |
| radius: SEARCH_BIAS_RADIUS_METERS, | |
| }); | |
| const match = pickEnrichmentMatch(results, { lat: place.lat, lng: place.lng }); | |
| if (!match) return; | |
| const gpid = str(match.google_place_id); | |
| if (!gpid) return; | |
| // COALESCE so enrichment only fills empty columns β never overwrites data the | |
| // import already captured (e.g. Naver's address) or anything the user edited. | |
| db.prepare( | |
| `UPDATE places | |
| SET google_place_id = COALESCE(google_place_id, ?), | |
| address = COALESCE(address, ?), | |
| website = COALESCE(website, ?), | |
| phone = COALESCE(phone, ?), | |
| updated_at = CURRENT_TIMESTAMP | |
| WHERE id = ? AND trip_id = ?`, | |
| ).run(gpid, str(match.address), str(match.website), str(match.phone), place.id, tripId); | |
| // Photo is best-effort: Google often has none, and getPlacePhoto throws 404 in | |
| // that case β a missing photo must never abort the rest of the enrichment. | |
| try { | |
| const photo = await getPlacePhoto(userId, gpid, place.lat, place.lng, place.name); | |
| if (photo?.photoUrl) { | |
| db.prepare( | |
| 'UPDATE places SET image_url = COALESCE(image_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?', | |
| ).run(photo.photoUrl, place.id, tripId); | |
| } | |
| } catch { | |
| /* no photo β leave image_url as-is */ | |
| } | |
| // Push the enriched row to every connected client (no socket exclusion: the | |
| // importer's own client should also receive the late update). | |
| const updated = getPlaceWithTags(place.id); | |
| if (updated) broadcast(tripId, 'place:updated', { place: updated }, undefined); | |
| } | |
| /** | |
| * Enrich a batch of just-imported places in the background. Never throws β | |
| * any per-place failure is swallowed so one bad lookup can't take down the | |
| * detached task or the process. No-ops when no Google Maps key is configured. | |
| */ | |
| export async function enrichImportedPlaces( | |
| tripId: string, | |
| userId: number, | |
| places: EnrichablePlace[], | |
| lang?: string, | |
| ): Promise<void> { | |
| try { | |
| if (!places.length) return; | |
| if (!getMapsKey(userId)) return; | |
| await mapWithConcurrency(places, ENRICH_CONCURRENCY, async (place) => { | |
| try { | |
| await enrichOne(tripId, userId, place, lang); | |
| } catch (err) { | |
| console.error(`[Places] enrichment failed for place ${place.id}:`, err instanceof Error ? err.message : err); | |
| } | |
| }); | |
| } catch (err) { | |
| console.error('[Places] import enrichment pass failed:', err instanceof Error ? err.message : err); | |
| } | |
| } | |