mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
fix(maps): reduce Google Places API quota usage with persistent caching
P0 — stop the bleeding:
- Honor place.image_url in MapView and TripPlannerPage to skip redundant fetchPhoto calls
- Trim Place Details field mask (drop reviews/editorialSummary from default; new getPlaceDetailsExpanded for inspector)
- Admin toggle places_photos_enabled (default ON) to kill Google photo fetches under quota pressure; Wikimedia unaffected
- Return { photoUrl: null } instead of 204 so client handles disabled state cleanly
P1 — structural fix:
- New placePhotoCache service: persistent disk cache at uploads/photos/google/<sha1>.jpg, atomic writes, stampede dedup via in-flight Map
- Migrations 105-107: google_place_photo_meta table, place_details_cache table, backfill signed Google URLs to stable proxy URLs
- getPlacePhoto rewrites to fetch image bytes directly, store on disk, return /api/maps/place-photo/:id/bytes proxy URL
- Stable proxy URLs written to places.image_url — survive container restarts, no expiry
- New GET /api/maps/place-photo/:placeId/bytes route serving cached files with long-lived Cache-Control
- Place Details DB row cache with 7-day TTL; ?refresh=1 escape hatch
- photoService fast-path: proxy URLs bypass the mapsApi round-trip and go straight to urlToBase64
Bug fixes:
- MapView now requests base64 thumbs for places with proxy image_url (markers were showing color fallback)
- createPlaceIcon accepts /api/maps/place-photo/ URLs as interim fallback while thumb generates
- setSelectedAssignmentId ReferenceError in mobile day-detail handler (use selectAssignment)
- Remove redundant decodeURIComponent on already-decoded Express route param
- Use SHA1 hash for disk filenames to prevent coords:lat:lng pseudo-ID collisions
- Add checkSsrf guard to Wikimedia byte fetch
- Tighten migration 107 LIKE filter to avoid rewriting manually-pasted Google image URLs
- Validate enabled is boolean on PUT /admin/places-photos
- Drop aggressive iconCache.clear() on every thumb arrival
Observability:
- googleFetch() wrapper counts and debug-logs every outbound Google API call with running total
This commit is contained in:
+3
-2
@@ -100,7 +100,7 @@ function RootRedirect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
|
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled } = useAuthStore()
|
||||||
const { loadSettings } = useSettingsStore()
|
const { loadSettings } = useSettingsStore()
|
||||||
const { loadAddons } = useAddonStore()
|
const { loadAddons } = useAddonStore()
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ export default function App() {
|
|||||||
loadUser()
|
loadUser()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||||
if (config?.demo_mode) setDemoMode(true)
|
if (config?.demo_mode) setDemoMode(true)
|
||||||
if (config?.dev_mode) setDevMode(true)
|
if (config?.dev_mode) setDevMode(true)
|
||||||
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
|
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
|
||||||
@@ -125,6 +125,7 @@ export default function App() {
|
|||||||
if (config?.timezone) setServerTimezone(config.timezone)
|
if (config?.timezone) setServerTimezone(config.timezone)
|
||||||
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
|
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
|
||||||
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
|
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
|
||||||
|
if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled)
|
||||||
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
||||||
|
|
||||||
if (config?.version) {
|
if (config?.version) {
|
||||||
|
|||||||
@@ -272,6 +272,8 @@ export const adminApi = {
|
|||||||
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
||||||
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
|
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
|
||||||
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
||||||
|
getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data),
|
||||||
|
updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data),
|
||||||
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
|
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
|
||||||
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
|
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
|
||||||
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
|
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
|
||||||
|
|||||||
@@ -66,9 +66,9 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
|||||||
">${label}</span>`
|
">${label}</span>`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Base64 data URL thumbnails — no external image fetch during zoom
|
// Prefer base64 data URLs (no zoom lag); also accept same-origin proxy URLs as a fallback
|
||||||
// Only use base64 data URLs for markers — external URLs cause zoom lag
|
// while the thumb is still being generated in the background
|
||||||
if (place.image_url && place.image_url.startsWith('data:')) {
|
if (place.image_url && (place.image_url.startsWith('data:') || place.image_url.startsWith('/api/maps/place-photo/'))) {
|
||||||
const imgIcon = L.divIcon({
|
const imgIcon = L.divIcon({
|
||||||
className: '',
|
className: '',
|
||||||
html: `<div style="
|
html: `<div style="
|
||||||
@@ -275,6 +275,7 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
|||||||
|
|
||||||
// Module-level photo cache shared with PlaceAvatar
|
// Module-level photo cache shared with PlaceAvatar
|
||||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
|
||||||
// Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
|
// Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
|
||||||
function LocationTracker() {
|
function LocationTracker() {
|
||||||
@@ -398,20 +399,19 @@ export const MapView = memo(function MapView({
|
|||||||
|
|
||||||
// photoUrls: only base64 thumbs for smooth map zoom
|
// photoUrls: only base64 thumbs for smooth map zoom
|
||||||
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
|
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
|
||||||
|
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||||
|
|
||||||
// Fetch photos via shared service — subscribe to thumb (base64) availability
|
// Fetch photos via shared service — subscribe to thumb (base64) availability
|
||||||
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
|
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!places || places.length === 0) return
|
if (!places || places.length === 0 || !placesPhotosEnabled) return
|
||||||
const cleanups: (() => void)[] = []
|
const cleanups: (() => void)[] = []
|
||||||
|
|
||||||
const setThumb = (cacheKey: string, thumb: string) => {
|
const setThumb = (cacheKey: string, thumb: string) => {
|
||||||
iconCache.clear()
|
|
||||||
setPhotoUrls(prev => prev[cacheKey] === thumb ? prev : { ...prev, [cacheKey]: thumb })
|
setPhotoUrls(prev => prev[cacheKey] === thumb ? prev : { ...prev, [cacheKey]: thumb })
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const place of places) {
|
for (const place of places) {
|
||||||
if (place.image_url && place.image_url.startsWith('data:')) continue
|
|
||||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||||
if (!cacheKey) continue
|
if (!cacheKey) continue
|
||||||
|
|
||||||
@@ -424,9 +424,9 @@ export const MapView = memo(function MapView({
|
|||||||
// Subscribe for when thumb becomes available
|
// Subscribe for when thumb becomes available
|
||||||
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
|
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
|
||||||
|
|
||||||
// Always fetch through API — returns fresh URL + converts to base64
|
|
||||||
if (!cached && !isLoading(cacheKey)) {
|
if (!cached && !isLoading(cacheKey)) {
|
||||||
const photoId = place.google_place_id || place.osm_id
|
// Use the persisted proxy URL as photoId so photoService generates a base64 thumb from it
|
||||||
|
const photoId = place.image_url || place.google_place_id || place.osm_id
|
||||||
if (photoId || (place.lat && place.lng)) {
|
if (photoId || (place.lat && place.lng)) {
|
||||||
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||||
}
|
}
|
||||||
@@ -434,7 +434,7 @@ export const MapView = memo(function MapView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return () => cleanups.forEach(fn => fn())
|
return () => cleanups.forEach(fn => fn())
|
||||||
}, [placeIds])
|
}, [placeIds, placesPhotosEnabled])
|
||||||
|
|
||||||
const clusterIconCreateFunction = useCallback((cluster) => {
|
const clusterIconCreateFunction = useCallback((cluster) => {
|
||||||
const count = cluster.getChildCount()
|
const count = cluster.getChildCount()
|
||||||
@@ -451,7 +451,7 @@ export const MapView = memo(function MapView({
|
|||||||
const markers = useMemo(() => places.map((place) => {
|
const markers = useMemo(() => places.map((place) => {
|
||||||
const isSelected = place.id === selectedPlaceId
|
const isSelected = place.id === selectedPlaceId
|
||||||
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||||
const resolvedPhoto = (pck && photoUrls[pck]) || (place.image_url?.startsWith('data:') ? place.image_url : null) || null
|
const resolvedPhoto = (pck && photoUrls[pck]) || place.image_url || null
|
||||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||||
const icon = createPlaceIcon({ ...place, image_url: resolvedPhoto }, orderNumbers, isSelected)
|
const icon = createPlaceIcon({ ...place, image_url: resolvedPhoto }, orderNumbers, isSelected)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import { getCategoryIcon } from './categoryIcons'
|
import { getCategoryIcon } from './categoryIcons'
|
||||||
import { getCached, isLoading, fetchPhoto, onThumbReady } from '../../services/photoService'
|
import { getCached, isLoading, fetchPhoto, onThumbReady } from '../../services/photoService'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import type { Place } from '../../types'
|
import type { Place } from '../../types'
|
||||||
|
|
||||||
interface Category {
|
interface Category {
|
||||||
@@ -18,10 +19,12 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
|
|||||||
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
||||||
const [visible, setVisible] = useState(false)
|
const [visible, setVisible] = useState(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||||
|
|
||||||
// Observe visibility — fetch photo only when avatar enters viewport
|
// Observe visibility — fetch photo only when avatar enters viewport
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (place.image_url) { setVisible(true); return }
|
if (place.image_url) { setVisible(true); return }
|
||||||
|
if (!placesPhotosEnabled) return
|
||||||
const el = ref.current
|
const el = ref.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
// Check if already cached — show immediately without waiting for intersection
|
// Check if already cached — show immediately without waiting for intersection
|
||||||
@@ -37,6 +40,7 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) return
|
if (!visible) return
|
||||||
if (place.image_url) { setPhotoSrc(place.image_url); return }
|
if (place.image_url) { setPhotoSrc(place.image_url); return }
|
||||||
|
if (!placesPhotosEnabled) return
|
||||||
const photoId = place.google_place_id || place.osm_id
|
const photoId = place.google_place_id || place.osm_id
|
||||||
if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return }
|
if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return }
|
||||||
|
|
||||||
|
|||||||
@@ -549,6 +549,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.fileTypesFormat': 'Kommagetrennte Endungen (z.B. jpg,png,pdf,doc). Verwende * um alle Typen zu erlauben.',
|
'admin.fileTypesFormat': 'Kommagetrennte Endungen (z.B. jpg,png,pdf,doc). Verwende * um alle Typen zu erlauben.',
|
||||||
'admin.fileTypesSaved': 'Dateityp-Einstellungen gespeichert',
|
'admin.fileTypesSaved': 'Dateityp-Einstellungen gespeichert',
|
||||||
|
|
||||||
|
'admin.placesPhotos.title': 'Ortsfotos',
|
||||||
|
'admin.placesPhotos.subtitle': 'Fotos von der Google Places API laden. Deaktivieren, um API-Kontingent zu sparen. Wikimedia-Fotos sind davon nicht betroffen.',
|
||||||
// Packing Templates & Bag Tracking
|
// Packing Templates & Bag Tracking
|
||||||
'admin.bagTracking.title': 'Gepäck-Tracking',
|
'admin.bagTracking.title': 'Gepäck-Tracking',
|
||||||
'admin.bagTracking.subtitle': 'Gewicht und Gepäckstück-Zuordnung für Packlisteneinträge aktivieren',
|
'admin.bagTracking.subtitle': 'Gewicht und Gepäckstück-Zuordnung für Packlisteneinträge aktivieren',
|
||||||
|
|||||||
@@ -609,6 +609,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.fileTypesFormat': 'Comma-separated extensions (e.g. jpg,png,pdf,doc). Use * to allow all types.',
|
'admin.fileTypesFormat': 'Comma-separated extensions (e.g. jpg,png,pdf,doc). Use * to allow all types.',
|
||||||
'admin.fileTypesSaved': 'File type settings saved',
|
'admin.fileTypesSaved': 'File type settings saved',
|
||||||
|
|
||||||
|
'admin.placesPhotos.title': 'Place Photos',
|
||||||
|
'admin.placesPhotos.subtitle': 'Fetch photos from the Google Places API. Disable to save API quota. Wikimedia photos are unaffected.',
|
||||||
// Packing Templates & Bag Tracking
|
// Packing Templates & Bag Tracking
|
||||||
'admin.bagTracking.title': 'Bag Tracking',
|
'admin.bagTracking.title': 'Bag Tracking',
|
||||||
'admin.bagTracking.subtitle': 'Enable weight and bag assignment for packing items',
|
'admin.bagTracking.subtitle': 'Enable weight and bag assignment for packing items',
|
||||||
|
|||||||
@@ -194,6 +194,10 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
const [bagTrackingEnabled, setBagTrackingEnabled] = useState<boolean>(false)
|
const [bagTrackingEnabled, setBagTrackingEnabled] = useState<boolean>(false)
|
||||||
useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, [])
|
useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, [])
|
||||||
|
|
||||||
|
// Places photos
|
||||||
|
const [placesPhotosEnabled, setPlacesPhotosEnabledState] = useState<boolean>(true)
|
||||||
|
useEffect(() => { adminApi.getPlacesPhotos().then(d => setPlacesPhotosEnabledState(d.enabled)).catch(() => {}) }, [])
|
||||||
|
|
||||||
// Collab features
|
// Collab features
|
||||||
const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true })
|
const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true })
|
||||||
useEffect(() => { adminApi.getCollabFeatures().then(d => setCollabFeatures(d)).catch(() => {}) }, [])
|
useEffect(() => { adminApi.getCollabFeatures().then(d => setCollabFeatures(d)).catch(() => {}) }, [])
|
||||||
@@ -242,7 +246,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
|
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
|
||||||
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false)
|
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false)
|
||||||
|
|
||||||
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, logout } = useAuthStore()
|
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, logout } = useAuthStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
@@ -1023,6 +1027,25 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Place Photos Toggle */}
|
||||||
|
<div className="flex items-center justify-between py-3 border-t border-slate-100">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700">{t('admin.placesPhotos.title')}</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesPhotos.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const next = !placesPhotosEnabled
|
||||||
|
setPlacesPhotosEnabledState(next)
|
||||||
|
setPlacesPhotosEnabled(next)
|
||||||
|
try { await adminApi.updatePlacesPhotos(next) } catch { setPlacesPhotosEnabledState(!next); setPlacesPhotosEnabled(!next) }
|
||||||
|
}}
|
||||||
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${placesPhotosEnabled ? 'bg-indigo-600' : 'bg-slate-200'}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${placesPhotosEnabled ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Open-Meteo Weather Info */}
|
{/* Open-Meteo Weather Info */}
|
||||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 dark:bg-emerald-950/30 dark:border-emerald-800 overflow-hidden">
|
<div className="rounded-lg border border-emerald-200 bg-emerald-50 dark:bg-emerald-950/30 dark:border-emerald-800 overflow-hidden">
|
||||||
<div className="px-4 py-3 flex items-center justify-between">
|
<div className="px-4 py-3 flex items-center justify-between">
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { useTranslation } from '../i18n'
|
|||||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client'
|
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client'
|
||||||
import { accommodationRepo } from '../repo/accommodationRepo'
|
import { accommodationRepo } from '../repo/accommodationRepo'
|
||||||
import { offlineDb } from '../db/offlineDb'
|
import { offlineDb } from '../db/offlineDb'
|
||||||
|
import { useAuthStore } from '../store/authStore'
|
||||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||||
import { useResizablePanels } from '../hooks/useResizablePanels'
|
import { useResizablePanels } from '../hooks/useResizablePanels'
|
||||||
import { useTripWebSocket } from '../hooks/useTripWebSocket'
|
import { useTripWebSocket } from '../hooks/useTripWebSocket'
|
||||||
@@ -75,6 +76,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, language } = useTranslation()
|
const { t, language } = useTranslation()
|
||||||
const { settings } = useSettingsStore()
|
const { settings } = useSettingsStore()
|
||||||
|
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||||
const trip = useTripStore(s => s.trip)
|
const trip = useTripStore(s => s.trip)
|
||||||
const days = useTripStore(s => s.days)
|
const days = useTripStore(s => s.days)
|
||||||
const places = useTripStore(s => s.places)
|
const places = useTripStore(s => s.places)
|
||||||
@@ -178,7 +180,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
|
|
||||||
// Start photo fetches during splash screen so images are ready when map mounts
|
// Start photo fetches during splash screen so images are ready when map mounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || !places || places.length === 0) return
|
if (isLoading || !places || places.length === 0 || !placesPhotosEnabled) return
|
||||||
for (const p of places) {
|
for (const p of places) {
|
||||||
if (p.image_url) continue
|
if (p.image_url) continue
|
||||||
const cacheKey = p.google_place_id || p.osm_id || `${p.lat},${p.lng}`
|
const cacheKey = p.google_place_id || p.osm_id || `${p.lat},${p.lng}`
|
||||||
@@ -900,7 +902,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
{mobileSidebarOpen === 'left'
|
{mobileSidebarOpen === 'left'
|
||||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} />
|
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} />
|
||||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
|
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -85,6 +85,19 @@ export function fetchPhoto(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If photoId is already our stable proxy URL, use it directly — no API round-trip needed
|
||||||
|
if (photoId && photoId.startsWith('/api/maps/place-photo/')) {
|
||||||
|
const entry: PhotoEntry = { photoUrl: photoId, thumbDataUrl: null }
|
||||||
|
cache.set(cacheKey, entry)
|
||||||
|
callback?.(entry)
|
||||||
|
notify(cacheKey, entry)
|
||||||
|
// Generate base64 thumb in background
|
||||||
|
urlToBase64(photoId).then(thumb => {
|
||||||
|
if (thumb) { entry.thumbDataUrl = thumb; notifyThumb(cacheKey, thumb) }
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
inFlight.add(cacheKey)
|
inFlight.add(cacheKey)
|
||||||
mapsApi.placePhoto(photoId, lat, lng, name)
|
mapsApi.placePhoto(photoId, lat, lng, name)
|
||||||
.then(async (data: { photoUrl?: string }) => {
|
.then(async (data: { photoUrl?: string }) => {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ interface AuthState {
|
|||||||
/** Server policy: all users must enable MFA */
|
/** Server policy: all users must enable MFA */
|
||||||
appRequireMfa: boolean
|
appRequireMfa: boolean
|
||||||
tripRemindersEnabled: boolean
|
tripRemindersEnabled: boolean
|
||||||
|
placesPhotosEnabled: boolean
|
||||||
|
|
||||||
login: (email: string, password: string) => Promise<LoginResult>
|
login: (email: string, password: string) => Promise<LoginResult>
|
||||||
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
|
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
|
||||||
@@ -53,6 +54,7 @@ interface AuthState {
|
|||||||
setServerTimezone: (tz: string) => void
|
setServerTimezone: (tz: string) => void
|
||||||
setAppRequireMfa: (val: boolean) => void
|
setAppRequireMfa: (val: boolean) => void
|
||||||
setTripRemindersEnabled: (val: boolean) => void
|
setTripRemindersEnabled: (val: boolean) => void
|
||||||
|
setPlacesPhotosEnabled: (val: boolean) => void
|
||||||
demoLogin: () => Promise<AuthResponse>
|
demoLogin: () => Promise<AuthResponse>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +76,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
appRequireMfa: false,
|
appRequireMfa: false,
|
||||||
tripRemindersEnabled: false,
|
tripRemindersEnabled: false,
|
||||||
|
placesPhotosEnabled: true,
|
||||||
|
|
||||||
login: async (email: string, password: string) => {
|
login: async (email: string, password: string) => {
|
||||||
authSequence++
|
authSequence++
|
||||||
@@ -257,6 +260,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
|
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
|
||||||
setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
|
setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
|
||||||
setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }),
|
setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }),
|
||||||
|
setPlacesPhotosEnabled: (val: boolean) => set({ placesPhotosEnabled: val }),
|
||||||
|
|
||||||
demoLogin: async () => {
|
demoLogin: async () => {
|
||||||
authSequence++
|
authSequence++
|
||||||
|
|||||||
@@ -1634,6 +1634,45 @@ function runMigrations(db: Database.Database): void {
|
|||||||
try { db.exec('ALTER TABLE trip_album_links ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
try { db.exec('ALTER TABLE trip_album_links ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||||
try { db.exec('ALTER TABLE trek_photos ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
try { db.exec('ALTER TABLE trek_photos ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||||
},
|
},
|
||||||
|
// Migration 105: Persistent Google place photo disk cache registry
|
||||||
|
() => {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS google_place_photo_meta (
|
||||||
|
place_id TEXT PRIMARY KEY,
|
||||||
|
attribution TEXT,
|
||||||
|
fetched_at INTEGER NOT NULL,
|
||||||
|
error_at INTEGER
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
// Migration 106: Persistent Place Details row cache
|
||||||
|
() => {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS place_details_cache (
|
||||||
|
place_id TEXT NOT NULL,
|
||||||
|
lang TEXT NOT NULL DEFAULT '',
|
||||||
|
expanded INTEGER NOT NULL DEFAULT 0,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
fetched_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (place_id, lang, expanded)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
// Migration 107: Backfill expired signed Google photo URLs to stable proxy URLs
|
||||||
|
{ raw: () => {
|
||||||
|
db.exec(`
|
||||||
|
UPDATE places
|
||||||
|
SET image_url = '/api/maps/place-photo/' || google_place_id || '/bytes',
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE google_place_id IS NOT NULL
|
||||||
|
AND image_url IS NOT NULL
|
||||||
|
AND image_url != ''
|
||||||
|
AND (
|
||||||
|
(image_url LIKE '%googleusercontent.com%' AND image_url LIKE '%/places/%/photos/%')
|
||||||
|
OR (image_url LIKE '%places.googleapis.com%' AND image_url LIKE '%/places/%/photos/%')
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (currentVersion < migrations.length) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
@@ -201,6 +201,25 @@ router.put('/bag-tracking', (req: Request, res: Response) => {
|
|||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Places Photos ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/places-photos', (_req: Request, res: Response) => {
|
||||||
|
res.json(svc.getPlacesPhotos());
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/places-photos', (req: Request, res: Response) => {
|
||||||
|
if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' });
|
||||||
|
const result = svc.updatePlacesPhotos(req.body.enabled);
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
writeAudit({
|
||||||
|
userId: authReq.user.id,
|
||||||
|
action: 'admin.places_photos',
|
||||||
|
ip: getClientIp(req),
|
||||||
|
details: { enabled: result.enabled },
|
||||||
|
});
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Collab Features ───────────────────────────────────────────────────────
|
// ── Collab Features ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get('/collab-features', (_req: Request, res: Response) => {
|
router.get('/collab-features', (_req: Request, res: Response) => {
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import { AuthRequest } from '../types';
|
|||||||
import {
|
import {
|
||||||
searchPlaces,
|
searchPlaces,
|
||||||
getPlaceDetails,
|
getPlaceDetails,
|
||||||
|
getPlaceDetailsExpanded,
|
||||||
getPlacePhoto,
|
getPlacePhoto,
|
||||||
reverseGeocode,
|
reverseGeocode,
|
||||||
resolveGoogleMapsUrl,
|
resolveGoogleMapsUrl,
|
||||||
autocompletePlaces,
|
autocompletePlaces,
|
||||||
} from '../services/mapsService';
|
} from '../services/mapsService';
|
||||||
|
import { db } from '../db/database';
|
||||||
|
import { serveFilePath } from '../services/placePhotoCache';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -72,9 +75,13 @@ router.post('/autocomplete', authenticate, async (req: Request, res: Response) =
|
|||||||
router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => {
|
router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { placeId } = req.params;
|
const { placeId } = req.params;
|
||||||
|
const expand = req.query.expand as string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getPlaceDetails(authReq.user.id, placeId, req.query.lang as string);
|
const refresh = req.query.refresh === '1';
|
||||||
|
const result = expand
|
||||||
|
? await getPlaceDetailsExpanded(authReq.user.id, placeId, req.query.lang as string, refresh)
|
||||||
|
: await getPlaceDetails(authReq.user.id, placeId, req.query.lang as string);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const status = (err as { status?: number }).status || 500;
|
const status = (err as { status?: number }).status || 500;
|
||||||
@@ -88,6 +95,12 @@ router.get('/details/:placeId', authenticate, async (req: Request, res: Response
|
|||||||
router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Response) => {
|
router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { placeId } = req.params;
|
const { placeId } = req.params;
|
||||||
|
|
||||||
|
// Kill-switch only applies to Google Places API fetches — Wikimedia (coords: prefix) is always allowed
|
||||||
|
if (!placeId.startsWith('coords:')) {
|
||||||
|
const photosEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined;
|
||||||
|
if (photosEnabledRow?.value === 'false') return res.status(200).json({ photoUrl: null });
|
||||||
|
}
|
||||||
const lat = parseFloat(req.query.lat as string);
|
const lat = parseFloat(req.query.lat as string);
|
||||||
const lng = parseFloat(req.query.lng as string);
|
const lng = parseFloat(req.query.lng as string);
|
||||||
|
|
||||||
@@ -102,6 +115,15 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /place-photo/:placeId/bytes — serve cached photo bytes from disk
|
||||||
|
router.get('/place-photo/:placeId/bytes', authenticate, (req: Request, res: Response) => {
|
||||||
|
const { placeId } = req.params;
|
||||||
|
const fp = serveFilePath(placeId);
|
||||||
|
if (!fp) return res.status(404).json({ error: 'Photo not cached' });
|
||||||
|
res.set('Cache-Control', 'public, max-age=2592000, immutable');
|
||||||
|
res.sendFile(fp);
|
||||||
|
});
|
||||||
|
|
||||||
// GET /reverse
|
// GET /reverse
|
||||||
router.get('/reverse', authenticate, async (req: Request, res: Response) => {
|
router.get('/reverse', authenticate, async (req: Request, res: Response) => {
|
||||||
const { lat, lng, lang } = req.query as { lat: string; lng: string; lang?: string };
|
const { lat, lng, lang } = req.query as { lat: string; lng: string; lang?: string };
|
||||||
|
|||||||
@@ -459,6 +459,18 @@ export function updateBagTracking(enabled: boolean) {
|
|||||||
return { enabled: !!enabled };
|
return { enabled: !!enabled };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Places Photos ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getPlacesPhotos() {
|
||||||
|
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined;
|
||||||
|
return { enabled: row?.value !== 'false' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePlacesPhotos(enabled: boolean) {
|
||||||
|
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_photos_enabled', ?)").run(enabled ? 'true' : 'false');
|
||||||
|
return { enabled: !!enabled };
|
||||||
|
}
|
||||||
|
|
||||||
// ── Collab Features ───────────────────────────────────────────────────────
|
// ── Collab Features ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const COLLAB_FEATURE_KEYS = ['collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled'] as const;
|
const COLLAB_FEATURE_KEYS = ['collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled'] as const;
|
||||||
|
|||||||
@@ -229,6 +229,8 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
|||||||
const activeChannels = notifChannelsRaw === 'none' ? [] : notifChannelsRaw.split(',').map((c: string) => c.trim()).filter(Boolean);
|
const activeChannels = notifChannelsRaw === 'none' ? [] : notifChannelsRaw.split(',').map((c: string) => c.trim()).filter(Boolean);
|
||||||
const hasWebhookEnabled = activeChannels.includes('webhook');
|
const hasWebhookEnabled = activeChannels.includes('webhook');
|
||||||
const tripRemindersEnabled = tripReminderSetting !== 'false';
|
const tripRemindersEnabled = tripReminderSetting !== 'false';
|
||||||
|
const placesPhotosSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined)?.value;
|
||||||
|
const placesPhotosEnabled = placesPhotosSetting !== 'false';
|
||||||
const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get());
|
const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -258,6 +260,7 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
|||||||
notification_channels: activeChannels,
|
notification_channels: activeChannels,
|
||||||
available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, inapp: true },
|
available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, inapp: true },
|
||||||
trip_reminders_enabled: tripRemindersEnabled,
|
trip_reminders_enabled: tripRemindersEnabled,
|
||||||
|
places_photos_enabled: placesPhotosEnabled,
|
||||||
permissions: authenticatedUser ? getAllPermissions() : undefined,
|
permissions: authenticatedUser ? getAllPermissions() : undefined,
|
||||||
dev_mode: process.env.NODE_ENV === 'development',
|
dev_mode: process.env.NODE_ENV === 'development',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,19 @@ import { db } from '../db/database';
|
|||||||
import { decrypt_api_key } from './apiKeyCrypto';
|
import { decrypt_api_key } from './apiKeyCrypto';
|
||||||
import { checkSsrf } from '../utils/ssrfGuard';
|
import { checkSsrf } from '../utils/ssrfGuard';
|
||||||
|
|
||||||
|
// ── Google API call counter ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let googleApiCallCount = 0;
|
||||||
|
|
||||||
|
export function getGoogleApiCallCount(): number { return googleApiCallCount; }
|
||||||
|
export function resetGoogleApiCallCount(): void { googleApiCallCount = 0; }
|
||||||
|
|
||||||
|
function googleFetch(endpoint: string, label: string, init?: RequestInit): Promise<Response> {
|
||||||
|
googleApiCallCount++;
|
||||||
|
console.debug(`[Google API] #${googleApiCallCount} ${label} → ${endpoint}`);
|
||||||
|
return fetch(endpoint, init);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Interfaces ───────────────────────────────────────────────────────────────
|
// ── Interfaces ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface NominatimResult {
|
interface NominatimResult {
|
||||||
@@ -55,26 +68,8 @@ interface GooglePlaceDetails extends GooglePlaceResult {
|
|||||||
|
|
||||||
const UA = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)';
|
const UA = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)';
|
||||||
|
|
||||||
// ── Photo cache ──────────────────────────────────────────────────────────────
|
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
|
||||||
|
import * as placePhotoCache from './placePhotoCache';
|
||||||
const photoCache = new Map<string, { photoUrl: string; attribution: string | null; fetchedAt: number; error?: boolean }>();
|
|
||||||
const PHOTO_TTL = 12 * 60 * 60 * 1000; // 12 hours
|
|
||||||
const ERROR_TTL = 5 * 60 * 1000; // 5 min for errors
|
|
||||||
const CACHE_MAX_ENTRIES = 1000;
|
|
||||||
const CACHE_PRUNE_TARGET = 500;
|
|
||||||
const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
|
||||||
|
|
||||||
setInterval(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [key, entry] of photoCache) {
|
|
||||||
if (now - entry.fetchedAt > PHOTO_TTL) photoCache.delete(key);
|
|
||||||
}
|
|
||||||
if (photoCache.size > CACHE_MAX_ENTRIES) {
|
|
||||||
const entries = [...photoCache.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
|
|
||||||
const toDelete = entries.slice(0, entries.length - CACHE_PRUNE_TARGET);
|
|
||||||
toDelete.forEach(([key]) => photoCache.delete(key));
|
|
||||||
}
|
|
||||||
}, CACHE_CLEANUP_INTERVAL);
|
|
||||||
|
|
||||||
// ── API key retrieval ────────────────────────────────────────────────────────
|
// ── API key retrieval ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -311,7 +306,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
|
|||||||
return { places, source: 'openstreetmap' };
|
return { places, source: 'openstreetmap' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch('https://places.googleapis.com/v1/places:searchText', {
|
const response = await googleFetch('https://places.googleapis.com/v1/places:searchText', 'searchText', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -371,7 +366,7 @@ export async function autocompletePlaces(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch('https://places.googleapis.com/v1/places:autocomplete', {
|
const response = await googleFetch('https://places.googleapis.com/v1/places:autocomplete', 'autocomplete', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -451,12 +446,79 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Google details
|
// Google details
|
||||||
|
const langKey = lang || 'de';
|
||||||
const apiKey = getMapsKey(userId);
|
const apiKey = getMapsKey(userId);
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
|
throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${lang || 'de'}`, {
|
// 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 = 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',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'X-Goog-Api-Key': apiKey,
|
'X-Goog-Api-Key': apiKey,
|
||||||
@@ -494,12 +556,21 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
|
|||||||
photo: r.authorAttribution?.photoUri || null,
|
photo: r.authorAttribution?.photoUri || null,
|
||||||
})),
|
})),
|
||||||
source: 'google' as const,
|
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 };
|
return { place };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Place photo (Google or Wikimedia, with caching + DB persistence) ─────────
|
// ── Place photo (Google or Wikimedia, disk-cached) ────────────────────────────
|
||||||
|
|
||||||
export async function getPlacePhoto(
|
export async function getPlacePhoto(
|
||||||
userId: number,
|
userId: number,
|
||||||
@@ -508,84 +579,110 @@ export async function getPlacePhoto(
|
|||||||
lng: number,
|
lng: number,
|
||||||
name?: string,
|
name?: string,
|
||||||
): Promise<{ photoUrl: string; attribution: string | null }> {
|
): Promise<{ photoUrl: string; attribution: string | null }> {
|
||||||
// Check cache first
|
// Disk cache hit — serve immediately, no Google call
|
||||||
const cached = photoCache.get(placeId);
|
const diskHit = placePhotoCache.get(placeId);
|
||||||
if (cached) {
|
if (diskHit) return { photoUrl: diskHit.photoUrl, attribution: diskHit.attribution };
|
||||||
const ttl = cached.error ? ERROR_TTL : PHOTO_TTL;
|
|
||||||
if (Date.now() - cached.fetchedAt < ttl) {
|
// Recent error — don't hammer the API
|
||||||
if (cached.error) throw Object.assign(new Error('(Cache) No photo available'), { status: 404 });
|
if (placePhotoCache.getErrored(placeId)) {
|
||||||
return { photoUrl: cached.photoUrl, attribution: cached.attribution };
|
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> => {
|
||||||
|
const apiKey = getMapsKey(userId);
|
||||||
|
const isCoordLookup = placeId.startsWith('coords:');
|
||||||
|
|
||||||
|
// No Google key or coordinate-only lookup → try Wikimedia (URL-based, not byte-cached)
|
||||||
|
if (!apiKey || isCoordLookup) {
|
||||||
|
if (!isNaN(lat) && !isNaN(lng)) {
|
||||||
|
try {
|
||||||
|
const wiki = await fetchWikimediaPhoto(lat, lng, name);
|
||||||
|
if (wiki) {
|
||||||
|
// Wikimedia photos: fetch bytes and cache to disk
|
||||||
|
const ssrf = await checkSsrf(wiki.photoUrl, true);
|
||||||
|
if (!ssrf.allowed) throw Object.assign(new Error('Photo URL blocked'), { status: 403 });
|
||||||
|
const imgRes = await fetch(wiki.photoUrl);
|
||||||
|
if (imgRes.ok) {
|
||||||
|
const bytes = Buffer.from(await imgRes.arrayBuffer());
|
||||||
|
const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution);
|
||||||
|
return { filePath: cached.filePath, attribution: cached.attribution };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
placePhotoCache.markError(placeId);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
photoCache.delete(placeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKey = getMapsKey(userId);
|
// Google Photos — fetch details to get photo name
|
||||||
const isCoordLookup = placeId.startsWith('coords:');
|
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 details = await detailsRes.json() as GooglePlaceDetails & { error?: { message?: string } };
|
||||||
|
|
||||||
// No Google key or coordinate-only lookup -> try Wikimedia
|
if (!detailsRes.ok) {
|
||||||
if (!apiKey || isCoordLookup) {
|
console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
|
||||||
if (!isNaN(lat) && !isNaN(lng)) {
|
placePhotoCache.markError(placeId);
|
||||||
try {
|
return null;
|
||||||
const wiki = await fetchWikimediaPhoto(lat, lng, name);
|
|
||||||
if (wiki) {
|
|
||||||
photoCache.set(placeId, { ...wiki, fetchedAt: Date.now() });
|
|
||||||
return wiki;
|
|
||||||
} else {
|
|
||||||
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
|
|
||||||
}
|
|
||||||
} catch { /* fall through */ }
|
|
||||||
}
|
}
|
||||||
throw Object.assign(new Error('(Wikimedia) No photo available'), { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Google Photos
|
if (!details.photos?.length) {
|
||||||
const detailsRes = await fetch(`https://places.googleapis.com/v1/places/${placeId}`, {
|
placePhotoCache.markError(placeId);
|
||||||
headers: {
|
return null;
|
||||||
'X-Goog-Api-Key': apiKey,
|
}
|
||||||
'X-Goog-FieldMask': 'photos',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const details = await detailsRes.json() as GooglePlaceDetails & { error?: { message?: string } };
|
|
||||||
|
|
||||||
if (!detailsRes.ok) {
|
const photo = details.photos[0];
|
||||||
console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
|
const photoName = photo.name;
|
||||||
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
|
const attribution = photo.authorAttributions?.[0]?.displayName || null;
|
||||||
throw Object.assign(new Error('(Google Places) Photo could not be retrieved'), { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!details.photos?.length) {
|
// Fetch actual image bytes
|
||||||
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
|
const mediaRes = await googleFetch(
|
||||||
throw Object.assign(new Error('(Google Places) No photo available'), { status: 404 });
|
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`,
|
||||||
}
|
`getPlacePhoto/media(${placeId})`,
|
||||||
|
{ headers: { 'X-Goog-Api-Key': apiKey } }
|
||||||
|
);
|
||||||
|
|
||||||
const photo = details.photos[0];
|
if (!mediaRes.ok) {
|
||||||
const photoName = photo.name;
|
placePhotoCache.markError(placeId);
|
||||||
const attribution = photo.authorAttributions?.[0]?.displayName || null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const mediaRes = await fetch(
|
const bytes = Buffer.from(await mediaRes.arrayBuffer());
|
||||||
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400&skipHttpRedirect=true`,
|
if (!bytes.length) {
|
||||||
{ headers: { 'X-Goog-Api-Key': apiKey } }
|
placePhotoCache.markError(placeId);
|
||||||
);
|
return null;
|
||||||
const mediaData = await mediaRes.json() as { photoUri?: string };
|
}
|
||||||
const photoUrl = mediaData.photoUri;
|
|
||||||
|
|
||||||
if (!photoUrl) {
|
const cached = await placePhotoCache.put(placeId, bytes, attribution);
|
||||||
photoCache.set(placeId, { photoUrl: '', attribution, fetchedAt: Date.now(), error: true });
|
|
||||||
throw Object.assign(new Error('(Google Places) Photo URL not available'), { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() });
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
// Persist photo URL to database
|
return { filePath: cached.filePath, attribution };
|
||||||
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(photoUrl, placeId, '');
|
|
||||||
} catch (dbErr) {
|
|
||||||
console.error('Failed to persist photo URL to database:', dbErr);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { photoUrl, attribution };
|
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 ────────────────────────────────────────────────────────
|
// ── Reverse geocoding ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import fsPromises from 'node:fs/promises';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { db } from '../db/database';
|
||||||
|
|
||||||
|
const GOOGLE_PHOTO_DIR = path.join(__dirname, '../../uploads/photos/google');
|
||||||
|
const ERROR_TTL = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// In-flight dedup — prevents stampedes when multiple requests hit the same uncached placeId simultaneously
|
||||||
|
const inFlight = new Map<string, Promise<{ filePath: string; attribution: string | null } | null>>();
|
||||||
|
|
||||||
|
function ensureDir(): void {
|
||||||
|
if (!fs.existsSync(GOOGLE_PHOTO_DIR)) {
|
||||||
|
fs.mkdirSync(GOOGLE_PHOTO_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filePath(placeId: string): string {
|
||||||
|
// Hash to avoid filename collisions — coords:lat:lng pseudo-IDs contain characters that
|
||||||
|
// collapse identically under sanitization (e.g. ':' and '.' both → '_')
|
||||||
|
const hash = crypto.createHash('sha1').update(placeId).digest('hex');
|
||||||
|
return path.join(GOOGLE_PHOTO_DIR, `${hash}.jpg`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function proxyUrl(placeId: string): string {
|
||||||
|
return `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CachedPhoto {
|
||||||
|
photoUrl: string;
|
||||||
|
filePath: string;
|
||||||
|
attribution: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function get(placeId: string): CachedPhoto | null {
|
||||||
|
const row = db.prepare(
|
||||||
|
'SELECT attribution FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NULL'
|
||||||
|
).get(placeId) as { attribution: string | null } | undefined;
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
const fp = filePath(placeId);
|
||||||
|
if (!fs.existsSync(fp)) {
|
||||||
|
// File missing (e.g. volume wiped) — clear row so it refetches
|
||||||
|
db.prepare('DELETE FROM google_place_photo_meta WHERE place_id = ?').run(placeId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { photoUrl: proxyUrl(placeId), filePath: fp, attribution: row.attribution };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getErrored(placeId: string): boolean {
|
||||||
|
const row = db.prepare(
|
||||||
|
'SELECT error_at FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NOT NULL'
|
||||||
|
).get(placeId) as { error_at: number } | undefined;
|
||||||
|
|
||||||
|
if (!row) return false;
|
||||||
|
return Date.now() - row.error_at < ERROR_TTL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markError(placeId: string): void {
|
||||||
|
db.prepare(
|
||||||
|
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)'
|
||||||
|
).run(placeId, Date.now(), Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function put(placeId: string, bytes: Buffer, attribution: string | null): Promise<CachedPhoto> {
|
||||||
|
ensureDir();
|
||||||
|
const fp = filePath(placeId);
|
||||||
|
const tmp = fp + '.tmp';
|
||||||
|
|
||||||
|
await fsPromises.writeFile(tmp, bytes);
|
||||||
|
await fsPromises.rename(tmp, fp);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)'
|
||||||
|
).run(placeId, attribution, Date.now());
|
||||||
|
|
||||||
|
return { photoUrl: proxyUrl(placeId), filePath: fp, attribution };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInFlight(placeId: string): Promise<{ filePath: string; attribution: string | null } | null> | undefined {
|
||||||
|
return inFlight.get(placeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setInFlight(placeId: string, promise: Promise<{ filePath: string; attribution: string | null } | null>): void {
|
||||||
|
inFlight.set(placeId, promise);
|
||||||
|
promise.finally(() => inFlight.delete(placeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serveFilePath(placeId: string): string | null {
|
||||||
|
const fp = filePath(placeId);
|
||||||
|
return fs.existsSync(fp) ? fp : null;
|
||||||
|
}
|
||||||
@@ -8,10 +8,19 @@
|
|||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||||
|
|
||||||
const { mockDbGet, mockDbRun, mockCheckSsrf } = vi.hoisted(() => ({
|
const { mockDbGet, mockDbRun, mockCheckSsrf, mockCacheGet, mockCacheGetErrored, mockCachePut, mockCacheGetInFlight, mockCacheSetInFlight } = vi.hoisted(() => ({
|
||||||
mockDbGet: vi.fn(() => undefined as any),
|
mockDbGet: vi.fn(() => undefined as any),
|
||||||
mockDbRun: vi.fn(),
|
mockDbRun: vi.fn(),
|
||||||
mockCheckSsrf: vi.fn(async () => ({ allowed: true })),
|
mockCheckSsrf: vi.fn(async () => ({ allowed: true })),
|
||||||
|
mockCacheGet: vi.fn(() => null as any),
|
||||||
|
mockCacheGetErrored: vi.fn(() => false),
|
||||||
|
mockCachePut: vi.fn(async (placeId: string, _bytes: Buffer, attribution: string | null) => ({
|
||||||
|
photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`,
|
||||||
|
filePath: `/tmp/${placeId}.jpg`,
|
||||||
|
attribution,
|
||||||
|
})),
|
||||||
|
mockCacheGetInFlight: vi.fn(() => undefined),
|
||||||
|
mockCacheSetInFlight: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../src/db/database', () => ({
|
vi.mock('../../../src/db/database', () => ({
|
||||||
@@ -33,6 +42,16 @@ vi.mock('../../../src/config', () => ({
|
|||||||
ENCRYPTION_KEY: '0'.repeat(64),
|
ENCRYPTION_KEY: '0'.repeat(64),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../src/services/placePhotoCache', () => ({
|
||||||
|
get: (placeId: string) => mockCacheGet(placeId),
|
||||||
|
getErrored: (placeId: string) => mockCacheGetErrored(placeId),
|
||||||
|
put: (placeId: string, bytes: Buffer, attribution: string | null) => mockCachePut(placeId, bytes, attribution),
|
||||||
|
markError: vi.fn(),
|
||||||
|
getInFlight: (placeId: string) => mockCacheGetInFlight(placeId),
|
||||||
|
setInFlight: (placeId: string, p: Promise<any>) => mockCacheSetInFlight(placeId, p),
|
||||||
|
serveFilePath: vi.fn(() => null),
|
||||||
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
parseOpeningHours,
|
parseOpeningHours,
|
||||||
buildOsmDetails,
|
buildOsmDetails,
|
||||||
@@ -46,6 +65,19 @@ afterEach(() => {
|
|||||||
mockDbRun.mockReset();
|
mockDbRun.mockReset();
|
||||||
mockCheckSsrf.mockReset();
|
mockCheckSsrf.mockReset();
|
||||||
mockCheckSsrf.mockResolvedValue({ allowed: true });
|
mockCheckSsrf.mockResolvedValue({ allowed: true });
|
||||||
|
mockCacheGet.mockReset();
|
||||||
|
mockCacheGet.mockReturnValue(null);
|
||||||
|
mockCacheGetErrored.mockReset();
|
||||||
|
mockCacheGetErrored.mockReturnValue(false);
|
||||||
|
mockCachePut.mockReset();
|
||||||
|
mockCachePut.mockImplementation(async (placeId: string, _bytes: Buffer, attribution: string | null) => ({
|
||||||
|
photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`,
|
||||||
|
filePath: `/tmp/${placeId}.jpg`,
|
||||||
|
attribution,
|
||||||
|
}));
|
||||||
|
mockCacheGetInFlight.mockReset();
|
||||||
|
mockCacheGetInFlight.mockReturnValue(undefined);
|
||||||
|
mockCacheSetInFlight.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── parseOpeningHours ─────────────────────────────────────────────────────────
|
// ── parseOpeningHours ─────────────────────────────────────────────────────────
|
||||||
@@ -995,11 +1027,9 @@ describe('getPlaceDetails (fetch stubbed)', () => {
|
|||||||
expect(place.rating_count).toBe(200000);
|
expect(place.rating_count).toBe(200000);
|
||||||
expect(place.open_now).toBe(true);
|
expect(place.open_now).toBe(true);
|
||||||
expect(place.source).toBe('google');
|
expect(place.source).toBe('google');
|
||||||
expect(place.reviews).toHaveLength(1);
|
// Lean mask — reviews/summary not fetched in getPlaceDetails; use getPlaceDetailsExpanded for those
|
||||||
expect(place.reviews[0].author).toBe('John');
|
expect(place.reviews).toHaveLength(0);
|
||||||
expect(place.reviews[0].rating).toBe(5);
|
expect(place.summary).toBeNull();
|
||||||
expect(place.reviews[0].text).toBe('Amazing!');
|
|
||||||
expect(place.reviews[0].photo).toBe('https://photo.url');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('MAPS-041c: throws with status when Google API returns non-ok response', async () => {
|
it('MAPS-041c: throws with status when Google API returns non-ok response', async () => {
|
||||||
@@ -1016,8 +1046,10 @@ describe('getPlaceDetails (fetch stubbed)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('MAPS-041d: maps reviews with optional fields absent to null', async () => {
|
it('MAPS-041d: getPlaceDetailsExpanded maps reviews with optional fields absent to null', async () => {
|
||||||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||||||
|
// expanded=1 cache miss → return undefined
|
||||||
|
mockDbGet.mockReturnValueOnce(undefined);
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@@ -1028,8 +1060,8 @@ describe('getPlaceDetails (fetch stubbed)', () => {
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
const { getPlaceDetails } = await import('../../../src/services/mapsService');
|
const { getPlaceDetailsExpanded } = await import('../../../src/services/mapsService');
|
||||||
const result = await getPlaceDetails(1, 'ChIJ456');
|
const result = await getPlaceDetailsExpanded(1, 'ChIJ456');
|
||||||
const review = (result.place as any).reviews[0];
|
const review = (result.place as any).reviews[0];
|
||||||
expect(review.author).toBeNull();
|
expect(review.author).toBeNull();
|
||||||
expect(review.rating).toBeNull();
|
expect(review.rating).toBeNull();
|
||||||
@@ -1104,8 +1136,10 @@ describe('getPlaceDetails (fetch stubbed)', () => {
|
|||||||
expect((result.place as any).open_now).toBe(false);
|
expect((result.place as any).open_now).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('MAPS-041g: truncates reviews to first 5 entries', async () => {
|
it('MAPS-041g: getPlaceDetailsExpanded truncates reviews to first 5 entries', async () => {
|
||||||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||||||
|
// expanded=1 cache miss
|
||||||
|
mockDbGet.mockReturnValueOnce(undefined);
|
||||||
const manyReviews = Array.from({ length: 8 }, (_, i) => ({
|
const manyReviews = Array.from({ length: 8 }, (_, i) => ({
|
||||||
authorAttribution: { displayName: `User${i}` },
|
authorAttribution: { displayName: `User${i}` },
|
||||||
rating: 4,
|
rating: 4,
|
||||||
@@ -1116,8 +1150,8 @@ describe('getPlaceDetails (fetch stubbed)', () => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ id: 'ChIJMany', reviews: manyReviews }),
|
json: async () => ({ id: 'ChIJMany', reviews: manyReviews }),
|
||||||
}));
|
}));
|
||||||
const { getPlaceDetails } = await import('../../../src/services/mapsService');
|
const { getPlaceDetailsExpanded } = await import('../../../src/services/mapsService');
|
||||||
const result = await getPlaceDetails(1, 'ChIJMany');
|
const result = await getPlaceDetailsExpanded(1, 'ChIJMany');
|
||||||
expect((result.place as any).reviews).toHaveLength(5);
|
expect((result.place as any).reviews).toHaveLength(5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1125,16 +1159,26 @@ describe('getPlaceDetails (fetch stubbed)', () => {
|
|||||||
// ── getPlacePhoto (fetch stubbed) ────────────────────────────────────────────
|
// ── getPlacePhoto (fetch stubbed) ────────────────────────────────────────────
|
||||||
|
|
||||||
describe('getPlacePhoto (fetch stubbed)', () => {
|
describe('getPlacePhoto (fetch stubbed)', () => {
|
||||||
it('MAPS-042: returns Wikimedia photo for coordinate-based lookup (no API key)', async () => {
|
it('MAPS-042: returns proxy URL for coordinate-based lookup via Wikimedia (no API key)', async () => {
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
vi.stubGlobal('fetch', vi.fn()
|
||||||
ok: true,
|
// First call: Wikimedia Commons API
|
||||||
json: async () => ({
|
.mockResolvedValueOnce({
|
||||||
query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/photo.jpg' } } } },
|
ok: true,
|
||||||
}),
|
json: async () => ({
|
||||||
}));
|
query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/photo.jpg' } } } },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
// Second call: fetch Wikimedia image bytes
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
arrayBuffer: async () => new ArrayBuffer(100),
|
||||||
|
})
|
||||||
|
);
|
||||||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||||||
const result = await getPlacePhoto(999, 'coords:48.8,2.3', 48.8, 2.3, 'Eiffel Tower');
|
const placeId = 'coords:48.8,2.3';
|
||||||
expect(result.photoUrl).toBe('https://wiki.org/photo.jpg');
|
const result = await getPlacePhoto(999, placeId, 48.8, 2.3, 'Eiffel Tower');
|
||||||
|
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`);
|
||||||
|
expect(mockCachePut).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('MAPS-043: throws 404 when Wikimedia returns nothing and no API key', async () => {
|
it('MAPS-043: throws 404 when Wikimedia returns nothing and no API key', async () => {
|
||||||
@@ -1146,37 +1190,28 @@ describe('getPlacePhoto (fetch stubbed)', () => {
|
|||||||
await expect(getPlacePhoto(999, 'coords:0.0,0.0', 0, 0)).rejects.toMatchObject({ status: 404 });
|
await expect(getPlacePhoto(999, 'coords:0.0,0.0', 0, 0)).rejects.toMatchObject({ status: 404 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('MAPS-043b: returns cached photo when cache entry is fresh and valid', async () => {
|
it('MAPS-043b: returns cached photo when disk cache returns a hit', async () => {
|
||||||
// First call populates cache; second call should use cache without fetching
|
const placeId = `coords:cache-test-${Date.now()}`;
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
const cachedUrl = `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`;
|
||||||
ok: true,
|
mockCacheGet.mockReturnValue({
|
||||||
json: async () => ({
|
photoUrl: cachedUrl,
|
||||||
query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/cached.jpg' } } } },
|
filePath: `/tmp/${placeId}.jpg`,
|
||||||
}),
|
attribution: null,
|
||||||
}));
|
});
|
||||||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
|
||||||
const uniqueId = `coords:cache-test-${Date.now()}`;
|
|
||||||
const first = await getPlacePhoto(999, uniqueId, 48.8, 2.3, 'Cache Test');
|
|
||||||
const fetchMock = vi.fn();
|
const fetchMock = vi.fn();
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
const second = await getPlacePhoto(999, uniqueId, 48.8, 2.3, 'Cache Test');
|
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||||||
expect(second.photoUrl).toBe(first.photoUrl);
|
const result = await getPlacePhoto(999, placeId, 48.8, 2.3, 'Cache Test');
|
||||||
|
expect(result.photoUrl).toBe(cachedUrl);
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('MAPS-043c: throws 404 from cache when cached entry is an error', async () => {
|
it('MAPS-043c: throws 404 from error cache without making a network request', async () => {
|
||||||
// Seed the cache with an error entry by triggering a no-result Wikimedia call
|
mockCacheGetErrored.mockReturnValue(true);
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ query: { pages: {} } }),
|
|
||||||
}));
|
|
||||||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
|
||||||
const errorId = `coords:error-cache-${Date.now()}`;
|
|
||||||
// First call causes error to be cached
|
|
||||||
await expect(getPlacePhoto(999, errorId, 0, 0)).rejects.toMatchObject({ status: 404 });
|
|
||||||
// Second call should throw directly from cache (no fetch)
|
|
||||||
const fetchMock = vi.fn();
|
const fetchMock = vi.fn();
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||||||
|
const errorId = `coords:error-cache-${Date.now()}`;
|
||||||
await expect(getPlacePhoto(999, errorId, 0, 0)).rejects.toMatchObject({ status: 404 });
|
await expect(getPlacePhoto(999, errorId, 0, 0)).rejects.toMatchObject({ status: 404 });
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -1194,7 +1229,7 @@ describe('getPlacePhoto (fetch stubbed)', () => {
|
|||||||
await expect(getPlacePhoto(999, throwId, 48.8, 2.3, 'Place')).rejects.toMatchObject({ status: 404 });
|
await expect(getPlacePhoto(999, throwId, 48.8, 2.3, 'Place')).rejects.toMatchObject({ status: 404 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('MAPS-044: returns photo via Google path when API key present and photos exist', async () => {
|
it('MAPS-044: returns proxy URL via Google path when API key present and photos exist', async () => {
|
||||||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||||||
const fetchMock = vi.fn()
|
const fetchMock = vi.fn()
|
||||||
// First call: get place details (with photos)
|
// First call: get place details (with photos)
|
||||||
@@ -1204,17 +1239,18 @@ describe('getPlacePhoto (fetch stubbed)', () => {
|
|||||||
photos: [{ name: 'places/ChIJABC/photos/photo1', authorAttributions: [{ displayName: 'Photographer' }] }],
|
photos: [{ name: 'places/ChIJABC/photos/photo1', authorAttributions: [{ displayName: 'Photographer' }] }],
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
// Second call: get media URL
|
// Second call: fetch image bytes
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ photoUri: 'https://lh3.googleusercontent.com/photo.jpg' }),
|
arrayBuffer: async () => new ArrayBuffer(200),
|
||||||
});
|
});
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||||||
const uniqueId = `ChIJABC-${Date.now()}`;
|
const uniqueId = `ChIJABC-${Date.now()}`;
|
||||||
const result = await getPlacePhoto(1, uniqueId, 48.8, 2.3, 'Place');
|
const result = await getPlacePhoto(1, uniqueId, 48.8, 2.3, 'Place');
|
||||||
expect(result.photoUrl).toBe('https://lh3.googleusercontent.com/photo.jpg');
|
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(uniqueId)}/bytes`);
|
||||||
expect(result.attribution).toBe('Photographer');
|
expect(result.attribution).toBe('Photographer');
|
||||||
|
expect(mockCachePut).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('MAPS-044b: throws 404 when Google details fetch returns non-ok', async () => {
|
it('MAPS-044b: throws 404 when Google details fetch returns non-ok', async () => {
|
||||||
@@ -1240,7 +1276,7 @@ describe('getPlacePhoto (fetch stubbed)', () => {
|
|||||||
await expect(getPlacePhoto(1, noPhotoId, 48.8, 2.3)).rejects.toMatchObject({ status: 404 });
|
await expect(getPlacePhoto(1, noPhotoId, 48.8, 2.3)).rejects.toMatchObject({ status: 404 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('MAPS-044d: throws 404 when media endpoint returns no photoUri', async () => {
|
it('MAPS-044d: throws 404 when media endpoint returns non-ok status', async () => {
|
||||||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||||||
const fetchMock = vi.fn()
|
const fetchMock = vi.fn()
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
@@ -1250,8 +1286,9 @@ describe('getPlacePhoto (fetch stubbed)', () => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: false,
|
||||||
json: async () => ({}), // no photoUri
|
status: 403,
|
||||||
|
arrayBuffer: async () => new ArrayBuffer(0),
|
||||||
});
|
});
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||||||
@@ -1259,7 +1296,7 @@ describe('getPlacePhoto (fetch stubbed)', () => {
|
|||||||
await expect(getPlacePhoto(1, noUriId, 48.8, 2.3)).rejects.toMatchObject({ status: 404 });
|
await expect(getPlacePhoto(1, noUriId, 48.8, 2.3)).rejects.toMatchObject({ status: 404 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('MAPS-044e: returns photo with null attribution when authorAttributions is empty', async () => {
|
it('MAPS-044e: returns proxy URL with null attribution when authorAttributions is empty', async () => {
|
||||||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||||||
const fetchMock = vi.fn()
|
const fetchMock = vi.fn()
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
@@ -1270,28 +1307,34 @@ describe('getPlacePhoto (fetch stubbed)', () => {
|
|||||||
})
|
})
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ photoUri: 'https://lh3.googleusercontent.com/noattr.jpg' }),
|
arrayBuffer: async () => new ArrayBuffer(150),
|
||||||
});
|
});
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||||||
const noAttrId = `ChIJNoAttr-${Date.now()}`;
|
const noAttrId = `ChIJNoAttr-${Date.now()}`;
|
||||||
const result = await getPlacePhoto(1, noAttrId, 48.8, 2.3);
|
const result = await getPlacePhoto(1, noAttrId, 48.8, 2.3);
|
||||||
expect(result.photoUrl).toBe('https://lh3.googleusercontent.com/noattr.jpg');
|
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(noAttrId)}/bytes`);
|
||||||
expect(result.attribution).toBeNull();
|
expect(result.attribution).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('MAPS-044f: uses Wikimedia when API key present but placeId is coords: prefix', async () => {
|
it('MAPS-044f: uses Wikimedia and returns proxy URL when API key present but placeId is coords: prefix', async () => {
|
||||||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
vi.stubGlobal('fetch', vi.fn()
|
||||||
ok: true,
|
.mockResolvedValueOnce({
|
||||||
json: async () => ({
|
ok: true,
|
||||||
query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/coords-photo.jpg' } } } },
|
json: async () => ({
|
||||||
}),
|
query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/coords-photo.jpg' } } } },
|
||||||
}));
|
}),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
arrayBuffer: async () => new ArrayBuffer(120),
|
||||||
|
})
|
||||||
|
);
|
||||||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||||||
// Use a unique placeId to avoid hitting the in-memory cache from other tests
|
|
||||||
const uniqueId = `coords:44f-test-${Date.now()}`;
|
const uniqueId = `coords:44f-test-${Date.now()}`;
|
||||||
const result = await getPlacePhoto(1, uniqueId, 48.8, 2.3, 'Coords Place');
|
const result = await getPlacePhoto(1, uniqueId, 48.8, 2.3, 'Coords Place');
|
||||||
expect(result.photoUrl).toBe('https://wiki.org/coords-photo.jpg');
|
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(uniqueId)}/bytes`);
|
||||||
|
expect(mockCachePut).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user