diff --git a/client/src/api/tourGpxExport.ts b/client/src/api/tourGpxExport.ts new file mode 100644 index 000000000..dbec9c4f2 --- /dev/null +++ b/client/src/api/tourGpxExport.ts @@ -0,0 +1,33 @@ +import apiClient from './client'; + +/** + * The file name a download's Content-Disposition gives: the UTF-8 `filename*` + * first, since it keeps the tour's own script, the plain `filename` after. + */ +export function attachmentFilename(header: unknown): string | null { + if (typeof header !== 'string') return null; + const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header)?.[1]; + if (encoded) { + try { + return decodeURIComponent(encoded.trim()); + } catch { + // A malformed escape falls through to the plain name. + } + } + return /filename="([^"]+)"/i.exec(header)?.[1] ?? null; +} + +/** PR1-06A: one saved tour as the GPX file the server writes. */ +export const tourGpxExportApi = { + download: async ( + tripId: number | string, + placeId: number, + signal?: AbortSignal + ): Promise<{ blob: Blob; filename: string | null }> => { + const response = await apiClient.get(`/trips/${tripId}/tours/${placeId}/export.gpx`, { + responseType: 'blob', + signal, + }); + return { blob: response.data, filename: attachmentFilename(response.headers['content-disposition']) }; + }, +}; diff --git a/client/src/components/Planner/planParts.tsx b/client/src/components/Planner/planParts.tsx index e023f3211..fa25c5da1 100644 --- a/client/src/components/Planner/planParts.tsx +++ b/client/src/components/Planner/planParts.tsx @@ -15,8 +15,11 @@ import { fs } from '../shared/DialogShell' export const PANEL_BAR = 'flex min-h-[52px] flex-none items-center gap-1.5 border-b border-edge-faint px-3 py-2' /** A 32px icon control on a panel bar. Raised like an active filter tab while `active`. */ -export function BarButton({ label, onClick, active = false, disabled = false, children, className = '', ariaPressed, ariaExpanded, ariaHaspopup, placement = 'bottom' }: { +export function BarButton({ label, tooltip, onClick, active = false, disabled = false, children, className = '', ariaPressed, ariaExpanded, ariaHaspopup, placement = 'bottom', testId }: { label: string + /** A longer explanation for the tooltip; the label still names the button. */ + tooltip?: string + testId?: string onClick: (e: MouseEvent) => void active?: boolean disabled?: boolean @@ -28,8 +31,8 @@ export function BarButton({ label, onClick, active = false, disabled = false, ch placement?: 'top' | 'bottom' | 'left' | 'right' }) { return ( - - - + + {gpxImport.importing ? : } + ) } @@ -673,13 +677,11 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo {canEdit && gpxImport && } {canEdit && planner.mode.type !== 'neutral' && ( - - - + { if (!isSaving) planner.startNewTour() }} + className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"> + + )} diff --git a/client/src/components/Tours/planner/useTourPlanner.ts b/client/src/components/Tours/planner/useTourPlanner.ts index be4923ef6..94323569d 100644 --- a/client/src/components/Tours/planner/useTourPlanner.ts +++ b/client/src/components/Tours/planner/useTourPlanner.ts @@ -927,7 +927,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe description, savedDurationSeconds, editingPlaceId, enrichedGeometry, invalidateDetailRequest, name, onSaved, plannedDurationMinutes, storageKey, tripId, typeFields, waypoints, website]) return { - canEdit, canAssign, + tripId, canEdit, canAssign, name, setName, maxHikingDifficulty, setMaxHikingDifficulty, tourType, setTourType, isPaddle, paddle, paddleSpeedInvalid, paddleIncomplete, description, setDescription, website, setWebsite, websiteInvalid, diff --git a/client/src/components/Tours/useTourGpxExport.test.tsx b/client/src/components/Tours/useTourGpxExport.test.tsx new file mode 100644 index 000000000..5ba4f09e0 --- /dev/null +++ b/client/src/components/Tours/useTourGpxExport.test.tsx @@ -0,0 +1,116 @@ +import { act, fireEvent, render, renderHook, screen } from '@testing-library/react'; +import { AxiosError, AxiosHeaders } from 'axios'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { attachmentFilename } from '../../api/tourGpxExport'; +import { TourGpxExportBarButton, TourGpxExportButton } from './TourGpxExportButton'; +import { useTourGpxExport } from './useTourGpxExport'; + +const repo = vi.hoisted(() => ({ exportGpx: vi.fn() })); +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() })); +const download = vi.hoisted(() => ({ downloadBlob: vi.fn() })); +vi.mock('../../repo/tourRepo', () => ({ tourRepo: repo })); +vi.mock('../shared/Toast', () => ({ useToast: () => toast })); +vi.mock('../../utils/fileDownload', () => download); + +const target = { tripId: 1, placeId: 7, name: 'Kramer' }; +const blob = new Blob([''], { type: 'application/gpx+xml' }); + +function refused(status: number) { + const headers = new AxiosHeaders(); + return new AxiosError('refused', 'ERR', undefined, undefined, { + status, + statusText: '', + headers, + config: { headers }, + data: {}, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + repo.exportGpx.mockResolvedValue({ offline: false, blob, filename: 'Kramer-über.gpx' }); +}); + +describe('attachmentFilename (PR1-06A)', () => { + it('prefers the UTF-8 name, falls back to the plain one, and reads nothing from nothing', () => { + expect(attachmentFilename(`attachment; filename="Kramer-_ber.gpx"; filename*=UTF-8''Kramer-%C3%BCber.gpx`)).toBe( + 'Kramer-über.gpx' + ); + expect(attachmentFilename('attachment; filename="plain.gpx"')).toBe('plain.gpx'); + expect(attachmentFilename(`attachment; filename="safe.gpx"; filename*=UTF-8''%E0%A4%A`)).toBe('safe.gpx'); + expect(attachmentFilename('attachment')).toBeNull(); + expect(attachmentFilename(undefined)).toBeNull(); + }); +}); + +describe('useTourGpxExport (PR1-06A)', () => { + it('hands the server file to the browser under its own name', async () => { + const { result } = renderHook(() => useTourGpxExport()); + await act(() => result.current.exportGpx(target)); + expect(repo.exportGpx).toHaveBeenCalledWith(1, 7, expect.any(AbortSignal)); + expect(download.downloadBlob).toHaveBeenCalledWith(blob, 'Kramer-über.gpx'); + expect(result.current.exporting).toBe(false); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('names the file after the tour when the server gave no name', async () => { + repo.exportGpx.mockResolvedValue({ offline: false, blob, filename: null }); + const { result } = renderHook(() => useTourGpxExport()); + await act(() => result.current.exportGpx({ ...target, name: ' ' })); + expect(download.downloadBlob).toHaveBeenCalledWith(blob, 'tour.gpx'); + }); + + it('says why nothing came: offline, a tour without a route, or a failure', async () => { + const { result } = renderHook(() => useTourGpxExport()); + repo.exportGpx.mockResolvedValueOnce({ offline: true }); + await act(() => result.current.exportGpx(target)); + expect(toast.info).toHaveBeenCalledWith('tours.gpxExport.offline'); + + repo.exportGpx.mockRejectedValueOnce(refused(422)); + await act(() => result.current.exportGpx(target)); + expect(toast.error).toHaveBeenLastCalledWith('tours.gpxExport.noRoute'); + + repo.exportGpx.mockRejectedValueOnce(new Error('network')); + await act(() => result.current.exportGpx(target)); + expect(toast.error).toHaveBeenLastCalledWith('tours.gpxExport.failed'); + expect(download.downloadBlob).not.toHaveBeenCalled(); + }); + + it('runs one export at a time, and a caller gone mid-download cancels it quietly', async () => { + let finish: (value: unknown) => void = () => undefined; + repo.exportGpx.mockImplementation(() => new Promise((resolve) => (finish = resolve))); + const { result, unmount } = renderHook(() => useTourGpxExport()); + act(() => { + void result.current.exportGpx(target); + }); + expect(result.current.exportingId).toBe(7); + await act(() => result.current.exportGpx(target)); + expect(repo.exportGpx).toHaveBeenCalledTimes(1); + + const signal = repo.exportGpx.mock.calls[0][2] as AbortSignal; + unmount(); + expect(signal.aborted).toBe(true); + await act(async () => finish({ offline: false, blob, filename: 'late.gpx' })); + expect(toast.error).not.toHaveBeenCalled(); + }); +}); + +describe('TourGpxExportButton (PR1-06A)', () => { + it('exports from the footer, apart from the navigation', async () => { + render(); + const button = screen.getByTestId('tour-gpx-export'); + expect(button).toHaveAccessibleName('tours.gpxExport.action'); + await act(async () => fireEvent.click(button)); + expect(repo.exportGpx).toHaveBeenCalledTimes(1); + }); + + it('waits in the planner head while the tour has unsaved changes, and says so', () => { + const { rerender } = render(); + expect(screen.getByRole('button', { name: 'tours.gpxExport.saveFirst' })).toBeDisabled(); + rerender(); + const button = screen.getByRole('button', { name: 'tours.gpxExport.action' }); + expect(button).toBeEnabled(); + fireEvent.click(button); + expect(repo.exportGpx).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/components/Tours/useTourGpxExport.ts b/client/src/components/Tours/useTourGpxExport.ts new file mode 100644 index 000000000..96f87db8f --- /dev/null +++ b/client/src/components/Tours/useTourGpxExport.ts @@ -0,0 +1,55 @@ +import { isAxiosError } from 'axios'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from '../../i18n'; +import { tourRepo } from '../../repo/tourRepo'; +import { downloadBlob } from '../../utils/fileDownload'; +import { useToast } from '../shared/Toast'; + +/** What a tour's GPX export needs to know about the tour. */ +export interface TourGpxExportTarget { + tripId: number | string; + placeId: number; + name: string; +} + +/** + * PR1-06A: a saved tour handed over as a GPX file, started on purpose. The + * file is the server's (the saved line, as saved), so an unsaved edit in the + * planner is not in it. One export at a time per caller; a dialog closed + * mid-download cancels it. Nothing about the tour changes either way. + */ +export function useTourGpxExport() { + const { t } = useTranslation(); + const toast = useToast(); + const [exportingId, setExportingId] = useState(null); + const abortRef = useRef(null); + + useEffect(() => () => abortRef.current?.abort(), []); + + const exportGpx = useCallback( + async ({ tripId, placeId, name }: TourGpxExportTarget) => { + if (abortRef.current) return; + const controller = new AbortController(); + abortRef.current = controller; + setExportingId(placeId); + try { + const result = await tourRepo.exportGpx(tripId, placeId, controller.signal); + if (!('blob' in result)) { + toast.info(t('tours.gpxExport.offline')); + return; + } + downloadBlob(result.blob, result.filename ?? `${name.trim() || 'tour'}.gpx`); + } catch (error: unknown) { + if (controller.signal.aborted) return; + const noRoute = isAxiosError(error) && error.response?.status === 422; + toast.error(t(noRoute ? 'tours.gpxExport.noRoute' : 'tours.gpxExport.failed')); + } finally { + if (abortRef.current === controller) abortRef.current = null; + if (!controller.signal.aborted) setExportingId(null); + } + }, + [t, toast] + ); + + return { exportGpx, exportingId, exporting: exportingId !== null }; +} diff --git a/client/src/repo/tourRepo.ts b/client/src/repo/tourRepo.ts index 54c04d91f..4c2084d80 100644 --- a/client/src/repo/tourRepo.ts +++ b/client/src/repo/tourRepo.ts @@ -4,6 +4,7 @@ import { tourGpxApi } from '../api/tourGpx' import { recordedTrackApi } from '../api/recordedTrack' import { tourPaddlePlanApi } from '../api/tourPaddlePlan' import { tourCopiesApi } from '../api/tourCopies' +import { tourGpxExportApi } from '../api/tourGpxExport' import { tourObstacleReviewsApi } from '../api/tourObstacleReviews' import { cacheTourAnswer } from '../db/cacheTour' import { offlineDb, replaceTripTours, type CachedTour } from '../db/offlineDb' @@ -258,6 +259,20 @@ export const tourRepo = { * P2C-07, online only like the restore: the copy is made from the server's * stored line and control points, so it needs the server to answer. */ + /** + * PR1-06A: the saved tour as the GPX file the server writes. Online only on + * purpose, since the file is the server's; offline it answers `offline` + * rather than a request that cannot reach anyone. + */ + async exportGpx( + tripId: number | string, + placeId: number, + signal?: AbortSignal, + ): Promise<{ offline: true } | { offline: false; blob: Blob; filename: string | null }> { + if (isEffectivelyOffline()) return { offline: true } + return { offline: false, ...(await tourGpxExportApi.download(tripId, placeId, signal)) } + }, + async copy(tripId: number | string, placeId: number, name: string): Promise { const result = await tourCopiesApi.copy(tripId, placeId, { name }) await cacheTourAnswer(Number(tripId), result) diff --git a/server/src/nest/place-import/place-import.service.ts b/server/src/nest/place-import/place-import.service.ts index b821a65aa..c68455b30 100644 --- a/server/src/nest/place-import/place-import.service.ts +++ b/server/src/nest/place-import/place-import.service.ts @@ -12,7 +12,7 @@ import type { import { CollectionGpxProvider, type ExportedCollectionFile } from './providers/collection-gpx.provider'; import { GoogleDirectionsProvider } from './providers/google-directions.provider'; import { GoogleListProvider } from './providers/google-list.provider'; -import { GpxProvider, type GpxExportInput, type GpxExportOptions } from './providers/gpx.provider'; +import { GpxProvider, type GpxExportInput, type GpxExportOptions, type GpxTourExport } from './providers/gpx.provider'; import { KmlProvider } from './providers/kml.provider'; import { NaverListProvider } from './providers/naver-list.provider'; import { Injectable } from '@nestjs/common'; @@ -21,7 +21,13 @@ import type { CollectionGpxExport, CollectionGpxReadResult } from '@trek/shared' // The directions-link reading is also what the road trip planner reads a // shared route with, and what the import routes dispatch on. export { isDirectionsUrl, parseDirectionsUrl, MAX_DIR_WAYPOINTS } from './directions-url.helpers'; -export type { GpxExportDay, GpxExportInput, GpxExportOptions, GpxExportPlace } from './providers/gpx.provider'; +export type { + GpxExportDay, + GpxExportInput, + GpxExportOptions, + GpxExportPlace, + GpxTourExport, +} from './providers/gpx.provider'; export type { ExportedCollectionFile } from './providers/collection-gpx.provider'; /** Places a name, or says nobody can. */ @@ -64,6 +70,15 @@ export class PlaceImportService { return this.gpx.filename(tripTitle); } + /** One tour as a GPX document (PR1-06A), or null when it has no line to write. */ + writeTourGpx(tour: GpxTourExport): string | null { + return this.gpx.writeTour(tour); + } + + tourGpxFilename(tourName: string): string { + return this.gpx.tourFilename(tourName); + } + /** A list file as GPX (#2301). */ writeCollectionGpx(file: ExportedCollectionFile): CollectionGpxExport { return this.collectionGpx.write(file); diff --git a/server/src/nest/place-import/providers/gpx.provider.ts b/server/src/nest/place-import/providers/gpx.provider.ts index 28b6d4c71..7ff1e566c 100644 --- a/server/src/nest/place-import/providers/gpx.provider.ts +++ b/server/src/nest/place-import/providers/gpx.provider.ts @@ -145,11 +145,58 @@ export function buildGpx(input: GpxExportInput, opts: GpxExportOptions = {}): st return writeGpx({ metadata: { name: input.tripTitle }, wpt, rte, trk }); } +/** One tour as the exporter needs it (PR1-06A). */ +export interface GpxTourExport { + name: string; + description: string | null; + /** The tour's own information link, written as the track's . */ + website: string | null; + /** The activity as GPX names it in , e.g. `hiking`. */ + activity: string; + /** JSON `[[lat, lng]]` or `[[lat, lng, ele]]`, the tour's saved line. */ + route_geometry: string | null; + /** Named stops along the tour, written as waypoints in their order. */ + stops: Array<{ name: string; lat: number; lng: number }>; +} + +/** + * One tour as a GPX document: its whole saved line as one track, with the + * heights it has and none it lacks, and its named stops as waypoints. Null when + * the tour has no line of at least two points, so the caller refuses instead + * of handing over a file a device would read as nothing. + */ +export function buildTourGpx(tour: GpxTourExport): string | null { + const geometry = parseGeometry(tour.route_geometry); + if (geometry.length < 2) return null; + const desc = tour.description?.trim() || undefined; + const link = tour.website ? { '@_href': tour.website } : undefined; + return writeGpx({ + metadata: { name: tour.name, ...(desc ? { desc } : {}), ...(link ? { link } : {}) }, + wpt: tour.stops.map((stop) => ({ '@_lat': coord(stop.lat), '@_lon': coord(stop.lng), name: stop.name })), + trk: [ + { + name: tour.name, + desc, + link, + type: tour.activity, + trkseg: { + trkpt: geometry.map((p) => ({ + '@_lat': coord(p.lat), + '@_lon': coord(p.lng), + ...(p.ele != null ? { ele: coord(p.ele) } : {}), + })), + }, + }, + ], + }); +} + /** Filenames land on the receiving filesystem, so its reserved characters are * folded away rather than escaped. Header safety is contentDisposition()'s * job since #2165, which is why the title's own script survives here — - * 沖縄 stays 沖縄 instead of being mangled into underscores. */ -export function gpxFilename(tripTitle: string): string { + * 沖縄 stays 沖縄 instead of being mangled into underscores. `fallback` + * names a file whose title folds away to nothing. */ +export function gpxFilename(tripTitle: string, fallback = 'trip'): string { const base = tripTitle // eslint-disable-next-line no-control-regex .replace(/[\x00-\x1f\x7f"\\/:*?<>|]+/g, ' ') @@ -158,7 +205,7 @@ export function gpxFilename(tripTitle: string): string { .replaceAll(' ', '-'); // Codepoints, not UTF-16 units: slice() would cut an emoji in half and the // lone surrogate is exactly what URI-encoding chokes on. - return `${[...base].slice(0, 60).join('') || 'trip'}.gpx`; + return `${[...base].slice(0, 60).join('') || fallback}.gpx`; } /** @@ -274,4 +321,12 @@ export class GpxProvider { filename(tripTitle: string): string { return gpxFilename(tripTitle); } + + writeTour(tour: GpxTourExport): string | null { + return buildTourGpx(tour); + } + + tourFilename(tourName: string): string { + return gpxFilename(tourName, 'tour'); + } } diff --git a/server/src/nest/tours/tour-gpx-export.service.ts b/server/src/nest/tours/tour-gpx-export.service.ts new file mode 100644 index 000000000..a56d64444 --- /dev/null +++ b/server/src/nest/tours/tour-gpx-export.service.ts @@ -0,0 +1,59 @@ +import { Places } from '../../db/entities/Places.entity'; +import { Tours } from '../../db/entities/Tours.entity'; +import type { PlacesRepository } from '../../db/repositories/Places.repository'; +import type { ToursRepository } from '../../db/repositories/Tours.repository'; +import { DomainError } from '../common/domain-error'; +import { toRowId } from '../common/row-id'; +import { PlaceImportService } from '../place-import/place-import.service'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import type { PaddleBoatType } from '@trek/shared'; + +/** What GPX calls the activity in a track's ; a paddle tour says what it is paddled in. */ +const PADDLE_ACTIVITY: Record = { + kayak: 'kayaking', + canoe: 'canoeing', + sup: 'stand_up_paddling', + other: 'paddling', +}; + +export function gpxActivityOf(type: string, boat: string | null): string { + if (type === 'kayak') return PADDLE_ACTIVITY[(boat ?? 'other') as PaddleBoatType] ?? PADDLE_ACTIVITY.other; + return type === 'hike' ? 'hiking' : type; +} + +/** + * PR1-06A: one saved tour as a GPX file, its whole saved line with the heights + * it has. A read: nothing about the tour, its days or where it came from + * changes, and a tour without a line of its own is refused rather than written + * as a file that imports as nothing. + */ +@Injectable() +export class TourGpxExportService { + constructor( + @InjectRepository(Tours) private readonly toursRepo: ToursRepository, + @InjectRepository(Places) private readonly placesRepo: PlacesRepository, + private readonly placeImport: PlaceImportService, + ) {} + + async export(tripId: string, placeId: string): Promise<{ gpx: string; filename: string }> { + const tid = toRowId(tripId); + const pid = toRowId(placeId); + const tour = tid !== null && pid !== null ? await this.toursRepo.findInTrip(tid, pid) : undefined; + const place = tour && tid !== null ? await this.placesRepo.findInTrip(tour.place_id, tid) : undefined; + if (!tour || !place) throw new NotFoundException('Tour not found'); + + const gpx = this.placeImport.writeTourGpx({ + name: tour.name, + description: tour.description ?? null, + website: tour.website ?? null, + activity: gpxActivityOf(tour.tour_type, tour.boat_type), + route_geometry: place.route_geometry ?? null, + stops: [], + }); + if (!gpx) { + throw new DomainError(422, 'This tour has no route to export', { code: 'tour_without_route' }); + } + return { gpx, filename: this.placeImport.tourGpxFilename(tour.name) }; + } +} diff --git a/server/src/nest/tours/tours.controller.ts b/server/src/nest/tours/tours.controller.ts index 582bc673d..bff97335a 100644 --- a/server/src/nest/tours/tours.controller.ts +++ b/server/src/nest/tours/tours.controller.ts @@ -4,17 +4,21 @@ import { AddonGuard } from '../addons/addon.guard'; import { RequireAddon } from '../addons/require-addon.decorator'; import { CurrentUser } from '../auth-core/current-user.decorator'; import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; +import { contentDisposition } from '../common/content-disposition'; import { ResponseContract } from '../common/response-contract'; import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard'; import { TourCopyDto, TourCreateDto } from './dto/tour-create.dto'; import { TourObstacleReviewsDto } from './dto/tour-obstacle-reviews.dto'; import { TourPaddlePlanDto } from './dto/tour-paddle-plan.dto'; +import { TourGpxExportService } from './tour-gpx-export.service'; import { TourObstacleReviewsService } from './tour-obstacle-reviews.service'; import { TourPaddlePlanService } from './tour-paddle-plan.service'; import { ToursService } from './tours.service'; -import { Body, Controller, Get, Headers, HttpCode, Param, Post, Put, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Headers, HttpCode, Param, Post, Put, Res, UseGuards } from '@nestjs/common'; import { tourCreateResponseSchema, tourDetailResponseSchema } from '@trek/shared'; +import type { Response } from 'express'; + /** * /api/trips/:tripId/tours: the Tours facet, read and route edits. * TripAccessGuard resolves @@ -30,6 +34,7 @@ export class ToursController { private readonly tours: ToursService, private readonly paddlePlans: TourPaddlePlanService, private readonly obstacleReviews: TourObstacleReviewsService, + private readonly gpxExport: TourGpxExportService, ) {} @Get() @@ -37,6 +42,19 @@ export class ToursController { return { tours: await this.tours.listTours(tripId) }; } + /** + * PR1-06A: the saved tour as a GPX file, its whole line with the heights it + * has. A read, so seeing the trip is enough; 422 when the tour has no line. + * response-contract-exempt: a file download written through @Res(), not a JSON body. + */ + @Get(':placeId/export.gpx') + async exportGpx(@Param('tripId') tripId: string, @Param('placeId') placeId: string, @Res() res: Response) { + const { gpx, filename } = await this.gpxExport.export(tripId, placeId); + res.setHeader('Content-Type', 'application/gpx+xml; charset=utf-8'); + res.setHeader('Content-Disposition', contentDisposition(filename, 'attachment')); + res.send(gpx); + } + @Get(':placeId') async detail(@Param('tripId') tripId: string, @Param('placeId') placeId: string) { return await this.tours.getTour(tripId, placeId); diff --git a/server/src/nest/tours/tours.mcp.ts b/server/src/nest/tours/tours.mcp.ts index 04ec837bb..74fc0db42 100644 --- a/server/src/nest/tours/tours.mcp.ts +++ b/server/src/nest/tours/tours.mcp.ts @@ -5,6 +5,7 @@ import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, errorResult, ok, type M import { addonGate } from '../addons/addon-gate'; import { AddonsService } from '../addons/addons.service'; import { TripAccessService } from '../trip-membership/trip-access.service'; +import { TourGpxExportService } from './tour-gpx-export.service'; import { ToursService } from './tours.service'; import { NotFoundException } from '@nestjs/common'; import { idSchema, type TourListItem } from '@trek/shared'; @@ -30,6 +31,7 @@ export class ToursMcp { constructor( private readonly tours: ToursService, private readonly access: TripAccessService, + private readonly gpxExport: TourGpxExportService, // Read by the addon gate of every tool here. readonly addons: AddonsService, ) {} @@ -95,4 +97,24 @@ export class ToursMcp { const onDay = tours.filter((tour) => (days.get(tour.place_id) ?? []).some((day) => day.day_id === dayId)); return ok({ tours: onDay.map((tour) => withDays(tour, days)) }); } + + @Tool({ + name: 'export_tour_gpx', + description: + 'Export one saved tour of a trip as a GPX 1.1 document: its whole saved line as one track, with the heights it carries and none it lacks. Returns the GPX text and a file name. A tour without a line of at least two points cannot be exported. Nothing about the tour changes.', + inputSchema: { tripId: idSchema, placeId: idSchema.describe('The place id of the tour, as list_tours gives it') }, + annotations: TOOL_ANNOTATIONS_READONLY, + when: addonGate(ADDON_IDS.TOURS), + access: { group: 'trips', mode: 'read' }, + }) + async exportTourGpx({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) { + // A read, like the REST route: seeing the trip is enough. + if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess(); + try { + return ok(await this.gpxExport.export(String(tripId), String(placeId))); + } catch (err: unknown) { + if (err instanceof NotFoundException) return errorResult('Tour not found.'); + throw err; + } + } } diff --git a/server/src/nest/tours/tours.module.ts b/server/src/nest/tours/tours.module.ts index 3d0cd11c0..4de1eecc3 100644 --- a/server/src/nest/tours/tours.module.ts +++ b/server/src/nest/tours/tours.module.ts @@ -11,6 +11,7 @@ import { PlaceImportModule } from '../place-import/place-import.module'; import { PlacesModule } from '../places/places.module'; import { TripMembershipModule } from '../trip-membership/trip-membership.module'; import { WandererModule } from '../wanderer/wanderer.module'; +import { TourGpxExportService } from './tour-gpx-export.service'; import { TourObstacleReviewsService } from './tour-obstacle-reviews.service'; import { PaddleCheckCache } from './tour-paddle-check.cache'; import { TourPaddleCheckService } from './tour-paddle-check.service'; @@ -52,6 +53,7 @@ import { Module } from '@nestjs/common'; PaddleCheckCache, TourPaddleCheckService, TourObstacleReviewsService, + TourGpxExportService, ToursWandererService, ToursMcp, ], diff --git a/server/tests/e2e/tours-planned.e2e.test.ts b/server/tests/e2e/tours-planned.e2e.test.ts new file mode 100644 index 000000000..98abe2f06 --- /dev/null +++ b/server/tests/e2e/tours-planned.e2e.test.ts @@ -0,0 +1,254 @@ +/** + * Tours e2e for the planner's second round: the GPX export of one tour + * (PR1-06A), through the real JwtAuthGuard, AddonGuard and TripAccessGuard + * against a migrated and seeded temp SQLite db. The permission check is a + * container spy and the broadcast goes to a FakeRealtimeService, as in + * tours.e2e.test.ts. + * + * Fixtures: user 1 owns trips 5 and 6, user 2 is a member of trip 5 whom the + * permission spy denies 'place_edit', user 3 has no access to either trip. + */ +import { db } from '../../src/db/database'; +import { DayAssignments } from '../../src/db/entities/DayAssignments.entity'; +import { Places } from '../../src/db/entities/Places.entity'; +import { Tours } from '../../src/db/entities/Tours.entity'; +import { Trips } from '../../src/db/entities/Trips.entity'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service'; +import { PermissionsService } from '../../src/nest/permissions/permissions.service'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; +import { RealtimeService } from '../../src/nest/realtime/realtime.service'; +import { ToursModule } from '../../src/nest/tours/tours.module'; +import { makePlace } from '../helpers/factories/places'; +import { deleteRows, findRow, insertRows } from '../helpers/factories/rows'; +import { setAddonEnabled } from '../helpers/factories/settings'; +import { makeTour } from '../helpers/factories/tours'; +import { addTripMember } from '../helpers/factories/trips'; +import { makeUser } from '../helpers/factories/users'; +import { FakeRealtimeService } from '../helpers/fake-realtime'; +import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; +import { sessionCookie } from './harness'; +import { Test } from '@nestjs/testing'; + +import cookieParser from 'cookie-parser'; +import type { Server } from 'http'; +import request from 'supertest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest'; + +vi.mock('../../src/db/database', async () => { + const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); + return buildDbMock(createSnapshotTestDb()); +}); +vi.mock('../../src/nest/audit/audit-log.logger', () => ({ + LOG_LEVEL: 'error', + logInfo: vi.fn(), + logDebug: vi.fn(), + logError: vi.fn(), + logWarn: vi.fn(), +})); + +const { journeyHooks } = vi.hoisted(() => ({ + journeyHooks: { + reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined), + onPlaceCreated: vi.fn().mockResolvedValue(undefined), + onPlaceUpdated: vi.fn().mockResolvedValue(undefined), + onPlaceDeleted: vi.fn().mockResolvedValue(undefined), + }, +})); + +const realtime = new FakeRealtimeService(); + +let orm: TestOrm; +let checkPermission: MockInstance; + +const OWNER = 1; +const VIEWER = 2; +const OUTSIDER = 3; + +const GEOMETRY = [ + [47, 11, 600], + [47.01, 11.01, 700], + [47.02, 11.02, 650], +]; +const tourBody = (overrides: Record = {}) => ({ + name: 'Ridge walk', + route_geometry: GEOMETRY, + waypoints: [ + { lat: 47, lng: 11, role: 'start', sequence: 0 }, + { lat: 47.02, lng: 11.02, role: 'end', sequence: 1 }, + ], + max_hiking_difficulty: 3, + duration_seconds: 3600, + ...overrides, +}); + +describe('Tours planned e2e (real guards + temp SQLite)', () => { + let server: Server; + let app: Awaited>; + + async function build() { + const moduleRef = await Test.createTestingModule({ + imports: [ + await TestUnitOfWorkModule.forRoot(db), + await createTestMikroOrmModule(db), + RealtimeModule, + ToursModule, + ], + }) + .overrideProvider(RealtimeService) + .useValue(realtime) + .overrideProvider(JourneyDomainService) + .useValue(journeyHooks) + .compile(); + const nest = moduleRef.createNestApplication(); + nest.use(cookieParser()); + nest.useGlobalPipes(new ZodValidationPipe()); + nest.useGlobalFilters(new TrekExceptionFilter()); + await nest.init(); + return nest; + } + + const createViaApi = async (tripId = 5, body: Record = tourBody()) => { + const res = await request(server).post(`/api/trips/${tripId}/tours`).set('Cookie', sessionCookie(OWNER)).send(body); + expect(res.status).toBe(201); + return res.body.tour.place_id as number; + }; + + beforeAll(async () => { + orm = await createTestOrm(db); + // Pinned ids: the session cookies sign for exactly these users. + await makeUser(orm, { id: 1, username: 'owner', email: 'owner@example.test', role: 'user', password_version: 0 }); + await makeUser(orm, { id: 2, username: 'viewer', email: 'viewer@example.test', role: 'user', password_version: 0 }); + await makeUser(orm, { + id: 3, + username: 'outsider', + email: 'outsider@example.test', + role: 'user', + password_version: 0, + }); + await insertRows(orm, Trips, [ + { id: 5, user: 1, title: 'Alps' }, + { id: 6, user: 1, title: 'Other' }, + ]); + await addTripMember(orm, 5, 2); + app = await build(); + checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission'); + server = app.getHttpServer(); + }); + + beforeEach(async () => { + checkPermission.mockImplementation( + (action: string, _role: string, _owner: number, userId: number) => + !(action === 'place_edit' && userId === VIEWER), + ); + await setAddonEnabled(orm, 'tours', true); + realtime.broadcastMock.mockClear(); + await deleteRows(orm, DayAssignments); + await deleteRows(orm, Places); + }); + + afterAll(async () => { + await app.close(); + await orm.close(); + }); + + describe('GET :placeId/export.gpx (PR1-06A)', () => { + const exportGpx = (userId: number, placeId: number, tripId = 5) => + request(server) + .get(`/api/trips/${tripId}/tours/${placeId}/export.gpx`) + .set('Cookie', sessionCookie(userId)) + .buffer(true) + .parse((res, done) => { + let text = ''; + res.setEncoding('utf8'); + res.on('data', (chunk: string) => (text += chunk)); + res.on('end', () => done(null, text)); + }); + + it('writes the whole saved line with its heights as one track, named after the tour', async () => { + const placeId = await createViaApi(5, tourBody({ name: 'Grat über Ü', description: 'Früh los.' })); + const res = await exportGpx(OWNER, placeId); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toBe('application/gpx+xml; charset=utf-8'); + expect(res.headers['content-disposition']).toContain("filename*=UTF-8''Grat-%C3%BCber-%C3%9C.gpx"); + const gpx = res.body as string; + expect(gpx).toContain('Grat über Ü'); + expect(gpx).toContain('Früh los.'); + expect(gpx).toContain('hiking'); + expect(gpx.match(/'); + expect(gpx).toContain('700'); + }); + + it('writes no height a point lacks, and names a paddle tour by its boat', async () => { + const place = await makePlace(orm, 5, { + name: 'Canal', + route_geometry: JSON.stringify([ + [48, 12], + [48.01, 12.01, 300], + ]), + }); + await makeTour(orm, place.id, { + tourTypeRef: 'kayak', + boat_type: 'canoe', + water_context: 'river', + origin: 'gpx', + }); + const res = await exportGpx(OWNER, place.id); + expect(res.status).toBe(200); + const gpx = res.body as string; + expect(gpx).toContain('canoeing'); + expect(gpx.match(//g)).toHaveLength(1); + }); + + it('a member who may only read can export; the export changes nothing', async () => { + const placeId = await createViaApi(); + const before = await findRow(orm, Tours, { place: placeId }); + realtime.broadcastMock.mockClear(); + const res = await exportGpx(VIEWER, placeId); + expect(res.status).toBe(200); + expect(await findRow(orm, Tours, { place: placeId })).toEqual(before); + expect(realtime.broadcastMock).not.toHaveBeenCalled(); + }); + + it('422 for a tour without a line of two points, 404 for one of another trip or none', async () => { + const lonely = await makePlace(orm, 5, { name: 'Dot', route_geometry: JSON.stringify([[47, 11, 1]]) }); + await makeTour(orm, lonely.id, { origin: 'gpx' }); + const refused = await request(server) + .get(`/api/trips/5/tours/${lonely.id}/export.gpx`) + .set('Cookie', sessionCookie(OWNER)); + expect(refused.status).toBe(422); + expect(refused.body).toEqual({ error: 'This tour has no route to export' }); + + const elsewhere = await createViaApi(6); + const wrongTrip = await request(server) + .get(`/api/trips/5/tours/${elsewhere}/export.gpx`) + .set('Cookie', sessionCookie(OWNER)); + expect(wrongTrip.status).toBe(404); + expect(wrongTrip.body).toEqual({ error: 'Tour not found' }); + const plain = await makePlace(orm, 5, { name: 'Cafe' }); + expect( + (await request(server).get(`/api/trips/5/tours/${plain.id}/export.gpx`).set('Cookie', sessionCookie(OWNER))) + .status, + ).toBe(404); + }); + + it('404 Trip not found for an outsider, 404 while the addon is off, 401 without a cookie', async () => { + const placeId = await createViaApi(); + const outsider = await request(server) + .get(`/api/trips/5/tours/${placeId}/export.gpx`) + .set('Cookie', sessionCookie(OUTSIDER)); + expect(outsider.status).toBe(404); + expect(outsider.body).toEqual({ error: 'Trip not found' }); + expect((await request(server).get(`/api/trips/5/tours/${placeId}/export.gpx`)).status).toBe(401); + await setAddonEnabled(orm, 'tours', false); + const off = await request(server) + .get(`/api/trips/5/tours/${placeId}/export.gpx`) + .set('Cookie', sessionCookie(OWNER)); + expect(off.status).toBe(404); + expect(off.body).toEqual({ error: 'Tours addon is not enabled' }); + }); + }); +}); diff --git a/server/tests/unit/nest/place-import/gpx.provider.test.ts b/server/tests/unit/nest/place-import/gpx.provider.test.ts index 6338b474f..b934d0e24 100644 --- a/server/tests/unit/nest/place-import/gpx.provider.test.ts +++ b/server/tests/unit/nest/place-import/gpx.provider.test.ts @@ -6,6 +6,7 @@ */ import { buildGpx, + buildTourGpx, gpxFilename, type GpxExportPlace, type GpxExportDay, @@ -278,3 +279,70 @@ describe('gpxFilename', () => { expect(gpxFilename('\u{1F5FE}'.repeat(70))).toBe('\u{1F5FE}'.repeat(60) + '.gpx'); }); }); + +describe('buildTourGpx (PR1-06A)', () => { + const tour = (route_geometry: string | null) => ({ + name: 'Grat ', + description: 'Früh los', + website: 'https://example.test/route?a=1&b=2', + activity: 'hiking', + route_geometry, + stops: [{ name: 'Hütte', lat: 47.5, lng: 11.1 }], + }); + const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' }); + + it('writes the line as one track with the heights it has and none it lacks', () => { + const gpx = buildTourGpx( + tour( + JSON.stringify([ + [47, 11, 600.4], + [47.1, 11.1], + [47.2, 11.2, 'x'], + [47.3, 11.3, 650], + ]), + ), + )!; + expect(XMLValidator.validate(gpx)).toBe(true); + const doc = parser.parse(gpx).gpx; + expect(doc.metadata).toMatchObject({ + name: 'Grat ', + desc: 'Früh los', + link: { '@_href': 'https://example.test/route?a=1&b=2' }, + }); + expect(doc.trk).toMatchObject({ name: 'Grat ', type: 'hiking' }); + const points = doc.trk.trkseg.trkpt; + expect(points).toHaveLength(4); + expect(points.map((p: { ele?: number }) => p.ele ?? null)).toEqual([600.4, null, null, 650]); + expect(doc.wpt).toMatchObject({ '@_lat': '47.5', '@_lon': '11.1', name: 'Hütte' }); + }); + + it('writes no description or link a tour has not got', () => { + const gpx = buildTourGpx({ + ...tour( + JSON.stringify([ + [47, 11], + [47.1, 11.1], + ]), + ), + description: ' ', + website: null, + stops: [], + })!; + const doc = parser.parse(gpx).gpx; + expect(doc.metadata).toEqual({ name: 'Grat ' }); + expect(doc.trk.desc).toBeUndefined(); + expect(doc.trk.link).toBeUndefined(); + expect(doc.wpt).toBeUndefined(); + }); + + it('refuses a line of fewer than two points, or none at all', () => { + expect(buildTourGpx(tour(JSON.stringify([[47, 11, 600]])))).toBeNull(); + expect(buildTourGpx(tour('not json'))).toBeNull(); + expect(buildTourGpx(tour(null))).toBeNull(); + }); + + it('names a tour whose name folds away to nothing "tour"', () => { + expect(gpxFilename('///', 'tour')).toBe('tour.gpx'); + expect(gpxFilename('Grat ', 'tour')).toBe('Grat-Ost.gpx'); + }); +}); diff --git a/server/tests/unit/nest/tours.mcp.test.ts b/server/tests/unit/nest/tours.mcp.test.ts index 6fbbaac9f..2da35e708 100644 --- a/server/tests/unit/nest/tours.mcp.test.ts +++ b/server/tests/unit/nest/tours.mcp.test.ts @@ -4,6 +4,7 @@ * container over a snapshot test db; the addon gate of the tools is the * registry's and is covered with the other addon-gated tools. */ +import { Places } from '../../../src/db/entities/Places.entity'; import type { McpContext } from '../../../src/nest-mcp'; import { ToursMcp } from '../../../src/nest/tours/tours.mcp'; import { ToursModule } from '../../../src/nest/tours/tours.module'; @@ -17,8 +18,10 @@ import { createTrip, createUser, } from '../../helpers/factories'; +import { updateRows } from '../../helpers/factories/rows'; import { resetTestDb } from '../../helpers/test-db'; import { createTestModule, type TestModule } from '../../helpers/test-module'; +import { createTestOrm } from '../../helpers/test-orm'; import { createTour } from '../../helpers/tours-repos'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -123,4 +126,36 @@ describe('ToursMcp', () => { getTour.mockRestore(); } }); + + it('TOURS-MCP-006: export_tour_gpx hands over the saved line as GPX, and only to someone who sees the trip', async () => { + const { user, member, outsider, trip, ridge, gorge } = fixture(); + const orm = await createTestOrm(db); + await updateRows( + orm, + Places, + { id: ridge.id }, + { + route_geometry: JSON.stringify([ + [47, 11, 600], + [47.01, 11.01, 650], + ]), + }, + ); + await orm.close(); + const exported = read(await mcp.exportTourGpx({ tripId: trip.id, placeId: ridge.id }, ctx(member.id))); + expect(exported.filename).toBe('Ridge.gpx'); + expect(exported.gpx).toContain('hiking'); + expect(exported.gpx.match(/