mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +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);
|
||||
|
||||
Reference in New Issue
Block a user