Spaces:
Running
Running
| import { | |
| Body, | |
| Controller, | |
| Get, | |
| HttpCode, | |
| HttpException, | |
| Param, | |
| Post, | |
| Query, | |
| Res, | |
| UseGuards, | |
| } from '@nestjs/common'; | |
| import type { Response } from 'express'; | |
| import { createReadStream } from 'node:fs'; | |
| import type { | |
| MapsAutocompleteResult, | |
| MapsPlaceDetailsResult, | |
| MapsPlacePhotoResult, | |
| MapsResolveUrlResult, | |
| MapsReverseResult, | |
| MapsSearchResult, | |
| } from '@trek/shared'; | |
| import type { User } from '../../types'; | |
| import { MapsService } from './maps.service'; | |
| import { JwtAuthGuard } from '../auth/jwt-auth.guard'; | |
| import { CurrentUser } from '../auth/current-user.decorator'; | |
| type LocationBias = { low: { lat: number; lng: number }; high: { lat: number; lng: number } }; | |
| /** Maps a thrown service error to the same status + { error } body Express sent. */ | |
| function toHttpException(err: unknown, fallbackMessage: string, defaultStatus: number): HttpException { | |
| const status = (err as { status?: number }).status || defaultStatus; | |
| const message = err instanceof Error ? err.message : fallbackMessage; | |
| return new HttpException({ error: message }, status); | |
| } | |
| /** | |
| * /api/maps — place search, autocomplete, details, photos, reverse geocoding and | |
| * Google-Maps-URL resolution. | |
| * | |
| * Behaviour is byte-identical to the legacy Express route (server/src/routes/ | |
| * maps.ts): same auth, same bespoke 400 validation messages, the same | |
| * per-endpoint kill-switch short-circuits, the same error status/body mapping, | |
| * and the same diagnostic logging. The SSRF guard lives in the underlying | |
| * service and is reused unchanged. | |
| */ | |
| ('api/maps') | |
| (JwtAuthGuard) | |
| export class MapsController { | |
| constructor(private readonly maps: MapsService) {} | |
| ('search') | |
| (200) // Express answers with res.json (200); Nest would otherwise default POST to 201. | |
| async search( | |
| () user: User, | |
| ('query') query: unknown, | |
| ('lang') lang?: string, | |
| ('locationBias') locationBias?: { lat: number; lng: number; radius?: number }, | |
| ): Promise<MapsSearchResult> { | |
| if (!query) { | |
| throw new HttpException({ error: 'Search query is required' }, 400); | |
| } | |
| // Optional bias toward a coordinate (lat/lng[/radius]); improves foreign-region queries. | |
| if (locationBias && !(Number.isFinite(locationBias.lat) && Number.isFinite(locationBias.lng))) { | |
| throw new HttpException({ error: 'Invalid locationBias: lat and lng must be finite numbers' }, 400); | |
| } | |
| try { | |
| return await this.maps.search(user.id, query as string, lang, locationBias); | |
| } catch (err: unknown) { | |
| console.error('Maps search error:', err); | |
| throw toHttpException(err, 'Search error', 500); | |
| } | |
| } | |
| // OSM-only POI explore: places of a category within the current map viewport. | |
| ('pois') | |
| async pois( | |
| ('category') category?: string, | |
| ('south') south?: string, | |
| ('west') west?: string, | |
| ('north') north?: string, | |
| ('east') east?: string, | |
| ) { | |
| if (!category) throw new HttpException({ error: 'A category is required' }, 400); | |
| const bbox = { south: Number(south), west: Number(west), north: Number(north), east: Number(east) }; | |
| if (Object.values(bbox).some(v => !Number.isFinite(v))) { | |
| throw new HttpException({ error: 'A valid bbox (south, west, north, east) is required' }, 400); | |
| } | |
| try { | |
| return await this.maps.pois(category, bbox); | |
| } catch (err: unknown) { | |
| throw toHttpException(err, 'POI search error', 500); | |
| } | |
| } | |
| ('autocomplete') | |
| (200) | |
| async autocomplete( | |
| () user: User, | |
| ('input') input: unknown, | |
| ('lang') lang?: string, | |
| ('locationBias') locationBias?: LocationBias, | |
| ): Promise<MapsAutocompleteResult | { suggestions: never[]; source: string }> { | |
| if (this.maps.autocompleteDisabled()) { | |
| return { suggestions: [], source: 'disabled' }; | |
| } | |
| if (!input || typeof input !== 'string') { | |
| throw new HttpException({ error: 'Input is required' }, 400); | |
| } | |
| if (input.length > 200) { | |
| throw new HttpException({ error: 'Input too long (max 200 chars)' }, 400); | |
| } | |
| 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)) { | |
| throw new HttpException({ error: 'Invalid locationBias: low and high must have finite lat and lng' }, 400); | |
| } | |
| } | |
| try { | |
| return await this.maps.autocomplete(user.id, input, lang, locationBias); | |
| } catch (err: unknown) { | |
| console.error('Maps autocomplete error:', err); | |
| throw toHttpException(err, 'Autocomplete error', 500); | |
| } | |
| } | |
| ('details/:placeId') | |
| async details( | |
| () user: User, | |
| ('placeId') placeId: string, | |
| ('expand') expand?: string, | |
| ('lang') lang?: string, | |
| ('refresh') refresh?: string, | |
| ): Promise<MapsPlaceDetailsResult> { | |
| if (this.maps.detailsDisabled()) { | |
| return { place: null, disabled: true }; | |
| } | |
| try { | |
| return expand | |
| ? await this.maps.detailsExpanded(user.id, placeId, lang, refresh === '1') | |
| : await this.maps.details(user.id, placeId, lang); | |
| } catch (err: unknown) { | |
| console.error('Maps details error:', err); | |
| throw toHttpException(err, 'Error fetching place details', 500); | |
| } | |
| } | |
| ('place-photo/:placeId') | |
| async placePhoto( | |
| () user: User, | |
| ('placeId') placeId: string, | |
| ('lat') lat?: string, | |
| ('lng') lng?: string, | |
| ('name') name?: string, | |
| ): Promise<MapsPlacePhotoResult | { photoUrl: null }> { | |
| // Kill-switch only applies to Google Places fetches — Wikimedia (coords:) stays allowed. | |
| if (!placeId.startsWith('coords:') && this.maps.photosDisabled()) { | |
| return { photoUrl: null }; | |
| } | |
| try { | |
| return await this.maps.photo(user.id, placeId, parseFloat(lat as string), parseFloat(lng as string), name); | |
| } catch (err: unknown) { | |
| const status = (err as { status?: number }).status || 500; | |
| if (status >= 500) console.error('Place photo error:', err); | |
| throw toHttpException(err, 'Error fetching photo', 500); | |
| } | |
| } | |
| ('place-photo/:placeId/bytes') | |
| placePhotoBytes(('placeId') placeId: string, () res: Response): void { | |
| const fp = this.maps.photoBytesPath(placeId); | |
| if (!fp) { | |
| res.status(404).json({ error: 'Photo not cached' }); | |
| return; | |
| } | |
| // Stream the cached file directly instead of res.sendFile(): the send library | |
| // bundled under @nestjs/platform-express rejects absolute Windows paths (drive | |
| // letter, no `root`) with a NotFoundError that surfaced as an unhandled 500, | |
| // even though the file exists. A plain read stream serves the bytes | |
| // cross-platform; a read error still yields the legacy 404. Cached photos are | |
| // always JPEG (placePhotoCache writes `<hash>.jpg`). | |
| res.set('Cache-Control', 'public, max-age=2592000, immutable'); | |
| res.type('image/jpeg'); | |
| const stream = createReadStream(fp); | |
| stream.on('error', () => { | |
| if (!res.headersSent) res.status(404).json({ error: 'Photo not cached' }); | |
| }); | |
| stream.pipe(res); | |
| } | |
| ('reverse') | |
| async reverse( | |
| ('lat') lat?: string, | |
| ('lng') lng?: string, | |
| ('lang') lang?: string, | |
| ): Promise<MapsReverseResult> { | |
| if (!lat || !lng) { | |
| throw new HttpException({ error: 'lat and lng required' }, 400); | |
| } | |
| try { | |
| return await this.maps.reverse(lat, lng, lang); | |
| } catch { | |
| // The legacy route swallows reverse-geocode failures into an empty result. | |
| return { name: null, address: null }; | |
| } | |
| } | |
| ('resolve-url') | |
| (200) | |
| async resolveUrl(('url') url: unknown): Promise<MapsResolveUrlResult> { | |
| if (!url || typeof url !== 'string') { | |
| throw new HttpException({ error: 'URL is required' }, 400); | |
| } | |
| try { | |
| return await this.maps.resolveUrl(url); | |
| } catch (err: unknown) { | |
| const message = err instanceof Error ? err.message : 'Failed to resolve URL'; | |
| console.error('[Maps] URL resolve error:', message); | |
| throw toHttpException(err, 'Failed to resolve URL', 400); | |
| } | |
| } | |
| } | |