mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
feat(tours): take a tour over from another trip, server and contract (LIB-02)
GET /api/tours/library lists the tours of every trip the user owns or is a member of, with the trip each is in and how many tours each trip holds, to choose one to take over into the trip being planned. It is narrowed by a part of the name (matched ignoring case, a "%" or "_" just a character), a type or one trip, and the asking trip can be left out; up to 100 tours, and the answer says when there were more. GET /api/tours/library/:placeId previews one: its facts and its line thinned to 400 points, its ends kept. Neither is trip-scoped, so every answer is bounded by the user's own trips, read anew each call: a tour of a trip out of reach is "not found", nothing hints that it exists. Measures stay null where the tour has none. POST /api/trips/:tripId/tours/take-over copies one into this trip, which the guard checks with 'place_edit'; the source trip is checked against the user's trips as they are at that moment, and the source is read again inside the write. The copy is a tour of its own: its line, measures, settings and control points, without days, without its stops' links to places of the other trip and without the obstacle decisions taken there; it keeps the import record of its line. A copy that fails leaves nothing behind. Where it came from is kept as text (tours.taken_over_from_trip_title and taken_over_from_tour_name, migration 045100), not as a pointer, and read back in the provenance as takenOverFrom; a copy of such a copy names it too. The in-trip copy (P2C-07) and the take-over share one copy routine. MCP: list_tour_library and take_over_tour, over the same service and checks. The repository tests follow the hiking preferences column of MF-08.
This commit is contained in:
@@ -36,6 +36,8 @@ export class Tours {
|
||||
paddle_lake_style?: TToursPaddleLakeStyle | null;
|
||||
revision: number & Opt = 1;
|
||||
hiking_preferences?: string | null;
|
||||
taken_over_from_trip_title?: string | null;
|
||||
taken_over_from_tour_name?: string | null;
|
||||
tourTypeRef!: Ref<TourTypes>;
|
||||
tour_obstacle_reviews_collection = new Collection<TourObstacleReviews>(this);
|
||||
tour_sources: Ref<TourSources> | null = null;
|
||||
@@ -112,6 +114,8 @@ export const ToursSchema = defineEntity({
|
||||
paddle_lake_style: p.enum(() => ToursPaddleLakeStyle).nullable(),
|
||||
revision: p.integer().default(1),
|
||||
hiking_preferences: p.text().nullable(),
|
||||
taken_over_from_trip_title: p.text().nullable(),
|
||||
taken_over_from_tour_name: p.text().nullable(),
|
||||
tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_tours_tour_type'),
|
||||
tour_obstacle_reviews_collection: () => p.oneToMany(TourObstacleReviews).mappedBy('place').hidden(),
|
||||
tour_sources: () => p.oneToOne(TourSources).ref().mappedBy('place').hidden(),
|
||||
|
||||
@@ -28,4 +28,6 @@ export interface ToursTable {
|
||||
paddle_lake_style: InsertOptional<string | null>;
|
||||
revision: InsertOptional<number>;
|
||||
hiking_preferences: InsertOptional<string | null>;
|
||||
taken_over_from_trip_title: InsertOptional<string | null>;
|
||||
taken_over_from_tour_name: InsertOptional<string | null>;
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
/**
|
||||
* LIB-02: a tour taken over from another trip keeps where it came from as
|
||||
* written then, the source trip's title and the tour's name. Text, not a
|
||||
* pointer: the source may be deleted, renamed or out of the user's reach
|
||||
* later, and the copy must never reveal or depend on it. NULL for every other tour.
|
||||
*/
|
||||
export class Migration20200101045100_a_tour_remembers_the_trip_it_was_taken_from extends Migration {
|
||||
override name = 'Migration20200101045100_a_tour_remembers_the_trip_it_was_taken_from';
|
||||
|
||||
override up(): void {
|
||||
this.addSql('ALTER TABLE tours ADD COLUMN taken_over_from_trip_title TEXT');
|
||||
this.addSql('ALTER TABLE tours ADD COLUMN taken_over_from_tour_name TEXT');
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,25 @@ export interface TourListRow {
|
||||
hiking_preferences: string | null;
|
||||
}
|
||||
|
||||
/** LIB-02: a tour of another trip as the take-over list reads it, with the trip it is in. */
|
||||
export interface TourLibraryRow {
|
||||
place_id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
tour_type: string;
|
||||
distance: number | null;
|
||||
elevation_gain: number | null;
|
||||
elevation_loss: number | null;
|
||||
duration: number | null;
|
||||
planned_duration_minutes: number | null;
|
||||
break_additional_minutes: number | null;
|
||||
max_hiking_difficulty: number | null;
|
||||
origin: string;
|
||||
trip_id: number;
|
||||
trip_title: string;
|
||||
has_waypoints: number;
|
||||
}
|
||||
|
||||
/** Every `tours` column, as the trip copy reads and re-inserts it. */
|
||||
export interface TourRow {
|
||||
place_id: number;
|
||||
@@ -71,6 +90,8 @@ export interface TourRow {
|
||||
track_marks: string | null;
|
||||
revision: number;
|
||||
hiking_preferences: string | null;
|
||||
taken_over_from_trip_title: string | null;
|
||||
taken_over_from_tour_name: string | null;
|
||||
}
|
||||
const _tourRowKeys: AssertRowKeys<TourRow, Tours> = true;
|
||||
|
||||
@@ -127,7 +148,14 @@ export interface TourPaddlePlanUpdate {
|
||||
|
||||
type ToursKyselyDB = Pick<
|
||||
DB,
|
||||
'tours' | 'places' | 'day_assignments' | 'tour_waypoints' | 'tour_sources' | 'days' | 'tour_obstacle_reviews'
|
||||
| 'tours'
|
||||
| 'places'
|
||||
| 'day_assignments'
|
||||
| 'tour_waypoints'
|
||||
| 'tour_sources'
|
||||
| 'days'
|
||||
| 'tour_obstacle_reviews'
|
||||
| 'trips'
|
||||
>;
|
||||
|
||||
/** One day a tour of the trip sits on, in the order of the trip. */
|
||||
@@ -147,6 +175,8 @@ export interface TourDayRow {
|
||||
*/
|
||||
export interface TourProvenanceRow {
|
||||
origin: string;
|
||||
taken_over_from_trip_title: string | null;
|
||||
taken_over_from_tour_name: string | null;
|
||||
origin_file_name: string | null;
|
||||
created_at: string | null;
|
||||
wanderer_ref: string | null;
|
||||
@@ -241,6 +271,96 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
return row ? toListRow(row) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* LIB-02: the tours of the given trips, as the take-over list reads them,
|
||||
* narrowed by a type or one of those trips; the newest trip first, its
|
||||
* newest tour first, at most `limit`. Never a trip outside `trip_ids`, which
|
||||
* the caller resolved from the user's own access.
|
||||
*/
|
||||
async listForLibrary(
|
||||
trip_ids: readonly number[],
|
||||
filter: { tour_type?: string; trip_id?: number },
|
||||
limit: number,
|
||||
): Promise<TourLibraryRow[]> {
|
||||
if (trip_ids.length === 0) return [];
|
||||
let query = this.libraryQuery().where('p.trip_id', 'in', [...trip_ids]);
|
||||
if (filter.trip_id !== undefined) query = query.where('p.trip_id', '=', filter.trip_id);
|
||||
if (filter.tour_type) query = query.where('t.tour_type', '=', filter.tour_type);
|
||||
const rows = await query
|
||||
.orderBy('p.trip_id', 'desc')
|
||||
.orderBy('t.created_at', 'desc')
|
||||
.orderBy('t.place_id', 'desc')
|
||||
.limit(limit)
|
||||
.execute();
|
||||
return rows.map(toLibraryRow);
|
||||
}
|
||||
|
||||
/** LIB-02: how many tours each of the given trips holds, with its title, for the trip filter. */
|
||||
async countForLibrary(
|
||||
trip_ids: readonly number[],
|
||||
): Promise<Array<{ id: number; title: string; tour_count: number }>> {
|
||||
if (trip_ids.length === 0) return [];
|
||||
const rows = await this.db()
|
||||
.selectFrom('tours as t')
|
||||
.innerJoin('places as p', 'p.id', 't.place_id')
|
||||
.innerJoin('trips as tr', 'tr.id', 'p.trip_id')
|
||||
.select((eb) => ['tr.id as id', 'tr.title as title', eb.fn.countAll<number>().as('tour_count')])
|
||||
.where('p.trip_id', 'in', [...trip_ids])
|
||||
.groupBy(['tr.id', 'tr.title'])
|
||||
.orderBy('tr.id', 'desc')
|
||||
.execute();
|
||||
return rows.map((row) => ({ id: row.id, title: row.title, tour_count: Number(row.tour_count) }));
|
||||
}
|
||||
|
||||
/** LIB-02: one tour of the given trips for its preview, with its line and how many control points it has. */
|
||||
async findForLibrary(
|
||||
trip_ids: readonly number[],
|
||||
place_id: number,
|
||||
): Promise<(TourLibraryRow & { route_geometry: string | null; waypoint_count: number }) | undefined> {
|
||||
if (trip_ids.length === 0) return undefined;
|
||||
const row = await this.libraryQuery()
|
||||
.select((eb) => [
|
||||
'p.route_geometry as route_geometry',
|
||||
eb
|
||||
.selectFrom('tour_waypoints as tw')
|
||||
.select(eb.fn.countAll<number>().as('n'))
|
||||
.whereRef('tw.place_id', '=', 'p.id')
|
||||
.as('waypoint_count'),
|
||||
])
|
||||
.where('p.trip_id', 'in', [...trip_ids])
|
||||
.where('p.id', '=', place_id)
|
||||
.executeTakeFirst();
|
||||
return row
|
||||
? { ...toLibraryRow(row), route_geometry: row.route_geometry, waypoint_count: Number(row.waypoint_count ?? 0) }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private libraryQuery() {
|
||||
return this.db()
|
||||
.selectFrom('tours as t')
|
||||
.innerJoin('places as p', 'p.id', 't.place_id')
|
||||
.innerJoin('trips as tr', 'tr.id', 'p.trip_id')
|
||||
.select((eb) => [
|
||||
'p.id as place_id',
|
||||
'p.name as name',
|
||||
'p.description as description',
|
||||
't.tour_type as tour_type',
|
||||
't.distance as distance',
|
||||
't.elevation_gain as elevation_gain',
|
||||
't.elevation_loss as elevation_loss',
|
||||
't.duration as duration',
|
||||
't.planned_duration_minutes as planned_duration_minutes',
|
||||
't.break_additional_minutes as break_additional_minutes',
|
||||
't.max_hiking_difficulty as max_hiking_difficulty',
|
||||
't.origin as origin',
|
||||
'tr.id as trip_id',
|
||||
'tr.title as trip_title',
|
||||
eb
|
||||
.exists(eb.selectFrom('tour_waypoints as tw').select('tw.id').whereRef('tw.place_id', '=', 'p.id'))
|
||||
.as('has_waypoints'),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Where a tour of this trip came from; `undefined` unless the place is a tour of that trip. */
|
||||
async findProvenance(trip_id: number, place_id: number): Promise<TourProvenanceRow | undefined> {
|
||||
return await this.db()
|
||||
@@ -253,6 +373,8 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
.select((eb) => [
|
||||
't.origin as origin',
|
||||
't.origin_file_name as origin_file_name',
|
||||
't.taken_over_from_trip_title as taken_over_from_trip_title',
|
||||
't.taken_over_from_tour_name as taken_over_from_tour_name',
|
||||
't.created_at as created_at',
|
||||
't.wanderer_ref as wanderer_ref',
|
||||
eb('t.recorded_route_geometry', 'is not', null).as('has_recorded_track'),
|
||||
@@ -443,6 +565,8 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
origin?: TToursOrigin;
|
||||
origin_file_name?: string | null;
|
||||
track_marks?: string | null;
|
||||
taken_over_from_trip_title?: string | null;
|
||||
taken_over_from_tour_name?: string | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
await this.insert({
|
||||
@@ -468,6 +592,8 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
paddle_lake_style: input.paddle_lake_style ?? null,
|
||||
track_marks: input.track_marks ?? null,
|
||||
hiking_preferences: input.hiking_preferences ?? null,
|
||||
taken_over_from_trip_title: input.taken_over_from_trip_title ?? null,
|
||||
taken_over_from_tour_name: input.taken_over_from_tour_name ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -621,6 +747,28 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
}
|
||||
}
|
||||
|
||||
function toLibraryRow<R extends Omit<TourLibraryRow, 'has_waypoints'> & { has_waypoints: unknown }>(
|
||||
r: R,
|
||||
): TourLibraryRow {
|
||||
return {
|
||||
place_id: r.place_id,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
tour_type: r.tour_type,
|
||||
distance: r.distance,
|
||||
elevation_gain: r.elevation_gain,
|
||||
elevation_loss: r.elevation_loss,
|
||||
duration: r.duration,
|
||||
planned_duration_minutes: r.planned_duration_minutes,
|
||||
break_additional_minutes: r.break_additional_minutes,
|
||||
max_hiking_difficulty: r.max_hiking_difficulty,
|
||||
origin: r.origin,
|
||||
trip_id: r.trip_id,
|
||||
trip_title: r.trip_title,
|
||||
has_waypoints: Number(r.has_waypoints),
|
||||
};
|
||||
}
|
||||
|
||||
function toListRow<
|
||||
R extends Omit<TourListRow, 'planned' | 'has_waypoints' | 'has_recorded_track'> & {
|
||||
planned: unknown;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { tourLibraryQuerySchema, tourTakeOverRequestSchema } from '@trek/shared';
|
||||
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
|
||||
export class TourLibraryQueryDto extends createZodDto(tourLibraryQuerySchema) {}
|
||||
export class TourTakeOverDto extends createZodDto(tourTakeOverRequestSchema) {}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import type { User } from '../../types';
|
||||
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 { ResponseContract } from '../common/response-contract';
|
||||
import { toRowId } from '../common/row-id';
|
||||
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { TourLibraryQueryDto, TourTakeOverDto } from './dto/tour-library.dto';
|
||||
import { TourLibraryService } from './tour-library.service';
|
||||
import { Body, Controller, Get, Headers, NotFoundException, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { tourCreateResponseSchema, tourLibraryDetailResponseSchema, tourLibraryResponseSchema } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* LIB-02 /api/tours/library: the tours of every trip the user can open, to
|
||||
* pick one to take over. Not trip-scoped, so there is no TripAccessGuard; the
|
||||
* service bounds every answer by the user's own trips.
|
||||
*/
|
||||
@Controller('api/tours/library')
|
||||
@UseGuards(AddonGuard, JwtAuthGuard)
|
||||
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
|
||||
export class TourLibraryController {
|
||||
constructor(private readonly library: TourLibraryService) {}
|
||||
|
||||
@Get()
|
||||
@ResponseContract(tourLibraryResponseSchema)
|
||||
async list(@CurrentUser() user: User, @Query() query: TourLibraryQueryDto) {
|
||||
return await this.library.list(user.id, query);
|
||||
}
|
||||
|
||||
@Get(':placeId')
|
||||
@ResponseContract(tourLibraryDetailResponseSchema)
|
||||
async detail(@CurrentUser() user: User, @Param('placeId') placeId: string) {
|
||||
const id = toRowId(placeId);
|
||||
if (id === null) throw new NotFoundException('Tour not found');
|
||||
return await this.library.detail(user.id, id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LIB-02 POST /api/trips/:tripId/tours/take-over: a tour of another trip the
|
||||
* user can open, copied into this one. The guard checks this trip and
|
||||
* 'place_edit' on it; the service checks the source trip.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/tours')
|
||||
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
|
||||
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
|
||||
export class TourTakeOverController {
|
||||
constructor(private readonly library: TourLibraryService) {}
|
||||
|
||||
@RequirePermission('place_edit')
|
||||
@Post('take-over')
|
||||
@ResponseContract(tourCreateResponseSchema)
|
||||
async takeOver(
|
||||
@Param('tripId') tripId: string,
|
||||
@CurrentUser() user: User,
|
||||
@Body() body: TourTakeOverDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
return await this.library.takeOver(tripId, user.id, body, socketId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import {
|
||||
McpController,
|
||||
Tool,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
TOOL_ANNOTATIONS_READONLY,
|
||||
errorResult,
|
||||
ok,
|
||||
type McpContext,
|
||||
} from '../../nest-mcp';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
||||
import { TripAccessService } from '../trip-membership/trip-access.service';
|
||||
import { TourLibraryService } from './tour-library.service';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { idSchema, tourLibraryQuerySchema, tourTakeOverRequestSchema, type TourLibraryQuery } from '@trek/shared';
|
||||
|
||||
const query = tourLibraryQuerySchema.shape;
|
||||
const takeOver = tourTakeOverRequestSchema.shape;
|
||||
|
||||
/**
|
||||
* LIB-02: the tours of the user's other trips, and taking one over into a
|
||||
* trip, over the same service and the same checks as REST: the list only ever
|
||||
* reaches the user's own trips, the take-over needs 'place_edit' on the trip
|
||||
* it writes into and access to the trip it reads from.
|
||||
*/
|
||||
@McpController()
|
||||
export class TourLibraryMcp {
|
||||
constructor(
|
||||
private readonly library: TourLibraryService,
|
||||
private readonly access: TripAccessService,
|
||||
private readonly guards: McpToolGuardsService,
|
||||
// Read by the addon gate of every tool here.
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
name: 'list_tour_library',
|
||||
description:
|
||||
'List the saved tours of every trip the user can open, to choose one to take over into another trip: name, type, distance in km, ascent and descent in metres, the planned times, and the trip each one is in. Narrow it with q (a part of the name), type, trip_id, or leave the trip being planned out with exclude_trip_id. Up to 100 tours; truncated says there were more. Values a tour does not have stay null.',
|
||||
inputSchema: {
|
||||
q: query.q,
|
||||
type: query.type,
|
||||
trip_id: query.trip_id,
|
||||
exclude_trip_id: query.exclude_trip_id,
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
})
|
||||
async listTourLibrary(input: TourLibraryQuery, ctx: McpContext) {
|
||||
return ok(await this.library.list(ctx.userId, input));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'take_over_tour',
|
||||
description:
|
||||
'Copy a saved tour of another trip (as list_tour_library gives it) into a trip, as an independent tour of that trip: its line, measures, settings and control points, without its days, its links to places of the other trip or the decisions taken there about obstacles. The copy remembers which trip and tour it was taken from. Nothing changes in the source trip.',
|
||||
inputSchema: {
|
||||
tripId: idSchema.describe('The trip to copy the tour into'),
|
||||
source_trip_id: takeOver.source_trip_id,
|
||||
source_place_id: takeOver.source_place_id,
|
||||
name: takeOver.name,
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'write' },
|
||||
})
|
||||
async takeOverTour(
|
||||
input: { tripId: number; source_trip_id: number; source_place_id: number; name?: string },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const { tripId, ...body } = input;
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
try {
|
||||
return ok(await this.library.takeOver(String(tripId), ctx.userId, body));
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof NotFoundException) return errorResult('Tour not found.');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import type { ToursRepository, TourLibraryRow } from '../../db/repositories/Tours.repository';
|
||||
import { TripMembershipService } from '../trip-membership/trip-membership.service';
|
||||
import { parseRouteGeometry } from './tours.helpers';
|
||||
import { ToursService } from './tours.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
TOUR_LIBRARY_LIMIT,
|
||||
TOUR_LIBRARY_PREVIEW_POINTS,
|
||||
type TourCreateResponse,
|
||||
type TourLibraryDetailResponse,
|
||||
type TourLibraryItem,
|
||||
type TourLibraryQuery,
|
||||
type TourLibraryResponse,
|
||||
type TourOrigin,
|
||||
type TourTakeOverRequest,
|
||||
type TourTypeKey,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** The most rows one list reads before the name is matched; past it the answer says it was cut. */
|
||||
const LIBRARY_READ_LIMIT = 2000;
|
||||
|
||||
function toItem(row: TourLibraryRow): TourLibraryItem {
|
||||
return {
|
||||
place_id: row.place_id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
tour_type: row.tour_type as TourTypeKey,
|
||||
distance: row.distance,
|
||||
elevation_gain: row.elevation_gain,
|
||||
elevation_loss: row.elevation_loss,
|
||||
duration: row.duration,
|
||||
planned_duration_minutes: row.planned_duration_minutes,
|
||||
break_additional_minutes: row.break_additional_minutes,
|
||||
max_hiking_difficulty: row.max_hiking_difficulty,
|
||||
has_waypoints: row.has_waypoints === 1,
|
||||
origin: row.origin as TourOrigin,
|
||||
trip: { id: row.trip_id, title: row.trip_title },
|
||||
};
|
||||
}
|
||||
|
||||
/** Every `step`-th point of a line, its last point always kept, so a preview draws the whole tour lightly. */
|
||||
function thinned(line: Array<[number, number]>, most: number): Array<[number, number]> {
|
||||
if (line.length <= most) return line;
|
||||
const step = (line.length - 1) / (most - 1);
|
||||
return Array.from({ length: most }, (_, index) => Math.min(line.length - 1, Math.round(index * step))).flatMap(
|
||||
(index) => {
|
||||
const point = line[index];
|
||||
return point ? [point] : [];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* LIB-02: the tours of every other trip the user can open, to take one over
|
||||
* into the trip they are planning. Listing and previewing read only. Every
|
||||
* answer is bounded by the trips the user owns or is a member of, read anew
|
||||
* for each call: a tour of a trip out of reach is "not found", never a hint
|
||||
* that it exists. Heights, times and distances are passed on as they are
|
||||
* stored, null staying null.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TourLibraryService {
|
||||
constructor(
|
||||
@InjectRepository(Tours) private readonly toursRepo: ToursRepository,
|
||||
private readonly membership: TripMembershipService,
|
||||
private readonly tours: ToursService,
|
||||
) {}
|
||||
|
||||
private async reachableTrips(userId: number, exclude?: number): Promise<number[]> {
|
||||
const ids = await this.membership.listAccessibleTripIds(userId);
|
||||
return exclude === undefined ? ids : ids.filter((id) => id !== exclude);
|
||||
}
|
||||
|
||||
/**
|
||||
* The name is matched here, ignoring case, so a "%" or "_" in it is just a
|
||||
* character; the read below it is bounded, and the answer says when the
|
||||
* list was cut.
|
||||
*/
|
||||
async list(userId: number, query: TourLibraryQuery): Promise<TourLibraryResponse> {
|
||||
const tripIds = await this.reachableTrips(userId, query.exclude_trip_id);
|
||||
const [rows, trips] = await Promise.all([
|
||||
this.toursRepo.listForLibrary(tripIds, { tour_type: query.type, trip_id: query.trip_id }, LIBRARY_READ_LIMIT),
|
||||
this.toursRepo.countForLibrary(tripIds),
|
||||
]);
|
||||
const needle = query.q?.toLocaleLowerCase();
|
||||
const matching = needle ? rows.filter((row) => row.name.toLocaleLowerCase().includes(needle)) : rows;
|
||||
return {
|
||||
tours: matching.slice(0, TOUR_LIBRARY_LIMIT).map(toItem),
|
||||
trips,
|
||||
truncated: matching.length > TOUR_LIBRARY_LIMIT || rows.length === LIBRARY_READ_LIMIT,
|
||||
};
|
||||
}
|
||||
|
||||
async detail(userId: number, placeId: number): Promise<TourLibraryDetailResponse> {
|
||||
const row = await this.toursRepo.findForLibrary(await this.reachableTrips(userId), placeId);
|
||||
if (!row) throw new NotFoundException('Tour not found');
|
||||
const line = parseRouteGeometry(row.route_geometry).map((point): [number, number] => [point[0], point[1]]);
|
||||
return { tour: toItem(row), line: thinned(line, TOUR_LIBRARY_PREVIEW_POINTS), waypoint_count: row.waypoint_count };
|
||||
}
|
||||
|
||||
/**
|
||||
* The copy into `tripId`, whose access and write permission the caller's
|
||||
* guard has checked; the source trip's access is checked here, against the
|
||||
* user's trips as they are now, not as they were when the list was read.
|
||||
*/
|
||||
async takeOver(
|
||||
tripId: string,
|
||||
userId: number,
|
||||
body: TourTakeOverRequest,
|
||||
socketId?: string,
|
||||
): Promise<TourCreateResponse> {
|
||||
const reachable = await this.reachableTrips(userId);
|
||||
const source = reachable.includes(body.source_trip_id)
|
||||
? await this.toursRepo.findForLibrary([body.source_trip_id], body.source_place_id)
|
||||
: undefined;
|
||||
if (!source) throw new NotFoundException('Tour not found');
|
||||
return await this.tours.takeOverTour(
|
||||
tripId,
|
||||
{ tripId: source.trip_id, placeId: source.place_id, tripTitle: source.trip_title, tourName: source.name },
|
||||
body.name ?? source.name,
|
||||
socketId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { TourTypes } from '../../db/entities/TourTypes.entity';
|
||||
import { TourWaypoints } from '../../db/entities/TourWaypoints.entity';
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
|
||||
import { PaddleObstaclesModule } from '../paddle-obstacles/paddle-obstacles.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { PlaceImportModule } from '../place-import/place-import.module';
|
||||
@@ -12,6 +13,9 @@ 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 { TourLibraryController, TourTakeOverController } from './tour-library.controller';
|
||||
import { TourLibraryMcp } from './tour-library.mcp';
|
||||
import { TourLibraryService } from './tour-library.service';
|
||||
import { TourObstacleReviewsService } from './tour-obstacle-reviews.service';
|
||||
import { PaddleCheckCache } from './tour-paddle-check.cache';
|
||||
import { TourPaddleCheckService } from './tour-paddle-check.service';
|
||||
@@ -38,6 +42,8 @@ import { Module } from '@nestjs/common';
|
||||
PlaceImportModule,
|
||||
PermissionsModule,
|
||||
TripMembershipModule,
|
||||
// The take-over tool checks 'place_edit' on the trip it writes into (LIB-02).
|
||||
McpSharedModule,
|
||||
|
||||
AddonsModule,
|
||||
// The wanderer import reads trails through the caller's own connection.
|
||||
@@ -46,7 +52,13 @@ import { Module } from '@nestjs/common';
|
||||
PaddleObstaclesModule,
|
||||
MikroOrmModule.forFeature([Tours, TourTypes, TourWaypoints, TourSources, TourObstacleReviews, Places]),
|
||||
],
|
||||
controllers: [ToursController, ToursImportController, ToursWandererController],
|
||||
controllers: [
|
||||
TourTakeOverController,
|
||||
ToursController,
|
||||
ToursImportController,
|
||||
ToursWandererController,
|
||||
TourLibraryController,
|
||||
],
|
||||
providers: [
|
||||
ToursService,
|
||||
TourPaddlePlanService,
|
||||
@@ -55,7 +67,9 @@ import { Module } from '@nestjs/common';
|
||||
TourObstacleReviewsService,
|
||||
TourGpxExportService,
|
||||
ToursWandererService,
|
||||
TourLibraryService,
|
||||
ToursMcp,
|
||||
TourLibraryMcp,
|
||||
],
|
||||
})
|
||||
export class ToursModule {}
|
||||
|
||||
@@ -265,6 +265,10 @@ export class ToursService {
|
||||
origin === 'wanderer' && row.source_local_hash !== null
|
||||
? localHashOf(row.name, parseRouteGeometry(row.route_geometry)) !== row.source_local_hash
|
||||
: null,
|
||||
takenOverFrom:
|
||||
row.taken_over_from_trip_title != null && row.taken_over_from_tour_name != null
|
||||
? { tripTitle: row.taken_over_from_trip_title, tourName: row.taken_over_from_tour_name }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -410,55 +414,112 @@ export class ToursService {
|
||||
const tid = toRowId(tripId);
|
||||
const pid = toRowId(placeId);
|
||||
if (tid === null || pid === null) throw new NotFoundException('Tour not found');
|
||||
// Within the trip: the copy points at the tour it was planned from, keeps its
|
||||
// stops' links to the trip's places and the decisions about its obstacles.
|
||||
const copyId = await this.uow.transactional(() =>
|
||||
this.insertCopyOf({ tid, pid }, { tid, name }, { derivedFrom: pid, sameTrip: true }),
|
||||
);
|
||||
return await this.announceCopy(tripId, copyId, socketId);
|
||||
}
|
||||
|
||||
const copyId = await this.uow.transactional(async () => {
|
||||
const [source, provenance, place] = await Promise.all([
|
||||
this.toursRepo.findInTrip(tid, pid),
|
||||
this.toursRepo.findProvenance(tid, pid),
|
||||
this.placesRepo.findInTrip(pid, tid),
|
||||
]);
|
||||
const start = parseRouteGeometry(place?.route_geometry)[0];
|
||||
// A tour without a line has nothing to copy.
|
||||
if (!source || !provenance || !place?.route_geometry || !start) throw new NotFoundException('Tour not found');
|
||||
const id = await this.placesRepo.insertTourPlace({
|
||||
trip_id: tid,
|
||||
name,
|
||||
lat: start[0],
|
||||
lng: start[1],
|
||||
route_geometry: place.route_geometry,
|
||||
description: place.description ?? null,
|
||||
website: place.website ?? null,
|
||||
});
|
||||
await this.toursRepo.insertTour({
|
||||
place_id: id,
|
||||
tour_type: source.tour_type,
|
||||
distance: source.distance,
|
||||
elevation_gain: source.elevation_gain,
|
||||
elevation_loss: source.elevation_loss,
|
||||
duration: source.duration,
|
||||
planned_duration_minutes: source.planned_duration_minutes,
|
||||
break_additional_minutes: source.break_additional_minutes,
|
||||
match_confidence: source.match_confidence,
|
||||
max_hiking_difficulty: source.max_hiking_difficulty,
|
||||
boat_type: source.boat_type as PaddleBoatType | null,
|
||||
water_context: source.water_context as PaddleWaterContext | null,
|
||||
paddle_speed_kmh: source.paddle_speed_kmh,
|
||||
portage_speed_kmh: source.portage_speed_kmh,
|
||||
paddle_lake_style: source.paddle_lake_style as PaddleLakeStyle | null,
|
||||
track_marks: source.track_marks,
|
||||
hiking_preferences: source.hiking_preferences,
|
||||
wanderer_ref: source.wanderer_ref,
|
||||
difficulty: source.difficulty,
|
||||
derived_from_place_id: pid,
|
||||
origin: source.origin as TourOrigin,
|
||||
origin_file_name: provenance.origin_file_name,
|
||||
});
|
||||
await this.waypointsRepo.insertForPlace(id, await this.waypointsRepo.listForPlace(pid));
|
||||
// The copy keeps the decisions about its obstacles, bound to what they were decided on.
|
||||
await this.toursRepo.copyObstacleReviews(pid, id);
|
||||
return id;
|
||||
/**
|
||||
* LIB-02: an independent copy of a tour of another trip, written into this
|
||||
* trip. The caller has checked that the user may open the source trip; the
|
||||
* source is read here again, inside the write, so a tour gone meanwhile
|
||||
* leaves nothing behind. The copy keeps where it came from as text, and none
|
||||
* of what belongs to the other trip: no link to its places or its import, no
|
||||
* decisions its people took about obstacles, no days.
|
||||
*/
|
||||
async takeOverTour(
|
||||
tripId: string,
|
||||
source: { tripId: number; placeId: number; tripTitle: string; tourName: string },
|
||||
name: string,
|
||||
socketId?: string,
|
||||
): Promise<TourCreateResponse> {
|
||||
const tid = toRowId(tripId);
|
||||
if (tid === null) throw new NotFoundException('Tour not found');
|
||||
const copyId = await this.uow.transactional(() =>
|
||||
this.insertCopyOf(
|
||||
{ tid: source.tripId, pid: source.placeId },
|
||||
{ tid, name },
|
||||
{ derivedFrom: null, sameTrip: false, takenOverFrom: source },
|
||||
),
|
||||
);
|
||||
return await this.announceCopy(tripId, copyId, socketId);
|
||||
}
|
||||
|
||||
/**
|
||||
* One copy of a tour: its place with the same line, its facet with every
|
||||
* measure and setting, its control points. Inside the caller's transaction.
|
||||
*/
|
||||
private async insertCopyOf(
|
||||
source: { tid: number; pid: number },
|
||||
target: { tid: number; name: string },
|
||||
how: { derivedFrom: number | null; sameTrip: boolean; takenOverFrom?: { tripTitle: string; tourName: string } },
|
||||
): Promise<number> {
|
||||
const [row, provenance, place] = await Promise.all([
|
||||
this.toursRepo.findInTrip(source.tid, source.pid),
|
||||
this.toursRepo.findProvenance(source.tid, source.pid),
|
||||
this.placesRepo.findInTrip(source.pid, source.tid),
|
||||
]);
|
||||
const start = parseRouteGeometry(place?.route_geometry)[0];
|
||||
// A tour without a line has nothing to copy.
|
||||
if (!row || !provenance || !place?.route_geometry || !start) throw new NotFoundException('Tour not found');
|
||||
if (!how.sameTrip) await this.assertTourTypeEnabled(row.tour_type);
|
||||
const id = await this.placesRepo.insertTourPlace({
|
||||
trip_id: target.tid,
|
||||
name: target.name,
|
||||
lat: start[0],
|
||||
lng: start[1],
|
||||
route_geometry: place.route_geometry,
|
||||
description: place.description ?? null,
|
||||
website: place.website ?? null,
|
||||
});
|
||||
await this.toursRepo.insertTour({
|
||||
place_id: id,
|
||||
tour_type: row.tour_type,
|
||||
distance: row.distance,
|
||||
elevation_gain: row.elevation_gain,
|
||||
elevation_loss: row.elevation_loss,
|
||||
duration: row.duration,
|
||||
planned_duration_minutes: row.planned_duration_minutes,
|
||||
break_additional_minutes: row.break_additional_minutes,
|
||||
match_confidence: row.match_confidence,
|
||||
max_hiking_difficulty: row.max_hiking_difficulty,
|
||||
boat_type: row.boat_type as PaddleBoatType | null,
|
||||
water_context: row.water_context as PaddleWaterContext | null,
|
||||
paddle_speed_kmh: row.paddle_speed_kmh,
|
||||
portage_speed_kmh: row.portage_speed_kmh,
|
||||
paddle_lake_style: row.paddle_lake_style as PaddleLakeStyle | null,
|
||||
track_marks: row.track_marks,
|
||||
hiking_preferences: row.hiking_preferences,
|
||||
wanderer_ref: row.wanderer_ref,
|
||||
difficulty: row.difficulty,
|
||||
derived_from_place_id: how.derivedFrom,
|
||||
origin: row.origin as TourOrigin,
|
||||
origin_file_name: provenance.origin_file_name,
|
||||
// A copy of a copy names where the first one came from.
|
||||
taken_over_from_trip_title: how.takenOverFrom?.tripTitle ?? provenance.taken_over_from_trip_title,
|
||||
taken_over_from_tour_name: how.takenOverFrom?.tourName ?? provenance.taken_over_from_tour_name,
|
||||
});
|
||||
const waypoints = await this.waypointsRepo.listForPlace(source.pid);
|
||||
// A stop standing for a place of the source trip stands for nothing in another trip.
|
||||
await this.waypointsRepo.insertForPlace(
|
||||
id,
|
||||
how.sameTrip ? waypoints : waypoints.map((point) => ({ ...point, linked_place_id: null })),
|
||||
);
|
||||
if (how.sameTrip) {
|
||||
// The copy keeps the decisions about its obstacles, bound to what they were decided on.
|
||||
await this.toursRepo.copyObstacleReviews(source.pid, id);
|
||||
} else {
|
||||
// The import record says where the line itself came from, whichever trip holds it.
|
||||
await this.toursRepo.copySource(source.pid, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** After a copy committed: the trip's tours and places follow it live, and the copy is answered as the editor reads it. */
|
||||
private async announceCopy(tripId: string, copyId: number, socketId?: string): Promise<TourCreateResponse> {
|
||||
const result = await this.getTour(tripId, String(copyId));
|
||||
const created = await this.placesRepo.findWithTagsAndRatings(copyId);
|
||||
this.broadcastToursChanged(tripId, [copyId], socketId);
|
||||
|
||||
@@ -473,4 +473,204 @@ describe('Tours planned e2e (real guards + temp SQLite)', () => {
|
||||
expect(paddle.status).toBe(400);
|
||||
});
|
||||
});
|
||||
describe('taking a tour over from another trip (LIB-02)', () => {
|
||||
beforeAll(async () => {
|
||||
// A trip of someone else's, which the owner never sees.
|
||||
await insertRows(orm, Trips, [{ id: 7, user: 3, title: 'Elsewhere' }]);
|
||||
});
|
||||
|
||||
const outsiderTour = async () => {
|
||||
const res = await request(server)
|
||||
.post('/api/trips/7/tours')
|
||||
.set('Cookie', sessionCookie(OUTSIDER))
|
||||
.send(tourBody({ name: 'Secret ridge' }));
|
||||
expect(res.status).toBe(201);
|
||||
return res.body.tour.place_id as number;
|
||||
};
|
||||
|
||||
it('lists the tours of the trips the user can open, with their trip, never one of another user', async () => {
|
||||
const ridge = await createViaApi(5, tourBody({ name: 'Ridge 100% walk' }));
|
||||
await createViaApi(
|
||||
5,
|
||||
tourBody({
|
||||
name: 'Lake loop',
|
||||
tour_type: 'kayak',
|
||||
boat_type: 'kayak',
|
||||
water_context: 'lake',
|
||||
max_hiking_difficulty: null,
|
||||
}),
|
||||
);
|
||||
await createViaApi(6, tourBody({ name: 'Valley walk' }));
|
||||
await outsiderTour();
|
||||
|
||||
const all = await request(server).get('/api/tours/library').set('Cookie', sessionCookie(OWNER));
|
||||
expect(all.status).toBe(200);
|
||||
expect(all.body.tours.map((tour: { name: string }) => tour.name).sort()).toEqual([
|
||||
'Lake loop',
|
||||
'Ridge 100% walk',
|
||||
'Valley walk',
|
||||
]);
|
||||
expect(all.body.tours.find((tour: { place_id: number }) => tour.place_id === ridge)).toMatchObject({
|
||||
trip: { id: 5, title: 'Alps' },
|
||||
tour_type: 'hike',
|
||||
has_waypoints: true,
|
||||
origin: 'trek',
|
||||
});
|
||||
expect(all.body.trips).toEqual([
|
||||
{ id: 6, title: 'Other', tour_count: 1 },
|
||||
{ id: 5, title: 'Alps', tour_count: 2 },
|
||||
]);
|
||||
expect(all.body.truncated).toBe(false);
|
||||
expect(JSON.stringify(all.body)).not.toContain('Secret ridge');
|
||||
|
||||
// Leaving the asking trip out, by name (case and a literal "%" alike), by type and by trip.
|
||||
const from6 = await request(server)
|
||||
.get('/api/tours/library?exclude_trip_id=6')
|
||||
.set('Cookie', sessionCookie(OWNER));
|
||||
expect(from6.body.tours.every((tour: { trip: { id: number } }) => tour.trip.id === 5)).toBe(true);
|
||||
expect(from6.body.trips).toEqual([{ id: 5, title: 'Alps', tour_count: 2 }]);
|
||||
const named = await request(server).get('/api/tours/library?q=100%25').set('Cookie', sessionCookie(OWNER));
|
||||
expect(named.body.tours.map((tour: { name: string }) => tour.name)).toEqual(['Ridge 100% walk']);
|
||||
const anyCase = await request(server).get('/api/tours/library?q=VALLEY').set('Cookie', sessionCookie(OWNER));
|
||||
expect(anyCase.body.tours.map((tour: { name: string }) => tour.name)).toEqual(['Valley walk']);
|
||||
const paddle = await request(server).get('/api/tours/library?type=kayak').set('Cookie', sessionCookie(OWNER));
|
||||
expect(paddle.body.tours.map((tour: { name: string }) => tour.name)).toEqual(['Lake loop']);
|
||||
const trip6 = await request(server).get('/api/tours/library?trip_id=6').set('Cookie', sessionCookie(OWNER));
|
||||
expect(trip6.body.tours.map((tour: { name: string }) => tour.name)).toEqual(['Valley walk']);
|
||||
const bad = await request(server).get('/api/tours/library?type=car').set('Cookie', sessionCookie(OWNER));
|
||||
expect(bad.status).toBe(400);
|
||||
|
||||
// A member sees the trips they belong to.
|
||||
const member = await request(server).get('/api/tours/library').set('Cookie', sessionCookie(VIEWER));
|
||||
expect(member.body.trips.map((trip: { id: number }) => trip.id)).toEqual([5]);
|
||||
});
|
||||
|
||||
it("previews one tour with its line and control points; another user's tour or none is not found", async () => {
|
||||
const ridge = await createViaApi(5);
|
||||
const preview = await request(server).get(`/api/tours/library/${ridge}`).set('Cookie', sessionCookie(OWNER));
|
||||
expect(preview.status).toBe(200);
|
||||
expect(preview.body.line).toEqual(GEOMETRY.map(([lat, lng]) => [lat, lng]));
|
||||
expect(preview.body.waypoint_count).toBe(2);
|
||||
expect(preview.body.tour).toMatchObject({ name: 'Ridge walk', trip: { id: 5, title: 'Alps' } });
|
||||
|
||||
const secret = await outsiderTour();
|
||||
const hidden = await request(server).get(`/api/tours/library/${secret}`).set('Cookie', sessionCookie(OWNER));
|
||||
expect(hidden.status).toBe(404);
|
||||
expect(hidden.body).toEqual({ error: 'Tour not found' });
|
||||
expect((await request(server).get('/api/tours/library/999999').set('Cookie', sessionCookie(OWNER))).status).toBe(
|
||||
404,
|
||||
);
|
||||
expect((await request(server).get('/api/tours/library/abc').set('Cookie', sessionCookie(OWNER))).status).toBe(
|
||||
404,
|
||||
);
|
||||
});
|
||||
|
||||
it('a long line is thinned for the preview, its ends kept', async () => {
|
||||
const long = Array.from({ length: 1200 }, (_, index) => [47 + index / 10000, 11 + index / 10000, 600]);
|
||||
const placeId = await createViaApi(5, tourBody({ route_geometry: long }));
|
||||
const preview = await request(server).get(`/api/tours/library/${placeId}`).set('Cookie', sessionCookie(OWNER));
|
||||
expect(preview.body.line).toHaveLength(400);
|
||||
expect(preview.body.line[0]).toEqual([47, 11]);
|
||||
expect(preview.body.line[399]).toEqual([long[1199]![0], long[1199]![1]]);
|
||||
});
|
||||
|
||||
it('takes a tour over as an independent tour of this trip, remembering where it came from', async () => {
|
||||
const hut = await makePlace(orm, 5, { name: 'Hut', lat: 47.01, lng: 11.01 });
|
||||
const source = await createViaApi(
|
||||
5,
|
||||
tourBody({
|
||||
waypoints: [
|
||||
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 47.01, lng: 11.01, role: 'via', sequence: 1, linked_place_id: hut.id },
|
||||
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 2 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
realtime.broadcastMock.mockClear();
|
||||
const res = await request(server)
|
||||
.post('/api/trips/6/tours/take-over')
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.send({ source_trip_id: 5, source_place_id: source });
|
||||
expect(res.status).toBe(201);
|
||||
const copy = res.body.tour.place_id as number;
|
||||
expect(copy).not.toBe(source);
|
||||
expect(res.body.tour).toMatchObject({
|
||||
name: 'Ridge walk',
|
||||
revision: 1,
|
||||
planned: false,
|
||||
max_hiking_difficulty: 3,
|
||||
});
|
||||
expect(res.body.provenance).toMatchObject({
|
||||
derivedFrom: null,
|
||||
takenOverFrom: { tripTitle: 'Alps', tourName: 'Ridge walk' },
|
||||
});
|
||||
// The stop stood for a place of the other trip: in this one it stands for nothing.
|
||||
expect(
|
||||
res.body.waypoints.map((point: { linked_place_id?: number | null }) => point.linked_place_id ?? null),
|
||||
).toEqual([null, null, null]);
|
||||
expect(await findRow(orm, Places, { id: copy })).toMatchObject({ trip_id: 6 });
|
||||
expect(await countRows(orm, DayAssignments, { place: copy })).toBe(0);
|
||||
expect(realtime.broadcastMock.mock.calls.every(([tripId]) => String(tripId) === '6')).toBe(true);
|
||||
|
||||
// The source is untouched, and a later change to one leaves the other as it is.
|
||||
const original = await request(server).get(`/api/trips/5/tours/${source}`).set('Cookie', sessionCookie(OWNER));
|
||||
expect(original.body.provenance.takenOverFrom).toBeNull();
|
||||
expect(original.body.waypoints[1].linked_place_id).toBe(hut.id);
|
||||
await request(server)
|
||||
.put(`/api/trips/6/tours/${copy}`)
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.send(tourBody({ name: 'Renamed copy' }));
|
||||
expect((await findRow(orm, Places, { id: source }))?.name).toBe('Ridge walk');
|
||||
|
||||
// A copy of the copy still names where the line came from.
|
||||
const again = await request(server)
|
||||
.post(`/api/trips/6/tours/${copy}/copy`)
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.send({ name: 'Again' });
|
||||
expect(again.body.provenance.takenOverFrom).toEqual({ tripTitle: 'Alps', tourName: 'Ridge walk' });
|
||||
});
|
||||
|
||||
it('a name can be given; a source out of reach or gone is not found and leaves nothing behind', async () => {
|
||||
const source = await createViaApi(5);
|
||||
const named = await request(server)
|
||||
.post('/api/trips/6/tours/take-over')
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.send({ source_trip_id: 5, source_place_id: source, name: 'My ridge' });
|
||||
expect(named.body.tour.name).toBe('My ridge');
|
||||
|
||||
const before = await countRows(orm, Places, { trip: 7 });
|
||||
const stolen = await request(server)
|
||||
.post('/api/trips/7/tours/take-over')
|
||||
.set('Cookie', sessionCookie(OUTSIDER))
|
||||
.send({ source_trip_id: 5, source_place_id: source });
|
||||
expect(stolen.status).toBe(404);
|
||||
expect(stolen.body).toEqual({ error: 'Tour not found' });
|
||||
// The place id named with the wrong trip is not found either.
|
||||
const mismatched = await request(server)
|
||||
.post('/api/trips/6/tours/take-over')
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.send({ source_trip_id: 6, source_place_id: source });
|
||||
expect(mismatched.status).toBe(404);
|
||||
expect(await countRows(orm, Places, { trip: 7 })).toBe(before);
|
||||
});
|
||||
|
||||
it('needs place_edit on the trip it writes into, the addon on, a session and a well-formed body', async () => {
|
||||
const source = await createViaApi(6);
|
||||
const viewer = await request(server)
|
||||
.post('/api/trips/5/tours/take-over')
|
||||
.set('Cookie', sessionCookie(VIEWER))
|
||||
.send({ source_trip_id: 6, source_place_id: source });
|
||||
expect(viewer.status).toBe(403);
|
||||
const extra = await request(server)
|
||||
.post('/api/trips/5/tours/take-over')
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.send({ source_trip_id: 6, source_place_id: source, day_id: 1 });
|
||||
expect(extra.status).toBe(400);
|
||||
expect((await request(server).get('/api/tours/library')).status).toBe(401);
|
||||
await setAddonEnabled(orm, 'tours', false);
|
||||
const off = await request(server).get('/api/tours/library').set('Cookie', sessionCookie(OWNER));
|
||||
expect(off.status).toBe(404);
|
||||
expect(off.body).toEqual({ error: 'Tours addon is not enabled' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,6 +62,7 @@ const UNNUMBERED = [
|
||||
'Migration20200101044800_a_tour_counts_its_revisions',
|
||||
'Migration20200101044900_a_tour_waypoint_can_be_a_place_of_the_trip',
|
||||
'Migration20200101045000_a_hike_keeps_its_routing_preferences',
|
||||
'Migration20200101045100_a_tour_remembers_the_trip_it_was_taken_from',
|
||||
];
|
||||
// The last step the positional runner ever had; planLegacyBaseline refuses a
|
||||
// schema_version past it.
|
||||
|
||||
@@ -100,6 +100,7 @@ describe('ToursRepository reads', () => {
|
||||
track_marks: null,
|
||||
has_recorded_track: 0,
|
||||
revision: 1,
|
||||
hiking_preferences: null,
|
||||
},
|
||||
{
|
||||
place_id: older.id,
|
||||
@@ -129,6 +130,7 @@ describe('ToursRepository reads', () => {
|
||||
track_marks: null,
|
||||
has_recorded_track: 0,
|
||||
revision: 1,
|
||||
hiking_preferences: null,
|
||||
},
|
||||
]);
|
||||
expect((await repo.listForTrip(trip.id)).map((r) => r.place_id)).not.toContain(plain.id);
|
||||
@@ -214,6 +216,7 @@ describe('ToursRepository writes', () => {
|
||||
track_marks: null,
|
||||
has_recorded_track: 0,
|
||||
revision: 1,
|
||||
hiking_preferences: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -293,6 +296,9 @@ describe('ToursRepository writes', () => {
|
||||
paddle_lake_style: null,
|
||||
track_marks: null,
|
||||
revision: 1,
|
||||
hiking_preferences: null,
|
||||
taken_over_from_trip_title: null,
|
||||
taken_over_from_tour_name: null,
|
||||
},
|
||||
]);
|
||||
expect(await repo.listRowsForTrip(other.id)).toEqual([]);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* TourLibraryMcp (LIB-02): the tours of the user's other trips and taking one
|
||||
* over, over the same service as REST. The suite takes its subject from the
|
||||
* container over a snapshot test db; the addon gate is the registry's.
|
||||
*/
|
||||
import { Places } from '../../../src/db/entities/Places.entity';
|
||||
import type { McpContext } from '../../../src/nest-mcp';
|
||||
import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service';
|
||||
import { TourLibraryMcp } from '../../../src/nest/tours/tour-library.mcp';
|
||||
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { createPlace, createTrip, createUser } from '../../helpers/factories';
|
||||
import { findRow, 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';
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
let t: TestModule;
|
||||
let mcp: TourLibraryMcp;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({ db, imports: [ToursModule] });
|
||||
mcp = t.get(TourLibraryMcp);
|
||||
});
|
||||
beforeEach(() => resetTestDb(db));
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
const ctx = (userId: number) => ({ userId }) as McpContext;
|
||||
|
||||
function read(result: { content: Array<{ type: string; text: string }>; isError?: boolean }) {
|
||||
const text = result.content[0]?.text ?? '';
|
||||
return result.isError ? { error: text } : JSON.parse(text);
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const { user } = createUser(db);
|
||||
const { user: outsider } = createUser(db);
|
||||
const alps = createTrip(db, user.id, { title: 'Alps' });
|
||||
const next = createTrip(db, user.id, { title: 'Next' });
|
||||
const elsewhere = createTrip(db, outsider.id, { title: 'Elsewhere' });
|
||||
const ridge = createPlace(db, alps.id, { name: 'Ridge' });
|
||||
createTour(db, ridge.id, { distance: 8.2, max_hiking_difficulty: 3 });
|
||||
const gorge = createPlace(db, alps.id, { name: 'Gorge' });
|
||||
createTour(db, gorge.id);
|
||||
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();
|
||||
return { user, outsider, alps, next, elsewhere, ridge, gorge };
|
||||
}
|
||||
|
||||
describe('TourLibraryMcp', () => {
|
||||
it('TOUR-LIB-MCP-001: list_tour_library lists the tours of the user’s trips with their trip, nothing of others', async () => {
|
||||
const { user, outsider, alps, next, ridge } = await fixture();
|
||||
const own = read(await mcp.listTourLibrary({ exclude_trip_id: next.id }, ctx(user.id)));
|
||||
expect(own.tours.map((tour: { name: string }) => tour.name).sort()).toEqual(['Gorge', 'Ridge']);
|
||||
expect(own.tours.find((tour: { place_id: number }) => tour.place_id === ridge.id)).toMatchObject({
|
||||
distance: 8.2,
|
||||
trip: { id: alps.id, title: 'Alps' },
|
||||
});
|
||||
expect(read(await mcp.listTourLibrary({ q: 'gor' }, ctx(user.id))).tours).toHaveLength(1);
|
||||
expect(read(await mcp.listTourLibrary({}, ctx(outsider.id))).tours).toEqual([]);
|
||||
});
|
||||
|
||||
it('TOUR-LIB-MCP-002: take_over_tour copies a tour into a trip, remembering where it came from', async () => {
|
||||
const { user, alps, next, ridge } = await fixture();
|
||||
const result = read(
|
||||
await mcp.takeOverTour({ tripId: next.id, source_trip_id: alps.id, source_place_id: ridge.id }, ctx(user.id)),
|
||||
);
|
||||
expect(result.tour).toMatchObject({ name: 'Ridge', revision: 1 });
|
||||
expect(result.provenance.takenOverFrom).toEqual({ tripTitle: 'Alps', tourName: 'Ridge' });
|
||||
const orm = await createTestOrm(db);
|
||||
expect(await findRow(orm, Places, { id: result.tour.place_id })).toMatchObject({ trip_id: next.id });
|
||||
await orm.close();
|
||||
});
|
||||
|
||||
it('TOUR-LIB-MCP-003: refuses a trip the user cannot open, and a source out of reach or without a line', async () => {
|
||||
const { user, outsider, alps, next, elsewhere, ridge, gorge } = await fixture();
|
||||
expect(
|
||||
read(
|
||||
await mcp.takeOverTour(
|
||||
{ tripId: alps.id, source_trip_id: alps.id, source_place_id: ridge.id },
|
||||
ctx(outsider.id),
|
||||
),
|
||||
),
|
||||
).toEqual({ error: 'Trip not found or access denied.' });
|
||||
expect(
|
||||
read(
|
||||
await mcp.takeOverTour(
|
||||
{ tripId: elsewhere.id, source_trip_id: alps.id, source_place_id: ridge.id },
|
||||
ctx(outsider.id),
|
||||
),
|
||||
),
|
||||
).toEqual({ error: 'Tour not found.' });
|
||||
expect(
|
||||
read(
|
||||
await mcp.takeOverTour({ tripId: next.id, source_trip_id: alps.id, source_place_id: gorge.id }, ctx(user.id)),
|
||||
),
|
||||
).toEqual({ error: 'Tour not found.' });
|
||||
});
|
||||
|
||||
it('TOUR-LIB-MCP-004: without place_edit on the trip it writes into, nothing is copied', async () => {
|
||||
const { user, alps, next, ridge } = await fixture();
|
||||
const denied = vi.spyOn(t.get(McpToolGuardsService), 'hasTripPermission').mockResolvedValue(false);
|
||||
const result = await mcp.takeOverTour(
|
||||
{ tripId: next.id, source_trip_id: alps.id, source_place_id: ridge.id },
|
||||
ctx(user.id),
|
||||
);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(denied).toHaveBeenCalledWith('place_edit', next.id, user.id);
|
||||
expect(read(await mcp.listTourLibrary({ trip_id: next.id }, ctx(user.id))).tours).toEqual([]);
|
||||
denied.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -265,6 +265,8 @@ function tourRepositoryFixture(ownerTripId: number) {
|
||||
has_recorded_track: 0,
|
||||
derived_from_place_id: null,
|
||||
derived_from_name: null,
|
||||
taken_over_from_trip_title: null,
|
||||
taken_over_from_tour_name: null,
|
||||
source_instance: null,
|
||||
source_external_id: null,
|
||||
source_external_url: null,
|
||||
|
||||
@@ -76,6 +76,8 @@ function makeService() {
|
||||
has_recorded_track: 0,
|
||||
derived_from_place_id: null,
|
||||
derived_from_name: null,
|
||||
taken_over_from_trip_title: null,
|
||||
taken_over_from_tour_name: null,
|
||||
source_instance: null,
|
||||
source_external_id: null,
|
||||
source_external_url: null,
|
||||
@@ -185,6 +187,7 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a
|
||||
paddle_speed_kmh: null,
|
||||
portage_speed_kmh: null,
|
||||
paddle_lake_style: null,
|
||||
hiking_preferences: null,
|
||||
});
|
||||
expect((setup.toursRepo.insertTour.mock.calls[0][0] as { distance: number }).distance).toBeGreaterThan(0);
|
||||
expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, request.waypoints);
|
||||
@@ -199,6 +202,7 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a
|
||||
derivedFrom: null,
|
||||
plannedOver: false,
|
||||
editedLocally: null,
|
||||
takenOverFrom: null,
|
||||
});
|
||||
expect(setup.places.broadcast).toHaveBeenNthCalledWith(1, '7', 'tours:changed', { placeIds: [42] }, 'socket-1');
|
||||
expect(setup.places.broadcast).toHaveBeenNthCalledWith(2, '7', 'place:created', { place: setup.place }, 'socket-1');
|
||||
@@ -300,6 +304,7 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a
|
||||
paddle_speed_kmh: null,
|
||||
portage_speed_kmh: null,
|
||||
paddle_lake_style: null,
|
||||
hiking_preferences: null,
|
||||
});
|
||||
expect(setup.waypointsRepo.deleteForPlace).toHaveBeenCalledExactlyOnceWith(42);
|
||||
expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, update.waypoints);
|
||||
|
||||
@@ -56,6 +56,7 @@ export * from './roadtrip/roadtrip.schema';
|
||||
export * from './place/track-colors';
|
||||
export * from './tours/tours.schema';
|
||||
export * from './tours/tour-provenance.schema';
|
||||
export * from './tours/tour-library.schema';
|
||||
export * from './tours/tours.metrics';
|
||||
export * from './tours/tour-gpx-import.schema';
|
||||
export * from './tours/paddle-plan.schema';
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
tourLibraryDetailResponseSchema,
|
||||
tourLibraryQuerySchema,
|
||||
tourLibraryResponseSchema,
|
||||
tourTakeOverRequestSchema,
|
||||
} from './tour-library.schema';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const item = {
|
||||
place_id: 7,
|
||||
name: 'Kramerplateauweg',
|
||||
description: null,
|
||||
tour_type: 'hike',
|
||||
distance: 4.8,
|
||||
elevation_gain: 533,
|
||||
elevation_loss: 530,
|
||||
duration: 156,
|
||||
planned_duration_minutes: null,
|
||||
break_additional_minutes: null,
|
||||
max_hiking_difficulty: 1,
|
||||
has_waypoints: true,
|
||||
origin: 'trek',
|
||||
trip: { id: 2, title: 'Garmisch' },
|
||||
};
|
||||
|
||||
describe('tour library contracts (LIB-02)', () => {
|
||||
it('reads the query from strings and refuses what it does not know', () => {
|
||||
expect(tourLibraryQuerySchema.parse({ q: ' ridge ', type: 'hike', trip_id: '3', exclude_trip_id: '1' })).toEqual({
|
||||
q: 'ridge',
|
||||
type: 'hike',
|
||||
trip_id: 3,
|
||||
exclude_trip_id: 1,
|
||||
});
|
||||
expect(tourLibraryQuerySchema.safeParse({ type: 'car' }).success).toBe(false);
|
||||
expect(tourLibraryQuerySchema.safeParse({ trip_id: '0' }).success).toBe(false);
|
||||
expect(tourLibraryQuerySchema.safeParse({ sort: 'name' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('lists tours with the trip they are in, measures left unknown where unknown', () => {
|
||||
const answer = tourLibraryResponseSchema.parse({
|
||||
tours: [item, { ...item, place_id: 8, distance: null, elevation_gain: null }],
|
||||
trips: [{ id: 2, title: 'Garmisch', tour_count: 2 }],
|
||||
truncated: false,
|
||||
});
|
||||
expect(answer.tours[1]?.distance).toBeNull();
|
||||
expect(
|
||||
tourLibraryResponseSchema.safeParse({ tours: [{ ...item, trip: undefined }], trips: [], truncated: false })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('previews a line as plain points', () => {
|
||||
expect(
|
||||
tourLibraryDetailResponseSchema.parse({ tour: item, line: [[47.4, 11.1]], waypoint_count: 4 }).line,
|
||||
).toHaveLength(1);
|
||||
expect(tourLibraryDetailResponseSchema.safeParse({ tour: item, line: [[47.4]], waypoint_count: 4 }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('takes over by trip and tour, the name optional and trimmed', () => {
|
||||
expect(tourTakeOverRequestSchema.parse({ source_trip_id: 2, source_place_id: 7, name: ' Copy ' }).name).toBe(
|
||||
'Copy',
|
||||
);
|
||||
expect(tourTakeOverRequestSchema.safeParse({ source_trip_id: 2, source_place_id: 7 }).success).toBe(true);
|
||||
expect(tourTakeOverRequestSchema.safeParse({ source_trip_id: 2, source_place_id: 7, name: '' }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(tourTakeOverRequestSchema.safeParse({ source_trip_id: '2', source_place_id: 7 }).success).toBe(false);
|
||||
expect(tourTakeOverRequestSchema.safeParse({ source_trip_id: 2, source_place_id: 7, day_id: 1 }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { tourListItemSchema, tourTypeKeySchema } from './tours.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* LIB-02: taking a tour over from another trip the user can open. The list
|
||||
* and the preview read across those trips without changing anything; the
|
||||
* take-over writes one independent copy into the trip it is asked in.
|
||||
*/
|
||||
|
||||
/** The most tours one list answer carries; the answer says when there were more. */
|
||||
export const TOUR_LIBRARY_LIMIT = 100;
|
||||
/** The most points a preview's line carries, thinned evenly along it. */
|
||||
export const TOUR_LIBRARY_PREVIEW_POINTS = 400;
|
||||
|
||||
/** What the list is narrowed by: a part of the name, a type, one trip; the trip asked from is left out. */
|
||||
export const tourLibraryQuerySchema = z
|
||||
.object({
|
||||
q: z.string().trim().max(100).optional(),
|
||||
type: tourTypeKeySchema.optional(),
|
||||
trip_id: z.coerce.number().int().positive().optional(),
|
||||
exclude_trip_id: z.coerce.number().int().positive().optional(),
|
||||
})
|
||||
.strict();
|
||||
export type TourLibraryQuery = z.infer<typeof tourLibraryQuerySchema>;
|
||||
|
||||
const tourLibraryTripSchema = z.object({ id: z.number().int().positive(), title: z.string() });
|
||||
|
||||
/** A tour of another trip as the list shows it: its measures as they are, and the trip it is in. */
|
||||
export const tourLibraryItemSchema = tourListItemSchema
|
||||
.pick({
|
||||
place_id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
tour_type: true,
|
||||
distance: true,
|
||||
elevation_gain: true,
|
||||
elevation_loss: true,
|
||||
duration: true,
|
||||
planned_duration_minutes: true,
|
||||
break_additional_minutes: true,
|
||||
max_hiking_difficulty: true,
|
||||
has_waypoints: true,
|
||||
origin: true,
|
||||
})
|
||||
.extend({ trip: tourLibraryTripSchema });
|
||||
export type TourLibraryItem = z.infer<typeof tourLibraryItemSchema>;
|
||||
|
||||
export const tourLibraryResponseSchema = z.object({
|
||||
tours: z.array(tourLibraryItemSchema),
|
||||
/** Every trip with a tour the user could take over, for the trip filter, the asking trip left out. */
|
||||
trips: z.array(tourLibraryTripSchema.extend({ tour_count: z.number().int().nonnegative() })),
|
||||
/** More tours matched than the answer carries. */
|
||||
truncated: z.boolean(),
|
||||
});
|
||||
export type TourLibraryResponse = z.infer<typeof tourLibraryResponseSchema>;
|
||||
|
||||
/** One tour's preview: the list's facts, its line thinned for drawing, and how many control points it has. */
|
||||
export const tourLibraryDetailResponseSchema = z.object({
|
||||
tour: tourLibraryItemSchema,
|
||||
line: z.array(z.tuple([z.number(), z.number()])),
|
||||
waypoint_count: z.number().int().nonnegative(),
|
||||
});
|
||||
export type TourLibraryDetailResponse = z.infer<typeof tourLibraryDetailResponseSchema>;
|
||||
|
||||
/** Which tour of which trip to take over, and the name the copy gets (the tour's own when left out). */
|
||||
export const tourTakeOverRequestSchema = z
|
||||
.object({
|
||||
source_trip_id: z.number().int().positive(),
|
||||
source_place_id: z.number().int().positive(),
|
||||
name: z.string().trim().min(1).max(255).optional(),
|
||||
})
|
||||
.strict();
|
||||
export type TourTakeOverRequest = z.infer<typeof tourTakeOverRequestSchema>;
|
||||
@@ -58,6 +58,11 @@ export const tourProvenanceSchema = z.object({
|
||||
* import, a snapshot whose import record is missing.
|
||||
*/
|
||||
editedLocally: z.boolean().nullable(),
|
||||
/**
|
||||
* LIB-02: the trip and tour this tour was taken over from, as they were
|
||||
* called then; null for every other tour, absent in a detail cached before.
|
||||
*/
|
||||
takenOverFrom: z.object({ tripTitle: z.string(), tourName: z.string() }).nullable().optional(),
|
||||
});
|
||||
export type TourProvenance = z.infer<typeof tourProvenanceSchema>;
|
||||
|
||||
|
||||
@@ -241,6 +241,8 @@ Read only: the saved tours of a trip, as the Tours list and the tour detail show
|
||||
| `get_tour_plan` | One tour: its facts, its ordered control points, its provenance (where it came from, when, and whether it was changed in TREK since) and its days. A control point can carry a fixed leg to the next one: a via ferrata (`ferrata`) or a stretch drawn by hand (`free`), which no router planned or checked. A shaping point (`shaping: true`) only steers the route and is no stop. A paddle tour also carries its obstacle checklist (`obstacle_check`): the obstacles of the OpenStreetMap data along its route, what the travellers decided about each, and their own portages and locks. The full track geometry is not included | `trips:read` |
|
||||
| `find_tours_for_day` | The tours put on one day of a trip, with the same facts as `list_tours` | `trips:read` |
|
||||
| `export_tour_gpx` | One saved tour as a GPX file: its whole saved line as one track, with the heights it carries and none it lacks, and a file name. A tour without a line cannot be exported | `trips:read` |
|
||||
| `list_tour_library` | The saved tours of every trip the user can open, to choose one to take over into another trip: name, type, distance, ascent, descent, planned times and the trip each is in. Narrowed by a part of the name, a type or a trip; the trip being planned can be left out. Up to 100 tours, `truncated` says there were more | `trips:read` |
|
||||
| `take_over_tour` | Copies a saved tour of another trip into a trip as an independent tour: its line, measures, settings and control points, without its days, its links to places of the other trip or the obstacle decisions taken there. The copy remembers which trip and tour it came from; the source trip is not changed. Needs `place_edit` on the trip it writes into | `trips:write` |
|
||||
| `plan_paddle_route` | Plans the legs of a paddle tour on the water network TREK builds from the paddle regions an admin added: each leg (`from`, `to`) along rivers and canals and across lakes, carried round weirs, dams and waterfalls, through or round locks (`lock_policy`), with `boat` and `lake_style` (`direct` or `shore`). Answers per leg its length, how far its ends were moved onto the water and its stretches (water, lake, whitewater, boat pass, portage with its source, lock, gap); a leg that cannot be planned says why (`not_on_water`, `no_region`, `cross_region`, `no_water_path`, `busy`, `timeout`). Nothing is saved; a planning aid, never a safety or legal clearance | `trips:read` |
|
||||
|
||||
### Road trip _(Road trip addon required)_
|
||||
|
||||
Reference in New Issue
Block a user