mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +00:00
8a58ce51c0
Add admin toggles for places_autocomplete_enabled and places_details_enabled
alongside the existing places_photos_enabled, all default ON.
- adminService: getPlacesAutocomplete/updatePlacesAutocomplete, getPlacesDetails/updatePlacesDetails
- admin routes: GET/PUT /admin/places-autocomplete, /admin/places-details
- maps routes: autocomplete returns { suggestions: [], source: 'disabled' } when off;
details returns { place: null, disabled: true } when off
- authService: both flags included in getAppConfig() response
- authStore: placesAutocompleteEnabled + placesDetailsEnabled state and setters
- App.tsx: wire both flags from app-config on load
- AdminPage: two new toggle rows using var(--text-primary)/var(--border-primary) consistent with rest of UI
- i18n: all 15 locales (en, de, ar, br, cs, es, fr, hu, id, it, nl, pl, ru, zh, zhTw)
163 lines
6.1 KiB
TypeScript
163 lines
6.1 KiB
TypeScript
import express, { Request, Response } from 'express';
|
|
import { authenticate } from '../middleware/auth';
|
|
import { AuthRequest } from '../types';
|
|
import {
|
|
searchPlaces,
|
|
getPlaceDetails,
|
|
getPlaceDetailsExpanded,
|
|
getPlacePhoto,
|
|
reverseGeocode,
|
|
resolveGoogleMapsUrl,
|
|
autocompletePlaces,
|
|
} from '../services/mapsService';
|
|
import { db } from '../db/database';
|
|
import { serveFilePath } from '../services/placePhotoCache';
|
|
|
|
const router = express.Router();
|
|
|
|
// POST /search
|
|
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' });
|
|
|
|
try {
|
|
const result = await searchPlaces(authReq.user.id, query, req.query.lang as string);
|
|
res.json(result);
|
|
} catch (err: unknown) {
|
|
const status = (err as { status?: number }).status || 500;
|
|
const message = err instanceof Error ? err.message : 'Search error';
|
|
console.error('Maps search error:', err);
|
|
res.status(status).json({ error: message });
|
|
}
|
|
});
|
|
|
|
// POST /autocomplete
|
|
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 { input, lang, locationBias } = req.body;
|
|
|
|
if (!input || typeof input !== 'string') {
|
|
return res.status(400).json({ error: 'Input is required' });
|
|
}
|
|
|
|
if (input.length > 200) {
|
|
return res.status(400).json({ error: 'Input too long (max 200 chars)' });
|
|
}
|
|
|
|
if (locationBias) {
|
|
const { low, high } = locationBias;
|
|
if (!low || !high
|
|
|| !Number.isFinite(low.lat) || !Number.isFinite(low.lng)
|
|
|| !Number.isFinite(high.lat) || !Number.isFinite(high.lng)) {
|
|
return res.status(400).json({ error: 'Invalid locationBias: low and high must have finite lat and lng' });
|
|
}
|
|
}
|
|
|
|
try {
|
|
const result = await autocompletePlaces(
|
|
authReq.user.id,
|
|
input,
|
|
lang as string,
|
|
locationBias as { low: { lat: number; lng: number }; high: { lat: number; lng: number } } | undefined,
|
|
);
|
|
res.json(result);
|
|
} catch (err: unknown) {
|
|
const status = (err as { status?: number }).status || 500;
|
|
const message = err instanceof Error ? err.message : 'Autocomplete error';
|
|
console.error('Maps autocomplete error:', err);
|
|
res.status(status).json({ error: message });
|
|
}
|
|
});
|
|
|
|
// GET /details/:placeId
|
|
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 { placeId } = req.params;
|
|
const expand = req.query.expand as string | undefined;
|
|
|
|
try {
|
|
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);
|
|
} catch (err: unknown) {
|
|
const status = (err as { status?: number }).status || 500;
|
|
const message = err instanceof Error ? err.message : 'Error fetching place details';
|
|
console.error('Maps details error:', err);
|
|
res.status(status).json({ error: message });
|
|
}
|
|
});
|
|
|
|
// GET /place-photo/:placeId
|
|
router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
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 lng = parseFloat(req.query.lng as string);
|
|
|
|
try {
|
|
const result = await getPlacePhoto(authReq.user.id, placeId, lat, lng, req.query.name as string);
|
|
res.json(result);
|
|
} catch (err: unknown) {
|
|
const status = (err as { status?: number }).status || 500;
|
|
const message = err instanceof Error ? err.message : 'Error fetching photo';
|
|
if (status >= 500) console.error('Place photo error:', err);
|
|
res.status(status).json({ error: message });
|
|
}
|
|
});
|
|
|
|
// 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
|
|
router.get('/reverse', authenticate, async (req: Request, res: Response) => {
|
|
const { lat, lng, lang } = req.query as { lat: string; lng: string; lang?: string };
|
|
if (!lat || !lng) return res.status(400).json({ error: 'lat and lng required' });
|
|
|
|
try {
|
|
const result = await reverseGeocode(lat, lng, lang);
|
|
res.json(result);
|
|
} catch {
|
|
res.json({ name: null, address: null });
|
|
}
|
|
});
|
|
|
|
// POST /resolve-url
|
|
router.post('/resolve-url', authenticate, async (req: Request, res: Response) => {
|
|
const { url } = req.body;
|
|
if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' });
|
|
|
|
try {
|
|
const result = await resolveGoogleMapsUrl(url);
|
|
res.json(result);
|
|
} catch (err: unknown) {
|
|
const status = (err as { status?: number }).status || 400;
|
|
const message = err instanceof Error ? err.message : 'Failed to resolve URL';
|
|
console.error('[Maps] URL resolve error:', message);
|
|
res.status(status).json({ error: message });
|
|
}
|
|
});
|
|
|
|
export default router;
|