feat(tours): show a multi-day tour's stages in the list and put each on a day

A multi-day tour in the planner's list of the trip's tours now unfolds into
its stages (MDT-02), so the stages can be seen together and fitted into the
trip without leaving the one route they are cut from.

- The fold beside a multi-day tour lists its stages in route order, each
  from stop to stop with its distance, climb, descent and moving time, read
  off the saved tour the way the planner reads them, and below them what the
  whole tour adds up to. A value no stage tells is left out, a total some
  stages do not tell is marked as at least that much and says for how many,
  and a stage whose route is not planned yet says so.
- Each stage has its own day: a pill opens the trip's days, moves the stage
  to another one or takes it off again. A stage without a day shows a dashed
  "No day" pill. A day holds a tour, or one of its stages, once, and a stage
  is on one day at most; the plan's own offline path writes it.
- A stage opens in the planner: the tour opens as any tour does and the
  stage is shown once its points are in.
- The day plan names the stage a day holds with its own figures, and the
  day's route runs to that stage's start and on from its end; the trip map
  draws that stretch only. The tour list still counts a tour once, however
  many days hold it or its stages.
- A saved change that takes a stage away (its stage end gone, the tour a day
  tour again, back to the recorded track) takes it off its day in the same
  write and tells every client; the planner says so before the save.
- REST and the assign_place_to_day tool take the stage by the key of the
  stop it starts at, with the same checks; list_tours names the stops
  between the stages and the stage each day holds.

