mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
1378c95078
* feat(maps): add an OSM POI search endpoint (category within a viewport) New /api/maps/pois queries OpenStreetMap via Overpass for places of a category (restaurants, cafes, hotels, sights, …) inside a bounding box. OSM-only by design — it never calls Google, even when a Google key is configured. * feat(map): explore nearby places on the trip map (OSM category pill) A floating, icon-only pill over the planner map lets you toggle a POI category and see those OpenStreetMap places in the current view; clicking a marker opens the add-place form pre-filled (name, address, website, phone). Single-select with a 'search this area' action after the map moves. Renders on both the Leaflet and Mapbox maps, and can be turned off in settings (discussion #841). * fix(planner): anchor timed places when optimising and route transports by location - The day optimiser no longer reshuffles places that have a set time — they stay anchored to their time, like locked places. - The route now uses a transport's departure/arrival location as a waypoint when it has one (e.g. a flight's airport), instead of breaking the route at every booking; transports without a location are ignored for routing but still show their leg's distance/duration under the booking. * feat(admin): instance-wide Mapbox defaults in default user settings Admins can set a shared Mapbox token (plus style, 3D and quality) as instance defaults, so the whole instance can use Mapbox without each user pasting their own key. Users without their own value inherit it via the existing admin-defaults merge; the shared token is stored encrypted (discussion #920).
220 lines
7.7 KiB
TypeScript
220 lines
7.7 KiB
TypeScript
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.
|
|
*/
|
|
@Controller('api/maps')
|
|
@UseGuards(JwtAuthGuard)
|
|
export class MapsController {
|
|
constructor(private readonly maps: MapsService) {}
|
|
|
|
@Post('search')
|
|
@HttpCode(200) // Express answers with res.json (200); Nest would otherwise default POST to 201.
|
|
async search(
|
|
@CurrentUser() user: User,
|
|
@Body('query') query: unknown,
|
|
@Query('lang') lang?: string,
|
|
): Promise<MapsSearchResult> {
|
|
if (!query) {
|
|
throw new HttpException({ error: 'Search query is required' }, 400);
|
|
}
|
|
try {
|
|
return await this.maps.search(user.id, query as string, lang);
|
|
} 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.
|
|
@Get('pois')
|
|
async pois(
|
|
@Query('category') category?: string,
|
|
@Query('south') south?: string,
|
|
@Query('west') west?: string,
|
|
@Query('north') north?: string,
|
|
@Query('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);
|
|
}
|
|
}
|
|
|
|
@Post('autocomplete')
|
|
@HttpCode(200)
|
|
async autocomplete(
|
|
@CurrentUser() user: User,
|
|
@Body('input') input: unknown,
|
|
@Body('lang') lang?: string,
|
|
@Body('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);
|
|
}
|
|
}
|
|
|
|
@Get('details/:placeId')
|
|
async details(
|
|
@CurrentUser() user: User,
|
|
@Param('placeId') placeId: string,
|
|
@Query('expand') expand?: string,
|
|
@Query('lang') lang?: string,
|
|
@Query('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);
|
|
}
|
|
}
|
|
|
|
@Get('place-photo/:placeId')
|
|
async placePhoto(
|
|
@CurrentUser() user: User,
|
|
@Param('placeId') placeId: string,
|
|
@Query('lat') lat?: string,
|
|
@Query('lng') lng?: string,
|
|
@Query('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);
|
|
}
|
|
}
|
|
|
|
@Get('place-photo/:placeId/bytes')
|
|
placePhotoBytes(@Param('placeId') placeId: string, @Res() 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);
|
|
}
|
|
|
|
@Get('reverse')
|
|
async reverse(
|
|
@Query('lat') lat?: string,
|
|
@Query('lng') lng?: string,
|
|
@Query('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 };
|
|
}
|
|
}
|
|
|
|
@Post('resolve-url')
|
|
@HttpCode(200)
|
|
async resolveUrl(@Body('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);
|
|
}
|
|
}
|
|
}
|