Merge pull request #708 from mauriceboe/fix/google-places-api-quota-reduction

fix(maps): reduce Google Places API quota with persistent caching and kill-switch
This commit is contained in:
Julien G.
2026-04-17 19:33:51 +02:00
committed by GitHub
31 changed files with 775 additions and 169 deletions
+5 -2
View File
@@ -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, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = 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; places_autocomplete_enabled?: boolean; places_details_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,9 @@ 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?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions) if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
if (config?.version) { if (config?.version) {
+6
View File
@@ -272,6 +272,12 @@ 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),
getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data),
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { 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),
+10 -10
View File
@@ -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 }
+6
View File
@@ -588,6 +588,12 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesSaved': 'تم حفظ إعدادات أنواع الملفات', 'admin.fileTypesSaved': 'تم حفظ إعدادات أنواع الملفات',
// Packing Templates & Bag Tracking // Packing Templates & Bag Tracking
'admin.placesPhotos.title': 'صور الأماكن',
'admin.placesPhotos.subtitle': 'جلب الصور من Google Places API. عطّلها للحفاظ على حصة API. صور Wikimedia غير متأثرة.',
'admin.placesAutocomplete.title': 'الإكمال التلقائي للأماكن',
'admin.placesAutocomplete.subtitle': 'استخدام Google Places API لاقتراحات البحث. عطّلها للحفاظ على حصة API.',
'admin.placesDetails.title': 'تفاصيل الأماكن',
'admin.placesDetails.subtitle': 'جلب معلومات تفصيلية عن الأماكن (الساعات، التقييم، الموقع) من Google Places API. عطّلها للحفاظ على حصة API.',
'admin.bagTracking.title': 'تتبع الأمتعة', 'admin.bagTracking.title': 'تتبع الأمتعة',
'admin.bagTracking.subtitle': 'تفعيل الوزن وتعيين الأمتعة للعناصر', 'admin.bagTracking.subtitle': 'تفعيل الوزن وتعيين الأمتعة للعناصر',
'admin.collab.chat.title': 'الدردشة', 'admin.collab.chat.title': 'الدردشة',
+6
View File
@@ -546,6 +546,12 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesSaved': 'Configurações de tipos de arquivo salvas', 'admin.fileTypesSaved': 'Configurações de tipos de arquivo salvas',
// Packing Templates & Bag Tracking // Packing Templates & Bag Tracking
'admin.placesPhotos.title': 'Fotos de Locais',
'admin.placesPhotos.subtitle': 'Busca fotos da Google Places API. Desative para economizar cota da API. Fotos do Wikimedia não são afetadas.',
'admin.placesAutocomplete.title': 'Autocompletar de Locais',
'admin.placesAutocomplete.subtitle': 'Usa a Google Places API para sugestões de pesquisa. Desative para economizar cota da API.',
'admin.placesDetails.title': 'Detalhes do Local',
'admin.placesDetails.subtitle': 'Busca informações detalhadas do local (horários, avaliação, site) da Google Places API. Desative para economizar cota da API.',
'admin.bagTracking.title': 'Rastreamento de malas', 'admin.bagTracking.title': 'Rastreamento de malas',
'admin.bagTracking.subtitle': 'Ativar peso e atribuição de mala para itens da lista', 'admin.bagTracking.subtitle': 'Ativar peso e atribuição de mala para itens da lista',
'admin.collab.chat.title': 'Chat', 'admin.collab.chat.title': 'Chat',
+6
View File
@@ -546,6 +546,12 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesSaved': 'Nastavení souborů uloženo', 'admin.fileTypesSaved': 'Nastavení souborů uloženo',
// Šablony balení (Packing Templates) // Šablony balení (Packing Templates)
'admin.placesPhotos.title': 'Fotografie míst',
'admin.placesPhotos.subtitle': 'Načítání fotografií z Google Places API. Zakázáním ušetříte kvótu API. Fotografie z Wikimedia nejsou ovlivněny.',
'admin.placesAutocomplete.title': 'Automatické doplňování míst',
'admin.placesAutocomplete.subtitle': 'Použití Google Places API pro návrhy vyhledávání. Zakázáním ušetříte kvótu API.',
'admin.placesDetails.title': 'Podrobnosti o místě',
'admin.placesDetails.subtitle': 'Načítání podrobných informací o místě (hodiny, hodnocení, web) z Google Places API. Zakázáním ušetříte kvótu API.',
'admin.bagTracking.title': 'Sledování zavazadel', 'admin.bagTracking.title': 'Sledování zavazadel',
'admin.bagTracking.subtitle': 'Povolit váhu a přiřazení k zavazadlům u položek balení', 'admin.bagTracking.subtitle': 'Povolit váhu a přiřazení k zavazadlům u položek balení',
'admin.collab.chat.title': 'Chat', 'admin.collab.chat.title': 'Chat',
+6
View File
@@ -549,6 +549,12 @@ 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.',
'admin.placesAutocomplete.title': 'Orts-Autovervollständigung',
'admin.placesAutocomplete.subtitle': 'Google Places API für Suchvorschläge nutzen. Deaktivieren, um API-Kontingent zu sparen.',
'admin.placesDetails.title': 'Ortsdetails',
'admin.placesDetails.subtitle': 'Detaillierte Ortsinformationen (Öffnungszeiten, Bewertung, Website) von der Google Places API laden. Deaktivieren, um API-Kontingent zu sparen.',
// 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',
+6
View File
@@ -609,6 +609,12 @@ 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.',
'admin.placesAutocomplete.title': 'Place Autocomplete',
'admin.placesAutocomplete.subtitle': 'Use the Google Places API for search suggestions. Disable to save API quota.',
'admin.placesDetails.title': 'Place Details',
'admin.placesDetails.subtitle': 'Fetch detailed place information (hours, rating, website) from the Google Places API. Disable to save API quota.',
// 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',
+6
View File
@@ -541,6 +541,12 @@ const es: Record<string, string> = {
'admin.fileTypesFormat': 'Extensiones separadas por comas (p. ej. jpg,png,pdf,doc). Usa * para permitir todos los tipos.', 'admin.fileTypesFormat': 'Extensiones separadas por comas (p. ej. jpg,png,pdf,doc). Usa * para permitir todos los tipos.',
'admin.fileTypesSaved': 'Ajustes de tipos de archivo guardados', 'admin.fileTypesSaved': 'Ajustes de tipos de archivo guardados',
'admin.placesPhotos.title': 'Fotos de Lugares',
'admin.placesPhotos.subtitle': 'Obtiene fotos de la Google Places API. Desactiva para ahorrar cuota de API. Las fotos de Wikimedia no se ven afectadas.',
'admin.placesAutocomplete.title': 'Autocompletado de Lugares',
'admin.placesAutocomplete.subtitle': 'Usa la Google Places API para sugerencias de búsqueda. Desactiva para ahorrar cuota de API.',
'admin.placesDetails.title': 'Detalles del Lugar',
'admin.placesDetails.subtitle': 'Obtiene información detallada del lugar (horarios, valoración, web) de la Google Places API. Desactiva para ahorrar cuota de API.',
'admin.bagTracking.title': 'Seguimiento de equipaje', 'admin.bagTracking.title': 'Seguimiento de equipaje',
'admin.bagTracking.subtitle': 'Activar peso y asignación de equipaje para artículos de la lista', 'admin.bagTracking.subtitle': 'Activar peso y asignación de equipaje para artículos de la lista',
'admin.collab.chat.title': 'Chat', 'admin.collab.chat.title': 'Chat',
+6
View File
@@ -545,6 +545,12 @@ const fr: Record<string, string> = {
'admin.fileTypesFormat': 'Extensions séparées par des virgules (ex. jpg,png,pdf,doc). Utilisez * pour autoriser tous les types.', 'admin.fileTypesFormat': 'Extensions séparées par des virgules (ex. jpg,png,pdf,doc). Utilisez * pour autoriser tous les types.',
'admin.fileTypesSaved': 'Paramètres des types de fichiers enregistrés', 'admin.fileTypesSaved': 'Paramètres des types de fichiers enregistrés',
'admin.placesPhotos.title': 'Photos de lieux',
'admin.placesPhotos.subtitle': "Récupère les photos depuis l'API Google Places. Désactivez pour économiser le quota API. Les photos Wikimedia ne sont pas affectées.",
'admin.placesAutocomplete.title': 'Autocomplétion des lieux',
'admin.placesAutocomplete.subtitle': "Utilise l'API Google Places pour les suggestions de recherche. Désactivez pour économiser le quota API.",
'admin.placesDetails.title': 'Détails du lieu',
'admin.placesDetails.subtitle': "Récupère les informations détaillées du lieu (horaires, note, site web) depuis l'API Google Places. Désactivez pour économiser le quota API.",
'admin.bagTracking.title': 'Suivi des bagages', 'admin.bagTracking.title': 'Suivi des bagages',
'admin.bagTracking.subtitle': 'Activer le poids et l\'attribution de bagages pour les articles', 'admin.bagTracking.subtitle': 'Activer le poids et l\'attribution de bagages pour les articles',
'admin.collab.chat.title': 'Chat', 'admin.collab.chat.title': 'Chat',
+6
View File
@@ -546,6 +546,12 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesSaved': 'Fájltípus-beállítások mentve', 'admin.fileTypesSaved': 'Fájltípus-beállítások mentve',
// Csomagolási sablonok és poggyászkövetés // Csomagolási sablonok és poggyászkövetés
'admin.placesPhotos.title': 'Helyfotók',
'admin.placesPhotos.subtitle': 'Fotók lekérése a Google Places API-ból. Tiltsa le az API-kvóta megtakarításához. A Wikimedia-fotók nem érintettek.',
'admin.placesAutocomplete.title': 'Hely automatikus kiegészítése',
'admin.placesAutocomplete.subtitle': 'A Google Places API használata keresési javaslatokhoz. Tiltsa le az API-kvóta megtakarításához.',
'admin.placesDetails.title': 'Hely részletei',
'admin.placesDetails.subtitle': 'Részletes helyinformációk lekérése (nyitvatartás, értékelés, weboldal) a Google Places API-ból. Tiltsa le az API-kvóta megtakarításához.',
'admin.bagTracking.title': 'Poggyászkövetés', 'admin.bagTracking.title': 'Poggyászkövetés',
'admin.bagTracking.subtitle': 'Súly- és táskahozzárendelés engedélyezése csomagolási tételeknél', 'admin.bagTracking.subtitle': 'Súly- és táskahozzárendelés engedélyezése csomagolási tételeknél',
'admin.collab.chat.title': 'Chat', 'admin.collab.chat.title': 'Chat',
+6
View File
@@ -610,6 +610,12 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesSaved': 'Pengaturan jenis file disimpan', 'admin.fileTypesSaved': 'Pengaturan jenis file disimpan',
// Packing Templates & Bag Tracking // Packing Templates & Bag Tracking
'admin.placesPhotos.title': 'Foto Tempat',
'admin.placesPhotos.subtitle': 'Mengambil foto dari Google Places API. Nonaktifkan untuk menghemat kuota API. Foto Wikimedia tidak terpengaruh.',
'admin.placesAutocomplete.title': 'Pelengkapan Otomatis Tempat',
'admin.placesAutocomplete.subtitle': 'Menggunakan Google Places API untuk saran pencarian. Nonaktifkan untuk menghemat kuota API.',
'admin.placesDetails.title': 'Detail Tempat',
'admin.placesDetails.subtitle': 'Mengambil informasi detail tempat (jam, penilaian, situs web) dari Google Places API. Nonaktifkan untuk menghemat kuota API.',
'admin.bagTracking.title': 'Pelacak Tas', 'admin.bagTracking.title': 'Pelacak Tas',
'admin.bagTracking.subtitle': 'Aktifkan berat dan penugasan tas untuk item packing', 'admin.bagTracking.subtitle': 'Aktifkan berat dan penugasan tas untuk item packing',
'admin.collab.chat.title': 'Chat', 'admin.collab.chat.title': 'Chat',
+6
View File
@@ -545,6 +545,12 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesFormat': 'Estensioni separate da virgola (es. jpg,png,pdf,doc). Usa * per consentire tutti i tipi.', 'admin.fileTypesFormat': 'Estensioni separate da virgola (es. jpg,png,pdf,doc). Usa * per consentire tutti i tipi.',
'admin.fileTypesSaved': 'Impostazioni dei tipi di file salvate', 'admin.fileTypesSaved': 'Impostazioni dei tipi di file salvate',
// Packing Templates & Bag Tracking // Packing Templates & Bag Tracking
'admin.placesPhotos.title': 'Foto dei luoghi',
'admin.placesPhotos.subtitle': "Recupera le foto dall'API Google Places. Disabilita per risparmiare la quota API. Le foto di Wikimedia non sono interessate.",
'admin.placesAutocomplete.title': 'Completamento automatico dei luoghi',
'admin.placesAutocomplete.subtitle': "Utilizza l'API Google Places per i suggerimenti di ricerca. Disabilita per risparmiare la quota API.",
'admin.placesDetails.title': 'Dettagli del luogo',
'admin.placesDetails.subtitle': "Recupera informazioni dettagliate sul luogo (orari, valutazione, sito web) dall'API Google Places. Disabilita per risparmiare la quota API.",
'admin.bagTracking.title': 'Tracciamento valigia', 'admin.bagTracking.title': 'Tracciamento valigia',
'admin.bagTracking.subtitle': 'Abilita il peso e l\'assegnazione della valigia per gli elementi della lista valigia', 'admin.bagTracking.subtitle': 'Abilita il peso e l\'assegnazione della valigia per gli elementi della lista valigia',
'admin.collab.chat.title': 'Chat', 'admin.collab.chat.title': 'Chat',
+6
View File
@@ -546,6 +546,12 @@ const nl: Record<string, string> = {
'admin.fileTypesFormat': 'Kommagescheiden extensies (bijv. jpg,png,pdf,doc). Gebruik * om alle typen toe te staan.', 'admin.fileTypesFormat': 'Kommagescheiden extensies (bijv. jpg,png,pdf,doc). Gebruik * om alle typen toe te staan.',
'admin.fileTypesSaved': 'Bestandstype-instellingen opgeslagen', 'admin.fileTypesSaved': 'Bestandstype-instellingen opgeslagen',
'admin.placesPhotos.title': "Plaatsfoto's",
'admin.placesPhotos.subtitle': "Haalt foto's op via de Google Places API. Schakel uit om API-quota te besparen. Wikimedia-foto's worden niet beïnvloed.",
'admin.placesAutocomplete.title': 'Plaatsautocomplete',
'admin.placesAutocomplete.subtitle': 'Gebruikt de Google Places API voor zoeksuggesties. Schakel uit om API-quota te besparen.',
'admin.placesDetails.title': 'Plaatsdetails',
'admin.placesDetails.subtitle': 'Haalt gedetailleerde plaatsinformatie (openingstijden, beoordeling, website) op via de Google Places API. Schakel uit om API-quota te besparen.',
'admin.bagTracking.title': 'Bagagetracking', 'admin.bagTracking.title': 'Bagagetracking',
'admin.bagTracking.subtitle': 'Gewicht en bagagetoewijzing inschakelen voor paklijstitems', 'admin.bagTracking.subtitle': 'Gewicht en bagagetoewijzing inschakelen voor paklijstitems',
'admin.collab.chat.title': 'Chat', 'admin.collab.chat.title': 'Chat',
+6
View File
@@ -518,6 +518,12 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesSaved': 'Ustawienia typów plików zostały zapisane', 'admin.fileTypesSaved': 'Ustawienia typów plików zostały zapisane',
// Packing Templates & Bag Tracking // Packing Templates & Bag Tracking
'admin.placesPhotos.title': 'Zdjęcia miejsc',
'admin.placesPhotos.subtitle': 'Pobiera zdjęcia z Google Places API. Wyłącz, aby zaoszczędzić limit API. Zdjęcia z Wikimedia nie są objęte.',
'admin.placesAutocomplete.title': 'Autouzupełnianie miejsc',
'admin.placesAutocomplete.subtitle': 'Używa Google Places API do sugestii wyszukiwania. Wyłącz, aby zaoszczędzić limit API.',
'admin.placesDetails.title': 'Szczegóły miejsca',
'admin.placesDetails.subtitle': 'Pobiera szczegółowe informacje o miejscu (godziny, ocena, strona) z Google Places API. Wyłącz, aby zaoszczędzić limit API.',
'admin.bagTracking.title': 'Kontrola bagażu', 'admin.bagTracking.title': 'Kontrola bagażu',
'admin.bagTracking.subtitle': 'Włącz wagę i przypisywanie do toreb dla przedmiotów do pakowania', 'admin.bagTracking.subtitle': 'Włącz wagę i przypisywanie do toreb dla przedmiotów do pakowania',
'admin.collab.chat.title': 'Czat', 'admin.collab.chat.title': 'Czat',
+6
View File
@@ -546,6 +546,12 @@ const ru: Record<string, string> = {
'admin.fileTypesFormat': 'Расширения через запятую (напр. jpg,png,pdf,doc). Используйте * для разрешения всех типов.', 'admin.fileTypesFormat': 'Расширения через запятую (напр. jpg,png,pdf,doc). Используйте * для разрешения всех типов.',
'admin.fileTypesSaved': 'Настройки типов файлов сохранены', 'admin.fileTypesSaved': 'Настройки типов файлов сохранены',
'admin.placesPhotos.title': 'Фотографии мест',
'admin.placesPhotos.subtitle': 'Загрузка фотографий из Google Places API. Отключите для экономии квоты API. Фотографии Wikimedia не затронуты.',
'admin.placesAutocomplete.title': 'Автодополнение мест',
'admin.placesAutocomplete.subtitle': 'Использование Google Places API для поисковых подсказок. Отключите для экономии квоты API.',
'admin.placesDetails.title': 'Сведения о месте',
'admin.placesDetails.subtitle': 'Загрузка подробной информации о месте (часы работы, рейтинг, веб-сайт) из Google Places API. Отключите для экономии квоты API.',
'admin.bagTracking.title': 'Отслеживание багажа', 'admin.bagTracking.title': 'Отслеживание багажа',
'admin.bagTracking.subtitle': 'Включить вес и привязку к багажу для вещей', 'admin.bagTracking.subtitle': 'Включить вес и привязку к багажу для вещей',
'admin.collab.chat.title': 'Чат', 'admin.collab.chat.title': 'Чат',
+6
View File
@@ -546,6 +546,12 @@ const zh: Record<string, string> = {
'admin.fileTypesFormat': '以逗号分隔的扩展名(如 jpg,png,pdf,doc)。使用 * 允许所有类型。', 'admin.fileTypesFormat': '以逗号分隔的扩展名(如 jpg,png,pdf,doc)。使用 * 允许所有类型。',
'admin.fileTypesSaved': '文件类型设置已保存', 'admin.fileTypesSaved': '文件类型设置已保存',
'admin.placesPhotos.title': '地点照片',
'admin.placesPhotos.subtitle': '从 Google Places API 获取照片。禁用可节省 API 配额。Wikimedia 照片不受影响。',
'admin.placesAutocomplete.title': '地点自动补全',
'admin.placesAutocomplete.subtitle': '使用 Google Places API 提供搜索建议。禁用可节省 API 配额。',
'admin.placesDetails.title': '地点详情',
'admin.placesDetails.subtitle': '从 Google Places API 获取地点详细信息(营业时间、评分、网站)。禁用可节省 API 配额。',
'admin.bagTracking.title': '行李追踪', 'admin.bagTracking.title': '行李追踪',
'admin.bagTracking.subtitle': '为打包物品启用重量和行李分配', 'admin.bagTracking.subtitle': '为打包物品启用重量和行李分配',
'admin.collab.chat.title': '聊天', 'admin.collab.chat.title': '聊天',
+6
View File
@@ -606,6 +606,12 @@ const zhTw: Record<string, string> = {
'admin.fileTypesFormat': '以逗號分隔的副檔名(如 jpg,png,pdf,doc)。使用 * 允許所有型別。', 'admin.fileTypesFormat': '以逗號分隔的副檔名(如 jpg,png,pdf,doc)。使用 * 允許所有型別。',
'admin.fileTypesSaved': '檔案型別設定已儲存', 'admin.fileTypesSaved': '檔案型別設定已儲存',
'admin.placesPhotos.title': '地點照片',
'admin.placesPhotos.subtitle': '從 Google Places API 獲取照片。停用可節省 API 配額。Wikimedia 照片不受影響。',
'admin.placesAutocomplete.title': '地點自動補全',
'admin.placesAutocomplete.subtitle': '使用 Google Places API 提供搜尋建議。停用可節省 API 配額。',
'admin.placesDetails.title': '地點詳情',
'admin.placesDetails.subtitle': '從 Google Places API 獲取地點詳細資訊(營業時間、評分、網站)。停用可節省 API 配額。',
'admin.bagTracking.title': '行李追蹤', 'admin.bagTracking.title': '行李追蹤',
'admin.bagTracking.subtitle': '為打包物品啟用重量和行李分配', 'admin.bagTracking.subtitle': '為打包物品啟用重量和行李分配',
'admin.collab.chat.title': '聊天', 'admin.collab.chat.title': '聊天',
+73 -1
View File
@@ -194,6 +194,18 @@ 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(() => {}) }, [])
// Places autocomplete
const [placesAutocompleteEnabled, setPlacesAutocompleteEnabledState] = useState<boolean>(true)
useEffect(() => { adminApi.getPlacesAutocomplete().then(d => setPlacesAutocompleteEnabledState(d.enabled)).catch(() => {}) }, [])
// Places details
const [placesDetailsEnabled, setPlacesDetailsEnabledState] = useState<boolean>(true)
useEffect(() => { adminApi.getPlacesDetails().then(d => setPlacesDetailsEnabledState(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 +254,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, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, logout } = useAuthStore()
const navigate = useNavigate() const navigate = useNavigate()
const toast = useToast() const toast = useToast()
@@ -1023,6 +1035,66 @@ 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 items-center rounded-full transition-colors"
style={{ background: placesPhotosEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesPhotosEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
{/* Place Autocomplete 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.placesAutocomplete.title')}</p>
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesAutocomplete.subtitle')}</p>
</div>
<button
onClick={async () => {
const next = !placesAutocompleteEnabled
setPlacesAutocompleteEnabledState(next)
setPlacesAutocompleteEnabled(next)
try { await adminApi.updatePlacesAutocomplete(next) } catch { setPlacesAutocompleteEnabledState(!next); setPlacesAutocompleteEnabled(!next) }
}}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: placesAutocompleteEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesAutocompleteEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
{/* Place Details 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.placesDetails.title')}</p>
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesDetails.subtitle')}</p>
</div>
<button
onClick={async () => {
const next = !placesDetailsEnabled
setPlacesDetailsEnabledState(next)
setPlacesDetailsEnabled(next)
try { await adminApi.updatePlacesDetails(next) } catch { setPlacesDetailsEnabledState(!next); setPlacesDetailsEnabled(!next) }
}}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: placesDetailsEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesDetailsEnabled ? 'translateX(20px)' : 'translateX(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">
+4 -2
View File
@@ -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>
+13
View File
@@ -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 }) => {
+12
View File
@@ -33,6 +33,9 @@ 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
placesAutocompleteEnabled: boolean
placesDetailsEnabled: 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 +56,9 @@ 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
setPlacesAutocompleteEnabled: (val: boolean) => void
setPlacesDetailsEnabled: (val: boolean) => void
demoLogin: () => Promise<AuthResponse> demoLogin: () => Promise<AuthResponse>
} }
@@ -74,6 +80,9 @@ 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,
placesAutocompleteEnabled: true,
placesDetailsEnabled: true,
login: async (email: string, password: string) => { login: async (email: string, password: string) => {
authSequence++ authSequence++
@@ -257,6 +266,9 @@ 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 }),
setPlacesAutocompleteEnabled: (val: boolean) => set({ placesAutocompleteEnabled: val }),
setPlacesDetailsEnabled: (val: boolean) => set({ placesDetailsEnabled: val }),
demoLogin: async () => { demoLogin: async () => {
authSequence++ authSequence++
+39
View File
@@ -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) {
+57
View File
@@ -201,6 +201,63 @@ 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);
});
// ── Places Autocomplete ──────────────────────────────────────────────────
router.get('/places-autocomplete', (_req: Request, res: Response) => {
res.json(svc.getPlacesAutocomplete());
});
router.put('/places-autocomplete', (req: Request, res: Response) => {
if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' });
const result = svc.updatePlacesAutocomplete(req.body.enabled);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.places_autocomplete',
ip: getClientIp(req),
details: { enabled: result.enabled },
});
res.json(result);
});
// ── Places Details ───────────────────────────────────────────────────────
router.get('/places-details', (_req: Request, res: Response) => {
res.json(svc.getPlacesDetails());
});
router.put('/places-details', (req: Request, res: Response) => {
if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' });
const result = svc.updatePlacesDetails(req.body.enabled);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.places_details',
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) => {
+29 -1
View File
@@ -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();
@@ -32,6 +35,9 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
// POST /autocomplete // POST /autocomplete
router.post('/autocomplete', authenticate, async (req: Request, res: Response) => { router.post('/autocomplete', authenticate, async (req: Request, res: Response) => {
const autocompleteEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined;
if (autocompleteEnabledRow?.value === 'false') return res.status(200).json({ suggestions: [], source: 'disabled' });
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const { input, lang, locationBias } = req.body; const { input, lang, locationBias } = req.body;
@@ -70,11 +76,18 @@ router.post('/autocomplete', authenticate, async (req: Request, res: Response) =
// GET /details/:placeId // GET /details/:placeId
router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => { router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => {
const detailsEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined;
if (detailsEnabledRow?.value === 'false') return res.status(200).json({ place: null, disabled: true });
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 +101,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 +121,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 };
+36
View File
@@ -459,6 +459,42 @@ 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 };
}
// ── Places Autocomplete ────────────────────────────────────────────────────
export function getPlacesAutocomplete() {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined;
return { enabled: row?.value !== 'false' };
}
export function updatePlacesAutocomplete(enabled: boolean) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_autocomplete_enabled', ?)").run(enabled ? 'true' : 'false');
return { enabled: !!enabled };
}
// ── Places Details ─────────────────────────────────────────────────────────
export function getPlacesDetails() {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined;
return { enabled: row?.value !== 'false' };
}
export function updatePlacesDetails(enabled: boolean) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_details_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;
+9
View File
@@ -229,6 +229,12 @@ 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 placesAutocompleteSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined)?.value;
const placesAutocompleteEnabled = placesAutocompleteSetting !== 'false';
const placesDetailsSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined)?.value;
const placesDetailsEnabled = placesDetailsSetting !== '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 +264,9 @@ 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,
places_autocomplete_enabled: placesAutocompleteEnabled,
places_details_enabled: placesDetailsEnabled,
permissions: authenticatedUser ? getAllPermissions() : undefined, permissions: authenticatedUser ? getAllPermissions() : undefined,
dev_mode: process.env.NODE_ENV === 'development', dev_mode: process.env.NODE_ENV === 'development',
}; };
+186 -89
View File
@@ -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 ────────────────────────────────────────────────────────
+95
View File
@@ -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;
}
+107 -64
View File
@@ -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();
}); });
}); });