A day assignment remembers its stage by that key (migration 046010); every
assignment made before holds the whole tour, as it did.
This commit is contained in:
Maurice
2026-10-20 13:21:10 +02:00
parent 6ec06c90f4
commit 8fa75b3c4e
100 changed files with 2787 additions and 127 deletions
@@ -30,6 +30,7 @@ export class DayAssignments {
end_day: number & Opt = 0;
accommodation_id?: number | null;
route_excluded: number & Opt = 0;
tour_stage_key?: string | null;
assignment_participants_collection = new Collection<AssignmentParticipants>(this);
file_links_collection = new Collection<FileLinks>(this);
reservations_collection = new Collection<Reservations>(this);
@@ -42,6 +43,13 @@ export class DayAssignments {
export const DayAssignmentsSchema = defineEntity({
class: DayAssignments,
repository: () => DayAssignmentsRepository,
indexes: [
{
name: 'idx_day_assignments_tour_stage',
where: 'tour_stage_key IS NOT NULL',
properties: ['place_id', 'tour_stage_key'],
},
],
properties: {
id: p.integer().primary(),
day: () => p.manyToOne(Days).ref().deleteRule('cascade').hidden().index('idx_day_assignments_day_id'),
@@ -61,6 +69,7 @@ export const DayAssignmentsSchema = defineEntity({
end_day: p.integer().default(0),
accommodation_id: p.integer().nullable().index('idx_day_assignments_accommodation_id'),
route_excluded: p.integer().default(0),
tour_stage_key: p.text().nullable(),
assignment_participants_collection: () => p.oneToMany(AssignmentParticipants).mappedBy('assignment').hidden(),
file_links_collection: () => p.oneToMany(FileLinks).mappedBy('assignment').hidden(),
reservations_collection: () => p.oneToMany(Reservations).mappedBy('assignment').hidden(),
@@ -19,4 +19,5 @@ export interface DayAssignmentsTable {
end_day: InsertOptional<number>;
accommodation_id: InsertOptional<number | null>;
route_excluded: InsertOptional<number>;
tour_stage_key: InsertOptional<string | null>;
}
@@ -0,0 +1,19 @@
import { Migration } from '@mikro-orm/migrations';
/**
* MDT-02: a day of the plan can hold one stage of a multi-day tour instead of
* the whole tour, named by the key of the stop the stage starts at (the key a
* save keeps for every point of a tour). NULL, as on every assignment made
* before, is the whole tour or an ordinary place. The index finds the day a
* stage is on, and the stages a changed tour no longer has.
*/
export class Migration20200101046010_a_day_can_hold_one_stage_of_a_tour extends Migration {
override name = 'Migration20200101046010_a_day_can_hold_one_stage_of_a_tour';
override up(): void {
this.addSql(`ALTER TABLE day_assignments ADD COLUMN tour_stage_key TEXT`);
this.addSql(
`CREATE INDEX idx_day_assignments_tour_stage ON day_assignments (place_id, tour_stage_key) WHERE tour_stage_key IS NOT NULL`,
);
}
}
@@ -105,6 +105,8 @@ export interface DayAssignmentRow {
end_day: number;
accommodation_id: number | null;
route_excluded: number;
/** MDT-02: the stage of a multi-day tour the day holds, by its starting stop's key; null for anything else. */
tour_stage_key: string | null;
}
const _dayAssignmentRowKeys: AssertRowKeys<DayAssignmentRow, DayAssignments> = true;
@@ -433,6 +435,8 @@ export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
order_index: number;
notes: string | null;
accommodation_id: number | null;
/** MDT-02: one stage of a multi-day tour, by its starting stop's key; absent for the whole tour or a place. */
tour_stage_key?: string | null;
}): Promise<number> {
return await this.insert({
day: input.day_id,
@@ -440,9 +444,43 @@ export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
order_index: input.order_index,
notes: input.notes,
accommodation_id: input.accommodation_id,
tour_stage_key: input.tour_stage_key ?? null,
});
}
/** MDT-02: the assignment holding this stage of a tour, with its day; `undefined` while the stage is on no day. */
async findTourStage(place_id: number, tour_stage_key: string): Promise<{ id: number; day_id: number } | undefined> {
return await this.kysely<Pick<DB, 'day_assignments'>>()
.selectFrom('day_assignments')
.select(['id', 'day_id'])
.where('place_id', '=', place_id)
.where('tour_stage_key', '=', tour_stage_key)
.executeTakeFirst();
}
/**
* MDT-02: takes off their days the stages of a tour whose starting stop is
* not among `keys` (every stage, for none), and answers what it took, so a
* stage a changed tour no longer has leaves no day holding it. The caller
* owns the transaction: the tour's points are written in the same one.
*/
async deleteTourStagesNotIn(place_id: number, keys: readonly string[]): Promise<Array<{ id: number; day_id: number }>> {
let query = this.kysely<Pick<DB, 'day_assignments'>>()
.selectFrom('day_assignments')
.select(['id', 'day_id'])
.where('place_id', '=', place_id)
.where('tour_stage_key', 'is not', null);
if (keys.length > 0) query = query.where('tour_stage_key', 'not in', [...keys]);
const gone = await query.orderBy('id', 'asc').execute();
if (gone.length > 0) {
await this.kysely<Pick<DB, 'day_assignments'>>()
.deleteFrom('day_assignments')
.where('id', 'in', gone.map((row) => row.id))
.execute();
}
return gone;
}
/** AS10 — `DELETE FROM day_assignments WHERE id = ?`. */
async deleteById(id: number): Promise<void> {
await this.nativeDelete({ id });
@@ -706,6 +744,8 @@ export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
assignment_time: string | null;
assignment_end_time: string | null;
end_day: number;
/** MDT-02: the copied tour keeps its stops' keys, so a day holding one of its stages keeps holding it. */
tour_stage_key?: string | null;
}): Promise<number> {
return await this.insert({
day: input.day_id,
@@ -718,6 +758,7 @@ export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
assignment_time: input.assignment_time,
assignment_end_time: input.assignment_end_time,
end_day: input.end_day,
tour_stage_key: input.tour_stage_key ?? null,
});
}
+38 -1
View File
@@ -219,12 +219,24 @@ type ToursKyselyDB = Pick<
>;
/** One day a tour of the trip sits on, in the order of the trip. */
/** MDT-02: a point of a tour as the stages a day can hold are read from it. */
export interface TourStagePointRow {
role: 'start' | 'via' | 'end';
lat: number;
lng: number;
key?: string;
stage_end?: boolean;
shaping?: boolean;
}
export interface TourDayRow {
place_id: number;
day_id: number;
day_number: number;
date: string | null;
title: string | null;
/** MDT-02: the stage of a multi-day tour the day holds, by its starting stop's key; null for the whole tour. */
tour_stage_key: string | null;
}
/**
@@ -557,7 +569,7 @@ export class ToursRepository extends TrekRepository<Tours> {
.execute();
}
/** The days every tour of a trip sits on, each tour and day once, in day order. */
/** The days every tour of a trip sits on, each tour and day once, in day order, with the stage a day holds (MDT-02). */
async listTourDays(trip_id: number): Promise<TourDayRow[]> {
return await this.db()
.selectFrom('day_assignments as da')
@@ -569,6 +581,7 @@ export class ToursRepository extends TrekRepository<Tours> {
'd.day_number as day_number',
'd.date as date',
'd.title as title',
'da.tour_stage_key as tour_stage_key',
])
.where('d.trip_id', '=', trip_id)
.distinct()
@@ -624,6 +637,30 @@ export class ToursRepository extends TrekRepository<Tours> {
return !!(await query.executeTakeFirst());
}
/**
* MDT-02: the points of a tour as its stages are read from them, in route
* order: role, key, stage end and where each sits. None for a place that is
* no tour, or a tour without points of its own.
*/
async listStagePoints(place_id: number): Promise<TourStagePointRow[]> {
const rows = await this.db()
.selectFrom('tour_waypoints')
.select(['role', 'station_key', 'stage_end', 'shaping', 'lat', 'lng'])
.where('place_id', '=', place_id)
.orderBy('sequence', 'asc')
// The column's CHECK holds it to the three roles.
.$narrowType<{ role: TourStagePointRow['role'] }>()
.execute();
return rows.map((row) => ({
role: row.role,
lat: row.lat,
lng: row.lng,
...(row.station_key ? { key: row.station_key } : {}),
...(row.stage_end ? { stage_end: true } : {}),
...(row.shaping ? { shaping: true } : {}),
}));
}
/** TO6 — every `tours` row of a trip's places, for the trip copy to remap. */
async listRowsForTrip(trip_id: number): Promise<TourRow[]> {
return await this.db()
@@ -33,7 +33,8 @@ import { Module } from '@nestjs/common';
* `DaysModule`/`PlacesModule`/`RoadtripModule` modules themselves, so the
* loop this module's docstring already avoids stays avoided (`DaysModule`'s
* own precedent for pulling in `Trips` the same way). `Tours` backs the
* one-Tour-per-day check create and move share.
* one-Tour-per-day check create and move share, and the stages of a
* multi-day tour a day can hold (MDT-02).
*/
@Module({
imports: [
@@ -56,7 +56,9 @@ export class DayAssignmentsController {
if (!(await this.assignments.placeExists(body.place_id, tripId))) {
throw new HttpException({ error: 'Place not found' }, 404);
}
const assignment = await this.assignments.createAssignment(dayId, body.place_id, body.notes);
const assignment = await this.assignments.createAssignment(dayId, body.place_id, body.notes, {
...(body.tour_stage_key !== undefined ? { tourStageKey: body.tour_stage_key } : {}),
});
this.assignments.broadcast(tripId, 'assignment:created', { assignment }, socketId);
await this.assignments.reconcile(tripId, socketId);
return { assignment };
+21 -4
View File
@@ -13,7 +13,12 @@ import {
import { DaysService } from '../days/days.service';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { AssignmentsService } from './assignments.service';
import { idSchema, assignmentEndDayRequestSchema, type AssignmentEndDayRequest } from '@trek/shared';
import {
idSchema,
assignmentCreateRequestSchema,
assignmentEndDayRequestSchema,
type AssignmentEndDayRequest,
} from '@trek/shared';
import { z } from 'zod';
@@ -36,25 +41,37 @@ export class AssignmentsMcp {
@Tool({
name: 'assign_place_to_day',
description: 'Assign a place to a specific day in a trip.',
description:
'Assign a place to a specific day in a trip. For a multi-day tour, pass tourStageKey to put one of its stages on the day instead of the whole tour: the key of the stop the stage starts at, from the stage_stops list_tours gives (every stop but the last starts a stage). A stage is on one day at most, and a day holds a tour, or one of its stages, once.',
inputSchema: {
tripId: idSchema,
dayId: idSchema,
placeId: idSchema,
notes: z.string().max(500).optional(),
tourStageKey: assignmentCreateRequestSchema.shape.tour_stage_key.describe(
'The key of the stop a stage of a multi-day tour starts at, to put that stage on the day instead of the whole tour',
),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'places', mode: 'write' },
})
async assignPlaceToDay(
{ tripId, dayId, placeId, notes }: { tripId: number; dayId: number; placeId: number; notes?: string },
{
tripId,
dayId,
placeId,
notes,
tourStageKey,
}: { tripId: number; dayId: number; placeId: number; notes?: string; tourStageKey?: string },
ctx: McpContext,
) {
if (!(await this.assignments.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.assignments.dayExists(dayId, tripId))) return errorResult('Day not found.');
if (!(await this.assignments.placeExists(placeId, tripId))) return errorResult('Place not found.');
const assignment = await this.assignments.createAssignment(dayId, placeId, notes || null);
const assignment = await this.assignments.createAssignment(dayId, placeId, notes || null, {
...(tourStageKey !== undefined ? { tourStageKey } : {}),
});
this.guards.safeBroadcast(tripId, 'assignment:created', { assignment });
await this.assignments.reconcile(tripId);
return ok({ assignment });
@@ -14,6 +14,7 @@ import type { ToursRepository } from '../../db/repositories/Tours.repository';
import type { TripMembersRepository } from '../../db/repositories/TripMembers.repository';
import type { TripAccess } from '../../db/repositories/Trips.repository';
import type { User } from '../../types';
import { DomainError } from '../common/domain-error';
import { toRowId } from '../common/row-id';
import { formatAssignmentWithPlace } from '../common/rowShape';
import { UnitOfWork } from '../database/unit-of-work';
@@ -24,7 +25,13 @@ import { RealtimeService } from '../realtime/realtime.service';
import { TripAccessService } from '../trip-membership/trip-access.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { ConflictException, Injectable } from '@nestjs/common';
import { chronoOrder, type RoadtripVia, type TrekWsPayload, type TrekWsTripEventName } from '@trek/shared';
import {
chronoOrder,
tourStageKeysOf,
type RoadtripVia,
type TrekWsPayload,
type TrekWsTripEventName,
} from '@trek/shared';
import { isEmptyReanchoring, reanchorByStopOrder } from '@trek/shared/roadtrip';
type Trip = TripAccess;
@@ -261,12 +268,14 @@ export class AssignmentsService {
* afterwards: the row this returns is what the answer hands the client, and a
* stop that reaches it without its booking id is one the day list cannot tell
* from a place the traveller added, so it draws the hotel a second time.
* @param opts.tourStageKey MDT-02: one stage of a multi-day tour instead of
* the whole tour, by the key of the stop it starts at.
*/
async createAssignment(
dayId: string | number,
placeId: unknown,
notes?: string | null,
opts: { accommodationId?: number; orderIndex?: number } = {},
opts: { accommodationId?: number; orderIndex?: number; tourStageKey?: string } = {},
) {
// Downstream of the caller's own dayExists/placeExists gate in the same
// request (every real call site), so the non-null assertion is
@@ -276,6 +285,7 @@ export class AssignmentsService {
const insertedId = await this.uow.transactional(async () => {
await this.assertTourFreeOnDay(dayIdNum, placeIdNum);
if (opts.tourStageKey !== undefined) await this.assertTourStageFree(placeIdNum, opts.tourStageKey);
// AS6 — the explicit null check (not `||`) is load-bearing: a stored 0
// order_index must survive, which `maxOrder || -1` would not.
const maxOrder = await this.dayAssignmentsRepo.maxOrderIndex(dayIdNum);
@@ -295,6 +305,7 @@ export class AssignmentsService {
order_index: orderIndex,
notes: notes || null,
accommodation_id: opts.accommodationId ?? null,
tour_stage_key: opts.tourStageKey ?? null,
});
});
@@ -395,6 +406,30 @@ export class AssignmentsService {
}
}
/**
* MDT-02: a stage goes on a day only as a stage the tour has (read off its
* saved points, so a place that is no tour has none) and on one day at most;
* moving it to another day is a move, not a second stage. Asked inside the
* transaction that writes it, as the one-tour-a-day check is.
*/
private async assertTourStageFree(placeId: number, key: string): Promise<void> {
if (!tourStageKeysOf(await this.toursRepo.listStagePoints(placeId)).includes(key)) {
throw new DomainError(400, 'Tour has no such stage', { code: 'tour_stage_unknown' });
}
if (await this.dayAssignmentsRepo.findTourStage(placeId, key)) {
throw new DomainError(409, 'Stage is already on a day', { code: 'tour_stage_taken' });
}
}
/**
* MDT-02: takes the stages a saved change of a tour no longer has off their
* days, inside the caller's transaction, and answers which, so the caller
* can tell the trip once the change is committed.
*/
async dropStaleTourStages(placeId: number, keys: readonly string[]): Promise<Array<{ id: number; day_id: number }>> {
return await this.dayAssignmentsRepo.deleteTourStagesNotIn(placeId, keys);
}
/** AS15 — `AssignmentParticipantsRepository.listWithDisplayName`, the same AS2/AS31 statement. */
async getParticipants(assignmentId: string | number) {
return await this.assignmentParticipantsRepo.listWithDisplayName(toRowId(assignmentId)!);
+2
View File
@@ -50,6 +50,8 @@ export function formatAssignmentWithPlace(
created_at: a.created_at,
tour_place_id: a.tour_place_id ?? null,
tour_route_geometry: a.tour_route_geometry ?? null,
// MDT-02: the stage of a multi-day tour the day holds; null for the whole tour or a place.
tour_stage_key: a.tour_stage_key ?? null,
place: {
id: a.place_id,
name: a.place_name,
+1
View File
@@ -206,6 +206,7 @@ export class DaysService {
created_at: a.created_at,
tour_place_id: a.tour_place_id ?? null,
tour_route_geometry: a.tour_route_geometry ?? null,
tour_stage_key: a.tour_stage_key ?? null,
place: {
id: a.place_id,
name: a.place_name,
@@ -269,6 +269,7 @@ export const PLUGIN_ENTITY_CONTRACT = {
'incoming_leg_transport_mode',
'route_excluded',
'accommodation_id',
'tour_stage_key',
'created_at',
],
withheld: [],
@@ -136,7 +136,7 @@ export class TourChangeSaveService {
// Laid once more on the tour as it is now: a tour moved on, a station done or a place gone refuses here.
const now = await this.preview.lay(tripId, placeId, changes, { action: 'saved', preparedOn });
const before = await this.beforeState(tripId, placeId, now);
const { revision, visitsDropped } = await this.tours.replaceRoute(tripId, placeId, write);
const { revision, visitsDropped, stagesDropped } = await this.tours.replaceRoute(tripId, placeId, write);
const id = await this.stepsRepo.record({
place_id: placeId,
proposal_id: proposalId,
@@ -146,10 +146,10 @@ export class TourChangeSaveService {
before_state: before,
saved_by: userId,
});
return { step: { id, revision }, visitsDropped };
return { step: { id, revision }, visitsDropped, stagesDropped };
});
if ('earlier' in saved) return alreadySavedAnswer(saved.earlier);
const detail = await this.tours.announceRouteUpdate(String(tripId), placeId, saved.visitsDropped);
const detail = await this.tours.announceRouteUpdate(String(tripId), placeId, saved);
return savedAnswer(detail, saved.step, changed);
}
@@ -177,7 +177,7 @@ export class TourChangeUndoService {
}
return { step, ...written };
});
const detail = await this.tours.announceRouteUpdate(String(tripId), placeId, restored.visitsDropped);
const detail = await this.tours.announceRouteUpdate(String(tripId), placeId, restored);
return {
undone: true as const,
step: { id: restored.step.id, revision: restored.step.revision, undone_revision: restored.revision },
@@ -78,7 +78,7 @@ export class TourLibraryService {
const needle = query.q?.toLocaleLowerCase();
const matching = needle ? rows.filter((row) => row.name.toLocaleLowerCase().includes(needle)) : rows;
const shown = matching.slice(0, TOUR_LIBRARY_LIMIT);
const walkPoints = await this.tours.walkPointsByPlace(shown);
const walkPoints = await this.tours.listedPointsByPlace(shown);
return {
tours: shown.map((row) => toItem(row, walkPoints.get(row.place_id))),
trips,
@@ -90,7 +90,7 @@ export class TourLibraryService {
const row = await this.toursRepo.findForLibrary(await this.reachableTrips(userId), placeId);
if (!row) throw new NotFoundException('Tour not found');
const line = parseRouteGeometry(row.route_geometry).map((point): [number, number] => [point[0], point[1]]);
const walkPoints = await this.tours.walkPointsByPlace([row]);
const walkPoints = await this.tours.listedPointsByPlace([row]);
return {
tour: toItem(row, walkPoints.get(row.place_id)),
line: thinnedLine(line, TOUR_LIBRARY_PREVIEW_POINTS),
+2 -2
View File
@@ -21,7 +21,7 @@ import {
type TourPoiSearchRequest,
} from '@trek/shared';
type TourDay = Pick<TourDayRow, 'day_id' | 'day_number' | 'date' | 'title'>;
type TourDay = Pick<TourDayRow, 'day_id' | 'day_number' | 'date' | 'title' | 'tour_stage_key'>;
const poiFields = tourPoiSearchRequestSchema.shape;
@@ -101,7 +101,7 @@ export class ToursMcp {
@Tool({
name: 'list_tours',
description:
'List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours, rides on horseback and ski tracks planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, horse for a ride, or ski for a ski track; a ride has no riding time worked out, so its duration is null), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the winter label of a hike (winter_activity: winter_hike or snowshoe, null for none; a label the travellers chose, while the route and its time are planned as for any hike, never a statement that a way can be walked in winter or anything about snow or avalanches), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the classification of a ski track (ski_discipline: alpine, snowboard, cross_country or touring for ski touring; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a ski track is imported from a GPX file and kept as recorded, never routed, its distance and heights come from the file, it has no ski time worked out, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), for a multi-day tour the number of stages its route is split into (stage_count: one route planned whole, each stage ending at a stop marked as its end; null for a tour of one day), the revision (raised by every saved change of the tour), the recorded tracks assigned to a tour (activity_tracks) and, for a recorded track, the tour it documents (activity_of), when an imported track was recorded (track_started_at, track_ended_at; null when the file carried no times), and the days of the trip each tour is on.',
'List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours, rides on horseback and ski tracks planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, horse for a ride, or ski for a ski track; a ride has no riding time worked out, so its duration is null), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the winter label of a hike (winter_activity: winter_hike or snowshoe, null for none; a label the travellers chose, while the route and its time are planned as for any hike, never a statement that a way can be walked in winter or anything about snow or avalanches), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the classification of a ski track (ski_discipline: alpine, snowboard, cross_country or touring for ski touring; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a ski track is imported from a GPX file and kept as recorded, never routed, its distance and heights come from the file, it has no ski time worked out, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), for a multi-day tour the number of stages its route is split into (stage_count: one route planned whole, each stage ending at a stop marked as its end; null for a tour of one day) and the stops they run between (stage_stops, in route order with key and coordinates: stage n runs from stop n-1 to stop n, and is known by the key of the stop it starts at), the revision (raised by every saved change of the tour), the recorded tracks assigned to a tour (activity_tracks) and, for a recorded track, the tour it documents (activity_of), when an imported track was recorded (track_started_at, track_ended_at; null when the file carried no times), and the days of the trip each tour is on (tour_stage_key names the stage a day holds by its starting stop, null where the day holds the whole tour; a stage may also be on no day yet).',
inputSchema: { tripId: idSchema },
annotations: TOOL_ANNOTATIONS_READONLY,
when: addonGate(ADDON_IDS.TOURS),
+3
View File
@@ -9,6 +9,7 @@ import { TourTypes } from '../../db/entities/TourTypes.entity';
import { TourWaypoints } from '../../db/entities/TourWaypoints.entity';
import { Tours } from '../../db/entities/Tours.entity';
import { AddonsModule } from '../addons/addons.module';
import { AssignmentsDomainModule } from '../assignments/assignments-domain.module';
import { MapsModule } from '../maps/maps.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { OverpassModule } from '../overpass/overpass.module';
@@ -104,6 +105,8 @@ import { Module } from '@nestjs/common';
PaddleObstaclesModule,
// A city walk prepared over MCP is planned on foot by the router the road trip uses (CW-11).
RoadtripModule,
// A saved change takes the stages a tour no longer has off their days (MDT-02).
AssignmentsDomainModule,
MikroOrmModule.forFeature([
Tours,
TourTypes,
+66 -16
View File
@@ -23,6 +23,7 @@ import type {
} from '../../db/repositories/Tours.repository';
import { utcSuffix } from '../../db/types';
import { decodeJson, encodeJson } from '../../utils/json-column';
import { AssignmentsService } from '../assignments/assignments.service';
import { badRequest, DomainError } from '../common/domain-error';
import { toRowId } from '../common/row-id';
import { UnitOfWork } from '../database/unit-of-work';
@@ -67,6 +68,8 @@ import {
type TourWaypoint,
tourStageCountOf,
tourStageIssues,
tourStageKeysOf,
tourStageStopsOf,
type WandererDifficulty,
type WinterActivity,
walkStationFacts,
@@ -109,6 +112,12 @@ export interface TourRouteWrite {
fields: TourUpdate;
}
/** What a route write took along: the visits of stations that are gone, and the days that held a stage that is gone (MDT-02). */
export interface TourRouteDrops {
visitsDropped: readonly number[];
stagesDropped: ReadonlyArray<{ id: number; day_id: number }>;
}
/** The way on foot the server's router planned for a city walk (CW-11): its line, and how long it walks in seconds. */
interface CityWalkFootRoute {
line: Array<[number, number]>;
@@ -155,6 +164,12 @@ function keyedWaypoints(waypoints: TourWaypoint[]): Array<TourWaypoint & { key:
});
}
/** MDT-02: the stops a multi-day tour's stages run between; null where its points do not tell them. */
function stageStopsOrNull(points: Parameters<typeof tourStageStopsOf>[0]): TourListItem['stage_stops'] {
const stops = tourStageStopsOf(points);
return stops.length > 0 ? stops : null;
}
@Injectable()
export class ToursService {
private readonly logger = new Logger(ToursService.name);
@@ -170,6 +185,7 @@ export class ToursService {
@InjectRepository(TourActivities) private readonly activitiesRepo: TourActivitiesRepository,
private readonly placeImport: PlaceImportService,
private readonly paddleChecks: TourPaddleCheckService,
private readonly assignments: AssignmentsService,
) {}
/**
@@ -224,6 +240,8 @@ export class ToursService {
...activityFacts(r.place_id, activities),
// MDT-01: a multi-day tour's stages, one more than the stops that end one.
stage_count: r.stage_ends > 0 ? r.stage_ends + 1 : null,
// MDT-02: the stops they run between, by which a day holds one of them.
stage_stops: r.stage_ends > 0 ? stageStopsOrNull(points) : null,
};
}
@@ -317,25 +335,28 @@ export class ToursService {
const tid = toRowId(tripId);
if (tid === null) return [];
const rows = await this.toursRepo.listForTrip(tid);
const walkPoints = await this.walkPointsByPlace(rows);
const listedPoints = await this.listedPointsByPlace(rows);
const activities = await this.activitiesRepo.listForTrip(tid);
return await this.paddleChecks.forTrip(
tid,
rows.map((r) => this.toItem(r, walkPoints.get(r.place_id) ?? [], activities)),
rows.map((r) => this.toItem(r, listedPoints.get(r.place_id) ?? [], activities)),
);
}
/**
* CW-02, CW-03: the points of the city walks among these tours, by tour, for
* a list that counts their stations and stays; the points of another type
* are not read.
* The points a list reads, by tour: a city walk's, which it counts the
* stations and stays of (CW-02, CW-03), and a multi-day tour's, which it
* reads the stops between the stages off (MDT-02). The points of any other
* tour are not read.
*/
async walkPointsByPlace(
rows: readonly { place_id: number; tour_type: string }[],
async listedPointsByPlace(
rows: readonly { place_id: number; tour_type: string; stage_ends?: number }[],
): Promise<Map<number, TourWaypointWithPlaceRow[]>> {
const byPlace = new Map<number, TourWaypointWithPlaceRow[]>();
const walks = rows.filter((row) => row.tour_type === 'walk').map((row) => row.place_id);
for (const point of await this.waypointsRepo.listForPlaces(walks)) {
const read = rows
.filter((row) => row.tour_type === 'walk' || (row.stage_ends ?? 0) > 0)
.map((row) => row.place_id);
for (const point of await this.waypointsRepo.listForPlaces(read)) {
const points = byPlace.get(point.place_id);
if (points) points.push(point);
else byPlace.set(point.place_id, [point]);
@@ -545,7 +566,7 @@ export class ToursService {
const pid = toRowId(placeId);
if (tid === null || pid === null) throw new NotFoundException('Tour not found');
await this.uow.transactional(async () => {
const stagesDropped = await this.uow.transactional(async () => {
const row = await this.toursRepo.findInTrip(tid, pid);
if (!row) throw new NotFoundException('Tour not found');
const recorded = await this.toursRepo.findRecordedTrack(tid, pid);
@@ -595,6 +616,8 @@ export class ToursService {
});
await this.waypointsRepo.deleteForPlace(pid);
await this.toursRepo.clearRecordedTrack(pid);
// MDT-02: back to the recorded track the tour has no stages, so no day holds one.
return await this.assignments.dropStaleTourStages(pid, []);
});
const result = await this.getTour(tripId, placeId);
@@ -606,6 +629,7 @@ export class ToursService {
} catch {
this.logger.warn(`Committed Tour ${placeId}: place notification failed`);
}
await this.announceStagesDropped(tripId, stagesDropped);
return result;
}
@@ -758,8 +782,8 @@ export class ToursService {
const pid = toRowId(placeId);
if (tid === null || pid === null) throw new NotFoundException('Tour not found');
const write = await this.routeWrite(input);
const { visitsDropped } = await this.uow.transactional(() => this.replaceRoute(tid, pid, write));
return await this.announceRouteUpdate(tripId, pid, visitsDropped, socketId);
const dropped = await this.uow.transactional(() => this.replaceRoute(tid, pid, write));
return await this.announceRouteUpdate(tripId, pid, dropped, socketId);
}
/**
@@ -788,7 +812,7 @@ export class ToursService {
tripId: number,
placeId: number,
{ input, start, fields }: TourRouteWrite,
): Promise<{ revision: number; visitsDropped: number[] }> {
): Promise<{ revision: number } & TourRouteDrops> {
// The trip predicate is the cross-trip boundary. It runs inside the write
// so an id from another accessible trip cannot be moved into the current
// one, and a place deleted meanwhile answers 404 instead of failing a
@@ -813,14 +837,19 @@ export class ToursService {
await this.waypointsRepo.insertForPlace(placeId, waypoints);
// CW-03: a station that is gone takes its visits with it; a tour that is no city walk keeps none.
const stations = input.tour_type === 'walk' ? walkStationsOf(waypoints).map((point) => point.key) : [];
return { revision, visitsDropped: await this.visitsRepo.dropStationsNotIn(placeId, stations) };
return {
revision,
visitsDropped: await this.visitsRepo.dropStationsNotIn(placeId, stations),
// MDT-02: a stage the change took away (its stage end gone, the tour a day tour again) leaves its day.
stagesDropped: await this.assignments.dropStaleTourStages(placeId, tourStageKeysOf(waypoints)),
};
}
/** After a route write committed: the tour read back, and the trip told of it and of the visits it took along. */
/** After a route write committed: the tour read back, and the trip told of it and of the visits and stages it took along. */
async announceRouteUpdate(
tripId: string,
placeId: number,
visitsDropped: readonly number[],
{ visitsDropped, stagesDropped }: TourRouteDrops,
socketId?: string,
): Promise<TourDetailResponse> {
const result = await this.getTour(tripId, String(placeId));
@@ -833,9 +862,30 @@ export class ToursService {
this.logger.warn(`Committed Tour ${placeId}: place notification failed`);
}
await this.announceVisits(tripId, visitsDropped);
await this.announceStagesDropped(tripId, stagesDropped);
return result;
}
/**
* MDT-02: the days that held a stage a change took away, told to every
* client of the trip, the one that saved included, since its day plan still
* shows them; the journey follows the days as after any unassign.
*/
private async announceStagesDropped(
tripId: string,
dropped: ReadonlyArray<{ id: number; day_id: number }>,
): Promise<void> {
if (dropped.length === 0) return;
for (const { id, day_id } of dropped) {
try {
this.places.broadcast(tripId, 'assignment:deleted', { assignmentId: id, dayId: day_id }, undefined);
} catch {
this.logger.warn(`Committed Tour stage change: realtime notification of assignment ${id} failed`);
}
}
await this.assignments.reconcile(tripId);
}
/**
* CW-03: the days whose visits an edit took along, told to every client of
* the trip, the one that saved included, so no day plan goes on counting a
+1
View File
@@ -768,6 +768,7 @@ export class TripsService {
reservation_status: a.reservation_status, reservation_notes: a.reservation_notes,
reservation_datetime: a.reservation_datetime, assignment_time: a.assignment_time,
assignment_end_time: a.assignment_end_time, end_day: a.end_day ?? 0,
tour_stage_key: a.tour_stage_key,
});
assignmentMap.set(a.id, newAssignmentId);
}
+30 -1
View File
@@ -39,7 +39,7 @@ import {
insertRows,
updateRows,
} from '../helpers/factories/rows';
import { makeTour } from '../helpers/factories/tours';
import { addTourWaypoints, makeTour } from '../helpers/factories/tours';
import { makeUser } from '../helpers/factories/users';
import { FakeRealtimeService } from '../helpers/fake-realtime';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
@@ -283,6 +283,35 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => {
}
});
it('MDT-E2E-001: 201 puts a stage of a multi-day tour on a day, 409 a second day, 400 a stage it has not, 400 a malformed key', async () => {
const tourPlaceId = 21;
await insertRow(orm, Places, { id: tourPlaceId, trip: 5, name: 'Hut to hut' });
await makeTour(orm, tourPlaceId, { tourTypeRef: 'hike' });
await addTourWaypoints(orm, tourPlaceId, [
{ lat: 47, lng: 11, station_key: 'stop-start' },
{ lat: 47.1, lng: 11, station_key: 'stop-hut', stage_end: 1 },
{ lat: 47.2, lng: 11, station_key: 'stop-end' },
]);
const post = (dayId: number, body: Record<string, unknown>) =>
request(server).post(`/api/trips/5/days/${dayId}/assignments`).set('Cookie', sessionCookie(1)).send(body);
try {
const ok = await post(3, { place_id: tourPlaceId, tour_stage_key: 'stop-hut' });
expect(ok.status).toBe(201);
expect(ok.body.assignment).toMatchObject({ day_id: 3, tour_place_id: tourPlaceId, tour_stage_key: 'stop-hut' });
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:created', { assignment: ok.body.assignment }, undefined);
const twice = await post(4, { place_id: tourPlaceId, tour_stage_key: 'stop-hut' });
expect([twice.status, twice.body]).toEqual([409, { error: 'Stage is already on a day' }]);
const none = await post(4, { place_id: tourPlaceId, tour_stage_key: 'stop-end' });
expect([none.status, none.body]).toEqual([400, { error: 'Tour has no such stage' }]);
const malformed = await post(4, { place_id: tourPlaceId, tour_stage_key: 'no' });
expect(malformed.status).toBe(400);
expect(await countRows(orm, DayAssignments, { place: tourPlaceId })).toBe(1);
} finally {
await dropTourPlace(tourPlaceId);
}
});
it('200 delete assignment reconciles journey skeletons', async () => {
reconcileTripSkeletons.mockClear();
const id = await seedAssignment();
+4 -3
View File
@@ -374,21 +374,22 @@ export interface TestDayAssignment {
place_id: number;
order_index: number;
notes: string | null;
tour_stage_key: string | null;
}
export function createDayAssignment(
db: Database.Database,
dayId: number,
placeId: number,
overrides: Partial<{ order_index: number; notes: string }> = {},
overrides: Partial<{ order_index: number; notes: string; tour_stage_key: string }> = {},
): TestDayAssignment {
const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId) as {
max: number | null;
};
const orderIndex = overrides.order_index ?? (maxOrder.max !== null ? maxOrder.max + 1 : 0);
const result = db
.prepare('INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, ?, ?)')
.run(dayId, placeId, orderIndex, overrides.notes ?? null);
.prepare('INSERT INTO day_assignments (day_id, place_id, order_index, notes, tour_stage_key) VALUES (?, ?, ?, ?, ?)')
.run(dayId, placeId, orderIndex, overrides.notes ?? null, overrides.tour_stage_key ?? null);
return db.prepare('SELECT * FROM day_assignments WHERE id = ?').get(result.lastInsertRowid) as TestDayAssignment;
}
@@ -0,0 +1,19 @@
/**
* The part of AssignmentsService that ToursService asks when a saved change
* takes stages of a multi-day tour off their days (MDT-02), for suites that
* build ToursService by hand. Given the day assignments repository it takes
* the rows off for real, so a suite on the test database sees them go;
* without one there is nothing to take off. The journey refresh after it is
* left out: the assignments suites cover it.
*/
import type { DayAssignmentsRepository } from '../../src/db/repositories/DayAssignments.repository';
import type { AssignmentsService } from '../../src/nest/assignments/assignments.service';
export function tourStageDays(dayAssignments?: DayAssignmentsRepository): AssignmentsService {
const stages = {
dropStaleTourStages: async (placeId: number, keys: readonly string[]) =>
dayAssignments ? await dayAssignments.deleteTourStagesNotIn(placeId, keys) : [],
reconcile: async () => undefined,
};
return stages as unknown as AssignmentsService;
}
@@ -75,6 +75,7 @@ const UNNUMBERED = [
'Migration20200101045900_a_tour_keeps_the_track_it_was_done_with',
'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',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -1341,3 +1341,79 @@ describe('DayAssignmentsRepository.listPublicForShare (SH9, share.service.ts)',
expect(await assignments.listPublicForShare([])).toEqual([]);
});
});
describe('DayAssignmentsRepository tour stages (MDT-02)', () => {
/** A tour on a trip with three days, the first holding nothing yet. */
function tourOnTrip() {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const days = [createDay(testDb, trip.id), createDay(testDb, trip.id), createDay(testDb, trip.id)];
const place = createPlace(testDb, trip.id);
createTour(testDb, place.id);
return { trip, days, place };
}
const stageOf = async (id: number) => (await findRow(t, DayAssignments, { id }))?.tour_stage_key;
it('ASSIGNREPO-043: writes the stage a day holds, and none for the whole tour', async () => {
const { days, place } = tourOnTrip();
const base = { place_id: place.id, order_index: 0, notes: null, accommodation_id: null };
const stage = await assignments.insertAssignment({ ...base, day_id: days[0].id, tour_stage_key: 'stage-one' });
const whole = await assignments.insertAssignment({ ...base, day_id: days[1].id });
expect(await stageOf(stage)).toBe('stage-one');
expect(await stageOf(whole)).toBeNull();
});
it('ASSIGNREPO-044: finds the day a stage is on, and nothing for a stage on none or of another tour', async () => {
const { trip, days, place } = tourOnTrip();
const held = createDayAssignment(testDb, days[1].id, place.id, { tour_stage_key: 'stage-two' });
expect(await assignments.findTourStage(place.id, 'stage-two')).toEqual({ id: held.id, day_id: days[1].id });
expect(await assignments.findTourStage(place.id, 'stage-one')).toBeUndefined();
const other = createPlace(testDb, trip.id);
expect(await assignments.findTourStage(other.id, 'stage-two')).toBeUndefined();
});
it('ASSIGNREPO-045: takes off only the stages not kept, leaving the whole tour and other tours alone', async () => {
const { trip, days, place } = tourOnTrip();
const first = createDayAssignment(testDb, days[0].id, place.id, { tour_stage_key: 'stage-one' });
const second = createDayAssignment(testDb, days[1].id, place.id, { tour_stage_key: 'stage-two' });
const whole = createDayAssignment(testDb, days[2].id, place.id);
const other = createPlace(testDb, trip.id);
const otherStage = createDayAssignment(testDb, days[2].id, other.id, { tour_stage_key: 'stage-two' });
const gone = await uow.transactional(() => assignments.deleteTourStagesNotIn(place.id, ['stage-one']));
expect(gone).toEqual([{ id: second.id, day_id: days[1].id }]);
const left = (await findRows(t, DayAssignments, {}, { id: 'asc' })).map((row) => row.id);
expect(left).toEqual([first.id, whole.id, otherStage.id]);
});
it('ASSIGNREPO-046: takes every stage off with no key kept, and writes nothing when no stage is held', async () => {
const { days, place } = tourOnTrip();
const first = createDayAssignment(testDb, days[0].id, place.id, { tour_stage_key: 'stage-one' });
const second = createDayAssignment(testDb, days[1].id, place.id, { tour_stage_key: 'stage-two' });
expect(await assignments.deleteTourStagesNotIn(place.id, [])).toEqual([
{ id: first.id, day_id: days[0].id },
{ id: second.id, day_id: days[1].id },
]);
expect(await assignments.deleteTourStagesNotIn(place.id, [])).toEqual([]);
});
it('ASSIGNREPO-047: a copied assignment keeps the stage it held', async () => {
const { days, place } = tourOnTrip();
const copy = {
place_id: place.id,
order_index: 0,
notes: null,
reservation_status: null,
reservation_notes: null,
reservation_datetime: null,
assignment_time: null,
assignment_end_time: null,
end_day: 0,
};
const stage = await assignments.insertAssignmentCopy({ ...copy, day_id: days[0].id, tour_stage_key: 'stage-one' });
const whole = await assignments.insertAssignmentCopy({ ...copy, day_id: days[1].id });
expect(await stageOf(stage)).toBe('stage-one');
expect(await stageOf(whole)).toBeNull();
});
});
@@ -263,6 +263,44 @@ describe('ToursRepository reads', () => {
});
});
describe('ToursRepository stages on days (MDT-02)', () => {
it('TOURREPO-017: listStagePoints reads the points in route order with key, role and stage end, none for a plain place', async () => {
const { trip } = fixture();
const hutToHut = await makePlace(t, trip.id, { name: 'Hut to hut' });
const plain = await makePlace(t, trip.id, { name: 'Plain' });
createTour(testDb, hutToHut.id);
await insertRows(t, TourWaypoints, [
{ place: hutToHut.id, lat: 47.2, lng: 11, role: 'end', sequence: 3, station_key: 'stop-end' },
{ place: hutToHut.id, lat: 47, lng: 11, role: 'start', sequence: 0, station_key: 'stop-start' },
{ place: hutToHut.id, lat: 47.1, lng: 11, role: 'via', sequence: 2, station_key: 'stop-hut', stage_end: 1 },
{ place: hutToHut.id, lat: 47.05, lng: 11, role: 'via', sequence: 1, shaping: 1 },
]);
expect(await repo.listStagePoints(hutToHut.id)).toEqual([
{ role: 'start', lat: 47, lng: 11, key: 'stop-start' },
{ role: 'via', lat: 47.05, lng: 11, shaping: true },
{ role: 'via', lat: 47.1, lng: 11, key: 'stop-hut', stage_end: true },
{ role: 'end', lat: 47.2, lng: 11, key: 'stop-end' },
]);
expect(await repo.listStagePoints(plain.id)).toEqual([]);
});
it('TOURREPO-018: listTourDays names the stage each day holds, null where a day holds the whole tour', async () => {
const { trip } = fixture();
const tour = createPlace(testDb, trip.id);
createTour(testDb, tour.id);
const first = createDay(testDb, trip.id);
const second = createDay(testDb, trip.id);
createDayAssignment(testDb, first.id, tour.id);
createDayAssignment(testDb, second.id, tour.id, { tour_stage_key: 'stop-hut' });
expect((await repo.listTourDays(trip.id)).map((row) => [row.day_id, row.tour_stage_key])).toEqual([
[first.id, null],
[second.id, 'stop-hut'],
]);
});
});
describe('ToursRepository writes', () => {
it('TOURREPO-006: insertTour writes the metrics and leaves the unset metadata NULL', async () => {
const { trip } = fixture();
@@ -6,6 +6,7 @@ import { db as testDb } from '../../../src/db/database';
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
import { JourneyEntries } from '../../../src/db/entities/JourneyEntries.entity';
import { RoadtripVias } from '../../../src/db/entities/RoadtripVias.entity';
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
import {
createUser,
createTrip,
@@ -15,11 +16,12 @@ import {
createJourney,
} from '../../helpers/factories';
import { linkTripToJourney } from '../../helpers/factories/journeys';
import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
import { countRows, findRow, findRows, insertRow, insertRows, updateRows } from '../../helpers/factories/rows';
import { FakeRealtimeService } from '../../helpers/fake-realtime';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { createTour } from '../../helpers/tours-repos';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
@@ -108,6 +110,49 @@ describe('Tool: assign_place_to_day', () => {
});
});
it('MDT-MCP-001: puts one stage of a multi-day tour on a day, refusing as REST does', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const secondDay = createDay(testDb, trip.id);
const tour = createPlace(testDb, trip.id);
createTour(testDb, tour.id);
await insertRows(
orm,
TourWaypoints,
['stop-start', 'stop-hut', 'stop-end'].map((station_key, sequence) => ({
place: tour.id,
lat: 47 + sequence / 10,
lng: 11,
role: sequence === 0 ? 'start' : sequence === 2 ? 'end' : 'via',
sequence,
station_key,
stage_end: sequence === 1 ? 1 : 0,
})),
);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'assign_place_to_day',
arguments: { tripId: trip.id, dayId: day.id, placeId: tour.id, tourStageKey: 'stop-hut' },
});
expect(parseToolResult(result)).toMatchObject({ assignment: { day_id: day.id, tour_stage_key: 'stop-hut' } });
const again = await h.client.callTool({
name: 'assign_place_to_day',
arguments: { tripId: trip.id, dayId: secondDay.id, placeId: tour.id, tourStageKey: 'stop-hut' },
});
expect(again.isError).toBe(true);
expect(JSON.stringify(again.content)).toContain('Stage is already on a day');
const unknown = await h.client.callTool({
name: 'assign_place_to_day',
arguments: { tripId: trip.id, dayId: secondDay.id, placeId: tour.id, tourStageKey: 'stop-end' },
});
expect(unknown.isError).toBe(true);
expect(JSON.stringify(unknown.content)).toContain('Tour has no such stage');
});
expect(await countRows(orm, DayAssignments, { place: tour.id })).toBe(1);
});
it('assigns a place to a day', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -69,11 +69,23 @@ describe('DayAssignmentsController (parity with the legacy day-assignments route
expect(await new DayAssignmentsController(s).create(user, '5', '3', { place_id: 2, notes: 'n' }, 'sock')).toEqual(
{ assignment: { id: 9 } },
);
expect(createAssignment).toHaveBeenCalledWith('3', 2, 'n');
expect(createAssignment).toHaveBeenCalledWith('3', 2, 'n', {});
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:created', { assignment: { id: 9 } }, 'sock');
expect(reconcile).toHaveBeenCalledWith('5', 'sock');
});
it('MDT-CTRL-001: hands a stage of a multi-day tour to the service, the same as the MCP tool', async () => {
const createAssignment = vi.fn().mockReturnValue({ id: 9, tour_stage_key: 'stop-hut' });
const broadcast = vi.fn();
const reconcile = vi.fn().mockResolvedValue(undefined);
const s = svc({ createAssignment, broadcast, reconcile } as Partial<AssignmentsService>);
const body = { place_id: 2, tour_stage_key: 'stop-hut' };
expect(await new DayAssignmentsController(s).create(user, '5', '3', body, 'sock')).toEqual({
assignment: { id: 9, tour_stage_key: 'stop-hut' },
});
expect(createAssignment).toHaveBeenCalledWith('3', 2, undefined, { tourStageKey: 'stop-hut' });
});
it('409 duplicate Tour assignment propagates without announcing a new assignment', async () => {
const broadcast = vi.fn();
const reconcile = vi.fn();
@@ -85,7 +97,7 @@ describe('DayAssignmentsController (parity with the legacy day-assignments route
expect(await thrownAsync(() => controller.create(user, '5', '3', { place_id: 42 }))).toMatchObject({
status: 409,
});
expect(createAssignment).toHaveBeenCalledWith('3', 42, undefined);
expect(createAssignment).toHaveBeenCalledWith('3', 42, undefined, {});
expect(broadcast).not.toHaveBeenCalled();
expect(reconcile).not.toHaveBeenCalled();
});
@@ -13,12 +13,14 @@ import { db as testDb } from '../../../src/db/database';
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
import { Days } from '../../../src/db/entities/Days.entity';
import { Places } from '../../../src/db/entities/Places.entity';
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
import { RoadtripVias } from '../../../src/db/entities/RoadtripVias.entity';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { Users } from '../../../src/db/entities/Users.entity';
import { type TripAccess } from '../../../src/db/repositories/Trips.repository';
import { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
import { catchDomainError, DomainError } from '../../../src/nest/common/domain-error';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photo-registration.service';
@@ -36,7 +38,7 @@ import {
} from '../../helpers/factories';
import { makeDayAccommodation } from '../../helpers/factories/itinerary';
import { tagPlace } from '../../helpers/factories/places';
import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
import { countRows, findRow, findRows, insertRow, insertRows, updateRows } from '../../helpers/factories/rows';
import {
createTestJourneysRepo,
createTestJourneyContributorsRepo,
@@ -377,6 +379,111 @@ describe('createAssignment', () => {
});
});
// ── stages of a multi-day tour on days (MDT-02) ─────────────────────────────
describe('createAssignment with a stage of a multi-day tour (MDT-02)', () => {
/** A tour of three stages: its start, two huts that end a stage, its end. */
async function hutToHut(placeId: number) {
await addTourFacet(placeId);
await insertRows(
await orm(),
TourWaypoints,
['stop-start', 'stop-hut-one', 'stop-hut-two', 'stop-end'].map((station_key, sequence) => ({
place: placeId,
lat: 47 + sequence / 10,
lng: 11,
role: sequence === 0 ? 'start' : sequence === 3 ? 'end' : 'via',
sequence,
station_key,
stage_end: sequence === 1 || sequence === 2 ? 1 : 0,
})),
);
}
it('ASG-SVC-051: puts a stage on a day by the stop it starts at, and the answer and the day list say which', async () => {
const { day, place } = fixture();
await hutToHut(place.id);
const stage = await svc.createAssignment(day.id, place.id, null, { tourStageKey: 'stop-hut-one' });
expect(stage).toMatchObject({ tour_place_id: place.id, tour_stage_key: 'stop-hut-one' });
expect((await svc.listDayAssignments(day.id)).map((a) => a.tour_stage_key)).toEqual(['stop-hut-one']);
});
it('ASG-SVC-052: a whole tour and an ordinary place carry no stage', async () => {
const { trip, day, place } = fixture();
await hutToHut(place.id);
const ordinary = createPlace(testDb, trip.id, { name: 'Ordinary place' });
expect(await svc.createAssignment(day.id, place.id)).toMatchObject({ tour_stage_key: null });
expect(await svc.createAssignment(day.id, ordinary.id)).toMatchObject({ tour_stage_key: null });
});
it('ASG-SVC-053: refuses a stage the tour does not have: its last stop, a stop ending none, a plain place, a day tour', async () => {
const { trip, day, place } = fixture();
await hutToHut(place.id);
const plain = createPlace(testDb, trip.id, { name: 'Ordinary place' });
const dayTour = createPlace(testDb, trip.id, { name: 'Day hike' });
await addTourFacet(dayTour.id);
await insertRows(await orm(), TourWaypoints, [
{ place: dayTour.id, lat: 47, lng: 11, role: 'start', sequence: 0, station_key: 'day-start' },
{ place: dayTour.id, lat: 47.1, lng: 11, role: 'end', sequence: 1, station_key: 'day-end' },
]);
for (const [placeId, key] of [
[place.id, 'stop-end'],
[place.id, 'stop-unknown'],
[plain.id, 'stop-start'],
[dayTour.id, 'day-start'],
] as const) {
const refusal = await catchDomainError(() => svc.createAssignment(day.id, placeId, null, { tourStageKey: key }));
expect(refusal instanceof DomainError && [refusal.getStatus(), refusal.publicMessage]).toEqual([
400,
'Tour has no such stage',
]);
}
expect(await countRows(await orm(), DayAssignments, {})).toBe(0);
});
it('ASG-SVC-054: a stage is on one day at most, and a day holds a tour or one of its stages once', async () => {
const { trip, day, place } = fixture();
await hutToHut(place.id);
const secondDay = createDay(testDb, trip.id);
await svc.createAssignment(day.id, place.id, null, { tourStageKey: 'stop-start' });
const taken = await catchDomainError(() =>
svc.createAssignment(secondDay.id, place.id, null, { tourStageKey: 'stop-start' }),
);
expect(taken instanceof DomainError && [taken.getStatus(), taken.publicMessage]).toEqual([
409,
'Stage is already on a day',
]);
await expect(svc.createAssignment(day.id, place.id, null, { tourStageKey: 'stop-hut-one' })).rejects.toThrow(
ConflictException,
);
// Another stage on another day is fine.
await expect(
svc.createAssignment(secondDay.id, place.id, null, { tourStageKey: 'stop-hut-one' }),
).resolves.toMatchObject({ day_id: secondDay.id, tour_stage_key: 'stop-hut-one' });
});
it('ASG-SVC-055: a stage keeps its key when it moves to another day', async () => {
const { trip, day, place } = fixture();
await hutToHut(place.id);
const secondDay = createDay(testDb, trip.id);
const stage = await svc.createAssignment(day.id, place.id, null, { tourStageKey: 'stop-hut-two' });
const { assignment } = await svc.moveAssignment(stage!.id, secondDay.id, 0);
expect(assignment).toMatchObject({ day_id: secondDay.id, tour_stage_key: 'stop-hut-two' });
});
it('ASG-SVC-056: dropStaleTourStages takes off the stages not kept and answers which', async () => {
const { trip, day, place } = fixture();
await hutToHut(place.id);
const secondDay = createDay(testDb, trip.id);
await svc.createAssignment(day.id, place.id, null, { tourStageKey: 'stop-start' });
const second = await svc.createAssignment(secondDay.id, place.id, null, { tourStageKey: 'stop-hut-one' });
expect(await svc.dropStaleTourStages(place.id, ['stop-start'])).toEqual([
{ id: second!.id, day_id: secondDay.id },
]);
expect(await countRows(await orm(), DayAssignments, { place: place.id })).toBe(1);
});
});
// ── listDayAssignments ────────────────────────────────────────────────────────
describe('listDayAssignments', () => {
@@ -56,6 +56,7 @@ function makeRow(overrides: Partial<AssignmentWithPlaceRow> = {}): AssignmentWit
fill_percent: null,
tour_place_id: null,
tour_route_geometry: null,
tour_stage_key: null,
...overrides,
};
}
@@ -118,6 +119,12 @@ describe('formatAssignmentWithPlace', () => {
expect(tour).toMatchObject({ tour_place_id: 100, tour_route_geometry: geometry });
});
it('MDT-SHAPE-001: carries the stage of a multi-day tour a day holds, null for the whole tour', () => {
expect(formatAssignmentWithPlace(makeRow(), [], []).tour_stage_key).toBeNull();
const stage = formatAssignmentWithPlace(makeRow({ tour_place_id: 100, tour_stage_key: 'stop-hut' }), [], []);
expect(stage.tour_stage_key).toBe('stop-hut');
});
it('passes tags and participants through untouched', () => {
const result = formatAssignmentWithPlace(makeRow(), sampleTags, sampleParticipants);
expect(result.place.tags).toEqual(sampleTags);
@@ -30,6 +30,7 @@ import { accommodationsOver } from '../../helpers/accommodations-service';
import { createTrip, createUser } from '../../helpers/factories';
import { countRows, findRows } from '../../helpers/factories/rows';
import { noPaddleChecks } from '../../helpers/fake-paddle-checks';
import { tourStageDays } from '../../helpers/fake-tour-stage-days';
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
import { noGoogleQuota } from '../../helpers/google-quota';
import {
@@ -176,6 +177,7 @@ beforeAll(async () => {
await createTestTourActivitiesRepo(testDb),
buildPlaceImportService(),
noPaddleChecks(),
tourStageDays(),
);
});
+24 -2
View File
@@ -86,8 +86,8 @@ describe('ToursMcp', () => {
const listedRidge = tours.find((tour: { place_id: number }) => tour.place_id === ridge.id);
expect(listedRidge).toMatchObject({ name: 'Ridge', origin: expect.any(String), max_hiking_difficulty: 3 });
expect(listedRidge.days).toEqual([
{ day_id: day1.id, day_number: 1, date: '2026-07-10', title: 'Arrive' },
{ day_id: day2.id, day_number: 2, date: '2026-07-11', title: null },
{ day_id: day1.id, day_number: 1, date: '2026-07-10', title: 'Arrive', tour_stage_key: null },
{ day_id: day2.id, day_number: 2, date: '2026-07-11', title: null, tour_stage_key: null },
]);
expect(tours.find((tour: { place_id: number }) => tour.place_id === gorge.id).days).toEqual([]);
});
@@ -363,6 +363,28 @@ describe('ToursMcp', () => {
]);
});
it('TOURS-MCP-016: list_tours names the stops between the stages and the stage each day holds (MDT-02)', async () => {
const { user, trip, ridge, day1, day2 } = fixture();
const orm = await createTestOrm(db);
await addTourWaypoints(orm, ridge.id, [
{ lat: 47, lng: 11, station_key: 'stop-start' },
{ lat: 47.05, lng: 11.05, station_key: 'stop-hut', stage_end: 1 },
{ lat: 47.1, lng: 11.1, station_key: 'stop-end' },
]);
await orm.close();
const day3 = createDay(db, trip.id, { day_number: 3 });
createDayAssignment(db, day3.id, ridge.id, { tour_stage_key: 'stop-hut' });
const { tours } = read(await mcp.listTours({ tripId: trip.id }, ctx(user.id)));
const listed = tours.find((tour: { place_id: number }) => tour.place_id === ridge.id);
expect(listed.stage_stops.map((stop: { key: string }) => stop.key)).toEqual(['stop-start', 'stop-hut', 'stop-end']);
expect(listed.days.map((day: { day_id: number; tour_stage_key: string | null }) => [day.day_id, day.tour_stage_key])).toEqual([
[day1.id, null],
[day2.id, null],
[day3.id, 'stop-hut'],
]);
});
it('TOURS-MCP-014: the tools name the station fields, and the deletes ask before a walk takes its protected stations along', () => {
const description = (ctor: unknown, method: string) => {
const entry = getEntry(ctor as ClassRef, method);
@@ -26,6 +26,7 @@ import type { StorageService } from '../../../src/nest/storage/storage.service';
import { ToursService } from '../../../src/nest/tours/tours.service';
import type { User } from '../../../src/types';
import { noActivities, noPaddleChecks } from '../../helpers/fake-paddle-checks';
import { tourStageDays } from '../../helpers/fake-tour-stage-days';
import { buildPlaceImportService } from '../../helpers/place-import';
import { keyed } from '../../helpers/tour-keys';
import type { EntityManager } from '@mikro-orm/core';
@@ -313,6 +314,7 @@ function tourRepositoryFixture(ownerTripId: number) {
noActivities(),
buildPlaceImportService(),
noPaddleChecks(),
tourStageDays(),
);
return { uow, toursRepo, waypointsRepo, placesRepo, places, service };
}
@@ -14,6 +14,7 @@ import type { UnitOfWork } from '../../../src/nest/database/unit-of-work';
import type { PlacesService } from '../../../src/nest/places/places.service';
import { ToursService } from '../../../src/nest/tours/tours.service';
import { noActivities, noPaddleChecks } from '../../helpers/fake-paddle-checks';
import { tourStageDays } from '../../helpers/fake-tour-stage-days';
import { buildPlaceImportService } from '../../helpers/place-import';
import { keyed } from '../../helpers/tour-keys';
import { NotFoundException } from '@nestjs/common';
@@ -148,6 +149,7 @@ function makeService() {
noActivities(),
buildPlaceImportService(),
noPaddleChecks(),
tourStageDays(),
);
return { uow, toursRepo, waypointsRepo, visitsRepo, placesRepo, places, place, service };
}
+118 -2
View File
@@ -8,18 +8,25 @@
* real PlacesService, is covered by tours.gpx.atomic.test.ts.
*/
import { db as testDb } from '../../../src/db/database';
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 { catchDomainError, DomainError } from '../../../src/nest/common/domain-error';
import type { PlacesService } from '../../../src/nest/places/places.service';
import { ToursService } from '../../../src/nest/tours/tours.service';
import { createPlace, createTrip, createUser } from '../../helpers/factories';
import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../helpers/factories';
import { countRows, deleteRows, findRow, findRows, updateRows } from '../../helpers/factories/rows';
import { noPaddleChecks } from '../../helpers/fake-paddle-checks';
import { tourStageDays } from '../../helpers/fake-tour-stage-days';
import { buildPlaceImportService } from '../../helpers/place-import';
import { resetTestDb } from '../../helpers/test-db';
import { createTestPlacesRepo, createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow';
import {
createTestDayAssignmentsRepo,
createTestPlacesRepo,
createTestUnitOfWork,
sharedTestOrm,
} from '../../helpers/test-uow';
import { keyed } from '../../helpers/tour-keys';
import {
createTestTourActivitiesRepo,
@@ -90,6 +97,7 @@ beforeAll(async () => {
await createTestTourActivitiesRepo(testDb),
buildPlaceImportService(),
noPaddleChecks(),
tourStageDays(await createTestDayAssignmentsRepo(testDb)),
);
});
@@ -748,3 +756,111 @@ describe('ToursService multi-day tours (MDT-01)', () => {
expect(await count(Tours)).toBe(0);
});
});
describe('ToursService stages on days (MDT-02)', () => {
/** Three days hut to hut: the stops at the second and third point end a stage. */
const hutToHut: TourCreateRequest = {
...request,
planned_duration_minutes: null,
break_additional_minutes: 60,
duration_seconds: 30 * 3600,
route_geometry: [
[48, 11, 600],
[48.01, 11.02, 650],
[48.02, 11.04, 630],
[48.03, 11.06, 700],
],
waypoints: [
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
{ lat: 48.01, lng: 11.02, role: 'via', sequence: 1, stage_end: true },
{ lat: 48.02, lng: 11.04, role: 'via', sequence: 2, stage_end: true },
{ lat: 48.03, lng: 11.06, role: 'end', sequence: 3 },
],
};
/** The tour saved, and each of its three stages on a day of its own. */
async function onThreeDays() {
const created = await service.createTour(tripId, hutToHut);
const placeId = created.tour.place_id;
const keys = created.waypoints.map((point) => String(point.key));
const days = [1, 2, 3].map(() => createDay(testDb, Number(tripId)));
const held = [0, 1, 2].map(
(stage) => createDayAssignment(testDb, days[stage].id, placeId, { tour_stage_key: keys[stage] }).id,
);
broadcast.mockReset();
return { created, placeId, keys, days, held };
}
const heldStages = async (placeId: number) =>
(await findRows(await sharedTestOrm(testDb), DayAssignments, { place: placeId }, { id: 'asc' })).map(
(row) => row.tour_stage_key,
);
it('MDT-SVC-004: lists the stops the stages run between, keyed, and none for a day tour', async () => {
const created = await service.createTour(tripId, hutToHut);
const keys = created.waypoints.map((point) => point.key);
expect(created.tour.stage_stops).toEqual(
hutToHut.waypoints.map((point, index) => ({ key: keys[index], lat: point.lat, lng: point.lng })),
);
const dayTour = await service.createTour(tripId, request);
expect(dayTour.tour.stage_stops).toBeNull();
const listed = new Map((await service.listTours(tripId)).map((tour) => [tour.place_id, tour.stage_stops]));
expect(listed.get(created.tour.place_id)?.map((stop) => stop.key)).toEqual(keys);
expect(listed.get(dayTour.tour.place_id)).toBeNull();
expect((await service.getTour(tripId, String(created.tour.place_id))).tour.stage_stops).toHaveLength(4);
});
it('MDT-SVC-005: a saved change takes only the stages it took away off their days, and tells every client', async () => {
const { created, placeId, keys, days, held } = await onThreeDays();
// The third point no longer ends a stage: the stage that started there is gone, the others keep their keys.
const joined = created.waypoints.map(({ stage_end: _end, ...point }, index) =>
index === 1 ? { ...point, stage_end: true } : point,
);
await service.updateTour(tripId, String(placeId), { ...hutToHut, waypoints: joined }, 'socket');
expect(await heldStages(placeId)).toEqual([keys[0], keys[1]]);
expect(broadcast).toHaveBeenCalledWith(
tripId,
'assignment:deleted',
{ assignmentId: held[2], dayId: days[2].id },
undefined,
);
// Back to a day tour, no day holds a stage of it any more.
await service.updateTour(tripId, String(placeId), request);
expect(await heldStages(placeId)).toEqual([]);
});
it('MDT-SVC-006: a change that keeps every stage leaves every day as it was and says nothing', async () => {
const { created, placeId, keys } = await onThreeDays();
await service.updateTour(tripId, String(placeId), { ...hutToHut, name: 'Renamed', waypoints: created.waypoints });
expect(await heldStages(placeId)).toEqual(keys.slice(0, 3));
expect(broadcast.mock.calls.filter(([, event]) => event === 'assignment:deleted')).toEqual([]);
});
it('MDT-SVC-007: going back to the recorded track takes every stage off its day', async () => {
const { placeId } = await onThreeDays();
await updateRows(
await sharedTestOrm(testDb),
Tours,
{ place: placeId },
{ recorded_route_geometry: JSON.stringify(hutToHut.route_geometry) },
);
(await sharedTestOrm(testDb)).clear();
await service.restoreRecordedTrack(tripId, String(placeId));
expect(await heldStages(placeId)).toEqual([]);
expect(broadcast.mock.calls.filter(([, event]) => event === 'assignment:deleted')).toHaveLength(3);
});
it('MDT-SVC-008: a stage notice that cannot be sent never fails the saved change', async () => {
const { created, placeId } = await onThreeDays();
broadcast.mockImplementation((_tripId: string, event: string) => {
if (event === 'assignment:deleted') throw new Error('socket closed');
});
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const dayTour = created.waypoints.map(({ stage_end: _end, ...point }) => point);
await expect(
service.updateTour(tripId, String(placeId), { ...hutToHut, duration_seconds: 3600, waypoints: dayTour }),
).resolves.toMatchObject({ tour: { stage_count: null } });
expect(await heldStages(placeId)).toEqual([]);
expect(warn).toHaveBeenCalled();
});
});