263 lines
9.4 KiB
TypeScript
263 lines
9.4 KiB
TypeScript
import express, { Request, Response } from 'express';
|
|
import fetch from 'node-fetch';
|
|
import { db } from '../db/database';
|
|
import { authenticate } from '../middleware/auth';
|
|
import { AuthRequest } from '../types';
|
|
|
|
interface NominatimResult {
|
|
osm_type: string;
|
|
osm_id: string;
|
|
name?: string;
|
|
display_name?: string;
|
|
lat: string;
|
|
lon: string;
|
|
}
|
|
|
|
interface GooglePlaceResult {
|
|
id: string;
|
|
displayName?: { text: string };
|
|
formattedAddress?: string;
|
|
location?: { latitude: number; longitude: number };
|
|
rating?: number;
|
|
websiteUri?: string;
|
|
nationalPhoneNumber?: string;
|
|
types?: string[];
|
|
}
|
|
|
|
interface GooglePlaceDetails extends GooglePlaceResult {
|
|
userRatingCount?: number;
|
|
regularOpeningHours?: { weekdayDescriptions?: string[]; openNow?: boolean };
|
|
googleMapsUri?: string;
|
|
editorialSummary?: { text: string };
|
|
reviews?: { authorAttribution?: { displayName?: string; photoUri?: string }; rating?: number; text?: { text?: string }; relativePublishTimeDescription?: string }[];
|
|
photos?: { name: string; authorAttributions?: { displayName?: string }[] }[];
|
|
}
|
|
|
|
const router = express.Router();
|
|
|
|
function getMapsKey(userId: number): string | null {
|
|
const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(userId) as { maps_api_key: string | null } | undefined;
|
|
if (user?.maps_api_key) return user.maps_api_key;
|
|
const admin = db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get() as { maps_api_key: string } | undefined;
|
|
return admin?.maps_api_key || null;
|
|
}
|
|
|
|
const photoCache = new Map<string, { photoUrl: string; attribution: string | null; fetchedAt: number }>();
|
|
const PHOTO_TTL = 12 * 60 * 60 * 1000; // 12 hours
|
|
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);
|
|
|
|
async function searchNominatim(query: string, lang?: string) {
|
|
const params = new URLSearchParams({
|
|
q: query,
|
|
format: 'json',
|
|
addressdetails: '1',
|
|
limit: '10',
|
|
'accept-language': lang || 'en',
|
|
});
|
|
const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, {
|
|
headers: { 'User-Agent': 'NOMAD Travel Planner (https://github.com/mauriceboe/NOMAD)' },
|
|
});
|
|
if (!response.ok) throw new Error('Nominatim API error');
|
|
const data = await response.json() as NominatimResult[];
|
|
return data.map(item => ({
|
|
google_place_id: null,
|
|
osm_id: `${item.osm_type}/${item.osm_id}`,
|
|
name: item.name || item.display_name?.split(',')[0] || '',
|
|
address: item.display_name || '',
|
|
lat: parseFloat(item.lat) || null,
|
|
lng: parseFloat(item.lon) || null,
|
|
rating: null,
|
|
website: null,
|
|
phone: null,
|
|
source: 'openstreetmap',
|
|
}));
|
|
}
|
|
|
|
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const { query } = req.body;
|
|
|
|
if (!query) return res.status(400).json({ error: 'Search query is required' });
|
|
|
|
const apiKey = getMapsKey(authReq.user.id);
|
|
|
|
if (!apiKey) {
|
|
try {
|
|
const places = await searchNominatim(query, req.query.lang as string);
|
|
return res.json({ places, source: 'openstreetmap' });
|
|
} catch (err: unknown) {
|
|
console.error('Nominatim search error:', err);
|
|
return res.status(500).json({ error: 'OpenStreetMap search error' });
|
|
}
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('https://places.googleapis.com/v1/places:searchText', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Goog-Api-Key': apiKey,
|
|
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types',
|
|
},
|
|
body: JSON.stringify({ textQuery: query, languageCode: (req.query.lang as string) || 'en' }),
|
|
});
|
|
|
|
const data = await response.json() as { places?: GooglePlaceResult[]; error?: { message?: string } };
|
|
|
|
if (!response.ok) {
|
|
return res.status(response.status).json({ error: data.error?.message || 'Google Places API error' });
|
|
}
|
|
|
|
const places = (data.places || []).map((p: GooglePlaceResult) => ({
|
|
google_place_id: p.id,
|
|
name: p.displayName?.text || '',
|
|
address: p.formattedAddress || '',
|
|
lat: p.location?.latitude || null,
|
|
lng: p.location?.longitude || null,
|
|
rating: p.rating || null,
|
|
website: p.websiteUri || null,
|
|
phone: p.nationalPhoneNumber || null,
|
|
source: 'google',
|
|
}));
|
|
|
|
res.json({ places, source: 'google' });
|
|
} catch (err: unknown) {
|
|
console.error('Maps search error:', err);
|
|
res.status(500).json({ error: 'Google Places search error' });
|
|
}
|
|
});
|
|
|
|
router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const { placeId } = req.params;
|
|
|
|
const apiKey = getMapsKey(authReq.user.id);
|
|
if (!apiKey) {
|
|
return res.status(400).json({ error: 'Google Maps API key not configured' });
|
|
}
|
|
|
|
try {
|
|
const lang = (req.query.lang as string) || 'de';
|
|
const response = await fetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${lang}`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'X-Goog-Api-Key': apiKey,
|
|
'X-Goog-FieldMask': 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri,reviews,editorialSummary',
|
|
},
|
|
});
|
|
|
|
const data = await response.json() as GooglePlaceDetails & { error?: { message?: string } };
|
|
|
|
if (!response.ok) {
|
|
return res.status(response.status).json({ error: data.error?.message || 'Google Places API error' });
|
|
}
|
|
|
|
const place = {
|
|
google_place_id: data.id,
|
|
name: data.displayName?.text || '',
|
|
address: data.formattedAddress || '',
|
|
lat: data.location?.latitude || null,
|
|
lng: data.location?.longitude || null,
|
|
rating: data.rating || null,
|
|
rating_count: data.userRatingCount || null,
|
|
website: data.websiteUri || null,
|
|
phone: data.nationalPhoneNumber || null,
|
|
opening_hours: data.regularOpeningHours?.weekdayDescriptions || null,
|
|
open_now: data.regularOpeningHours?.openNow ?? null,
|
|
google_maps_url: data.googleMapsUri || null,
|
|
summary: data.editorialSummary?.text || null,
|
|
reviews: (data.reviews || []).slice(0, 5).map((r: NonNullable<GooglePlaceDetails['reviews']>[number]) => ({
|
|
author: r.authorAttribution?.displayName || null,
|
|
rating: r.rating || null,
|
|
text: r.text?.text || null,
|
|
time: r.relativePublishTimeDescription || null,
|
|
photo: r.authorAttribution?.photoUri || null,
|
|
})),
|
|
};
|
|
|
|
res.json({ place });
|
|
} catch (err: unknown) {
|
|
console.error('Maps details error:', err);
|
|
res.status(500).json({ error: 'Error fetching place details' });
|
|
}
|
|
});
|
|
|
|
router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const { placeId } = req.params;
|
|
|
|
const cached = photoCache.get(placeId);
|
|
if (cached && Date.now() - cached.fetchedAt < PHOTO_TTL) {
|
|
return res.json({ photoUrl: cached.photoUrl, attribution: cached.attribution });
|
|
}
|
|
|
|
const apiKey = getMapsKey(authReq.user.id);
|
|
if (!apiKey) {
|
|
return res.status(400).json({ error: 'Google Maps API key not configured' });
|
|
}
|
|
|
|
try {
|
|
const detailsRes = await fetch(`https://places.googleapis.com/v1/places/${placeId}`, {
|
|
headers: {
|
|
'X-Goog-Api-Key': apiKey,
|
|
'X-Goog-FieldMask': 'photos',
|
|
},
|
|
});
|
|
const details = await detailsRes.json() as GooglePlaceDetails & { error?: { message?: string } };
|
|
|
|
if (!detailsRes.ok) {
|
|
console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
|
|
return res.status(404).json({ error: 'Photo could not be retrieved' });
|
|
}
|
|
|
|
if (!details.photos?.length) {
|
|
return res.status(404).json({ error: 'No photo available' });
|
|
}
|
|
|
|
const photo = details.photos[0];
|
|
const photoName = photo.name;
|
|
const attribution = photo.authorAttributions?.[0]?.displayName || null;
|
|
|
|
const mediaRes = await fetch(
|
|
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=600&key=${apiKey}&skipHttpRedirect=true`
|
|
);
|
|
const mediaData = await mediaRes.json() as { photoUri?: string };
|
|
const photoUrl = mediaData.photoUri;
|
|
|
|
if (!photoUrl) {
|
|
return res.status(404).json({ error: 'Photo URL not available' });
|
|
}
|
|
|
|
photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() });
|
|
|
|
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);
|
|
}
|
|
|
|
res.json({ photoUrl, attribution });
|
|
} catch (err: unknown) {
|
|
console.error('Place photo error:', err);
|
|
res.status(500).json({ error: 'Error fetching photo' });
|
|
}
|
|
});
|
|
|
|
export default router;
|