mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
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:
@@ -20,6 +20,7 @@
|
||||
"CollectionMembers": "collections",
|
||||
"CollectionPlaceRatings": "collections",
|
||||
"CollectionPlaces": "collections",
|
||||
"CollectionTours": "tours",
|
||||
"Collections": "collections",
|
||||
"DawarichConnections": "integrations",
|
||||
"DawarichVisitSuggestions": "integrations",
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Whole tours kept in a collection (LIB-03), through the real JwtAuthGuard,
|
||||
* AddonGuard and TripAccessGuard against a migrated and seeded temp SQLite db,
|
||||
* every answer checked against its response contract. The permission check is
|
||||
* a container spy and the broadcasts go to a FakeRealtimeService.
|
||||
*
|
||||
* Fixtures: user 1 owns trips 5 and 6 and the collection, user 2 is a member
|
||||
* of trip 5 whom the spy denies 'place_edit' and a viewer of the collection,
|
||||
* user 3 has no access to either.
|
||||
*/
|
||||
import { CollectionTours } from '../../src/db/entities/CollectionTours.entity';
|
||||
import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
|
||||
import { Places } from '../../src/db/entities/Places.entity';
|
||||
import { TourWaypoints } from '../../src/db/entities/TourWaypoints.entity';
|
||||
import { Trips } from '../../src/db/entities/Trips.entity';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
|
||||
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
||||
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
||||
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
|
||||
import { ToursModule } from '../../src/nest/tours/tours.module';
|
||||
import { createSnapshotTestDb } from '../helpers/db-mock';
|
||||
import { addCollectionMember, makeCollection } from '../helpers/factories/collections';
|
||||
import { countRows, deleteRows, findRow, insertRows } from '../helpers/factories/rows';
|
||||
import { setAddonEnabled } from '../helpers/factories/settings';
|
||||
import { addTripMember } from '../helpers/factories/trips';
|
||||
import { makeUser } from '../helpers/factories/users';
|
||||
import { FakeRealtimeService } from '../helpers/fake-realtime';
|
||||
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
|
||||
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
||||
import { sessionCookie } from './harness';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
|
||||
|
||||
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
|
||||
LOG_LEVEL: 'error',
|
||||
logInfo: vi.fn(),
|
||||
logDebug: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
logWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
const { journeyHooks } = vi.hoisted(() => ({
|
||||
journeyHooks: {
|
||||
reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined),
|
||||
onPlaceCreated: vi.fn().mockResolvedValue(undefined),
|
||||
onPlaceUpdated: vi.fn().mockResolvedValue(undefined),
|
||||
onPlaceDeleted: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
const realtime = new FakeRealtimeService();
|
||||
|
||||
let orm: TestOrm;
|
||||
let checkPermission: MockInstance;
|
||||
let collectionId: number;
|
||||
|
||||
const OWNER = 1;
|
||||
const MEMBER = 2;
|
||||
const OUTSIDER = 3;
|
||||
|
||||
const GEOMETRY = [
|
||||
[47, 11, 600],
|
||||
[47.01, 11.01, 700],
|
||||
[47.02, 11.02, 650],
|
||||
];
|
||||
const tourBody = (overrides: Record<string, unknown> = {}) => ({
|
||||
name: 'Ridge walk',
|
||||
route_geometry: GEOMETRY,
|
||||
waypoints: [
|
||||
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 47.01, lng: 11.01, role: 'via', sequence: 1, name: 'Saddle' },
|
||||
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 2 },
|
||||
],
|
||||
max_hiking_difficulty: 3,
|
||||
duration_seconds: 3600,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('Collection tours e2e (real guards + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [
|
||||
await TestUnitOfWorkModule.forRoot(db),
|
||||
await createTestMikroOrmModule(db),
|
||||
RealtimeModule,
|
||||
ToursModule,
|
||||
],
|
||||
})
|
||||
.overrideProvider(RealtimeService)
|
||||
.useValue(realtime)
|
||||
.overrideProvider(JourneyDomainService)
|
||||
.useValue(journeyHooks)
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalPipes(new ZodValidationPipe());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
const as = (userId: number) => ({ Cookie: sessionCookie(userId) });
|
||||
|
||||
const createTour = async (tripId = 5, body: Record<string, unknown> = tourBody()) => {
|
||||
const res = await request(server).post(`/api/trips/${tripId}/tours`).set(as(OWNER)).send(body);
|
||||
expect(res.status).toBe(201);
|
||||
return res.body.tour.place_id as number;
|
||||
};
|
||||
|
||||
const keep = async (placeId: number, userId = OWNER, extra: Record<string, unknown> = {}) =>
|
||||
request(server)
|
||||
.post(`/api/addons/collections/${collectionId}/tours`)
|
||||
.set(as(userId))
|
||||
.send({ source_trip_id: 5, source_place_id: placeId, ...extra });
|
||||
|
||||
beforeAll(async () => {
|
||||
orm = await createTestOrm(db);
|
||||
// Pinned ids: the session cookies sign for exactly these users.
|
||||
await makeUser(orm, { id: 1, username: 'owner', email: 'owner@example.test', role: 'user', password_version: 0 });
|
||||
await makeUser(orm, { id: 2, username: 'member', email: 'member@example.test', role: 'user', password_version: 0 });
|
||||
await makeUser(orm, {
|
||||
id: 3,
|
||||
username: 'outsider',
|
||||
email: 'outsider@example.test',
|
||||
role: 'user',
|
||||
password_version: 0,
|
||||
});
|
||||
await insertRows(orm, Trips, [
|
||||
{ id: 5, user: 1, title: 'Alps' },
|
||||
{ id: 6, user: 1, title: 'Other' },
|
||||
]);
|
||||
await addTripMember(orm, 5, 2);
|
||||
const list = await makeCollection(orm, OWNER, { name: 'Hikes' });
|
||||
collectionId = list.id;
|
||||
await addCollectionMember(orm, collectionId, MEMBER, { role: 'viewer' });
|
||||
app = await build();
|
||||
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
checkPermission.mockImplementation(
|
||||
async (action: string, _role: string, _owner: number, userId: number) =>
|
||||
!(action === 'place_edit' && userId === MEMBER),
|
||||
);
|
||||
await setAddonEnabled(orm, 'tours', true);
|
||||
await setAddonEnabled(orm, 'collections', true);
|
||||
realtime.broadcastMock.mockClear();
|
||||
realtime.broadcastToUserMock.mockClear();
|
||||
await deleteRows(orm, CollectionTours);
|
||||
await deleteRows(orm, DayAssignments);
|
||||
await deleteRows(orm, Places);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await orm.close();
|
||||
});
|
||||
|
||||
it('keeps a tour, lists it for every member and shows what taking it over asks about', async () => {
|
||||
const source = await createTour();
|
||||
const kept = await keep(source, OWNER, { name: 'Ridge, kept' });
|
||||
expect(kept.status).toBe(201);
|
||||
expect(kept.body.tour).toMatchObject({
|
||||
name: 'Ridge, kept',
|
||||
tour_type: 'hike',
|
||||
has_line: true,
|
||||
waypoint_count: 3,
|
||||
source: { tripTitle: 'Alps', tourName: 'Ridge walk' },
|
||||
saved_by: 'owner',
|
||||
});
|
||||
expect(realtime.broadcastToUserMock).toHaveBeenCalledWith(
|
||||
MEMBER,
|
||||
{ type: 'collections:updated', collectionId },
|
||||
undefined,
|
||||
);
|
||||
|
||||
const listed = await request(server).get(`/api/addons/collections/${collectionId}/tours`).set(as(MEMBER));
|
||||
expect(listed.status).toBe(200);
|
||||
expect(listed.body.tours.map((tour: { id: number }) => tour.id)).toEqual([kept.body.tour.id]);
|
||||
|
||||
const detail = await request(server)
|
||||
.get(`/api/addons/collections/${collectionId}/tours/${kept.body.tour.id}`)
|
||||
.set(as(MEMBER));
|
||||
expect(detail.status).toBe(200);
|
||||
expect(detail.body.line).toEqual(GEOMETRY.map(([lat, lng]) => [lat, lng]));
|
||||
expect(detail.body.points).toEqual([
|
||||
{ role: 'start', name: null, shaping: false },
|
||||
{ role: 'via', name: 'Saddle', shaping: false },
|
||||
{ role: 'end', name: null, shaping: false },
|
||||
]);
|
||||
// The member sees the trip they belong to, without the right to add a tour to it.
|
||||
expect(detail.body.trips).toEqual([{ id: 5, title: 'Alps', start_date: null, end_date: null, can_add: false }]);
|
||||
});
|
||||
|
||||
it('takes a kept tour into a trip with its points, or with its line only, and changes neither source', async () => {
|
||||
const source = await createTour();
|
||||
const kept = (await keep(source)).body.tour as { id: number };
|
||||
// A later change to the source does not reach the kept tour.
|
||||
await request(server)
|
||||
.put(`/api/trips/5/tours/${source}`)
|
||||
.set(as(OWNER))
|
||||
.send(tourBody({ name: 'Changed later' }));
|
||||
|
||||
const full = await request(server)
|
||||
.post('/api/trips/6/tours/from-collection')
|
||||
.set(as(OWNER))
|
||||
.send({ collection_id: collectionId, collection_tour_id: kept.id, include_waypoints: true });
|
||||
expect(full.status).toBe(201);
|
||||
expect(full.body.tour).toMatchObject({ name: 'Ridge walk', planned: false, revision: 1 });
|
||||
const roles = full.body.waypoints.map((point: { role: string; name?: string }) => [point.role, point.name ?? null]);
|
||||
expect(roles).toEqual([
|
||||
['start', null],
|
||||
['via', 'Saddle'],
|
||||
['end', null],
|
||||
]);
|
||||
expect(full.body.provenance.takenOverFrom).toEqual({ tripTitle: 'Alps', tourName: 'Ridge walk' });
|
||||
expect(realtime.broadcastMock.mock.calls.every(([tripId]) => String(tripId) === '6')).toBe(true);
|
||||
|
||||
const bare = await request(server)
|
||||
.post('/api/trips/6/tours/from-collection')
|
||||
.set(as(OWNER))
|
||||
.send({ collection_id: collectionId, collection_tour_id: kept.id, include_waypoints: false, name: 'Bare' });
|
||||
expect(bare.status).toBe(201);
|
||||
expect(bare.body.tour).toMatchObject({ name: 'Bare', has_waypoints: false });
|
||||
expect(await countRows(orm, TourWaypoints, { place: bare.body.tour.place_id })).toBe(0);
|
||||
expect((await findRow(orm, Places, { id: bare.body.tour.place_id }))?.route_geometry).toBe(
|
||||
JSON.stringify(GEOMETRY),
|
||||
);
|
||||
expect(await countRows(orm, Places, { trip: 6 })).toBe(2);
|
||||
expect(await countRows(orm, DayAssignments)).toBe(0);
|
||||
expect(await countRows(orm, CollectionTours)).toBe(1);
|
||||
expect((await findRow(orm, Places, { id: source }))?.name).toBe('Changed later');
|
||||
});
|
||||
|
||||
it('refuses a viewer keeping, a stranger reading, a trip without place_edit and an unsaid choice about the points', async () => {
|
||||
const source = await createTour();
|
||||
expect((await keep(source, MEMBER)).status).toBe(403);
|
||||
const kept = (await keep(source)).body.tour as { id: number };
|
||||
const stranger = await request(server).get(`/api/addons/collections/${collectionId}/tours`).set(as(OUTSIDER));
|
||||
expect(stranger.status).toBe(404);
|
||||
expect(stranger.body).toEqual({ error: 'Collection not found' });
|
||||
|
||||
const body = { collection_id: collectionId, collection_tour_id: kept.id, include_waypoints: true };
|
||||
const takeOver = (tripId: number, userId: number) =>
|
||||
request(server).post(`/api/trips/${tripId}/tours/from-collection`).set(as(userId)).send(body);
|
||||
expect((await takeOver(5, MEMBER)).status).toBe(403);
|
||||
expect((await takeOver(6, OUTSIDER)).status).toBe(404);
|
||||
const unsaid = await request(server)
|
||||
.post('/api/trips/6/tours/from-collection')
|
||||
.set(as(OWNER))
|
||||
.send({ collection_id: collectionId, collection_tour_id: kept.id });
|
||||
expect(unsaid.status).toBe(400);
|
||||
const wrongList = await request(server)
|
||||
.post('/api/trips/6/tours/from-collection')
|
||||
.set(as(OWNER))
|
||||
.send({ ...body, collection_id: collectionId + 1000 });
|
||||
expect(wrongList.status).toBe(404);
|
||||
expect(await countRows(orm, Places, { trip: 6 })).toBe(0);
|
||||
});
|
||||
|
||||
it('removes a kept tour for the owner only, and answers malformed ids, sessions and addons as the guards do', async () => {
|
||||
const source = await createTour();
|
||||
const kept = (await keep(source)).body.tour as { id: number };
|
||||
const path = `/api/addons/collections/${collectionId}/tours/${kept.id}`;
|
||||
expect((await request(server).delete(path).set(as(MEMBER))).status).toBe(403);
|
||||
const removed = await request(server).delete(path).set(as(OWNER));
|
||||
expect(removed.status).toBe(200);
|
||||
expect(removed.body).toEqual({ success: true });
|
||||
expect((await request(server).delete(path).set(as(OWNER))).status).toBe(404);
|
||||
expect((await request(server).get(`/api/addons/collections/abc/tours`).set(as(OWNER))).status).toBe(404);
|
||||
const malformed = await request(server).get(`/api/addons/collections/${collectionId}/tours/x`).set(as(OWNER));
|
||||
expect(malformed.status).toBe(404);
|
||||
expect((await request(server).get(`/api/addons/collections/${collectionId}/tours`)).status).toBe(401);
|
||||
await setAddonEnabled(orm, 'collections', false);
|
||||
const noCollections = await request(server).get(`/api/addons/collections/${collectionId}/tours`).set(as(OWNER));
|
||||
expect(noCollections.body).toEqual({ error: 'Collections addon is not enabled' });
|
||||
await setAddonEnabled(orm, 'tours', false);
|
||||
const noTours = await request(server).get(`/api/addons/collections/${collectionId}/tours`).set(as(OWNER));
|
||||
expect(noTours.status).toBe(404);
|
||||
expect(noTours.body).toEqual({ error: 'Tours addon is not enabled' });
|
||||
});
|
||||
});
|
||||
@@ -43,14 +43,15 @@ describe('ALL_ENTITIES', () => {
|
||||
* + tour_station_visits, how far a city walk got on one of its days
|
||||
* + tour_change_steps, the changes to a tour saved from a confirmed proposal
|
||||
* + tour_activities, the recorded tracks assigned to a tour
|
||||
* + tour_activity_exclusions, the tours a user left out of their own activity sums)
|
||||
* + tour_activity_exclusions, the tours a user left out of their own activity sums
|
||||
* + collection_tours, the whole tours a collection keeps)
|
||||
* is registered, and no two share a class name (a silent duplicate would
|
||||
* shadow one of them in `orm.getMetadata()`, exactly the kind of drift
|
||||
* `entity-schema-parity.test.ts` can't see — it iterates the metadata map,
|
||||
* which de-duplicates by class name).
|
||||
*/
|
||||
it('ENT-003: exactly 146 entities, every class name unique', () => {
|
||||
expect(ALL_ENTITIES.length).toBe(146);
|
||||
it('ENT-003: exactly 147 entities, every class name unique', () => {
|
||||
expect(ALL_ENTITIES.length).toBe(147);
|
||||
const names = ALL_ENTITIES.map((s) => s.meta.className);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
@@ -40,7 +40,9 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
// only ever written whole, by the save of a confirmed change).
|
||||
// + tour_activities: id, place_id, track_place_id (an assignment always names its tour and its recording).
|
||||
// + tour_activity_exclusions: id, user_id, place_id (an exclusion always names its user and its tour).
|
||||
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 510;
|
||||
// + collection_tours: id, collection_id, name, waypoints, tour_type, origin, source_trip_title, source_tour_name
|
||||
// (a kept tour is only ever written whole, from the tour it was saved from).
|
||||
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 518;
|
||||
|
||||
describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => {
|
||||
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 133 entities', () => {
|
||||
|
||||
@@ -76,6 +76,7 @@ const UNNUMBERED = [
|
||||
'Migration20200101045910_a_tour_can_be_left_out_of_ones_activities',
|
||||
'Migration20200101046000_a_tour_waypoint_can_end_a_stage',
|
||||
'Migration20200101046010_a_day_can_hold_one_stage_of_a_tour',
|
||||
'Migration20200101046100_a_collection_keeps_whole_tours',
|
||||
];
|
||||
// The last step the positional runner ever had; planLegacyBaseline refuses a
|
||||
// schema_version past it.
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* `CollectionToursRepository` (LIB-03): the whole tours a collection keeps.
|
||||
* A kept tour is written whole (CT3), listed newest first (CT1), read only
|
||||
* within its collection (CT2) and removed only from it (CT4); the trips one
|
||||
* could go into come with their owner (CT5).
|
||||
*/
|
||||
import { CollectionTours } from '../../../../src/db/entities/CollectionTours.entity';
|
||||
import { Collections } from '../../../../src/db/entities/Collections.entity';
|
||||
import { Users } from '../../../../src/db/entities/Users.entity';
|
||||
import type {
|
||||
CollectionTourInsert,
|
||||
CollectionToursRepository,
|
||||
} from '../../../../src/db/repositories/CollectionTours.repository';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { makeCollection } from '../../../helpers/factories/collections';
|
||||
import { deleteRows, findRow, updateRows } from '../../../helpers/factories/rows';
|
||||
import { makeTrip } from '../../../helpers/factories/trips';
|
||||
import { makeUser } from '../../../helpers/factories/users';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let repo: CollectionToursRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
repo = t.repo(CollectionTours);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
t.clear();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
const POINTS = [
|
||||
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 47.01, lng: 11.01, role: 'via', sequence: 1, name: 'Hut', key: 'stop-hut-1' },
|
||||
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 2 },
|
||||
];
|
||||
|
||||
/** A kept hike with a line and three points, the middle one named, its points encoded as the service writes them. */
|
||||
function snapshot(collectionId: number, savedBy: number | null, overrides: Partial<CollectionTourInsert> = {}) {
|
||||
const row: CollectionTourInsert = {
|
||||
collection_id: collectionId,
|
||||
saved_by: savedBy,
|
||||
name: 'Ridge',
|
||||
description: 'Up high',
|
||||
website: null,
|
||||
route_geometry: '[[47,11,600],[47.01,11.01,650],[47.02,11.02,630]]',
|
||||
waypoints: JSON.stringify(POINTS),
|
||||
tour_type: 'hike',
|
||||
distance: 4.2,
|
||||
elevation_gain: 300,
|
||||
elevation_loss: null,
|
||||
duration: 90,
|
||||
planned_duration_minutes: null,
|
||||
break_additional_minutes: 20,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 3,
|
||||
bike_profile: null,
|
||||
boat_type: null,
|
||||
water_context: null,
|
||||
paddle_speed_kmh: null,
|
||||
portage_speed_kmh: null,
|
||||
paddle_lake_style: null,
|
||||
track_marks: null,
|
||||
hiking_preferences: null,
|
||||
origin: 'trek',
|
||||
origin_file_name: null,
|
||||
source_trip_title: 'Alps',
|
||||
source_tour_name: 'Ridge',
|
||||
...overrides,
|
||||
};
|
||||
return row;
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const { user } = await makeUser(t, { username: 'keeper' });
|
||||
const mine = await makeCollection(t, user.id, { name: 'Hikes' });
|
||||
const other = await makeCollection(t, user.id, { name: 'Other' });
|
||||
return { user, mine, other };
|
||||
}
|
||||
|
||||
describe('CollectionToursRepository', () => {
|
||||
it('CT3/CT2: keeps a snapshot whole and reads it back with its points decoded and who saved it', async () => {
|
||||
const { user, mine, other } = await fixture();
|
||||
const id = await repo.insertSnapshot(snapshot(mine.id, user.id));
|
||||
const row = await repo.findInCollection(mine.id, id);
|
||||
expect(row).toMatchObject({
|
||||
id,
|
||||
collection_id: mine.id,
|
||||
saved_by: user.id,
|
||||
saved_by_name: 'keeper',
|
||||
name: 'Ridge',
|
||||
tour_type: 'hike',
|
||||
distance: 4.2,
|
||||
elevation_loss: null,
|
||||
source_trip_title: 'Alps',
|
||||
});
|
||||
expect(row?.waypoints).toEqual(POINTS);
|
||||
expect(typeof row?.created_at).toBe('string');
|
||||
// Only within its own collection.
|
||||
expect(await repo.findInCollection(other.id, id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('CT1: lists a collection’s tours newest first, the id breaking ties, and nothing of another', async () => {
|
||||
const { user, mine, other } = await fixture();
|
||||
const first = await repo.insertSnapshot(snapshot(mine.id, user.id, { name: 'First' }));
|
||||
const second = await repo.insertSnapshot(snapshot(mine.id, user.id, { name: 'Second' }));
|
||||
const older = await repo.insertSnapshot(snapshot(mine.id, user.id, { name: 'Older' }));
|
||||
await repo.insertSnapshot(snapshot(other.id, user.id, { name: 'Elsewhere' }));
|
||||
await updateRows(t, CollectionTours, { id: older }, { created_at: '2020-01-01 00:00:00' });
|
||||
await updateRows(t, CollectionTours, { id: { $in: [first, second] } }, { created_at: '2026-10-20 10:00:00' });
|
||||
const rows = await repo.listForCollection(mine.id);
|
||||
expect(rows.map((row) => row.name)).toEqual(['Second', 'First', 'Older']);
|
||||
});
|
||||
|
||||
it('CT1/CT2: an account that is gone leaves the kept tour with nobody named', async () => {
|
||||
const { mine } = await fixture();
|
||||
const { user: gone } = await makeUser(t, { username: 'gone' });
|
||||
const id = await repo.insertSnapshot(snapshot(mine.id, gone.id));
|
||||
await deleteRows(t, Users, { id: gone.id });
|
||||
expect(await repo.findInCollection(mine.id, id)).toMatchObject({ saved_by: null, saved_by_name: null });
|
||||
});
|
||||
|
||||
it('CT2: a broken point list, or one of another shape, reads as no points, the line kept as it is', async () => {
|
||||
const { user, mine } = await fixture();
|
||||
const id = await repo.insertSnapshot(snapshot(mine.id, user.id));
|
||||
await updateRows(t, CollectionTours, { id }, { waypoints: 'not json' });
|
||||
const row = await repo.findInCollection(mine.id, id);
|
||||
expect(row?.waypoints).toEqual([]);
|
||||
expect(row?.route_geometry).toContain('47.01');
|
||||
await updateRows(t, CollectionTours, { id }, { waypoints: JSON.stringify([{ lat: 147, lng: 11 }]) });
|
||||
expect((await repo.listForCollection(mine.id))[0]?.waypoints).toEqual([]);
|
||||
});
|
||||
|
||||
it('CT4: removes a kept tour only from its own collection', async () => {
|
||||
const { user, mine, other } = await fixture();
|
||||
const id = await repo.insertSnapshot(snapshot(mine.id, user.id));
|
||||
expect(await repo.deleteInCollection(other.id, id)).toBe(false);
|
||||
expect(await findRow(t, CollectionTours, { id })).not.toBeNull();
|
||||
expect(await repo.deleteInCollection(mine.id, id)).toBe(true);
|
||||
expect(await repo.findInCollection(mine.id, id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('CT5: names the trips asked for with their owner and dates, and none for no ids', async () => {
|
||||
const { user } = await fixture();
|
||||
const alps = await makeTrip(t, user.id, { title: 'Alps', start_date: '2026-07-01', end_date: '2026-07-08' });
|
||||
const coast = await makeTrip(t, user.id, { title: 'Coast', start_date: null, end_date: null });
|
||||
await makeTrip(t, user.id, { title: 'Not asked' });
|
||||
const rows = await repo.targetTrips([coast.id, alps.id]);
|
||||
expect([...rows].sort((a, b) => a.id - b.id)).toEqual([
|
||||
{ id: alps.id, user_id: user.id, title: 'Alps', start_date: '2026-07-01', end_date: '2026-07-08' },
|
||||
{ id: coast.id, user_id: user.id, title: 'Coast', start_date: null, end_date: null },
|
||||
]);
|
||||
expect(await repo.targetTrips([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('goes with its collection', async () => {
|
||||
const { user, mine } = await fixture();
|
||||
const id = await repo.insertSnapshot(snapshot(mine.id, user.id));
|
||||
await deleteRows(t, Collections, { id: mine.id });
|
||||
expect(await findRow(t, CollectionTours, { id })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* CollectionToursController and CollectionTourTakeOverController (LIB-03):
|
||||
* route metadata, the guards and the addon they run behind, the permission
|
||||
* the take-over needs, and the delegation to CollectionToursService, a
|
||||
* malformed id answering 404 before the service is asked.
|
||||
*/
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
|
||||
import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator';
|
||||
import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard';
|
||||
import { DomainError } from '../../../src/nest/common/domain-error';
|
||||
import { TRIP_PERMISSION_KEY, TripAccessGuard } from '../../../src/nest/permissions/trip-access.guard';
|
||||
import {
|
||||
CollectionTourTakeOverController,
|
||||
CollectionToursController,
|
||||
} from '../../../src/nest/tours/collection-tours.controller';
|
||||
import type { CollectionToursService } from '../../../src/nest/tours/collection-tours.service';
|
||||
import type { User } from '../../../src/types';
|
||||
import { RequestMethod } from '@nestjs/common';
|
||||
import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const user = { id: 2, role: 'user', email: 'u@example.test' } as User;
|
||||
|
||||
function fakeService() {
|
||||
return {
|
||||
list: vi.fn().mockResolvedValue({ tours: [] }),
|
||||
detail: vi.fn().mockResolvedValue({ tour: {}, line: [], points: [], trips: [] }),
|
||||
save: vi.fn().mockResolvedValue({ tour: { id: 4 } }),
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
takeOver: vi.fn().mockResolvedValue({ tour: { place_id: 9 }, waypoints: [] }),
|
||||
};
|
||||
}
|
||||
|
||||
function route(controller: object, method: string) {
|
||||
const handler = (controller as Record<string, unknown>)[method] as object;
|
||||
return {
|
||||
path: Reflect.getMetadata(PATH_METADATA, handler) as string,
|
||||
verb: Reflect.getMetadata(METHOD_METADATA, handler) as RequestMethod,
|
||||
permission: Reflect.getMetadata(TRIP_PERMISSION_KEY, handler) as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function notFound(work: () => Promise<unknown>): Promise<string> {
|
||||
try {
|
||||
await work();
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DomainError);
|
||||
expect((err as DomainError).getStatus()).toBe(404);
|
||||
return (err as DomainError).publicMessage;
|
||||
}
|
||||
throw new Error('expected a 404');
|
||||
}
|
||||
|
||||
describe('CollectionToursController', () => {
|
||||
it('LIB3-CTRL-001: lives under the collection, behind the Tours addon and a session, without a trip guard', () => {
|
||||
expect(Reflect.getMetadata(PATH_METADATA, CollectionToursController)).toBe(
|
||||
'api/addons/collections/:collectionId/tours',
|
||||
);
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, CollectionToursController)).toEqual([AddonGuard, JwtAuthGuard]);
|
||||
expect(Reflect.getMetadata(REQUIRE_ADDON, CollectionToursController)).toEqual({ addonId: 'tours', label: 'Tours' });
|
||||
const proto = CollectionToursController.prototype;
|
||||
expect(route(proto, 'list')).toMatchObject({ path: '/', verb: RequestMethod.GET });
|
||||
expect(route(proto, 'save')).toMatchObject({ path: '/', verb: RequestMethod.POST });
|
||||
expect(route(proto, 'detail')).toMatchObject({ path: ':tourId', verb: RequestMethod.GET });
|
||||
expect(route(proto, 'remove')).toMatchObject({ path: ':tourId', verb: RequestMethod.DELETE });
|
||||
});
|
||||
|
||||
it('LIB3-CTRL-002: hands the user, the ids and the socket on to the service', async () => {
|
||||
const service = fakeService();
|
||||
const controller = new CollectionToursController(service as unknown as CollectionToursService);
|
||||
const body = { source_trip_id: 5, source_place_id: 7 };
|
||||
await controller.list(user, '3');
|
||||
await controller.save(user, '3', body, 'sock');
|
||||
await controller.detail(user, '3', '4');
|
||||
expect(await controller.remove(user, '3', '4', 'sock')).toEqual({ success: true });
|
||||
expect(service.list).toHaveBeenCalledWith(2, 3);
|
||||
expect(service.save).toHaveBeenCalledWith(2, 3, body, 'sock');
|
||||
expect(service.detail).toHaveBeenCalledWith(user, 3, 4);
|
||||
expect(service.remove).toHaveBeenCalledWith(2, 3, 4, 'sock');
|
||||
});
|
||||
|
||||
it('LIB3-CTRL-003: a malformed id is a missing collection or tour, never a 500', async () => {
|
||||
const service = fakeService();
|
||||
const controller = new CollectionToursController(service as unknown as CollectionToursService);
|
||||
expect(await notFound(() => controller.list(user, 'abc'))).toBe('Collection not found');
|
||||
expect(await notFound(() => controller.detail(user, '3', '1e3'))).toBe('Tour not found');
|
||||
expect(await notFound(() => controller.remove(user, '-1', '4'))).toBe('Collection not found');
|
||||
expect(service.list).not.toHaveBeenCalled();
|
||||
expect(service.detail).not.toHaveBeenCalled();
|
||||
expect(service.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CollectionTourTakeOverController', () => {
|
||||
it('LIB3-CTRL-004: is trip-scoped, needs place_edit and hands the body on', async () => {
|
||||
expect(Reflect.getMetadata(PATH_METADATA, CollectionTourTakeOverController)).toBe('api/trips/:tripId/tours');
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, CollectionTourTakeOverController)).toEqual([
|
||||
AddonGuard,
|
||||
JwtAuthGuard,
|
||||
TripAccessGuard,
|
||||
]);
|
||||
expect(route(CollectionTourTakeOverController.prototype, 'takeOver')).toEqual({
|
||||
path: 'from-collection',
|
||||
verb: RequestMethod.POST,
|
||||
permission: 'place_edit',
|
||||
});
|
||||
const service = fakeService();
|
||||
const controller = new CollectionTourTakeOverController(service as unknown as CollectionToursService);
|
||||
const body = { collection_id: 3, collection_tour_id: 4, include_waypoints: false };
|
||||
expect(await controller.takeOver('6', user, body, 'sock')).toEqual({ tour: { place_id: 9 }, waypoints: [] });
|
||||
expect(service.takeOver).toHaveBeenCalledWith('6', 2, body, 'sock');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* The pure halves of LIB-03 and the copy model it shares with P2C-07 and
|
||||
* LIB-02: where a copy starts, what a kept tour shows and what a tour made
|
||||
* from it carries.
|
||||
*/
|
||||
import type { CollectionTourRow } from '../../../src/db/repositories/CollectionTours.repository';
|
||||
import {
|
||||
keptTourCopySource,
|
||||
keptTourItem,
|
||||
keptTourLine,
|
||||
keptTourPoints,
|
||||
keptTourSnapshot,
|
||||
} from '../../../src/nest/tours/collection-tours.helpers';
|
||||
import { copiedFacet, copyStart } from '../../../src/nest/tours/tour-copy.helpers';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const row: CollectionTourRow = {
|
||||
id: 4,
|
||||
collection_id: 9,
|
||||
saved_by: 1,
|
||||
saved_by_name: 'owner',
|
||||
name: 'Ridge',
|
||||
description: null,
|
||||
website: 'https://example.test/ridge',
|
||||
route_geometry: '[[47,11,600],[47.02,11.02,630]]',
|
||||
waypoints: [
|
||||
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 2 },
|
||||
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 47.01, lng: 11.01, role: 'via', sequence: 1, name: 'Hut', shaping: true },
|
||||
],
|
||||
tour_type: 'bike',
|
||||
distance: 12,
|
||||
elevation_gain: null,
|
||||
elevation_loss: null,
|
||||
duration: null,
|
||||
planned_duration_minutes: 120,
|
||||
break_additional_minutes: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: null,
|
||||
bike_profile: 'gravel',
|
||||
boat_type: null,
|
||||
water_context: null,
|
||||
paddle_speed_kmh: null,
|
||||
portage_speed_kmh: null,
|
||||
paddle_lake_style: null,
|
||||
track_marks: null,
|
||||
hiking_preferences: null,
|
||||
origin: 'gpx',
|
||||
origin_file_name: 'ridge.gpx',
|
||||
source_trip_title: 'Alps',
|
||||
source_tour_name: 'Ridge',
|
||||
created_at: '2026-10-20 10:00:00',
|
||||
};
|
||||
|
||||
describe('collection tour helpers (LIB-03)', () => {
|
||||
it('LIB3-HLP-001: a copy starts at the line, a walk without one at its first station, nothing without either', () => {
|
||||
expect(copyStart({ route_geometry: '[[47,11,600],[48,12]]', waypoints: [] })).toEqual([47, 11]);
|
||||
expect(copyStart({ route_geometry: null, waypoints: [{ lat: 1, lng: 2, role: 'start', sequence: 0 }] })).toEqual([
|
||||
1, 2,
|
||||
]);
|
||||
expect(copyStart({ route_geometry: null, waypoints: [] })).toBeUndefined();
|
||||
expect(copyStart({ route_geometry: '[]', waypoints: [] })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('LIB3-HLP-002: lists a kept tour as it was saved, unknown measures staying unknown', () => {
|
||||
expect(keptTourItem(row)).toMatchObject({
|
||||
id: 4,
|
||||
tour_type: 'bike',
|
||||
distance: 12,
|
||||
elevation_gain: null,
|
||||
origin: 'gpx',
|
||||
has_line: true,
|
||||
waypoint_count: 3,
|
||||
station_count: null,
|
||||
source: { tripTitle: 'Alps', tourName: 'Ridge' },
|
||||
saved_by: 'owner',
|
||||
saved_at: '2026-10-20 10:00:00',
|
||||
});
|
||||
expect(keptTourItem({ ...row, route_geometry: null }).has_line).toBe(false);
|
||||
});
|
||||
|
||||
it('LIB3-HLP-003: shows the points in route order and the line thinned for drawing', () => {
|
||||
expect(keptTourPoints(row)).toEqual([
|
||||
{ role: 'start', name: null, shaping: false },
|
||||
{ role: 'via', name: 'Hut', shaping: true },
|
||||
{ role: 'end', name: null, shaping: false },
|
||||
]);
|
||||
expect(keptTourLine(row)).toEqual([
|
||||
[47, 11],
|
||||
[47.02, 11.02],
|
||||
]);
|
||||
const long = JSON.stringify(Array.from({ length: 900 }, (_, i) => [47 + i / 1000, 11]));
|
||||
expect(keptTourLine({ route_geometry: long })).toHaveLength(400);
|
||||
expect(keptTourLine({ route_geometry: null })).toEqual([]);
|
||||
});
|
||||
|
||||
it('LIB3-HLP-004: a tour made from it carries everything, its points only when asked for', () => {
|
||||
const withPoints = keptTourCopySource(row, true);
|
||||
expect(withPoints).toMatchObject({ place_id: null, name: 'Ridge', website: 'https://example.test/ridge' });
|
||||
expect(withPoints.waypoints).toHaveLength(3);
|
||||
expect(keptTourCopySource(row, false).waypoints).toEqual([]);
|
||||
expect(keptTourCopySource(row, false).route_geometry).toBe(row.route_geometry);
|
||||
expect(copiedFacet(withPoints, 77, { derivedFrom: null, sameTrip: false })).toMatchObject({
|
||||
place_id: 77,
|
||||
tour_type: 'bike',
|
||||
bike_profile: 'gravel',
|
||||
planned_duration_minutes: 120,
|
||||
origin: 'gpx',
|
||||
origin_file_name: 'ridge.gpx',
|
||||
derived_from_place_id: null,
|
||||
taken_over_from_trip_title: 'Alps',
|
||||
taken_over_from_tour_name: 'Ridge',
|
||||
});
|
||||
expect(
|
||||
copiedFacet(withPoints, 77, {
|
||||
derivedFrom: null,
|
||||
sameTrip: false,
|
||||
takenOverFrom: { tripTitle: 'Coast', tourName: 'Cliffs' },
|
||||
}),
|
||||
).toMatchObject({ taken_over_from_trip_title: 'Coast', taken_over_from_tour_name: 'Cliffs' });
|
||||
});
|
||||
|
||||
it('LIB3-HLP-005: keeping one leaves its links to the trip behind and names where it came from', () => {
|
||||
const source = keptTourCopySource(
|
||||
{
|
||||
...row,
|
||||
waypoints: [
|
||||
{ lat: 47, lng: 11, role: 'start', sequence: 0, linked_place_id: 3 },
|
||||
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 1 },
|
||||
],
|
||||
},
|
||||
true,
|
||||
);
|
||||
const snapshot = keptTourSnapshot(source, { collectionId: 9, userId: 1, name: 'Kept', tripTitle: 'Coast' });
|
||||
expect(snapshot).toMatchObject({
|
||||
collection_id: 9,
|
||||
saved_by: 1,
|
||||
name: 'Kept',
|
||||
bike_profile: 'gravel',
|
||||
origin_file_name: 'ridge.gpx',
|
||||
source_trip_title: 'Coast',
|
||||
source_tour_name: 'Ridge',
|
||||
});
|
||||
const kept = JSON.parse(snapshot.waypoints) as Array<{ linked_place_id?: number | null }>;
|
||||
expect(kept.map((point) => point.linked_place_id)).toEqual([null, null]);
|
||||
});
|
||||
|
||||
it('LIB3-HLP-006: a point that is no control point refuses the whole snapshot', () => {
|
||||
const broken = keptTourCopySource({ ...row, waypoints: [{ lat: 147, lng: 11, role: 'start', sequence: 0 }] }, true);
|
||||
expect(() => keptTourSnapshot(broken, { collectionId: 9, userId: 1, name: 'Kept', tripTitle: 'Coast' })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* CollectionToursMcp (LIB-03): the tours a collection keeps, over the same
|
||||
* service and the same checks as REST. The suite takes its subject from the
|
||||
* container over a snapshot test db; the addon gate is the registry's.
|
||||
*/
|
||||
import { CollectionTours } from '../../../src/db/entities/CollectionTours.entity';
|
||||
import { Places } from '../../../src/db/entities/Places.entity';
|
||||
import type { McpContext } from '../../../src/nest-mcp';
|
||||
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service';
|
||||
import { CollectionToursMcp } from '../../../src/nest/tours/collection-tours.mcp';
|
||||
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
||||
import { ToursService } from '../../../src/nest/tours/tours.service';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { addCollectionMember, makeCollection } from '../../helpers/factories/collections';
|
||||
import { makePlace } from '../../helpers/factories/places';
|
||||
import { countRows, findRow } from '../../helpers/factories/rows';
|
||||
import { setAddonEnabled } from '../../helpers/factories/settings';
|
||||
import { addTourWaypoints, makeTour } from '../../helpers/factories/tours';
|
||||
import { makeTrip } from '../../helpers/factories/trips';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
let t: TestModule;
|
||||
let orm: TestOrm;
|
||||
let mcp: CollectionToursMcp;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({ db, imports: [ToursModule] });
|
||||
mcp = t.get(CollectionToursMcp);
|
||||
orm = await createTestOrm(db);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
resetTestDb(db);
|
||||
orm.clear();
|
||||
vi.restoreAllMocks();
|
||||
await setAddonEnabled(orm, 'collections', true);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await orm.close();
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
const ctx = (userId: number) => ({ userId }) as McpContext;
|
||||
|
||||
function read(result: { content: Array<{ type: string; text: string }>; isError?: boolean }) {
|
||||
const text = result.content[0]?.text ?? '';
|
||||
return result.isError ? { error: text } : JSON.parse(text);
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const { user } = await makeUser(orm);
|
||||
const { user: viewer } = await makeUser(orm);
|
||||
const { user: outsider } = await makeUser(orm);
|
||||
const alps = await makeTrip(orm, user.id, { title: 'Alps' });
|
||||
const next = await makeTrip(orm, user.id, { title: 'Next' });
|
||||
const ridge = await makePlace(orm, alps.id, {
|
||||
name: 'Ridge',
|
||||
route_geometry: JSON.stringify([
|
||||
[47, 11, 600],
|
||||
[47.01, 11.01, 650],
|
||||
]),
|
||||
});
|
||||
await makeTour(orm, ridge.id, { distance: 3.5 });
|
||||
await addTourWaypoints(orm, ridge.id, [
|
||||
{ lat: 47, lng: 11 },
|
||||
{ lat: 47.01, lng: 11.01 },
|
||||
]);
|
||||
const list = await makeCollection(orm, user.id, { name: 'Hikes' });
|
||||
await addCollectionMember(orm, list.id, viewer.id, { role: 'viewer' });
|
||||
return { user, viewer, outsider, alps, next, ridge, list };
|
||||
}
|
||||
|
||||
describe('CollectionToursMcp', () => {
|
||||
it('LIB3-MCP-001: keeps a tour, lists it and reads it with the trips it could go into', async () => {
|
||||
const f = await fixture();
|
||||
const saved = read(
|
||||
await mcp.saveTourToCollection(
|
||||
{ collection_id: f.list.id, source_trip_id: f.alps.id, source_place_id: f.ridge.id },
|
||||
ctx(f.user.id),
|
||||
),
|
||||
);
|
||||
expect(saved.tour).toMatchObject({
|
||||
name: 'Ridge',
|
||||
distance: 3.5,
|
||||
source: { tripTitle: 'Alps', tourName: 'Ridge' },
|
||||
});
|
||||
const listed = read(await mcp.listCollectionTours({ collection_id: f.list.id }, ctx(f.viewer.id)));
|
||||
expect(listed.tours.map((tour: { id: number }) => tour.id)).toEqual([saved.tour.id]);
|
||||
const detail = read(
|
||||
await mcp.getCollectionTour({ collection_id: f.list.id, collection_tour_id: saved.tour.id }, ctx(f.user.id)),
|
||||
);
|
||||
expect(detail.points).toEqual([
|
||||
{ role: 'start', name: null, shaping: false },
|
||||
{ role: 'end', name: null, shaping: false },
|
||||
]);
|
||||
expect(detail.trips.map((trip: { title: string }) => trip.title).sort()).toEqual(['Alps', 'Next']);
|
||||
});
|
||||
|
||||
it('LIB3-MCP-002: an admin may add to every trip; the role is read for the check', async () => {
|
||||
const f = await fixture();
|
||||
const admin = vi.spyOn(t.get(McpToolGuardsService), 'isAdminUser').mockResolvedValue(true);
|
||||
const saved = read(
|
||||
await mcp.saveTourToCollection(
|
||||
{ collection_id: f.list.id, source_trip_id: f.alps.id, source_place_id: f.ridge.id },
|
||||
ctx(f.user.id),
|
||||
),
|
||||
);
|
||||
const detail = read(
|
||||
await mcp.getCollectionTour({ collection_id: f.list.id, collection_tour_id: saved.tour.id }, ctx(f.user.id)),
|
||||
);
|
||||
expect(admin).toHaveBeenCalledWith(f.user.id);
|
||||
expect(detail.trips.every((trip: { can_add: boolean }) => trip.can_add)).toBe(true);
|
||||
});
|
||||
|
||||
it('LIB3-MCP-003: takes a kept tour into a trip with or without its points', async () => {
|
||||
const f = await fixture();
|
||||
const { tour } = read(
|
||||
await mcp.saveTourToCollection(
|
||||
{ collection_id: f.list.id, source_trip_id: f.alps.id, source_place_id: f.ridge.id },
|
||||
ctx(f.user.id),
|
||||
),
|
||||
);
|
||||
const base = { tripId: f.next.id, collection_id: f.list.id, collection_tour_id: tour.id };
|
||||
const withPoints = read(await mcp.takeOverCollectionTour({ ...base, include_waypoints: true }, ctx(f.user.id)));
|
||||
expect(withPoints.waypoints).toHaveLength(2);
|
||||
expect(withPoints.provenance.takenOverFrom).toEqual({ tripTitle: 'Alps', tourName: 'Ridge' });
|
||||
const lineOnly = read(
|
||||
await mcp.takeOverCollectionTour({ ...base, include_waypoints: false, name: 'Bare' }, ctx(f.user.id)),
|
||||
);
|
||||
expect(lineOnly.tour.name).toBe('Bare');
|
||||
expect(lineOnly.tour.has_waypoints).toBe(false);
|
||||
expect(await findRow(orm, Places, { id: lineOnly.tour.place_id })).toMatchObject({ trip_id: f.next.id });
|
||||
});
|
||||
|
||||
it('LIB3-MCP-004: refuses a trip out of reach, a trip without place_edit and a list out of sight', async () => {
|
||||
const f = await fixture();
|
||||
const { tour } = read(
|
||||
await mcp.saveTourToCollection(
|
||||
{ collection_id: f.list.id, source_trip_id: f.alps.id, source_place_id: f.ridge.id },
|
||||
ctx(f.user.id),
|
||||
),
|
||||
);
|
||||
const body = { collection_id: f.list.id, collection_tour_id: tour.id, include_waypoints: true };
|
||||
expect(read(await mcp.takeOverCollectionTour({ ...body, tripId: f.next.id }, ctx(f.outsider.id)))).toEqual({
|
||||
error: 'Trip not found or access denied.',
|
||||
});
|
||||
const denied = vi.spyOn(t.get(McpToolGuardsService), 'hasTripPermission').mockResolvedValue(false);
|
||||
const refused = await mcp.takeOverCollectionTour({ ...body, tripId: f.next.id }, ctx(f.user.id));
|
||||
expect(refused.isError).toBe(true);
|
||||
expect(denied).toHaveBeenCalledWith('place_edit', f.next.id, f.user.id);
|
||||
denied.mockRestore();
|
||||
const own = await makeTrip(orm, f.outsider.id);
|
||||
// A service refusal reaches the session through the registry's error mapper.
|
||||
await expect(mcp.takeOverCollectionTour({ ...body, tripId: own.id }, ctx(f.outsider.id))).rejects.toThrow(
|
||||
'Collection not found',
|
||||
);
|
||||
expect(await countRows(orm, Places, { trip: f.next.id })).toBe(0);
|
||||
});
|
||||
|
||||
it('LIB3-MCP-005: a tour the copy model cannot make is not found', async () => {
|
||||
const f = await fixture();
|
||||
const { tour } = read(
|
||||
await mcp.saveTourToCollection(
|
||||
{ collection_id: f.list.id, source_trip_id: f.alps.id, source_place_id: f.ridge.id },
|
||||
ctx(f.user.id),
|
||||
),
|
||||
);
|
||||
vi.spyOn(t.get(ToursService), 'createFromKeptTour').mockRejectedValueOnce(new NotFoundException('Tour not found'));
|
||||
expect(
|
||||
read(
|
||||
await mcp.takeOverCollectionTour(
|
||||
{ tripId: f.next.id, collection_id: f.list.id, collection_tour_id: tour.id, include_waypoints: true },
|
||||
ctx(f.user.id),
|
||||
),
|
||||
),
|
||||
).toEqual({ error: 'Tour not found.' });
|
||||
vi.spyOn(t.get(ToursService), 'createFromKeptTour').mockRejectedValueOnce(new Error('boom'));
|
||||
await expect(
|
||||
mcp.takeOverCollectionTour(
|
||||
{ tripId: f.next.id, collection_id: f.list.id, collection_tour_id: tour.id, include_waypoints: true },
|
||||
ctx(f.user.id),
|
||||
),
|
||||
).rejects.toThrow('boom');
|
||||
});
|
||||
|
||||
it('LIB3-MCP-006: the owner removes a kept tour, a viewer may not', async () => {
|
||||
const f = await fixture();
|
||||
const { tour } = read(
|
||||
await mcp.saveTourToCollection(
|
||||
{ collection_id: f.list.id, source_trip_id: f.alps.id, source_place_id: f.ridge.id },
|
||||
ctx(f.user.id),
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
mcp.deleteCollectionTour({ collection_id: f.list.id, collection_tour_id: tour.id }, ctx(f.viewer.id)),
|
||||
).rejects.toThrow('Only an admin can remove tours from this list');
|
||||
expect(
|
||||
read(await mcp.deleteCollectionTour({ collection_id: f.list.id, collection_tour_id: tour.id }, ctx(f.user.id))),
|
||||
).toEqual({ success: true });
|
||||
expect(await countRows(orm, CollectionTours)).toBe(0);
|
||||
});
|
||||
|
||||
it('LIB3-MCP-007: the tools are there only while both addons are on, and only for the scopes they need', async () => {
|
||||
const options = (name: string) => {
|
||||
const entry = getEntry(CollectionToursMcp as unknown as ClassRef, name);
|
||||
if (entry?.kind !== 'tool') throw new Error(`${name} is no tool`);
|
||||
return entry.options;
|
||||
};
|
||||
const enabled = vi.spyOn(t.get(AddonsService), 'isAddonEnabled').mockResolvedValue(true);
|
||||
const gate = options('listCollectionTours').when;
|
||||
expect(await gate?.(ctx(1), mcp)).toBe(true);
|
||||
enabled.mockImplementation(async (id: string) => id !== 'collections');
|
||||
expect(await gate?.(ctx(1), mcp)).toBe(false);
|
||||
enabled.mockImplementation(async (id: string) => id !== 'tours');
|
||||
expect(await gate?.(ctx(1), mcp)).toBe(false);
|
||||
|
||||
const allowed = (name: string, scopes: string[]) => {
|
||||
const access = options(name).access;
|
||||
return typeof access === 'function' ? access({ userId: 1, scopes } as McpContext) : access;
|
||||
};
|
||||
expect(allowed('getCollectionTour', ['collections:read', 'trips:read'])).toBe(true);
|
||||
expect(allowed('getCollectionTour', ['collections:read'])).toBe(false);
|
||||
expect(allowed('saveTourToCollection', ['collections:write', 'trips:read'])).toBe(true);
|
||||
expect(allowed('saveTourToCollection', ['collections:read', 'trips:read'])).toBe(false);
|
||||
expect(allowed('takeOverCollectionTour', ['trips:write', 'collections:read'])).toBe(true);
|
||||
expect(allowed('takeOverCollectionTour', ['trips:write'])).toBe(false);
|
||||
expect(allowed('deleteCollectionTour', [])).toEqual({ group: 'collections', mode: 'write' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* CollectionToursService (LIB-03): whole tours kept in a collection and taken
|
||||
* into a trip again, over the container and a snapshot test db. A kept tour is
|
||||
* an independent snapshot made through the tours' one copy model; the
|
||||
* collection's roles decide who reads, keeps and removes one, and nothing is
|
||||
* kept in step afterwards.
|
||||
*/
|
||||
import { CollectionTours } from '../../../src/db/entities/CollectionTours.entity';
|
||||
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
|
||||
import { Places } from '../../../src/db/entities/Places.entity';
|
||||
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
|
||||
import { Tours } from '../../../src/db/entities/Tours.entity';
|
||||
import type { CollectionToursRepository } from '../../../src/db/repositories/CollectionTours.repository';
|
||||
import { DomainError } from '../../../src/nest/common/domain-error';
|
||||
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import { CollectionToursService } from '../../../src/nest/tours/collection-tours.service';
|
||||
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { addCollectionMember, makeCollection } from '../../helpers/factories/collections';
|
||||
import { makePlace } from '../../helpers/factories/places';
|
||||
import { countRows, deleteRows, findRow, findRows, updateRows } from '../../helpers/factories/rows';
|
||||
import { setAddonEnabled } from '../../helpers/factories/settings';
|
||||
import { addTourWaypoints, makeTour } from '../../helpers/factories/tours';
|
||||
import { addTripMember, makeTrip } from '../../helpers/factories/trips';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { getRepositoryToken } from '@mikro-orm/nestjs';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
let t: TestModule;
|
||||
let orm: TestOrm;
|
||||
let service: CollectionToursService;
|
||||
|
||||
const LINE = [
|
||||
[47, 11, 600],
|
||||
[47.01, 11.01, 650],
|
||||
[47.02, 11.02, 630],
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({ db, imports: [ToursModule] });
|
||||
service = t.get(CollectionToursService);
|
||||
orm = await createTestOrm(db);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
resetTestDb(db);
|
||||
orm.clear();
|
||||
vi.restoreAllMocks();
|
||||
t.realtime.broadcastToUserMock.mockClear();
|
||||
await setAddonEnabled(orm, 'collections', true);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await orm.close();
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
/** Status and text of a refusal. */
|
||||
async function refusal(work: Promise<unknown>): Promise<{ status: number; error: string }> {
|
||||
try {
|
||||
await work;
|
||||
} catch (err) {
|
||||
if (err instanceof DomainError) return { status: err.getStatus(), error: err.publicMessage };
|
||||
const http = err as { getStatus?: () => number; message: string };
|
||||
return { status: http.getStatus?.() ?? 500, error: http.message };
|
||||
}
|
||||
throw new Error('expected a refusal');
|
||||
}
|
||||
|
||||
/**
|
||||
* The owner's trips Alps (with a hike of three points, one standing for a
|
||||
* place of the trip) and Next, someone else's trip Away the owner is a member
|
||||
* of, a collection with an editor and a viewer, and an outsider.
|
||||
*/
|
||||
async function fixture() {
|
||||
const { user: owner } = await makeUser(orm, { username: 'owner' });
|
||||
const { user: editor } = await makeUser(orm, { username: 'editor' });
|
||||
const { user: viewer } = await makeUser(orm, { username: 'viewer' });
|
||||
const { user: outsider } = await makeUser(orm, { username: 'outsider' });
|
||||
const alps = await makeTrip(orm, owner.id, { title: 'Alps' });
|
||||
const next = await makeTrip(orm, owner.id, { title: 'Next' });
|
||||
const away = await makeTrip(orm, outsider.id, { title: 'Away' });
|
||||
await addTripMember(orm, away.id, owner.id);
|
||||
const hut = await makePlace(orm, alps.id, { name: 'Hut', lat: 47.01, lng: 11.01 });
|
||||
const ridge = await makePlace(orm, alps.id, {
|
||||
name: 'Ridge',
|
||||
lat: 47,
|
||||
lng: 11,
|
||||
description: 'Up high',
|
||||
route_geometry: JSON.stringify(LINE),
|
||||
});
|
||||
await makeTour(orm, ridge.id, { distance: 4.2, elevation_gain: 300, max_hiking_difficulty: 3, duration: 90 });
|
||||
await addTourWaypoints(orm, ridge.id, [
|
||||
{ lat: 47, lng: 11 },
|
||||
{ lat: 47.01, lng: 11.01, name: 'Hut', linkedPlace: hut.id, shaping: 0 },
|
||||
{ lat: 47.015, lng: 11.015, shaping: 1 },
|
||||
{ lat: 47.02, lng: 11.02 },
|
||||
]);
|
||||
const list = await makeCollection(orm, owner.id, { name: 'Hikes' });
|
||||
await addCollectionMember(orm, list.id, editor.id, { role: 'editor' });
|
||||
await addCollectionMember(orm, list.id, viewer.id, { role: 'viewer' });
|
||||
return { owner, editor, viewer, outsider, alps, next, away, hut, ridge, list };
|
||||
}
|
||||
|
||||
async function keep(f: Awaited<ReturnType<typeof fixture>>, name?: string) {
|
||||
const { tour } = await service.save(f.owner.id, f.list.id, {
|
||||
source_trip_id: f.alps.id,
|
||||
source_place_id: f.ridge.id,
|
||||
...(name ? { name } : {}),
|
||||
});
|
||||
return tour;
|
||||
}
|
||||
|
||||
describe('CollectionToursService', () => {
|
||||
it('LIB3-SVC-001: keeps a whole independent snapshot, its points in order, their links to the trip left behind', async () => {
|
||||
const f = await fixture();
|
||||
const tour = await keep(f);
|
||||
expect(tour).toMatchObject({
|
||||
collection_id: f.list.id,
|
||||
name: 'Ridge',
|
||||
tour_type: 'hike',
|
||||
distance: 4.2,
|
||||
elevation_gain: 300,
|
||||
elevation_loss: null,
|
||||
max_hiking_difficulty: 3,
|
||||
origin: 'trek',
|
||||
has_line: true,
|
||||
waypoint_count: 4,
|
||||
source: { tripTitle: 'Alps', tourName: 'Ridge' },
|
||||
saved_by: 'owner',
|
||||
});
|
||||
const stored = await findRow(orm, CollectionTours, { id: tour.id });
|
||||
expect(stored?.route_geometry).toBe(JSON.stringify(LINE));
|
||||
const points = JSON.parse(stored?.waypoints ?? '[]') as Array<Record<string, unknown>>;
|
||||
expect(points.map((point) => [point.role, point.sequence])).toEqual([
|
||||
['start', 0],
|
||||
['via', 1],
|
||||
['via', 2],
|
||||
['end', 3],
|
||||
]);
|
||||
expect(points.every((point) => point.linked_place_id == null)).toBe(true);
|
||||
// Every member of the collection hears of it, the one who kept it included.
|
||||
expect(t.realtime.broadcastToUserMock.mock.calls.map(([userId]) => userId).sort()).toEqual(
|
||||
[f.owner.id, f.editor.id, f.viewer.id].sort(),
|
||||
);
|
||||
|
||||
// Later changes to the tour it came from do not reach it.
|
||||
await updateRows(orm, Places, { id: f.ridge.id }, { name: 'Renamed', route_geometry: '[[1,1],[2,2]]' });
|
||||
await deleteRows(orm, TourWaypoints, { place: f.ridge.id });
|
||||
const [listed] = (await service.list(f.viewer.id, f.list.id)).tours;
|
||||
expect(listed).toMatchObject({ name: 'Ridge', waypoint_count: 4, has_line: true });
|
||||
// And the trip it came from is as it was, beyond what the test changed itself.
|
||||
expect(await countRows(orm, Places, { trip: f.alps.id })).toBe(2);
|
||||
});
|
||||
|
||||
it('LIB3-SVC-002: keeps it under another name, an editor may keep one too', async () => {
|
||||
const f = await fixture();
|
||||
const { tour } = await service.save(f.editor.id, f.list.id, {
|
||||
source_trip_id: f.alps.id,
|
||||
source_place_id: f.ridge.id,
|
||||
name: 'Ridge, spring',
|
||||
});
|
||||
expect(tour).toMatchObject({ name: 'Ridge, spring', source: { tripTitle: 'Alps', tourName: 'Ridge' } });
|
||||
});
|
||||
|
||||
it('LIB3-SVC-003: refuses a viewer, a stranger, a trip out of reach and a place of another trip, writing nothing', async () => {
|
||||
const f = await fixture();
|
||||
const body = { source_trip_id: f.alps.id, source_place_id: f.ridge.id };
|
||||
expect(await refusal(service.save(f.viewer.id, f.list.id, body))).toEqual({
|
||||
status: 403,
|
||||
error: 'You have read-only access to this list',
|
||||
});
|
||||
expect(await refusal(service.save(f.outsider.id, f.list.id, body))).toEqual({
|
||||
status: 404,
|
||||
error: 'Collection not found',
|
||||
});
|
||||
const own = await makeCollection(orm, f.outsider.id);
|
||||
expect(await refusal(service.save(f.outsider.id, own.id, body))).toEqual({ status: 404, error: 'Tour not found' });
|
||||
expect(
|
||||
await refusal(service.save(f.owner.id, f.list.id, { source_trip_id: f.next.id, source_place_id: f.ridge.id })),
|
||||
).toEqual({ status: 404, error: 'Tour not found' });
|
||||
expect(
|
||||
await refusal(service.save(f.owner.id, f.list.id, { source_trip_id: f.alps.id, source_place_id: f.hut.id })),
|
||||
).toEqual({ status: 404, error: 'Tour not found' });
|
||||
expect(await countRows(orm, CollectionTours)).toBe(0);
|
||||
});
|
||||
|
||||
it('LIB3-SVC-004: lists for every member, the newest first, and refuses whoever cannot see the list', async () => {
|
||||
const f = await fixture();
|
||||
const first = await keep(f, 'First');
|
||||
const second = await keep(f, 'Second');
|
||||
await updateRows(orm, CollectionTours, { id: first.id }, { created_at: '2020-01-01 00:00:00' });
|
||||
expect((await service.list(f.viewer.id, f.list.id)).tours.map((tour) => tour.id)).toEqual([second.id, first.id]);
|
||||
expect(await refusal(service.list(f.outsider.id, f.list.id))).toEqual({
|
||||
status: 404,
|
||||
error: 'Collection not found',
|
||||
});
|
||||
});
|
||||
|
||||
it('LIB3-SVC-005: the detail has the line, the points in order and every trip, each saying whether a tour may go there', async () => {
|
||||
const f = await fixture();
|
||||
const tour = await keep(f);
|
||||
vi.spyOn(t.get(PermissionsService), 'checkPermission').mockImplementation(
|
||||
async (_action: string, _role: string, tripOwner: number | null, userId: number) => tripOwner === userId,
|
||||
);
|
||||
const detail = await service.detail({ id: f.owner.id, role: 'user' }, f.list.id, tour.id);
|
||||
expect(detail.line).toEqual(LINE.map(([lat, lng]) => [lat, lng]));
|
||||
expect(detail.points).toEqual([
|
||||
{ role: 'start', name: null, shaping: false },
|
||||
{ role: 'via', name: 'Hut', shaping: false },
|
||||
{ role: 'via', name: null, shaping: true },
|
||||
{ role: 'end', name: null, shaping: false },
|
||||
]);
|
||||
expect(detail.trips.map((trip) => [trip.title, trip.can_add]).sort()).toEqual([
|
||||
['Alps', true],
|
||||
['Away', false],
|
||||
['Next', true],
|
||||
]);
|
||||
expect(await refusal(service.detail({ id: f.owner.id, role: 'user' }, f.list.id, 999999))).toEqual({
|
||||
status: 404,
|
||||
error: 'Tour not found',
|
||||
});
|
||||
});
|
||||
|
||||
it('LIB3-SVC-006: takes a kept tour into a trip with its points, as a tour of its own with no days', async () => {
|
||||
const f = await fixture();
|
||||
const kept = await keep(f);
|
||||
const result = await service.takeOver(String(f.next.id), f.editor.id, {
|
||||
collection_id: f.list.id,
|
||||
collection_tour_id: kept.id,
|
||||
include_waypoints: true,
|
||||
});
|
||||
const copy = result.tour.place_id;
|
||||
expect(copy).not.toBe(f.ridge.id);
|
||||
expect(result.tour).toMatchObject({ name: 'Ridge', tour_type: 'hike', distance: 4.2, revision: 1, planned: false });
|
||||
expect(result.provenance?.takenOverFrom).toEqual({ tripTitle: 'Alps', tourName: 'Ridge' });
|
||||
expect(result.waypoints.map((point) => [point.role, point.sequence, point.shaping === true])).toEqual([
|
||||
['start', 0, false],
|
||||
['via', 1, false],
|
||||
['via', 2, true],
|
||||
['end', 3, false],
|
||||
]);
|
||||
expect(result.waypoints.every((point) => point.linked_place_id == null)).toBe(true);
|
||||
expect(await findRow(orm, Places, { id: copy })).toMatchObject({
|
||||
trip_id: f.next.id,
|
||||
route_geometry: JSON.stringify(LINE),
|
||||
});
|
||||
expect(await countRows(orm, DayAssignments, { place: copy })).toBe(0);
|
||||
// Only the tour itself is new in the trip, no places for its points.
|
||||
expect(await countRows(orm, Places, { trip: f.next.id })).toBe(1);
|
||||
// The kept tour and the tour it was saved from are as they were.
|
||||
expect((await service.list(f.owner.id, f.list.id)).tours).toHaveLength(1);
|
||||
expect(await countRows(orm, TourWaypoints, { place: f.ridge.id })).toBe(4);
|
||||
});
|
||||
|
||||
it('LIB3-SVC-007: without its points the new tour keeps the kept line as it is and has no points at all', async () => {
|
||||
const f = await fixture();
|
||||
const kept = await keep(f);
|
||||
const result = await service.takeOver(String(f.next.id), f.owner.id, {
|
||||
collection_id: f.list.id,
|
||||
collection_tour_id: kept.id,
|
||||
include_waypoints: false,
|
||||
name: 'Line only',
|
||||
});
|
||||
const copy = result.tour.place_id;
|
||||
expect(result.tour.name).toBe('Line only');
|
||||
expect(await countRows(orm, TourWaypoints, { place: copy })).toBe(0);
|
||||
expect((await findRow(orm, Places, { id: copy }))?.route_geometry).toBe(JSON.stringify(LINE));
|
||||
expect(await countRows(orm, Places, { trip: f.next.id })).toBe(1);
|
||||
expect((await findRows(orm, Tours, { place: copy }))[0]).toMatchObject({ distance: 4.2, elevation_gain: 300 });
|
||||
});
|
||||
|
||||
it('LIB3-SVC-008: a kept walk without a line cannot leave its points behind, and nothing is written', async () => {
|
||||
const f = await fixture();
|
||||
const walk = await makePlace(orm, f.alps.id, { name: 'Old town', lat: 48.1, lng: 11.5 });
|
||||
await makeTour(orm, walk.id, { tourTypeRef: 'walk' });
|
||||
await addTourWaypoints(orm, walk.id, [
|
||||
{ lat: 48.1, lng: 11.5, station_key: 'stop-gate-1', name: 'Gate', stay_minutes: 20 },
|
||||
{ lat: 48.11, lng: 11.51, station_key: 'stop-dome-1', name: 'Dome' },
|
||||
]);
|
||||
const { tour: kept } = await service.save(f.owner.id, f.list.id, {
|
||||
source_trip_id: f.alps.id,
|
||||
source_place_id: walk.id,
|
||||
});
|
||||
expect(kept).toMatchObject({
|
||||
has_line: false,
|
||||
station_count: 2,
|
||||
station_stay_minutes: 20,
|
||||
station_stays_unknown: 1,
|
||||
});
|
||||
const before = await countRows(orm, Places);
|
||||
expect(
|
||||
await refusal(
|
||||
service.takeOver(String(f.next.id), f.owner.id, {
|
||||
collection_id: f.list.id,
|
||||
collection_tour_id: kept.id,
|
||||
include_waypoints: false,
|
||||
}),
|
||||
),
|
||||
).toEqual({ status: 409, error: 'This tour has no line yet, so its points have to come along' });
|
||||
expect(await countRows(orm, Places)).toBe(before);
|
||||
// With its points it comes over as a walk still waiting for its way.
|
||||
const result = await service.takeOver(String(f.next.id), f.owner.id, {
|
||||
collection_id: f.list.id,
|
||||
collection_tour_id: kept.id,
|
||||
include_waypoints: true,
|
||||
});
|
||||
expect(result.tour).toMatchObject({ tour_type: 'walk', route_pending: true });
|
||||
expect(result.waypoints.map((point) => point.name)).toEqual(['Gate', 'Dome']);
|
||||
});
|
||||
|
||||
it('LIB3-SVC-009: refuses a list out of sight, a tour of another list, and a tour removed while it is made', async () => {
|
||||
const f = await fixture();
|
||||
const kept = await keep(f);
|
||||
const other = await makeCollection(orm, f.owner.id, { name: 'Other' });
|
||||
const body = { collection_id: f.list.id, collection_tour_id: kept.id, include_waypoints: true };
|
||||
expect(await refusal(service.takeOver(String(f.away.id), f.outsider.id, body))).toEqual({
|
||||
status: 404,
|
||||
error: 'Collection not found',
|
||||
});
|
||||
expect(
|
||||
await refusal(service.takeOver(String(f.next.id), f.owner.id, { ...body, collection_id: other.id })),
|
||||
).toEqual({ status: 404, error: 'Tour not found' });
|
||||
|
||||
// Gone between the check and the write: the write leaves no half-made tour behind.
|
||||
const repo = t.moduleRef.get<CollectionToursRepository>(getRepositoryToken(CollectionTours), { strict: false });
|
||||
const find = repo.findInCollection.bind(repo);
|
||||
vi.spyOn(repo, 'findInCollection')
|
||||
.mockImplementationOnce(find)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const before = await countRows(orm, Places);
|
||||
expect(await refusal(service.takeOver(String(f.next.id), f.owner.id, body))).toEqual({
|
||||
status: 404,
|
||||
error: 'Tour not found',
|
||||
});
|
||||
expect(await countRows(orm, Places)).toBe(before);
|
||||
});
|
||||
|
||||
it('LIB3-SVC-010: a tour type switched off since cannot be taken into a trip', async () => {
|
||||
const f = await fixture();
|
||||
const kept = await keep(f);
|
||||
await updateRows(orm, CollectionTours, { id: kept.id }, { tourTypeRef: 'food' });
|
||||
const before = await countRows(orm, Places);
|
||||
const refused = await refusal(
|
||||
service.takeOver(String(f.next.id), f.owner.id, {
|
||||
collection_id: f.list.id,
|
||||
collection_tour_id: kept.id,
|
||||
include_waypoints: true,
|
||||
}),
|
||||
);
|
||||
expect(refused.status).toBe(400);
|
||||
expect(await countRows(orm, Places)).toBe(before);
|
||||
});
|
||||
|
||||
it('LIB3-SVC-011: only the owner or an admin of the list removes a kept tour; tours made from it stay', async () => {
|
||||
const f = await fixture();
|
||||
const kept = await keep(f);
|
||||
const made = await service.takeOver(String(f.next.id), f.owner.id, {
|
||||
collection_id: f.list.id,
|
||||
collection_tour_id: kept.id,
|
||||
include_waypoints: true,
|
||||
});
|
||||
expect(await refusal(service.remove(f.editor.id, f.list.id, kept.id))).toEqual({
|
||||
status: 403,
|
||||
error: 'Only an admin can remove tours from this list',
|
||||
});
|
||||
t.realtime.broadcastToUserMock.mockClear();
|
||||
await service.remove(f.owner.id, f.list.id, kept.id);
|
||||
expect(t.realtime.broadcastToUserMock).toHaveBeenCalled();
|
||||
expect(await countRows(orm, CollectionTours)).toBe(0);
|
||||
expect(await findRow(orm, Places, { id: made.tour.place_id })).not.toBeNull();
|
||||
expect(await refusal(service.remove(f.owner.id, f.list.id, kept.id))).toEqual({
|
||||
status: 404,
|
||||
error: 'Tour not found',
|
||||
});
|
||||
});
|
||||
|
||||
it('LIB3-SVC-012: with the Collections addon off there is nothing to read, keep or take', async () => {
|
||||
const f = await fixture();
|
||||
const kept = await keep(f);
|
||||
await setAddonEnabled(orm, 'collections', false);
|
||||
const off = { status: 404, error: 'Collections addon is not enabled' };
|
||||
expect(await refusal(service.list(f.owner.id, f.list.id))).toEqual(off);
|
||||
expect(
|
||||
await refusal(service.save(f.owner.id, f.list.id, { source_trip_id: f.alps.id, source_place_id: f.ridge.id })),
|
||||
).toEqual(off);
|
||||
expect(await refusal(service.remove(f.owner.id, f.list.id, kept.id))).toEqual(off);
|
||||
expect(
|
||||
await refusal(
|
||||
service.takeOver(String(f.next.id), f.owner.id, {
|
||||
collection_id: f.list.id,
|
||||
collection_tour_id: kept.id,
|
||||
include_waypoints: true,
|
||||
}),
|
||||
),
|
||||
).toEqual(off);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user