feat(tours): keep whole tours in collections and take them into trips

A saved tour can now be kept in a collection, apart from any trip, and
later made into a tour of another trip. The bookmark button in a tour's
footer and in the planner's head (waiting for the save while there are
unsaved changes) opens a dialog with every list of the user; a list they
may only read stays in it, dimmed with a lock and a tooltip. The list
keeps an independent copy: the line, the type, every measure and setting,
the waypoints in order with their roles, the origin, and the trip and tour
it was saved from as they were called then. Its days, its links to places
of the trip and its obstacle decisions stay in the trip, and later changes
to the tour do not reach the copy.

A collection shows its kept tours as cards above its places: the grade
tile with the source badge, measures and the waypoint count as badges,
the trip it came from, and who kept it. The owner or an admin of the list
can remove one after a question; tours made from it stay. "Add to a trip"
shows the kept tour on a map with its facts and numbered waypoints, lists
the user's trips (the ones they may not add tours to dimmed), and asks
outright whether the waypoints come along. "Line only" keeps the line as
saved and adds no waypoints; a tour kept without a line can only bring
them. The new tour has an id of its own, no days and no new places, and
names the trip and tour it was saved from; the dialog then offers to open
that trip. Neither the collection nor the original tour changes.

On the server the kept tours are a table of their own in the tours domain
(collection_tours, migration 046100), each row written whole once. Every
copy, the in-trip "save as new tour", the take-over from another trip and
both directions here, now goes through one copy model (tour-copy.helpers)
instead of a second one. Reading and removing follow the collection's
roles, keeping one also needs access to the source trip, and taking one
over needs place_edit on the target trip; the kept tour is read again
inside the write, so one removed meanwhile leaves no half-made tour. The
MCP tools list_collection_tours, get_collection_tour,
save_tour_to_collection, take_over_collection_tour and
delete_collection_tour run over the same service and checks. All 27
locales carry the new strings, and the wiki describes the flow.
This commit is contained in:
Maurice
2026-10-20 13:15:42 +02:00
parent 3659297af1
commit 3fe34061fd
85 changed files with 6144 additions and 181 deletions
@@ -0,0 +1,101 @@
import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core';
import { CollectionToursRepository } from '../repositories/CollectionTours.repository';
import { DbTimestampType } from '../types';
import { Collections } from './Collections.entity';
import { TourTypes } from './TourTypes.entity';
import { Users } from './Users.entity';
export class CollectionTours {
[EntityRepositoryType]?: CollectionToursRepository;
id!: number & Opt;
collection!: Ref<Collections>;
collection_id!: number;
saved_by?: number | null;
name!: string;
description?: string | null;
website?: string | null;
route_geometry?: string | null;
waypoints!: string;
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;
difficulty?: string | null;
wanderer_ref?: string | null;
match_confidence?: number | null;
max_hiking_difficulty?: number | null;
bike_profile?: string | null;
winter_activity?: string | null;
run_profile?: string | null;
run_pace_seconds_per_km?: number | null;
ski_discipline?: string | null;
ski_technique?: string | null;
boat_type?: string | null;
water_context?: string | null;
paddle_speed_kmh?: number | null;
portage_speed_kmh?: number | null;
paddle_lake_style?: string | null;
track_marks?: string | null;
hiking_preferences?: string | null;
origin!: string;
origin_file_name?: string | null;
track_started_at?: string | null;
track_ended_at?: string | null;
source_trip_title!: string;
source_tour_name!: string;
created_at!: string & Opt;
savedByRef?: Ref<Users> | null;
tourTypeRef!: Ref<TourTypes>;
}
export const CollectionToursSchema = defineEntity({
class: CollectionTours,
repository: () => CollectionToursRepository,
properties: {
id: p.integer().primary(),
collection: () => p.manyToOne(Collections).ref().deleteRule('cascade').hidden().index('idx_collection_tours_collection'),
collection_id: p.integer().persist(false).index('idx_collection_tours_collection'),
saved_by: p.integer().nullable().persist(false).index('idx_collection_tours_saved_by'),
name: p.text(),
description: p.text().nullable(),
website: p.text().nullable(),
route_geometry: p.text().nullable(),
waypoints: p.text(),
tour_type: p.text().persist(false).index('idx_collection_tours_tour_type'),
distance: p.double().nullable(),
elevation_gain: p.double().nullable(),
elevation_loss: p.double().nullable(),
duration: p.double().nullable(),
planned_duration_minutes: p.integer().nullable(),
break_additional_minutes: p.integer().nullable(),
difficulty: p.text().nullable(),
wanderer_ref: p.text().nullable(),
match_confidence: p.double().nullable(),
max_hiking_difficulty: p.integer().nullable(),
bike_profile: p.text().nullable(),
winter_activity: p.text().nullable(),
run_profile: p.text().nullable(),
run_pace_seconds_per_km: p.integer().nullable(),
ski_discipline: p.text().nullable(),
ski_technique: p.text().nullable(),
boat_type: p.text().nullable(),
water_context: p.text().nullable(),
paddle_speed_kmh: p.double().nullable(),
portage_speed_kmh: p.double().nullable(),
paddle_lake_style: p.text().nullable(),
track_marks: p.text().nullable(),
hiking_preferences: p.text().nullable(),
origin: p.text(),
origin_file_name: p.text().nullable(),
track_started_at: p.type(DbTimestampType).nullable(),
track_ended_at: p.type(DbTimestampType).nullable(),
source_trip_title: p.text(),
source_tour_name: p.text(),
created_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`),
savedByRef: () => p.manyToOne(Users).ref().joinColumn('saved_by').nullable().hidden().index('idx_collection_tours_saved_by'),
tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_collection_tours_tour_type'),
},
});
@@ -4,6 +4,7 @@ import { DbTimestampType } from '../types';
import { CollectionLabels } from './CollectionLabels.entity';
import { CollectionMembers } from './CollectionMembers.entity';
import { CollectionPlaces } from './CollectionPlaces.entity';
import { CollectionTours } from './CollectionTours.entity';
import { Users } from './Users.entity';
export class Collections {
@@ -23,6 +24,7 @@ export class Collections {
collection_labels_collection = new Collection<CollectionLabels>(this);
collection_members_collection = new Collection<CollectionMembers>(this);
collection_places_collection = new Collection<CollectionPlaces>(this);
collection_tours_collection = new Collection<CollectionTours>(this);
}
export const CollectionsSchema = defineEntity({
@@ -44,5 +46,6 @@ export const CollectionsSchema = defineEntity({
collection_labels_collection: () => p.oneToMany(CollectionLabels).mappedBy('collection').hidden(),
collection_members_collection: () => p.oneToMany(CollectionMembers).mappedBy('collection').hidden(),
collection_places_collection: () => p.oneToMany(CollectionPlaces).mappedBy('collection').hidden(),
collection_tours_collection: () => p.oneToMany(CollectionTours).mappedBy('collection').hidden(),
},
});
@@ -1,5 +1,6 @@
import { Collection, EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core';
import { TourTypesRepository } from '../repositories/TourTypes.repository';
import { CollectionTours } from './CollectionTours.entity';
import { Tours } from './Tours.entity';
export class TourTypes {
@@ -13,6 +14,7 @@ export class TourTypes {
is_sport: number & Opt = 1;
enabled: number & Opt = 1;
sort_order: number & Opt = 0;
collection_tours_collection = new Collection<CollectionTours>(this);
tours_collection = new Collection<Tours>(this);
}
@@ -28,6 +30,7 @@ export const TourTypesSchema = defineEntity({
is_sport: p.integer().default(1),
enabled: p.integer().default(1),
sort_order: p.integer().default(0),
collection_tours_collection: () => p.oneToMany(CollectionTours).mappedBy('tourTypeRef').hidden(),
tours_collection: () => p.oneToMany(Tours).mappedBy('tourTypeRef').hidden(),
},
});
+3
View File
@@ -18,6 +18,7 @@ import { CollabPolls } from './CollabPolls.entity';
import { CollectionMembers } from './CollectionMembers.entity';
import { CollectionPlaceRatings } from './CollectionPlaceRatings.entity';
import { CollectionPlaces } from './CollectionPlaces.entity';
import { CollectionTours } from './CollectionTours.entity';
import { Collections } from './Collections.entity';
import { DawarichConnections } from './DawarichConnections.entity';
import { DawarichVisitSuggestions } from './DawarichVisitSuggestions.entity';
@@ -142,6 +143,7 @@ export class Users {
collection_place_ratings_collection = new Collection<CollectionPlaceRatings>(this);
collection_places_collection = new Collection<CollectionPlaces>(this);
collection_places_collection1 = new Collection<CollectionPlaces>(this);
collection_tours_collection = new Collection<CollectionTours>(this);
collections_collection = new Collection<Collections>(this);
dawarich_connections: Ref<DawarichConnections> | null = null;
dawarich_visit_suggestions_collection = new Collection<DawarichVisitSuggestions>(this);
@@ -289,6 +291,7 @@ export const UsersSchema = defineEntity({
collection_place_ratings_collection: () => p.oneToMany(CollectionPlaceRatings).mappedBy('user').hidden(),
collection_places_collection: () => p.oneToMany(CollectionPlaces).mappedBy('owner').hidden(),
collection_places_collection1: () => p.oneToMany(CollectionPlaces).mappedBy('savedByRef').hidden(),
collection_tours_collection: () => p.oneToMany(CollectionTours).mappedBy('savedByRef').hidden(),
collections_collection: () => p.oneToMany(Collections).mappedBy('owner').hidden(),
dawarich_connections: () => p.oneToOne(DawarichConnections).ref().mappedBy('user').hidden(),
dawarich_visit_suggestions_collection: () => p.oneToMany(DawarichVisitSuggestions).mappedBy('user').hidden(),
+2
View File
@@ -20,6 +20,7 @@ import { CollectionLabelsSchema } from './CollectionLabels.entity';
import { CollectionMembersSchema } from './CollectionMembers.entity';
import { CollectionPlaceRatingsSchema } from './CollectionPlaceRatings.entity';
import { CollectionPlacesSchema } from './CollectionPlaces.entity';
import { CollectionToursSchema } from './CollectionTours.entity';
import { CollectionsSchema } from './Collections.entity';
import { DawarichConnectionsSchema } from './DawarichConnections.entity';
import { DawarichVisitSuggestionsSchema } from './DawarichVisitSuggestions.entity';
@@ -168,6 +169,7 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [
CollectionMembersSchema,
CollectionPlaceRatingsSchema,
CollectionPlacesSchema,
CollectionToursSchema,
CollectionsSchema,
DawarichConnectionsSchema,
DawarichVisitSuggestionsSchema,
+11
View File
@@ -267,3 +267,14 @@ export const TOUR_CHANGE_STEP_BEFORE: JsonColumn<TourChangeStepBefore | null> =
.nullable(),
fallback: () => null,
};
/**
* LIB-03: the control points of a tour kept in a collection, in route order,
* as they were when it was saved. A broken row reads as none and is logged;
* the line is kept in a column of its own and stays as it was.
*/
export const COLLECTION_TOUR_WAYPOINTS: JsonColumn<TourWaypoint[]> = {
column: 'collection_tours.waypoints',
schema: z.array(tourWaypointSchema),
fallback: () => [],
};
@@ -0,0 +1,45 @@
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
// change the migration, then run `npm run gen:db-types`.
import type { InsertOptional } from './columns';
export interface CollectionToursTable {
id: InsertOptional<number>;
collection_id: number;
saved_by: InsertOptional<number | null>;
name: string;
description: InsertOptional<string | null>;
website: InsertOptional<string | null>;
route_geometry: InsertOptional<string | null>;
waypoints: string;
tour_type: string;
distance: InsertOptional<number | null>;
elevation_gain: InsertOptional<number | null>;
elevation_loss: InsertOptional<number | null>;
duration: InsertOptional<number | null>;
planned_duration_minutes: InsertOptional<number | null>;
break_additional_minutes: InsertOptional<number | null>;
difficulty: InsertOptional<string | null>;
wanderer_ref: InsertOptional<string | null>;
match_confidence: InsertOptional<number | null>;
max_hiking_difficulty: InsertOptional<number | null>;
bike_profile: InsertOptional<string | null>;
winter_activity: InsertOptional<string | null>;
run_profile: InsertOptional<string | null>;
run_pace_seconds_per_km: InsertOptional<number | null>;
ski_discipline: InsertOptional<string | null>;
ski_technique: InsertOptional<string | null>;
boat_type: InsertOptional<string | null>;
water_context: InsertOptional<string | null>;
paddle_speed_kmh: InsertOptional<number | null>;
portage_speed_kmh: InsertOptional<number | null>;
paddle_lake_style: InsertOptional<string | null>;
track_marks: InsertOptional<string | null>;
hiking_preferences: InsertOptional<string | null>;
origin: string;
origin_file_name: InsertOptional<string | null>;
track_started_at: InsertOptional<string | null>;
track_ended_at: InsertOptional<string | null>;
source_trip_title: string;
source_tour_name: string;
created_at: InsertOptional<string>;
}
+2
View File
@@ -23,6 +23,7 @@ import type { CollectionPlaceLabelsTable } from './CollectionPlaceLabels.table';
import type { CollectionPlaceRatingsTable } from './CollectionPlaceRatings.table';
import type { CollectionPlaceTagsTable } from './CollectionPlaceTags.table';
import type { CollectionPlacesTable } from './CollectionPlaces.table';
import type { CollectionToursTable } from './CollectionTours.table';
import type { CollectionsTable } from './Collections.table';
import type { DawarichConnectionsTable } from './DawarichConnections.table';
import type { DawarichVisitSuggestionsTable } from './DawarichVisitSuggestions.table';
@@ -177,6 +178,7 @@ export interface DB {
collection_place_ratings: CollectionPlaceRatingsTable;
collection_place_tags: CollectionPlaceTagsTable;
collection_places: CollectionPlacesTable;
collection_tours: CollectionToursTable;
collections: CollectionsTable;
dawarich_connections: DawarichConnectionsTable;
dawarich_visit_suggestions: DawarichVisitSuggestionsTable;
@@ -0,0 +1,64 @@
import { Migration } from '@mikro-orm/migrations';
/**
* LIB-03: a collection keeps whole tours beside its places. Each row is an
* independent snapshot of a tour as it was saved: the line, the type, every
* measure and setting of the `tours` facet, and its control points as one
* JSON array in route order. Nothing points back at the tour it came from;
* the trip's title and the tour's name are kept as text, as written then,
* because the trip may be deleted, renamed or out of a member's reach later.
* `saved_by` names who kept it and turns unknown when that account goes. The
* rows go with their collection.
*/
export class Migration20200101046100_a_collection_keeps_whole_tours extends Migration {
override name = 'Migration20200101046100_a_collection_keeps_whole_tours';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS collection_tours (
id INTEGER PRIMARY KEY,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
saved_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
name TEXT NOT NULL,
description TEXT,
website TEXT,
route_geometry TEXT,
waypoints TEXT NOT NULL,
tour_type TEXT NOT NULL REFERENCES tour_types(key),
distance REAL,
elevation_gain REAL,
elevation_loss REAL,
duration REAL,
planned_duration_minutes INTEGER,
break_additional_minutes INTEGER,
difficulty TEXT,
wanderer_ref TEXT,
match_confidence REAL,
max_hiking_difficulty INTEGER,
bike_profile TEXT,
winter_activity TEXT,
run_profile TEXT,
run_pace_seconds_per_km INTEGER,
ski_discipline TEXT,
ski_technique TEXT,
boat_type TEXT,
water_context TEXT,
paddle_speed_kmh REAL,
portage_speed_kmh REAL,
paddle_lake_style TEXT,
track_marks TEXT,
hiking_preferences TEXT,
origin TEXT NOT NULL,
origin_file_name TEXT,
track_started_at DATETIME,
track_ended_at DATETIME,
source_trip_title TEXT NOT NULL,
source_tour_name TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql('CREATE INDEX IF NOT EXISTS idx_collection_tours_collection ON collection_tours(collection_id)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_collection_tours_saved_by ON collection_tours(saved_by)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_collection_tours_tour_type ON collection_tours(tour_type)');
}
}
@@ -0,0 +1,148 @@
import { decodeJson } from '../../utils/json-column';
import type { CollectionTours } from '../entities/CollectionTours.entity';
import { COLLECTION_TOUR_WAYPOINTS } from '../json-columns';
import type { DB } from '../kysely/db';
import type { TourWaypointRow } from './TourWaypoints.repository';
import { TrekRepository } from './_shared/trek-repository';
/**
* LIB-03: a tour kept in a collection, every column as stored but its control
* points, which come decoded in route order, and the name of who saved it
* (null once that account is gone).
*/
export interface CollectionTourRow {
id: number;
collection_id: number;
saved_by: number | null;
saved_by_name: string | null;
name: string;
description: string | null;
website: string | null;
route_geometry: string | null;
waypoints: TourWaypointRow[];
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;
difficulty: string | null;
wanderer_ref: string | null;
match_confidence: number | null;
max_hiking_difficulty: number | null;
bike_profile: string | null;
winter_activity: string | null;
run_profile: string | null;
run_pace_seconds_per_km: number | null;
ski_discipline: string | null;
ski_technique: string | null;
boat_type: string | null;
water_context: string | null;
paddle_speed_kmh: number | null;
portage_speed_kmh: number | null;
paddle_lake_style: string | null;
track_marks: string | null;
hiking_preferences: string | null;
origin: string;
origin_file_name: string | null;
track_started_at: string | null;
track_ended_at: string | null;
source_trip_title: string;
source_tour_name: string;
created_at: string;
}
/**
* What saving a tour into a collection writes: the whole snapshot, written
* once and never changed, its points already encoded for their column
* (`COLLECTION_TOUR_WAYPOINTS`) by the service that built them.
*/
export type CollectionTourInsert = Omit<CollectionTourRow, 'id' | 'saved_by_name' | 'created_at' | 'waypoints'> & {
waypoints: string;
};
/** A trip a kept tour could be taken into, with its owner for the permission check. */
interface CollectionTourTargetRow {
id: number;
user_id: number;
title: string;
start_date: string | null;
end_date: string | null;
}
type CollectionToursKyselyDB = Pick<DB, 'collection_tours' | 'users' | 'trips'>;
/**
* `collection_tours` (LIB-03): whole tours kept in a collection, apart from
* any trip. A row is written whole when a tour is saved and only ever read or
* deleted afterwards; it goes with its collection.
*/
export class CollectionToursRepository extends TrekRepository<CollectionTours> {
private db() {
return this.kysely<CollectionToursKyselyDB>();
}
/** The shared SELECT of {@link listForCollection} and {@link findInCollection}. */
private rowQuery() {
return this.db()
.selectFrom('collection_tours as ct')
.leftJoin('users as u', 'u.id', 'ct.saved_by')
.selectAll('ct')
.select('u.username as saved_by_name');
}
/** CT1: the tours kept in one collection, the newest first; the id breaks ties inside one second. */
async listForCollection(collection_id: number): Promise<CollectionTourRow[]> {
const rows = await this.rowQuery()
.where('ct.collection_id', '=', collection_id)
.orderBy('ct.created_at', 'desc')
.orderBy('ct.id', 'desc')
.execute();
return rows.map(readRow);
}
/** CT2: one kept tour, undefined unless it is in that collection. */
async findInCollection(collection_id: number, id: number): Promise<CollectionTourRow | undefined> {
const row = await this.rowQuery()
.where('ct.collection_id', '=', collection_id)
.where('ct.id', '=', id)
.executeTakeFirst();
return row ? readRow(row) : undefined;
}
/** CT3: keeps a snapshot in a collection and answers its id. */
async insertSnapshot(row: CollectionTourInsert): Promise<number> {
const inserted = await this.db()
.insertInto('collection_tours')
.values(row)
.returning('id')
.executeTakeFirstOrThrow();
return inserted.id;
}
/** CT4: removes a kept tour from its collection; false when it was not there. */
async deleteInCollection(collection_id: number, id: number): Promise<boolean> {
const result = await this.db()
.deleteFrom('collection_tours')
.where('collection_id', '=', collection_id)
.where('id', '=', id)
.executeTakeFirst();
return Number(result.numDeletedRows) > 0;
}
/** CT5: the given trips with their owner, title and dates, in no particular order; the caller orders them. */
async targetTrips(trip_ids: readonly number[]): Promise<CollectionTourTargetRow[]> {
if (trip_ids.length === 0) return [];
return await this.db()
.selectFrom('trips as t')
.select(['t.id', 't.user_id', 't.title', 't.start_date', 't.end_date'])
.where('t.id', 'in', [...trip_ids])
.execute();
}
}
/** A stored row as callers read it: its points decoded, a broken list read as none and logged. */
function readRow(row: Omit<CollectionTourRow, 'waypoints'> & { waypoints: string }): CollectionTourRow {
return { ...row, waypoints: decodeJson(COLLECTION_TOUR_WAYPOINTS, row.waypoints, `collection tour ${row.id}`) };
}
@@ -0,0 +1,108 @@
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 { DomainError } from '../common/domain-error';
import { ResponseContract } from '../common/response-contract';
import { toRowId } from '../common/row-id';
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
import { CollectionToursService } from './collection-tours.service';
import { CollectionTourSaveDto, CollectionTourTakeOverDto } from './dto/collection-tours.dto';
import { Body, Controller, Delete, Get, Headers, Param, Post, UseGuards } from '@nestjs/common';
import {
collectionTourDetailResponseSchema,
collectionTourListResponseSchema,
collectionTourSaveResponseSchema,
successResponseSchema,
tourCreateResponseSchema,
} from '@trek/shared';
/** A route id as a row id; a malformed one is a missing row, never a 500. */
function rowId(raw: string, missing: string): number {
const id = toRowId(raw);
if (id === null) throw new DomainError(404, missing);
return id;
}
/**
* LIB-03 /api/addons/collections/:collectionId/tours: the whole tours a
* collection keeps. Not trip-scoped; the service checks the user's role in
* the collection (404 for one they cannot see, 403 for a role that may not)
* and, when a tour is kept, that they can open the trip it comes from.
*/
@Controller('api/addons/collections/:collectionId/tours')
@UseGuards(AddonGuard, JwtAuthGuard)
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
export class CollectionToursController {
constructor(private readonly kept: CollectionToursService) {}
@Get()
@ResponseContract(collectionTourListResponseSchema)
async list(@CurrentUser() user: User, @Param('collectionId') collectionId: string) {
return await this.kept.list(user.id, rowId(collectionId, 'Collection not found'));
}
@Post()
@ResponseContract(collectionTourSaveResponseSchema)
async save(
@CurrentUser() user: User,
@Param('collectionId') collectionId: string,
@Body() body: CollectionTourSaveDto,
@Headers('x-socket-id') socketId?: string,
) {
return await this.kept.save(user.id, rowId(collectionId, 'Collection not found'), body, socketId);
}
@Get(':tourId')
@ResponseContract(collectionTourDetailResponseSchema)
async detail(
@CurrentUser() user: User,
@Param('collectionId') collectionId: string,
@Param('tourId') tourId: string,
) {
return await this.kept.detail(user, rowId(collectionId, 'Collection not found'), rowId(tourId, 'Tour not found'));
}
@Delete(':tourId')
@ResponseContract(successResponseSchema)
async remove(
@CurrentUser() user: User,
@Param('collectionId') collectionId: string,
@Param('tourId') tourId: string,
@Headers('x-socket-id') socketId?: string,
) {
await this.kept.remove(
user.id,
rowId(collectionId, 'Collection not found'),
rowId(tourId, 'Tour not found'),
socketId,
);
return { success: true as const };
}
}
/**
* LIB-03 POST /api/trips/:tripId/tours/from-collection: a tour kept in a
* collection made into a new tour of this trip. The guard checks this trip and
* 'place_edit' on it; the service checks the collection.
*/
@Controller('api/trips/:tripId/tours')
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
export class CollectionTourTakeOverController {
constructor(private readonly kept: CollectionToursService) {}
@RequirePermission('place_edit')
@Post('from-collection')
@ResponseContract(tourCreateResponseSchema)
async takeOver(
@Param('tripId') tripId: string,
@CurrentUser() user: User,
@Body() body: CollectionTourTakeOverDto,
@Headers('x-socket-id') socketId?: string,
) {
return await this.kept.takeOver(tripId, user.id, body, socketId);
}
}
@@ -0,0 +1,105 @@
import { COLLECTION_TOUR_WAYPOINTS } from '../../db/json-columns';
import type { CollectionTourInsert, CollectionTourRow } from '../../db/repositories/CollectionTours.repository';
import { encodeJson } from '../../utils/json-column';
import { copiedColumns, type TourCopySource } from './tour-copy.helpers';
import { parseRouteGeometry, thinnedLine } from './tours.helpers';
import {
COLLECTION_TOUR_PREVIEW_POINTS,
walkStationFacts,
type CollectionTourItem,
type CollectionTourPoint,
type TourOrigin,
type TourTypeKey,
} from '@trek/shared';
/** LIB-03: a kept tour as the collection lists it, its measures as they were saved, null staying null. */
export function keptTourItem(row: CollectionTourRow): CollectionTourItem {
return {
id: row.id,
collection_id: row.collection_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,
wanderer_ref: row.wanderer_ref,
origin: row.origin as TourOrigin,
has_line: row.route_geometry !== null,
waypoint_count: row.waypoints.length,
...walkStationFacts(row.tour_type, row.waypoints),
source: { tripTitle: row.source_trip_title, tourName: row.source_tour_name },
saved_by: row.saved_by_name,
saved_at: row.created_at,
};
}
/** The kept line thinned for the preview, as plain points. */
export function keptTourLine(row: Pick<CollectionTourRow, 'route_geometry'>): Array<[number, number]> {
const line = parseRouteGeometry(row.route_geometry).map((point): [number, number] => [point[0], point[1]]);
return thinnedLine(line, COLLECTION_TOUR_PREVIEW_POINTS);
}
/** The kept control points in route order, as the preview names them. */
export function keptTourPoints(row: Pick<CollectionTourRow, 'waypoints'>): CollectionTourPoint[] {
return [...row.waypoints]
.sort((a, b) => a.sequence - b.sequence)
.map((point) => ({ role: point.role, name: point.name ?? null, shaping: point.shaping === true }));
}
/**
* What saving a tour into a collection keeps of it: the whole tour as its
* copy model reads it, under the name chosen, and the trip and tour it came
* from as they are called now. A point standing for a place of the trip
* stands for nothing outside it, so that link stays behind. The points are
* checked against their column's shape as they are encoded; a point that
* does not fit refuses the whole snapshot before anything is written.
*/
export function keptTourSnapshot(
source: TourCopySource,
keep: { collectionId: number; userId: number; name: string; tripTitle: string },
): CollectionTourInsert {
const { facet } = source;
return {
collection_id: keep.collectionId,
saved_by: keep.userId,
name: keep.name,
description: source.description,
website: source.website,
route_geometry: source.route_geometry,
waypoints: encodeJson(
COLLECTION_TOUR_WAYPOINTS,
source.waypoints.map((point) => ({ ...point, linked_place_id: null })),
),
...copiedColumns(facet),
origin_file_name: facet.origin_file_name,
source_trip_title: keep.tripTitle,
source_tour_name: source.name,
};
}
/**
* A kept tour as a new tour of a trip is made from it, through the one copy
* model. With its points left out it keeps its line as saved and takes no
* points at all: none are made up from the line.
*/
export function keptTourCopySource(row: CollectionTourRow, includeWaypoints: boolean): TourCopySource {
return {
place_id: null,
name: row.name,
description: row.description,
website: row.website,
route_geometry: row.route_geometry,
facet: {
...copiedColumns(row),
origin_file_name: row.origin_file_name,
taken_over_from_trip_title: row.source_trip_title,
taken_over_from_tour_name: row.source_tour_name,
},
waypoints: includeWaypoints ? row.waypoints : [],
};
}
@@ -0,0 +1,148 @@
import { ADDON_IDS } from '../../addons';
import { canRead, canReadTrips, canWrite } from '../../mcp/scopes';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import {
McpController,
Tool,
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_READONLY,
errorResult,
ok,
type McpContext,
} from '../../nest-mcp';
import { addonGate, type HasAddons } 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 { CollectionToursService } from './collection-tours.service';
import { NotFoundException } from '@nestjs/common';
import {
collectionTourSaveRequestSchema,
collectionTourTakeOverRequestSchema,
idSchema,
type CollectionTourSaveRequest,
type CollectionTourTakeOverRequest,
} from '@trek/shared';
const save = collectionTourSaveRequestSchema.shape;
const takeOver = collectionTourTakeOverRequestSchema.shape;
const toursOn = addonGate(ADDON_IDS.TOURS);
const collectionsOn = addonGate(ADDON_IDS.COLLECTIONS);
/** A kept tour is a tour that lives in a collection, so both addons have to be on. */
async function keptToursOn(ctx: McpContext, self: HasAddons): Promise<boolean> {
return (await toursOn(ctx, self)) && (await collectionsOn(ctx, self));
}
/**
* LIB-03: the whole tours a collection keeps, over the same service and the
* same checks as REST. Reading and removing follow the collection's roles;
* keeping one also needs the trip it comes from to be open to the user, and
* taking one into a trip needs 'place_edit' on that trip.
*/
@McpController()
export class CollectionToursMcp {
constructor(
private readonly kept: CollectionToursService,
private readonly access: TripAccessService,
private readonly guards: McpToolGuardsService,
// Read by the addon gate of every tool here.
readonly addons: AddonsService,
) {}
@Tool({
name: 'list_collection_tours',
description:
'List the whole tours kept in a collection (a saved-places list), the newest first: name, type, distance in km, ascent and descent in metres, the planned times, how many control points it keeps, whether it has a line, the trip and tour it was saved from and who saved it. Each is an independent snapshot; later changes to the tour it came from do not reach it. Values a tour does not have stay null.',
inputSchema: { collection_id: idSchema.describe('The collection to read') },
annotations: TOOL_ANNOTATIONS_READONLY,
when: keptToursOn,
access: { group: 'collections', mode: 'read' },
})
async listCollectionTours(input: { collection_id: number }, ctx: McpContext) {
return ok(await this.kept.list(ctx.userId, input.collection_id));
}
@Tool({
name: 'get_collection_tour',
description:
'One tour kept in a collection, with what taking it into a trip asks about: its facts, its line thinned to at most 400 points, its control points in order with their roles (start, via, end), names and whether they only shape the route, and every trip the user can open with can_add saying whether they may add a tour to it.',
inputSchema: {
collection_id: idSchema.describe('The collection the tour is kept in'),
collection_tour_id: idSchema.describe('The kept tour, as list_collection_tours gives it'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
when: keptToursOn,
access: (ctx) => canRead(ctx.scopes, 'collections') && canReadTrips(ctx.scopes),
})
async getCollectionTour(input: { collection_id: number; collection_tour_id: number }, ctx: McpContext) {
const role = (await this.guards.isAdminUser(ctx.userId)) ? 'admin' : 'user';
return ok(await this.kept.detail({ id: ctx.userId, role }, input.collection_id, input.collection_tour_id));
}
@Tool({
name: 'save_tour_to_collection',
description:
'Keep a saved tour of a trip in a collection the user may add to, as an independent snapshot: its line, type, measures, settings and control points in order with their roles, and the trip and tour it came from. Its days, its links to places of the trip and the obstacle decisions taken there stay in the trip. Later changes to the tour do not reach the kept one, and the trip is not changed. Give name to keep it under another name.',
inputSchema: {
collection_id: idSchema.describe('The collection to keep the tour in'),
source_trip_id: save.source_trip_id,
source_place_id: save.source_place_id,
name: save.name,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: keptToursOn,
access: (ctx) => canWrite(ctx.scopes, 'collections') && canReadTrips(ctx.scopes),
})
async saveTourToCollection(input: { collection_id: number } & CollectionTourSaveRequest, ctx: McpContext) {
const { collection_id, ...body } = input;
return ok(await this.kept.save(ctx.userId, collection_id, body));
}
@Tool({
name: 'take_over_collection_tour',
description:
'Make a new, independent tour of a trip from a tour kept in a collection. include_waypoints must be said: true brings its control points along in their order with their roles, false keeps only its line as saved and adds no points (a kept tour without a line needs its points). It gets no days and no new places, remembers the trip and tour it was first saved from, and neither the collection nor the tour it came from changes. Needs place_edit on the trip.',
inputSchema: {
tripId: idSchema.describe('The trip to add the tour to'),
collection_id: takeOver.collection_id,
collection_tour_id: takeOver.collection_tour_id,
include_waypoints: takeOver.include_waypoints,
name: takeOver.name,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: keptToursOn,
access: (ctx) => canWrite(ctx.scopes, 'trips') && canRead(ctx.scopes, 'collections'),
})
async takeOverCollectionTour(input: { tripId: number } & CollectionTourTakeOverRequest, 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.kept.takeOver(String(tripId), ctx.userId, body));
} catch (err: unknown) {
// The copy model refuses a tour with nothing to copy the way LIB-02's take-over does.
if (err instanceof NotFoundException) return errorResult('Tour not found.');
throw err;
}
}
@Tool({
name: 'delete_collection_tour',
description:
'Remove a tour kept in a collection. Only the collection’s owner or an admin of it may. Tours already made from it in trips stay as they are.',
inputSchema: {
collection_id: idSchema.describe('The collection the tour is kept in'),
collection_tour_id: idSchema.describe('The kept tour to remove'),
},
annotations: TOOL_ANNOTATIONS_DELETE,
when: keptToursOn,
access: { group: 'collections', mode: 'write' },
})
async deleteCollectionTour(input: { collection_id: number; collection_tour_id: number }, ctx: McpContext) {
await this.kept.remove(ctx.userId, input.collection_id, input.collection_tour_id);
return ok({ success: true });
}
}
@@ -0,0 +1,195 @@
import { ADDON_IDS } from '../../addons';
import { CollectionTours } from '../../db/entities/CollectionTours.entity';
import type { CollectionTourRow, CollectionToursRepository } from '../../db/repositories/CollectionTours.repository';
import { AddonsService } from '../addons/addons.service';
import { CollectionsService } from '../collections/collections.service';
import { DomainError } from '../common/domain-error';
import { UnitOfWork } from '../database/unit-of-work';
import { PermissionsService } from '../permissions/permissions.service';
import { TripAccessService } from '../trip-membership/trip-access.service';
import { TripMembershipService } from '../trip-membership/trip-membership.service';
import {
keptTourCopySource,
keptTourItem,
keptTourLine,
keptTourPoints,
keptTourSnapshot,
} from './collection-tours.helpers';
import { copyStart } from './tour-copy.helpers';
import { ToursService } from './tours.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import type {
CollectionTourDetailResponse,
CollectionTourListResponse,
CollectionTourSaveRequest,
CollectionTourSaveResponse,
CollectionTourTakeOverRequest,
CollectionTourTarget,
TourCreateResponse,
} from '@trek/shared';
/** Who asks, as the permission check reads them. */
interface CollectionTourUser {
id: number;
role: string;
}
const tourNotFound = () => new DomainError(404, 'Tour not found', { code: 'tour_not_found' });
/**
* LIB-03: whole tours kept in a collection and taken into a trip again. A
* kept tour is an independent snapshot made through the tours' one copy
* model; nothing keeps it in step with the tour it came from or with a tour
* later made from it. Who may see, keep and remove one follows the
* collection's own roles; who may take one into a trip follows that trip's
* `place_edit`, which the caller's guard checks. Both addons have to be on.
*/
@Injectable()
export class CollectionToursService {
constructor(
@InjectRepository(CollectionTours) private readonly kept: CollectionToursRepository,
private readonly collections: CollectionsService,
private readonly tours: ToursService,
private readonly access: TripAccessService,
private readonly membership: TripMembershipService,
private readonly permissions: PermissionsService,
private readonly addons: AddonsService,
private readonly uow: UnitOfWork,
) {}
/** The tours kept in a collection the user can see, the newest first. */
async list(userId: number, collectionId: number): Promise<CollectionTourListResponse> {
await this.assertCollectionsOn();
await this.collections.assertAccess(userId, collectionId);
return { tours: (await this.kept.listForCollection(collectionId)).map(keptTourItem) };
}
/** One kept tour with what taking it into a trip asks about: its line, its points and the trips it could go into. */
async detail(user: CollectionTourUser, collectionId: number, tourId: number): Promise<CollectionTourDetailResponse> {
const row = await this.readable(user.id, collectionId, tourId);
return {
tour: keptTourItem(row),
line: keptTourLine(row),
points: keptTourPoints(row),
trips: await this.targets(user),
};
}
/**
* Keeps a saved tour of a trip the user can open in a collection they may
* add to, as one independent snapshot. The tour is read inside the write, so
* a tour gone meanwhile leaves nothing behind; the trip is not changed.
*/
async save(
userId: number,
collectionId: number,
body: CollectionTourSaveRequest,
socketId?: string,
): Promise<CollectionTourSaveResponse> {
await this.assertCollectionsOn();
await this.collections.assertCanEdit(userId, collectionId);
if (!(await this.access.findAccessible(body.source_trip_id, userId))) throw tourNotFound();
const [trip] = await this.kept.targetTrips([body.source_trip_id]);
if (!trip) throw tourNotFound();
const id = await this.uow.transactional(async () => {
const source = await this.tours.readCopySource(body.source_trip_id, body.source_place_id);
// A tour with neither a line nor points has nothing a later tour could be made from.
if (!source || !copyStart(source)) throw tourNotFound();
return await this.kept.insertSnapshot(
keptTourSnapshot(source, { collectionId, userId, name: body.name ?? source.name, tripTitle: trip.title }),
);
});
await this.collections.notifyCollectionUsers(collectionId, socketId);
return { tour: keptTourItem(await this.found(collectionId, id)) };
}
/** Removes a kept tour from its collection; like a saved place, only its owner or an admin of it may. */
async remove(userId: number, collectionId: number, tourId: number, socketId?: string): Promise<void> {
await this.assertCollectionsOn();
const role = await this.collections.roleOf(userId, collectionId);
if (role === null) throw new DomainError(404, 'Collection not found', { code: 'collection_not_found' });
if (role !== 'owner' && role !== 'admin') {
throw new DomainError(403, 'Only an admin can remove tours from this list', { code: 'collection_role' });
}
if (!(await this.kept.deleteInCollection(collectionId, tourId))) throw tourNotFound();
await this.collections.notifyCollectionUsers(collectionId, socketId);
}
/**
* Makes a new tour of `tripId` from a kept tour of a collection the user can
* see. The caller has checked the trip and `place_edit` on it. The points
* come along only when asked for, in their order with their roles; left out,
* the new tour keeps the kept line as it is and has no points. A kept tour
* without a line cannot leave its points behind. The collection is not changed.
*/
async takeOver(
tripId: string,
userId: number,
body: CollectionTourTakeOverRequest,
socketId?: string,
): Promise<TourCreateResponse> {
const chosen = await this.readable(userId, body.collection_id, body.collection_tour_id);
if (!body.include_waypoints && chosen.route_geometry === null) {
throw new DomainError(409, 'This tour has no line yet, so its points have to come along', {
code: 'kept_tour_needs_points',
details: { code: 'kept_tour_needs_points' },
});
}
return await this.tours.createFromKeptTour(
tripId,
// Read again inside the write: a kept tour removed meanwhile leaves no half-made tour behind.
async () =>
keptTourCopySource(await this.found(body.collection_id, body.collection_tour_id), body.include_waypoints),
body.name ?? chosen.name,
{ tripTitle: chosen.source_trip_title, tourName: chosen.source_tour_name },
socketId,
);
}
/** A kept tour of a collection the user can see; the collection's own refusal when they cannot see it. */
private async readable(userId: number, collectionId: number, tourId: number): Promise<CollectionTourRow> {
await this.assertCollectionsOn();
await this.collections.assertAccess(userId, collectionId);
return await this.found(collectionId, tourId);
}
/** The kept tour, 404 when the collection holds no such tour. */
private async found(collectionId: number, tourId: number): Promise<CollectionTourRow> {
const row = await this.kept.findInCollection(collectionId, tourId);
if (!row) throw tourNotFound();
return row;
}
/** Every trip the user can open, the newest first, each saying whether they may add a tour to it. */
private async targets(user: CollectionTourUser): Promise<CollectionTourTarget[]> {
const ids = await this.membership.listAccessibleTripIds(user.id);
const trips = new Map((await this.kept.targetTrips(ids)).map((trip) => [trip.id, trip]));
const known = ids.flatMap((id) => {
const trip = trips.get(id);
return trip ? [trip] : [];
});
return await Promise.all(
known.map(async (trip) => ({
id: trip.id,
title: trip.title,
start_date: trip.start_date,
end_date: trip.end_date,
can_add: await this.permissions.checkPermission(
'place_edit',
user.role,
trip.user_id,
user.id,
trip.user_id !== user.id,
),
})),
);
}
/** The Tours addon gates the routes; a kept tour lives in a collection, so the Collections addon has to be on too. */
private async assertCollectionsOn(): Promise<void> {
if (!(await this.addons.isAddonEnabled(ADDON_IDS.COLLECTIONS))) {
throw new DomainError(404, 'Collections addon is not enabled', { code: 'addon_disabled' });
}
}
}
@@ -0,0 +1,6 @@
import { collectionTourSaveRequestSchema, collectionTourTakeOverRequestSchema } from '@trek/shared';
import { createZodDto } from 'nestjs-zod';
export class CollectionTourSaveDto extends createZodDto(collectionTourSaveRequestSchema) {}
export class CollectionTourTakeOverDto extends createZodDto(collectionTourTakeOverRequestSchema) {}
+184
View File
@@ -0,0 +1,184 @@
import type { TourWaypointRow } from '../../db/repositories/TourWaypoints.repository';
import type { TourListRow, TourProvenanceRow, ToursRepository } from '../../db/repositories/Tours.repository';
import { parseRouteGeometry } from './tours.helpers';
import type {
BikeProfile,
PaddleBoatType,
PaddleLakeStyle,
PaddleWaterContext,
RunProfile,
SkiDiscipline,
SkiTechnique,
TourOrigin,
WinterActivity,
} from '@trek/shared';
/** The facet columns a copy carries over as its source has them, measures and settings alike. */
interface TourCopiedColumns {
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;
difficulty: string | null;
wanderer_ref: string | null;
match_confidence: number | null;
max_hiking_difficulty: number | null;
bike_profile: string | null;
winter_activity: string | null;
run_profile: string | null;
run_pace_seconds_per_km: number | null;
ski_discipline: string | null;
ski_technique: string | null;
boat_type: string | null;
water_context: string | null;
paddle_speed_kmh: number | null;
portage_speed_kmh: number | null;
paddle_lake_style: string | null;
track_marks: string | null;
hiking_preferences: string | null;
origin: string;
/** A copy of a recording was recorded when it was (ACT-01); the assignments stay with the tours they joined. */
track_started_at: string | null;
track_ended_at: string | null;
}
/** The facet a copy is made from: the columns it carries over and where the tour came from. */
interface TourCopyFacet extends TourCopiedColumns {
origin_file_name: string | null;
taken_over_from_trip_title: string | null;
taken_over_from_tour_name: string | null;
}
/** The columns a copy carries over, read off any row that has them, and nothing else of it. */
export function copiedColumns(row: TourCopiedColumns): TourCopiedColumns {
return {
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,
difficulty: row.difficulty,
wanderer_ref: row.wanderer_ref,
match_confidence: row.match_confidence,
max_hiking_difficulty: row.max_hiking_difficulty,
bike_profile: row.bike_profile,
winter_activity: row.winter_activity,
run_profile: row.run_profile,
run_pace_seconds_per_km: row.run_pace_seconds_per_km,
ski_discipline: row.ski_discipline,
ski_technique: row.ski_technique,
boat_type: row.boat_type,
water_context: row.water_context,
paddle_speed_kmh: row.paddle_speed_kmh,
portage_speed_kmh: row.portage_speed_kmh,
paddle_lake_style: row.paddle_lake_style,
track_marks: row.track_marks,
hiking_preferences: row.hiking_preferences,
origin: row.origin,
track_started_at: row.track_started_at,
track_ended_at: row.track_ended_at,
};
}
/**
* What one copy of a tour is made from, whatever it is made into: a copy in
* its own trip (P2C-07), a take-over from another trip (LIB-02), a tour kept in
* a collection or a tour made from one (LIB-03). The one model every copy
* reads and writes, so the line, the type, the measures and the points never
* come apart on the way.
*/
export interface TourCopySource {
/** The tour of a trip it is read from; null for a tour kept in a collection, which belongs to no trip. */
place_id: number | null;
name: string;
description: string | null;
website: string | null;
/** The line as stored; null for a city walk saved with its stations only (CW-11). */
route_geometry: string | null;
facet: TourCopyFacet;
/** The control points in route order; empty where none come along. */
waypoints: TourWaypointRow[];
}
/** The trip and tour a copy says it was taken over from, as they were called then. */
export interface TakenOverFrom {
tripTitle: string;
tourName: string;
}
/** How a copy relates to where it came from. */
export interface TourCopyLinks {
/** The tour of the same trip the copy was planned from (P2C-07); null for any other copy. */
derivedFrom: number | null;
/** Within its own trip a copy keeps its points' links to the trip's places and the decisions about its obstacles. */
sameTrip: boolean;
/** Where the copy was taken over from; left out, it keeps what its source says. */
takenOverFrom?: TakenOverFrom;
}
/** A saved tour of a trip as a copy is made from it: its row, its provenance, its points. */
export function tourCopySourceOf(
row: TourListRow,
provenance: TourProvenanceRow,
waypoints: TourWaypointRow[],
): TourCopySource {
return {
place_id: row.place_id,
name: row.name,
description: row.description ?? null,
website: row.website ?? null,
route_geometry: provenance.route_geometry,
facet: {
...copiedColumns(row),
origin_file_name: provenance.origin_file_name,
taken_over_from_trip_title: provenance.taken_over_from_trip_title,
taken_over_from_tour_name: provenance.taken_over_from_tour_name,
},
waypoints,
};
}
/**
* Where a copy's place goes: the line's first point, or for a city walk saved
* with its stations only its first station. Undefined when there is neither,
* a source a copy cannot be made from.
*/
export function copyStart(source: Pick<TourCopySource, 'route_geometry' | 'waypoints'>): [number, number] | undefined {
if (source.route_geometry == null) {
const first = source.waypoints[0];
return first ? [first.lat, first.lng] : undefined;
}
const point = parseRouteGeometry(source.route_geometry)[0];
return point ? [point[0], point[1]] : undefined;
}
/** The facet row a copy is written with: every measure and setting as its source has it, its own place and links. */
export function copiedFacet(
source: TourCopySource,
placeId: number,
links: TourCopyLinks,
): Parameters<ToursRepository['insertTour']>[0] {
const { facet } = source;
return {
...facet,
place_id: placeId,
bike_profile: facet.bike_profile as BikeProfile | null,
winter_activity: facet.winter_activity as WinterActivity | null,
run_profile: facet.run_profile as RunProfile | null,
ski_discipline: facet.ski_discipline as SkiDiscipline | null,
ski_technique: facet.ski_technique as SkiTechnique | null,
boat_type: facet.boat_type as PaddleBoatType | null,
water_context: facet.water_context as PaddleWaterContext | null,
paddle_lake_style: facet.paddle_lake_style as PaddleLakeStyle | null,
origin: facet.origin as TourOrigin,
derived_from_place_id: links.derivedFrom,
// A copy of a copy names where the first one came from.
taken_over_from_trip_title: links.takenOverFrom?.tripTitle ?? facet.taken_over_from_trip_title,
taken_over_from_tour_name: links.takenOverFrom?.tourName ?? facet.taken_over_from_tour_name,
};
}
+16 -1
View File
@@ -1,3 +1,4 @@
import { CollectionTours } from '../../db/entities/CollectionTours.entity';
import { Places } from '../../db/entities/Places.entity';
import { TourActivities } from '../../db/entities/TourActivities.entity';
import { TourActivityExclusions } from '../../db/entities/TourActivityExclusions.entity';
@@ -10,6 +11,7 @@ import { TourWaypoints } from '../../db/entities/TourWaypoints.entity';
import { Tours } from '../../db/entities/Tours.entity';
import { AddonsModule } from '../addons/addons.module';
import { AssignmentsDomainModule } from '../assignments/assignments-domain.module';
import { CollectionsModule } from '../collections/collections.module';
import { MapsModule } from '../maps/maps.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { OverpassModule } from '../overpass/overpass.module';
@@ -23,6 +25,9 @@ import { WandererModule } from '../wanderer/wanderer.module';
import { CityWalkBriefingService } from './city-walk-briefing.service';
import { CityWalkFootWayService } from './city-walk-foot-way.service';
import { CityWalkMcp } from './city-walk.mcp';
import { CollectionTourTakeOverController, CollectionToursController } from './collection-tours.controller';
import { CollectionToursMcp } from './collection-tours.mcp';
import { CollectionToursService } from './collection-tours.service';
import { TourActivitiesController } from './tour-activities.controller';
import { TourActivitiesMcp } from './tour-activities.mcp';
import { TourActivitiesService } from './tour-activities.service';
@@ -83,7 +88,9 @@ import { Module } from '@nestjs/common';
* TourActivitiesService, over the day assignments the visits repository reads.
* The user's own activity sums across their trips (STA-02) are read by
* TourActivityStatsService, over the trips TripMembershipModule says they can
* open, with the tours the user left out of them.
* open, with the tours the user left out of them. Whole tours kept in a
* collection (LIB-03) are this domain's own rows; the collection's roles and
* its live notice to its members come from CollectionsService.
*/
@Module({
imports: [
@@ -107,7 +114,10 @@ import { Module } from '@nestjs/common';
RoadtripModule,
// A saved change takes the stages a tour no longer has off their days (MDT-02).
AssignmentsDomainModule,
// A collection keeps whole tours (LIB-03): its roles decide who sees, keeps and removes one.
CollectionsModule,
MikroOrmModule.forFeature([
CollectionTours,
Tours,
TourTypes,
TourWaypoints,
@@ -121,6 +131,8 @@ import { Module } from '@nestjs/common';
]),
],
controllers: [
// The static `from-collection` and `take-over` routes before ToursController's `:placeId`.
CollectionTourTakeOverController,
TourHorseRoutesController,
TourTakeOverController,
TourActivitiesController,
@@ -134,6 +146,7 @@ import { Module } from '@nestjs/common';
ToursImportController,
ToursWandererController,
TourLibraryController,
CollectionToursController,
],
providers: [
ToursService,
@@ -157,6 +170,7 @@ import { Module } from '@nestjs/common';
TourChangeUndoService,
TourActivitiesService,
TourActivityStatsService,
CollectionToursService,
ToursMcp,
TourLibraryMcp,
TourWalkImportMcp,
@@ -165,6 +179,7 @@ import { Module } from '@nestjs/common';
TourChangeUndoMcp,
TourActivitiesMcp,
TourActivityStatsMcp,
CollectionToursMcp,
],
})
export class ToursModule {}
+72 -62
View File
@@ -30,6 +30,14 @@ import { UnitOfWork } from '../database/unit-of-work';
import { PlaceImportService } from '../place-import/place-import.service';
import { PlacesService } from '../places/places.service';
import { activityFacts, toActivity, trackTimesFor } from './tour-activities.helpers';
import {
copiedFacet,
copyStart,
tourCopySourceOf,
type TakenOverFrom,
type TourCopyLinks,
type TourCopySource,
} from './tour-copy.helpers';
import { readTourFile } from './tour-file-import.helpers';
import { TourPaddleCheckService } from './tour-paddle-check.service';
import { advanceTourRevision } from './tour-revision';
@@ -647,8 +655,8 @@ export class ToursService {
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 }),
const copyId = await this.uow.transactional(async () =>
this.insertCopy(await this.requireCopySource(tid, pid), { tid, name }, { derivedFrom: pid, sameTrip: true }),
);
return await this.announceCopy(tripId, copyId, socketId);
}
@@ -669,9 +677,9 @@ export class ToursService {
): 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 },
const copyId = await this.uow.transactional(async () =>
this.insertCopy(
await this.requireCopySource(source.tripId, source.placeId),
{ tid, name },
{ derivedFrom: null, sameTrip: false, takenOverFrom: source },
),
@@ -679,82 +687,84 @@ export class ToursService {
return await this.announceCopy(tripId, copyId, socketId);
}
/**
* LIB-03: a tour of this trip made from a tour kept in a collection, the way
* a take-over makes one: its own id, the kept line, type, measures and
* settings, the points `read` hands over (none when they were left out) and
* no days. `read` runs inside the write, so a kept tour removed meanwhile
* leaves nothing behind. The new tour names the trip and tour the kept one
* was saved from.
*/
async createFromKeptTour(
tripId: string,
read: () => Promise<TourCopySource>,
name: string,
takenOverFrom: TakenOverFrom,
socketId?: string,
): Promise<TourCreateResponse> {
const tid = toRowId(tripId);
if (tid === null) throw new NotFoundException('Tour not found');
const copyId = await this.uow.transactional(async () =>
this.insertCopy(await read(), { tid, name }, { derivedFrom: null, sameTrip: false, takenOverFrom }),
);
return await this.announceCopy(tripId, copyId, socketId);
}
/**
* A saved tour of a trip as a copy is made from it, read whole: its line and
* texts, every measure and setting, where it came from and its control
* points. Undefined when the place is no tour of that trip.
*/
async readCopySource(tripId: number, placeId: number): Promise<TourCopySource | undefined> {
const [row, provenance, waypoints] = await Promise.all([
this.toursRepo.findInTrip(tripId, placeId),
this.toursRepo.findProvenance(tripId, placeId),
this.waypointsRepo.listForPlace(placeId),
]);
return row && provenance ? tourCopySourceOf(row, provenance, waypoints) : undefined;
}
private async requireCopySource(tripId: number, placeId: number): Promise<TourCopySource> {
const source = await this.readCopySource(tripId, placeId);
if (!source) throw new NotFoundException('Tour not found');
return source;
}
/**
* 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 },
private async insertCopy(
source: TourCopySource,
target: { tid: number; name: string },
how: { derivedFrom: number | null; sameTrip: boolean; takenOverFrom?: { tripTitle: string; tourName: string } },
links: TourCopyLinks,
): Promise<number> {
const [row, provenance, place, waypoints] = await Promise.all([
this.toursRepo.findInTrip(source.tid, source.pid),
this.toursRepo.findProvenance(source.tid, source.pid),
this.placesRepo.findInTrip(source.pid, source.tid),
this.waypointsRepo.listForPlace(source.pid),
]);
// A city walk saved with its stations only (CW-11) is copied from its first one, its way still to plan;
// a line that holds no point has nothing to copy.
const start = place?.route_geometry == null ? firstPointOf(waypoints) : parseRouteGeometry(place.route_geometry)[0];
if (!row || !provenance || !place || !start) throw new NotFoundException('Tour not found');
if (!how.sameTrip) await this.assertTourTypeEnabled(row.tour_type);
// A line that holds no point, and a walk without line or stations, have nothing to copy.
const start = copyStart(source);
if (!start) throw new NotFoundException('Tour not found');
if (!links.sameTrip) await this.assertTourTypeEnabled(source.facet.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 ?? null,
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,
bike_profile: row.bike_profile as BikeProfile | null,
winter_activity: row.winter_activity as WinterActivity | null,
run_profile: row.run_profile as RunProfile | null,
run_pace_seconds_per_km: row.run_pace_seconds_per_km,
ski_discipline: row.ski_discipline as SkiDiscipline | null,
ski_technique: row.ski_technique as SkiTechnique | null,
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,
// A copy of a recording was recorded when it was; the assignments stay with the tours they joined.
track_started_at: row.track_started_at,
track_ended_at: row.track_ended_at,
route_geometry: source.route_geometry,
description: source.description,
website: source.website,
});
await this.toursRepo.insertTour(copiedFacet(source, id, links));
// 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 })),
links.sameTrip ? source.waypoints : source.waypoints.map((point) => ({ ...point, linked_place_id: null })),
);
if (how.sameTrip) {
if (source.place_id === null) return id;
if (links.sameTrip) {
// The copy keeps the decisions about its obstacles, bound to what they were decided on.
await this.toursRepo.copyObstacleReviews(source.pid, id);
await this.toursRepo.copyObstacleReviews(source.place_id, id);
} else {
// The import record says where the line itself came from, whichever trip holds it.
await this.toursRepo.copySource(source.pid, id);
await this.toursRepo.copySource(source.place_id, id);
}
return id;
}