mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
fix(tours): fit the merged tracks together after integration
Types, test expectations and helpers the seven tracks each changed on their own, brought in line with the integrated code. tours.service.ts and the tours repository grew past the size limit with every track and hand their row and write helpers to modules of their own.
This commit is contained in:
@@ -50,7 +50,7 @@ import { markdownLinkComponents } from '../shared/markdownLink';
|
||||
import { TourDayRowAvatar } from './TourDayRowFacts';
|
||||
import { TourGpxExportButton } from './TourGpxExportButton';
|
||||
import TourActivitySection from './activity/TourActivitySection';
|
||||
import type { PlanValues } from './activity/tourActivityCompare';
|
||||
import type { PlanValues } from './activity/planComparison';
|
||||
import { TourSaveToCollectionButton } from './collection/TourSaveToCollectionButton';
|
||||
import { PaddleCheckSection } from './paddle/PaddleCheckSection';
|
||||
import { TourPackingSection } from './packing/TourPackingSection';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import type { TourActivity } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import TourActivityCompare, { formatDifference } from './TourActivityCompare';
|
||||
import type { PlanValues } from './tourActivityCompare';
|
||||
import type { PlanValues } from './planComparison';
|
||||
|
||||
const plan: PlanValues = {
|
||||
distanceKm: 2,
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type CompareKey,
|
||||
type CompareRow,
|
||||
type PlanValues,
|
||||
} from './tourActivityCompare';
|
||||
} from './planComparison';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
@@ -167,7 +167,9 @@ function deviationFields(
|
||||
unit: DistanceUnit,
|
||||
t: Translate
|
||||
): TourMetricField[] {
|
||||
const deviation = planDeviation(activity.geometry, plan.line);
|
||||
// Latitude and longitude only: how far the track strayed is measured on the map, not in height.
|
||||
const recorded = activity.geometry.map(([lat, lng]) => [lat, lng]);
|
||||
const deviation = planDeviation(recorded, plan.line);
|
||||
const offLabel = t('tours.activity.compare.offPlan');
|
||||
if (!deviation) return [{ label: offLabel, value: t('tours.activity.compare.noLine') }];
|
||||
const offInfo = {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
|
||||
import type { Day } from '../../../types';
|
||||
import TourActivitySection from './TourActivitySection';
|
||||
import type { PlanValues } from './tourActivityCompare';
|
||||
import type { PlanValues } from './planComparison';
|
||||
import type { TourActivities } from './useTourActivities';
|
||||
|
||||
const hook = vi.hoisted(() => ({ state: null as unknown }));
|
||||
@@ -116,7 +116,7 @@ describe('TourActivitySection (ACT-01)', () => {
|
||||
expect(current.askRemove).toHaveBeenCalledWith(current.activities?.[0]);
|
||||
|
||||
hook.state = state({ assigning: {}, removing: activity() });
|
||||
rerender(<TourActivitySection tripId={1} tour={tour} days={days} assignments={assignments} canEdit />);
|
||||
rerender(<TourActivitySection tripId={1} tour={tour} days={days} assignments={assignments} canEdit plan={plan} planColor="plan-paint" />);
|
||||
expect(screen.getByTestId('tour-activity-dialog')).toBeInTheDocument();
|
||||
expect(screen.getByText('tours.activity.removeTitle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ import { CardAction, TourNotice } from '../tourParts';
|
||||
import TourActivityCompare from './TourActivityCompare';
|
||||
import TourActivityDialog from './TourActivityDialog';
|
||||
import { ACTUAL_TRACK_COLOR } from './activityCompareParts';
|
||||
import type { PlanValues } from './tourActivityCompare';
|
||||
import type { PlanValues } from './planComparison';
|
||||
import { analysisOfLine, executionOf, executionsOf, recordedDay, type TourExecution } from './tourActivityModel';
|
||||
import { useActivityComparison, type ActivityComparisonState } from './useActivityComparison';
|
||||
import { useTourActivities, type TourActivities } from './useTourActivities';
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
// how far the recorded line strayed from the planned one.
|
||||
import type { TourActivityMetrics } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { compareRows, MEASURED_POINTS, OFF_PLAN_M, planDeviation, type PlanValues } from './tourActivityCompare';
|
||||
import { compareRows, MEASURED_POINTS, OFF_PLAN_M, planDeviation, type PlanValues } from './planComparison';
|
||||
|
||||
const plan: PlanValues = {
|
||||
distanceKm: 8,
|
||||
@@ -15,6 +15,7 @@ const edge = (from: number, wayId: number | null, lengthM = 100): RouteWayEdge =
|
||||
lengthM,
|
||||
surface: 'paved',
|
||||
way: 'path',
|
||||
sac: 'none',
|
||||
wayId,
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ const edge = (from: number, to: number, wayId = 1): RouteWayEdge => ({
|
||||
lengthM: 100,
|
||||
surface: 'unknown',
|
||||
way: 'path',
|
||||
sac: 'none',
|
||||
wayId,
|
||||
});
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ const edge = (from: number, wayId: number): RouteWayEdge => ({
|
||||
// The router's own reading, which no facet may take for a recorded value.
|
||||
surface: 'paved',
|
||||
way: 'path',
|
||||
sac: 'none',
|
||||
wayId,
|
||||
});
|
||||
const WAYS: RouteWays = { line: ROUTE, edges: [edge(0, 1), edge(1, 2), edge(2, 3)] };
|
||||
|
||||
@@ -73,6 +73,11 @@ beforeEach(() => {
|
||||
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } });
|
||||
});
|
||||
|
||||
/** The last of the elements found: the card rendered most recently. */
|
||||
function lastOf<T>(items: T[]): T | undefined {
|
||||
return items[items.length - 1];
|
||||
}
|
||||
|
||||
describe('LegVariantsCard', () => {
|
||||
it('FE-TOUR-LEG-VARIANTS-CARD-001: names the section by its ends and lists each way with its figures', () => {
|
||||
renderCard(comparison());
|
||||
@@ -129,9 +134,9 @@ describe('LegVariantsCard', () => {
|
||||
expect(actions.apply).toHaveBeenCalledTimes(1);
|
||||
|
||||
renderCard(comparison({ selected: 'current' }));
|
||||
expect(screen.getAllByTestId('leg-variants-apply').at(-1)).toBeDisabled();
|
||||
expect(lastOf(screen.getAllByTestId('leg-variants-apply'))).toBeDisabled();
|
||||
renderCard(comparison({ selected: 'a' }), { isSaving: true });
|
||||
expect(screen.getAllByTestId('leg-variants-apply').at(-1)).toBeDisabled();
|
||||
expect(lastOf(screen.getAllByTestId('leg-variants-apply'))).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-TOUR-LEG-VARIANTS-CARD-005: while a choice is checked the list stands still and the button says what happens', async () => {
|
||||
@@ -146,15 +151,15 @@ describe('LegVariantsCard', () => {
|
||||
renderCard(comparison({ phase: 'loading', variants: [way('current')] }));
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Looking for other ways');
|
||||
renderCard(comparison({ phase: 'none', variants: [way('current')] }));
|
||||
expect(screen.getAllByRole('status').at(-1)).toHaveTextContent('The router knows no other way');
|
||||
expect(lastOf(screen.getAllByRole('status'))).toHaveTextContent('The router knows no other way');
|
||||
renderCard(comparison({ phase: 'failed', variants: [way('current')] }));
|
||||
expect(screen.getAllByRole('status').at(-1)).toHaveTextContent('The router did not answer');
|
||||
expect(lastOf(screen.getAllByRole('status'))).toHaveTextContent('The router did not answer');
|
||||
renderCard(comparison({ notice: 'notHeld' }));
|
||||
expect(screen.getAllByRole('status').at(-1)).toHaveTextContent('The router does not keep to this way');
|
||||
expect(lastOf(screen.getAllByRole('status'))).toHaveTextContent('The router does not keep to this way');
|
||||
renderCard(comparison({ notice: 'stale' }));
|
||||
expect(screen.getAllByRole('status').at(-1)).toHaveTextContent('The tour changed in the meantime');
|
||||
expect(lastOf(screen.getAllByRole('status'))).toHaveTextContent('The tour changed in the meantime');
|
||||
renderCard(comparison({ notice: 'failed' }));
|
||||
expect(screen.getAllByRole('status').at(-1)).toHaveTextContent('This way could not be checked');
|
||||
expect(lastOf(screen.getAllByRole('status'))).toHaveTextContent('This way could not be checked');
|
||||
});
|
||||
|
||||
it('FE-TOUR-LEG-VARIANTS-CARD-007: the overlay shows only while a section is compared; cancel, the cross and Escape close it', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FE-TOUR-ROUND-HIKE-CARD-001 to FE-TOUR-ROUND-HIKE-CARD-009: the card over the planner map that
|
||||
// looks for round hikes out of a stop and takes one on purpose (RTE-03).
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
import { seedStore } from '../../../../tests/helpers/store';
|
||||
import { idleRoundHikes } from '../../../../tests/helpers/tourPlannerStub';
|
||||
@@ -116,7 +116,7 @@ describe('RoundHikeCard', () => {
|
||||
expect(screen.getByLabelText('Distance')).toHaveValue('6.2');
|
||||
expect(screen.getByText('mi')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByTestId('round-hike-find'));
|
||||
expect(actions.find.mock.calls[0][0]).toBeCloseTo(9_978, 0);
|
||||
expect(vi.mocked(actions.find).mock.calls[0][0]).toBeCloseTo(9_978, 0);
|
||||
});
|
||||
|
||||
it('FE-TOUR-ROUND-HIKE-CARD-004: while it looks, the bar fills, the rounds found show and nothing can be taken yet', async () => {
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('a run wherever a tour shows (RUN-01)', () => {
|
||||
expect(screen.queryByTestId('tour-detail-run-profile')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-RUN-MARK-007: a trail run goes by its own profile, and its time and pace read as any run's (RUN-02)', () => {
|
||||
it("FE-TOUR-RUN-MARK-007: a trail run goes by its own profile, and its time and pace read as any run's (RUN-02)", () => {
|
||||
const trail = { ...run, run_profile: 'trail' as const, run_pace_seconds_per_km: 450, duration: 60 };
|
||||
render(<RunHeadPills tour={trail} className="head-pill" />);
|
||||
expect(screen.getByTestId('tour-detail-run-profile')).toHaveTextContent('Trail running');
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface SavedTourStage extends TourStage {
|
||||
export function savedStagePoints(tour: TourListItem, saved: readonly TourWaypoint[]): TourPlannerWaypoint[] {
|
||||
let next = 0;
|
||||
const points = plannerWaypointsOf(saved, () => `saved-${next++}`);
|
||||
return normalizePlannerWaypoints(points, openedTourFields(tour).tourType === 'kayak');
|
||||
return normalizePlannerWaypoints(points, openedTourFields(tour).tourType);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,7 @@ import { focusDialog, trapTab } from './dialogFocus'
|
||||
import { useDiscardGuard } from './useDiscardGuard'
|
||||
|
||||
/** A font size that follows the user's text size setting for its tier. */
|
||||
export const fs = (px: number, tier: 'caption' | 'body' | 'subtitle' = 'caption'): CSSProperties => ({ fontSize: `calc(${px}px * var(--fs-scale-${tier}, 1))` })
|
||||
export const fs = (px: number, tier: 'caption' | 'body' | 'subtitle' | 'title' = 'caption'): CSSProperties => ({ fontSize: `calc(${px}px * var(--fs-scale-${tier}, 1))` })
|
||||
|
||||
const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint'
|
||||
const BACKDROP = 'trek-modal-backdrop trek-backdrop-enter fixed inset-0 z-[10000] flex justify-center bg-[rgba(15,23,42,0.5)] px-4' // theme-lint-disable: the dim every shared/Modal draws
|
||||
|
||||
@@ -341,7 +341,7 @@ describe('TourPlannerMapStage', () => {
|
||||
it('FE-PAGE-TOURSTAGE-007: switched on, the layer asks for the area the flat map reported and draws what it found, grouped', () => {
|
||||
renderStage(planner('view-saved'));
|
||||
const area = { south: 46.5, west: 11.7, north: 46.6, east: 11.9 };
|
||||
act(() => stage.flatMap.props?.onViewportChange?.(area));
|
||||
act(() => stage.flatMap.props?.onViewportChange?.(area, 12));
|
||||
// HOR-01: the rail's horse route search looks in the same view.
|
||||
expect(useTourMapViewportStore.getState().bounds).toEqual(area);
|
||||
act(() => stage.flatMap.props?.ferrataLayer?.onToggle());
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-explicit-any": 90,
|
||||
"@typescript-eslint/no-explicit-any": 89,
|
||||
"@typescript-eslint/no-non-null-assertion": 264,
|
||||
"@typescript-eslint/no-require-imports": 18,
|
||||
"@typescript-eslint/no-unsafe-function-type": 1,
|
||||
@@ -21,7 +21,7 @@
|
||||
"@typescript-eslint/no-require-imports": 25,
|
||||
"@typescript-eslint/no-unsafe-function-type": 7,
|
||||
"@typescript-eslint/no-unused-vars": 96,
|
||||
"no-await-in-loop": 453,
|
||||
"no-await-in-loop": 452,
|
||||
"no-console": 18,
|
||||
"no-empty": 2,
|
||||
"no-useless-escape": 1,
|
||||
|
||||
@@ -236,18 +236,29 @@ export const TOUR_CHANGE_STEP_CHANGES: JsonColumn<TourChange[]> = {
|
||||
fallback: () => [],
|
||||
};
|
||||
|
||||
/** A point of a saved line: latitude, longitude and the height where it has one. */
|
||||
const savedLinePoint = z.union([z.tuple([z.number(), z.number()]), z.tuple([z.number(), z.number(), z.number()])]);
|
||||
/**
|
||||
* A point of a saved line: latitude, longitude and the height where it has one.
|
||||
* Checked by hand rather than as a union of tuples: the server compiles without
|
||||
* strictNullChecks, where a tuple schema reads as one of optional elements and
|
||||
* would not hand back the GeometryPoint the restore writes.
|
||||
*/
|
||||
const savedLinePoint = z.custom<GeometryPoint>(
|
||||
(value) =>
|
||||
Array.isArray(value) &&
|
||||
(value.length === 2 || value.length === 3) &&
|
||||
value.every((n: unknown) => typeof n === 'number'),
|
||||
);
|
||||
|
||||
/** A tour as it was before a saved step (MCP-T03), as its JSON column holds it. */
|
||||
export interface TourChangeStepBefore {
|
||||
route_geometry: GeometryPoint[] | null;
|
||||
waypoints: TourWaypoint[];
|
||||
distance: number | null;
|
||||
duration: number | null;
|
||||
elevation_gain: number | null;
|
||||
elevation_loss: number | null;
|
||||
}
|
||||
const tourChangeStepBeforeSchema = z.object({
|
||||
route_geometry: z.array(savedLinePoint).nullable(),
|
||||
waypoints: z.array(tourWaypointSchema),
|
||||
distance: z.number().nullable(),
|
||||
duration: z.number().nullable(),
|
||||
elevation_gain: z.number().nullable(),
|
||||
elevation_loss: z.number().nullable(),
|
||||
});
|
||||
export type TourChangeStepBefore = z.infer<typeof tourChangeStepBeforeSchema>;
|
||||
|
||||
/**
|
||||
* MCP-T03: a tour as it was before a saved step changed it: its line (null
|
||||
@@ -257,16 +268,7 @@ export interface TourChangeStepBefore {
|
||||
*/
|
||||
export const TOUR_CHANGE_STEP_BEFORE: JsonColumn<TourChangeStepBefore | null> = {
|
||||
column: 'tour_change_steps.before_state',
|
||||
schema: z
|
||||
.object({
|
||||
route_geometry: z.array(savedLinePoint).nullable(),
|
||||
waypoints: z.array(tourWaypointSchema),
|
||||
distance: z.number().nullable(),
|
||||
duration: z.number().nullable(),
|
||||
elevation_gain: z.number().nullable(),
|
||||
elevation_loss: z.number().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
schema: tourChangeStepBeforeSchema.nullable(),
|
||||
fallback: () => null,
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type Tours, type TToursOrigin, ToursOrigin } from '../entities/Tours.en
|
||||
import type { DB } from '../kysely/db';
|
||||
import type { AssertRowKeys } from './_shared/rows';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
import { toLibraryRow, toListRow } from './tour-rows';
|
||||
import type {
|
||||
BikeProfile,
|
||||
PaddleBoatType,
|
||||
@@ -588,17 +589,6 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* What a copied trip's tour keeps besides its facet and its points: its
|
||||
* import record, its obstacle decisions and its packing link, onto its copy.
|
||||
* Called inside the trip copy's transaction.
|
||||
*/
|
||||
async copyTourRelations(source_place_id: number, place_id: number): Promise<void> {
|
||||
await this.copySource(source_place_id, place_id);
|
||||
await this.copyObstacleReviews(source_place_id, place_id);
|
||||
await this.copyPackingLink(source_place_id, place_id);
|
||||
}
|
||||
|
||||
/** 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()
|
||||
@@ -973,45 +963,3 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
await this.nativeUpdate({ place: place_id }, { recorded_route_geometry: null });
|
||||
}
|
||||
}
|
||||
|
||||
function toLibraryRow<R extends Omit<TourLibraryRow, 'has_waypoints'> & { has_waypoints: unknown }>(
|
||||
r: R,
|
||||
): TourLibraryRow {
|
||||
return {
|
||||
place_id: r.place_id,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
tour_type: r.tour_type,
|
||||
distance: r.distance,
|
||||
elevation_gain: r.elevation_gain,
|
||||
elevation_loss: r.elevation_loss,
|
||||
duration: r.duration,
|
||||
planned_duration_minutes: r.planned_duration_minutes,
|
||||
break_additional_minutes: r.break_additional_minutes,
|
||||
max_hiking_difficulty: r.max_hiking_difficulty,
|
||||
origin: r.origin,
|
||||
trip_id: r.trip_id,
|
||||
trip_title: r.trip_title,
|
||||
has_waypoints: Number(r.has_waypoints),
|
||||
};
|
||||
}
|
||||
|
||||
function toListRow<
|
||||
R extends Omit<TourListRow, 'planned' | 'has_waypoints' | 'has_recorded_track' | 'route_missing' | 'stage_ends'> & {
|
||||
planned: unknown;
|
||||
has_waypoints: unknown;
|
||||
has_recorded_track: unknown;
|
||||
route_missing: unknown;
|
||||
stage_ends: unknown;
|
||||
},
|
||||
>(r: R): TourListRow {
|
||||
return {
|
||||
...r,
|
||||
planned: Number(r.planned),
|
||||
has_waypoints: Number(r.has_waypoints),
|
||||
has_recorded_track: Number(r.has_recorded_track),
|
||||
route_missing: Number(r.route_missing),
|
||||
// Postgres counts in a bigint, which its driver hands back as text.
|
||||
stage_ends: Number(r.stage_ends),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* The rows the tours repository answers, as its queries read them: counts and
|
||||
* flags come back from SQLite and Postgres in different spellings, and are
|
||||
* made numbers here.
|
||||
*/
|
||||
import type { TourLibraryRow, TourListRow } from './Tours.repository';
|
||||
|
||||
export function toLibraryRow<R extends Omit<TourLibraryRow, 'has_waypoints'> & { has_waypoints: unknown }>(
|
||||
r: R,
|
||||
): TourLibraryRow {
|
||||
return {
|
||||
place_id: r.place_id,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
tour_type: r.tour_type,
|
||||
distance: r.distance,
|
||||
elevation_gain: r.elevation_gain,
|
||||
elevation_loss: r.elevation_loss,
|
||||
duration: r.duration,
|
||||
planned_duration_minutes: r.planned_duration_minutes,
|
||||
break_additional_minutes: r.break_additional_minutes,
|
||||
max_hiking_difficulty: r.max_hiking_difficulty,
|
||||
origin: r.origin,
|
||||
trip_id: r.trip_id,
|
||||
trip_title: r.trip_title,
|
||||
has_waypoints: Number(r.has_waypoints),
|
||||
};
|
||||
}
|
||||
|
||||
export function toListRow<
|
||||
R extends Omit<TourListRow, 'planned' | 'has_waypoints' | 'has_recorded_track' | 'route_missing' | 'stage_ends'> & {
|
||||
planned: unknown;
|
||||
has_waypoints: unknown;
|
||||
has_recorded_track: unknown;
|
||||
route_missing: unknown;
|
||||
stage_ends: unknown;
|
||||
},
|
||||
>(r: R): TourListRow {
|
||||
return {
|
||||
...r,
|
||||
planned: Number(r.planned),
|
||||
has_waypoints: Number(r.has_waypoints),
|
||||
has_recorded_track: Number(r.has_recorded_track),
|
||||
route_missing: Number(r.route_missing),
|
||||
// Postgres counts in a bigint, which its driver hands back as text.
|
||||
stage_ends: Number(r.stage_ends),
|
||||
};
|
||||
}
|
||||
@@ -13,8 +13,8 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { TourActivityAssignRequest, TourActivityRefusal, TourDetailResponse } from '@trek/shared';
|
||||
|
||||
/** A refusal with its code in the body, so the app can say what went wrong in its own words. */
|
||||
function refused(status: 400 | 409, code: TourActivityRefusal, message: string): DomainError {
|
||||
return new DomainError(status, message, { code, details: { code } });
|
||||
function refused(httpStatus: 400 | 409, code: TourActivityRefusal, message: string): DomainError {
|
||||
return new DomainError(httpStatus, message, { code, details: { code } });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -59,7 +59,7 @@ export class TourActivityStatsMcp {
|
||||
@Tool({
|
||||
name: 'get_tour_activity_stats',
|
||||
description:
|
||||
'The tours the user has done, summed: how many, distance in km, ascent and descent in metres, as a whole and by type, for a period (from and to, YYYY-MM-DD, both included) and one type, or all of them. A tour counts once the last day it is on is over (today is UTC here), and only where the user takes part: a day of the tour names the user among its participants, or names none, which means everyone of the trip. A day naming only others is theirs. basis "plan" means the values come from the planning of the trip, not from a measurement, and must be said so; basis "track" means a recorded track linked to the tour replaced them. A tour on several days counts once; a whole route stays out while a stage planned from it (derived_from_place_id) counts; a paddle tour sums no heights; null means unknown, never zero. Tours the user left out (excluded true) are listed but not summed. uncounted names the tours of the user's trips that are on no day or only on the user's days without a date.',
|
||||
'The tours the user has done, summed: how many, distance in km, ascent and descent in metres, as a whole and by type, for a period (from and to, YYYY-MM-DD, both included) and one type, or all of them. A tour counts once the last day it is on is over (today is UTC here), and only where the user takes part: a day of the tour names the user among its participants, or names none, which means everyone of the trip. A day naming only others is theirs. basis "plan" means the values come from the planning of the trip, not from a measurement, and must be said so; basis "track" means a recorded track linked to the tour replaced them. A tour on several days counts once; a whole route stays out while a stage planned from it (derived_from_place_id) counts; a paddle tour sums no heights; null means unknown, never zero. Tours the user left out (excluded true) are listed but not summed. uncounted names the tours of the user\'s trips that are on no day or only on the user\'s days without a date.',
|
||||
inputSchema: { from: query.from, to: query.to, type: query.type },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
|
||||
@@ -48,7 +48,7 @@ export class TourChangeUndoMcp {
|
||||
@Tool({
|
||||
name: 'list_tour_change_steps',
|
||||
description:
|
||||
'List the changes saved to a tour with save_tour_change, the latest first (at most listed_at_most): each step with its id, saved_at, base_revision (the revision it was prepared on), revision (the one it raised the tour to) and the changes as the traveller confirmed them. standing says whether it can be taken back: latest (it is the tour\'s latest change; prepare_tour_undo checks the rest), undone (taken back already, undone names the revision that raised the tour to and when), later_change (something was saved on top of it since, in the planner or by another step, so taking it back would lose that) or no_earlier_state (the record of the tour before it cannot be read). Edits saved in the planner are no steps and are not listed; a draft in the planner is never seen here. Reads only.',
|
||||
"List the changes saved to a tour with save_tour_change, the latest first (at most listed_at_most): each step with its id, saved_at, base_revision (the revision it was prepared on), revision (the one it raised the tour to) and the changes as the traveller confirmed them. standing says whether it can be taken back: latest (it is the tour's latest change; prepare_tour_undo checks the rest), undone (taken back already, undone names the revision that raised the tour to and when), later_change (something was saved on top of it since, in the planner or by another step, so taking it back would lose that) or no_earlier_state (the record of the tour before it cannot be read). Edits saved in the planner are no steps and are not listed; a draft in the planner is never seen here. Reads only.",
|
||||
inputSchema: { tripId: idSchema, placeId },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
@@ -89,7 +89,7 @@ export class TourChangeUndoMcp {
|
||||
@Tool({
|
||||
name: 'undo_tour_change',
|
||||
description:
|
||||
'Take back a saved step of a tour that prepare_tour_undo showed and the traveller confirmed: the tour is restored exactly as it was before the step, its line, its stations with their keys, and the distance and walking time stored then, whole or not at all. Call it only after the traveller said yes to taking back exactly that step, with step_id and expected_revision as undo of the preview gave them and confirmed true. Everything is checked again before anything is written: a tour changed since the preview is refused as a conflict (prepare it again), and a step that is no longer the latest change, was taken back already, or would now remove a station visited or skipped meanwhile is refused with the reason; the tour then stays as it is. A step is taken back once, and taking back raises the tour\'s revision like any other save. The answer: undone, step (its id, its revision and undone_revision, the revision taking it back raised the tour to), the tour as now saved (revision, distance_km, walking_minutes, route_pending) and the stations in order with their keys. Read the result with get_tour_plan. It never touches a draft in the planner. Needs place editing permission on the trip.',
|
||||
"Take back a saved step of a tour that prepare_tour_undo showed and the traveller confirmed: the tour is restored exactly as it was before the step, its line, its stations with their keys, and the distance and walking time stored then, whole or not at all. Call it only after the traveller said yes to taking back exactly that step, with step_id and expected_revision as undo of the preview gave them and confirmed true. Everything is checked again before anything is written: a tour changed since the preview is refused as a conflict (prepare it again), and a step that is no longer the latest change, was taken back already, or would now remove a station visited or skipped meanwhile is refused with the reason; the tour then stays as it is. A step is taken back once, and taking back raises the tour's revision like any other save. The answer: undone, step (its id, its revision and undone_revision, the revision taking it back raised the tour to), the tour as now saved (revision, distance_km, walking_minutes, route_pending) and the stations in order with their keys. Read the result with get_tour_plan. It never touches a draft in the planner. Needs place editing permission on the trip.",
|
||||
inputSchema: {
|
||||
tripId: idSchema,
|
||||
placeId,
|
||||
|
||||
@@ -16,9 +16,7 @@ import { walkStationsOf, type TourWaypoint } from '@trek/shared';
|
||||
export type StepStanding = 'latest' | 'undone' | 'later_change' | 'no_earlier_state';
|
||||
|
||||
/** The standing of a step, the latest one carrying the tour as it kept it before. */
|
||||
export type Standing =
|
||||
| { kind: 'latest'; before: TourChangeStepBefore }
|
||||
| { kind: Exclude<StepStanding, 'latest'> };
|
||||
export type Standing = { kind: 'latest'; before: TourChangeStepBefore } | { kind: Exclude<StepStanding, 'latest'> };
|
||||
|
||||
/** What a recorded step says of itself for its standing. */
|
||||
interface StepFacts {
|
||||
|
||||
@@ -62,7 +62,7 @@ export class TourHorseRoutesService {
|
||||
/** One query for the box, shared with every request for it until it settles. */
|
||||
private fetch(key: string, box: FerrataBbox, now: number): Promise<Answer> {
|
||||
const running = this.pending.get(key);
|
||||
if (running) return running;
|
||||
if (running !== undefined) return running;
|
||||
const run = this.query(box).then((answer) => {
|
||||
this.keep(key, answer, now);
|
||||
return answer;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* The read half of ToursService that needs no database: a tour as it is
|
||||
* listed, and where it came from as its provenance names it.
|
||||
*/
|
||||
import { TOUR_HIKING_PREFERENCES, TOUR_TRACK_MARKS } from '../../db/json-columns';
|
||||
import type { TourActivityRefRow } from '../../db/repositories/TourActivities.repository';
|
||||
import type { TourWaypointRow } from '../../db/repositories/TourWaypoints.repository';
|
||||
import type { TourListRow, TourProvenanceRow } from '../../db/repositories/Tours.repository';
|
||||
import { utcSuffix } from '../../db/types';
|
||||
import { decodeJson } from '../../utils/json-column';
|
||||
import { activityFacts } from './tour-activities.helpers';
|
||||
import { LOW_CONFIDENCE_THRESHOLD } from './tours.helpers';
|
||||
import {
|
||||
tourStageStopsOf,
|
||||
walkStationFacts,
|
||||
type BikeProfile,
|
||||
type PaddleBoatType,
|
||||
type PaddleLakeStyle,
|
||||
type PaddleWaterContext,
|
||||
type RunProfile,
|
||||
type SkiDiscipline,
|
||||
type SkiTechnique,
|
||||
type TourListItem,
|
||||
type TourOrigin,
|
||||
type TourProvenanceSource,
|
||||
type WinterActivity,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tour as it is listed; a city walk with the stations its points make and their stays (CW-02, CW-03),
|
||||
* every tour with the recordings assigned to it and the tour it documents itself (ACT-01).
|
||||
*/
|
||||
export function tourItemOf(
|
||||
r: TourListRow,
|
||||
points: readonly TourWaypointRow[] = [],
|
||||
activities: readonly TourActivityRefRow[] = [],
|
||||
): TourListItem {
|
||||
return {
|
||||
place_id: r.place_id,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
website: r.website,
|
||||
tour_type: r.tour_type as TourListItem['tour_type'],
|
||||
distance: r.distance,
|
||||
elevation_gain: r.elevation_gain,
|
||||
elevation_loss: r.elevation_loss,
|
||||
duration: r.duration,
|
||||
planned_duration_minutes: r.planned_duration_minutes,
|
||||
break_additional_minutes: r.break_additional_minutes,
|
||||
difficulty: r.difficulty,
|
||||
wanderer_ref: r.wanderer_ref,
|
||||
match_confidence: r.match_confidence,
|
||||
max_hiking_difficulty: r.max_hiking_difficulty,
|
||||
derived_from_place_id: r.derived_from_place_id,
|
||||
origin: r.origin as TourOrigin,
|
||||
bike_profile: r.bike_profile as BikeProfile | null,
|
||||
winter_activity: r.winter_activity as WinterActivity | null,
|
||||
run_profile: r.run_profile as RunProfile | null,
|
||||
run_pace_seconds_per_km: r.run_pace_seconds_per_km,
|
||||
ski_discipline: r.ski_discipline as SkiDiscipline | null,
|
||||
ski_technique: r.ski_technique as SkiTechnique | null,
|
||||
boat_type: r.boat_type as PaddleBoatType | null,
|
||||
water_context: r.water_context as PaddleWaterContext | null,
|
||||
paddle_speed_kmh: r.paddle_speed_kmh,
|
||||
portage_speed_kmh: r.portage_speed_kmh,
|
||||
paddle_lake_style: r.paddle_lake_style as PaddleLakeStyle | null,
|
||||
track_marks: decodeJson(TOUR_TRACK_MARKS, r.track_marks, 'tour'),
|
||||
has_recorded_track: Boolean(r.has_recorded_track),
|
||||
planned: Boolean(r.planned),
|
||||
caution: r.match_confidence !== null && r.match_confidence < LOW_CONFIDENCE_THRESHOLD,
|
||||
has_waypoints: Boolean(r.has_waypoints),
|
||||
revision: r.revision,
|
||||
hiking_preferences: decodeJson(TOUR_HIKING_PREFERENCES, r.hiking_preferences, 'tour'),
|
||||
...walkStationFacts(r.tour_type, points),
|
||||
route_pending: Boolean(r.route_missing) && Boolean(r.has_waypoints),
|
||||
track_started_at: utcSuffix(r.track_started_at),
|
||||
track_ended_at: utcSuffix(r.track_ended_at),
|
||||
...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,
|
||||
};
|
||||
}
|
||||
|
||||
/** What the source of a tour carried, read from the tour's own record and its import's snapshot row. */
|
||||
export function provenanceSource(origin: TourOrigin, row: TourProvenanceRow): TourProvenanceSource {
|
||||
if (origin === 'gpx') return { kind: 'gpx', fileName: row.origin_file_name };
|
||||
if (origin === 'wanderer') {
|
||||
// The reference `instance#trail` is on the tour itself, so a snapshot whose row is missing still names its source.
|
||||
const [refInstance, refTrail] = splitWandererRef(row.wanderer_ref);
|
||||
return {
|
||||
kind: 'wanderer',
|
||||
instance: row.source_instance ?? refInstance,
|
||||
externalId: row.source_external_id ?? refTrail,
|
||||
externalUrl: row.source_external_url,
|
||||
externalUpdated: row.source_external_updated,
|
||||
importedAt: row.source_imported_at,
|
||||
};
|
||||
}
|
||||
return { kind: 'trek' };
|
||||
}
|
||||
|
||||
function splitWandererRef(ref: string | null): [string, string] {
|
||||
const at = ref?.lastIndexOf('#') ?? -1;
|
||||
return ref && at > 0 ? [ref.slice(0, at), ref.slice(at + 1)] : ['', ''];
|
||||
}
|
||||
@@ -9,7 +9,13 @@
|
||||
*/
|
||||
import type { WalkProgress } from './tour-visits.service';
|
||||
import { computeTourMetrics, thinnedLine, type GeometryPoint } from './tours.helpers';
|
||||
import { walkStationsOf, type TourDetailResponse, type TourFixedLegKind, type TourWaypoint } from '@trek/shared';
|
||||
import {
|
||||
walkStationsOf,
|
||||
type TourDetailResponse,
|
||||
type TourFixedLeg,
|
||||
type TourFixedLegKind,
|
||||
type TourWaypoint,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** The most points of the saved line an answer carries: its shape without the thousands of points. */
|
||||
export const TOUR_PLAN_LINE_POINTS = 200;
|
||||
@@ -84,6 +90,11 @@ interface PlanLegSource {
|
||||
recorded: boolean;
|
||||
}
|
||||
|
||||
/** A fixed leg's line as points of a line: latitude and longitude, no height. */
|
||||
function legLine(geometry: TourFixedLeg['geometry']): GeometryPoint[] {
|
||||
return geometry.map(([lat, lng]): GeometryPoint => [lat, lng]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The legs between consecutive control points, each saying whether the router
|
||||
* planned it, it follows a recorded track, or it is a fixed line.
|
||||
@@ -100,7 +111,7 @@ export function planLegs(points: readonly TourWaypoint[], { recorded }: PlanLegS
|
||||
to_key: to.key ?? null,
|
||||
kind: leg?.kind ?? unfixed,
|
||||
name: leg?.kind === 'ferrata' ? leg.name : null,
|
||||
length_km: leg ? roundKm(computeTourMetrics(leg.geometry).distanceKm) : null,
|
||||
length_km: leg ? roundKm(computeTourMetrics(legLine(leg.geometry)).distanceKm) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* The write half of ToursService that needs no database: the fields a create
|
||||
* or an update stores, the checks a write passes before its transaction, and
|
||||
* the keys its points keep.
|
||||
*/
|
||||
import { TOUR_HIKING_PREFERENCES } from '../../db/json-columns';
|
||||
import type { TourUpdate } from '../../db/repositories/Tours.repository';
|
||||
import { encodeJson } from '../../utils/json-column';
|
||||
import { badRequest } from '../common/domain-error';
|
||||
import { computeTourMetrics } from './tours.helpers';
|
||||
import type { TourCreateCommand } from './tours.service';
|
||||
import {
|
||||
hikingPreferencesOf,
|
||||
maxTourMinutesFor,
|
||||
plannedTimeLimitMessage,
|
||||
plannedTourDurationMinutesSchema,
|
||||
tourStageCountOf,
|
||||
tourStageIssues,
|
||||
tourWebsiteSchema,
|
||||
type TourWaypoint,
|
||||
type WinterActivity,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
/** MF-08: a save's hiking preferences as stored: a hike's switched-on ones as JSON, else NULL. */
|
||||
function hikingFields(input: TourCreateCommand): string | null {
|
||||
const preferences = input.tour_type === 'hike' ? hikingPreferencesOf(input.hiking_preferences) : null;
|
||||
return preferences ? encodeJson(TOUR_HIKING_PREFERENCES, preferences) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* HIK-01: a save's winter label as stored. Only a hike keeps one; another type
|
||||
* takes it off. A hike's save that leaves the field out (a client from before
|
||||
* the label) keeps the one the tour has, rather than taking it off unasked.
|
||||
*/
|
||||
function winterField(input: TourCreateCommand): WinterActivity | null | undefined {
|
||||
return input.tour_type === 'hike' ? input.winter_activity : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* RUN-01: a save's run profile and pace as stored. Only a run keeps them;
|
||||
* another type takes them off. A run without a pace keeps none: its time stays
|
||||
* unknown rather than reckoned at a pace nobody chose.
|
||||
*/
|
||||
function runFields(input: TourCreateCommand): Pick<TourUpdate, 'run_profile' | 'run_pace_seconds_per_km'> {
|
||||
if (input.tour_type !== 'run') return { run_profile: null, run_pace_seconds_per_km: null };
|
||||
return { run_profile: input.run_profile ?? null, run_pace_seconds_per_km: input.run_pace_seconds_per_km ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-03: every point with its key, kept from the write where it came with one,
|
||||
* so the visits of a city walk's stations outlast a reorder or a new point. A
|
||||
* key sent twice stays with its first point; a later one gets a fresh key, so
|
||||
* checking one station off never marks another.
|
||||
*/
|
||||
export function keyedWaypoints(waypoints: TourWaypoint[]): Array<TourWaypoint & { key: string }> {
|
||||
const taken = new Set<string>();
|
||||
return waypoints.map((point) => {
|
||||
const key = point.key && !taken.has(point.key) ? point.key : randomUUID();
|
||||
taken.add(key);
|
||||
return { ...point, key };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The route a create or an update writes: metrics derived from the geometry, duration stored in minutes.
|
||||
* Without a line (CW-11) distance, heights and walking time stay unknown until the planner plans the way.
|
||||
*/
|
||||
export function tourRouteFields(input: TourCreateCommand): TourUpdate {
|
||||
const metrics = input.route_geometry ? computeTourMetrics(input.route_geometry) : null;
|
||||
// A paddle tour drawn here has its heights read off the banks at the points set, not
|
||||
// off the water: its ascent and descent stay unknown rather than wrong.
|
||||
const heightsKnown = input.tour_type !== 'kayak';
|
||||
return {
|
||||
tour_type: input.tour_type,
|
||||
distance: metrics?.distanceKm ?? null,
|
||||
elevation_gain: heightsKnown ? (metrics?.elevationGainM ?? null) : null,
|
||||
elevation_loss: heightsKnown ? (metrics?.elevationLossM ?? null) : null,
|
||||
duration: input.duration_seconds == null ? null : Math.round(input.duration_seconds / 60),
|
||||
...(input.planned_duration_minutes !== undefined
|
||||
? { planned_duration_minutes: input.planned_duration_minutes }
|
||||
: {}),
|
||||
...(input.break_additional_minutes !== undefined
|
||||
? { break_additional_minutes: input.break_additional_minutes }
|
||||
: {}),
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: input.max_hiking_difficulty,
|
||||
bike_profile: input.tour_type === 'bike' ? (input.bike_profile ?? null) : null,
|
||||
winter_activity: winterField(input),
|
||||
...runFields(input),
|
||||
// SKI-02: TREK plans no ski track, so a planned save is never one and takes its classification off.
|
||||
ski_discipline: null,
|
||||
ski_technique: null,
|
||||
boat_type: input.boat_type ?? null,
|
||||
water_context: input.water_context ?? null,
|
||||
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
|
||||
portage_speed_kmh: input.portage_speed_kmh ?? null,
|
||||
paddle_lake_style: input.paddle_lake_style ?? null,
|
||||
// MF-08: only a hike keeps preferences, and only those switched on; none is NULL.
|
||||
hiking_preferences: hikingFields(input),
|
||||
};
|
||||
}
|
||||
|
||||
function validateInformationalMetadata(input: TourCreateCommand): void {
|
||||
if (input.description != null && input.description.length > 2000) {
|
||||
throw badRequest('description must be 2000 characters or less');
|
||||
}
|
||||
if (input.website != null && !tourWebsiteSchema.safeParse(input.website).success) {
|
||||
throw badRequest('website must be a valid HTTPS URL without credentials');
|
||||
}
|
||||
}
|
||||
|
||||
function validatePlannedTimes(input: TourCreateCommand): void {
|
||||
for (const value of [input.planned_duration_minutes, input.break_additional_minutes]) {
|
||||
if (value !== undefined && !plannedTourDurationMinutesSchema.safeParse(value).success) {
|
||||
throw badRequest('planned Tour times must be whole minutes from 0 to 1440');
|
||||
}
|
||||
}
|
||||
// MDT-01: a multi-day tour is held to a day a stage, a day tour to one day.
|
||||
const stages = tourStageCountOf(input.waypoints);
|
||||
if (
|
||||
input.planned_duration_minutes == null &&
|
||||
input.duration_seconds != null &&
|
||||
Math.round(input.duration_seconds / 60) + (input.break_additional_minutes ?? 0) > maxTourMinutesFor(stages)
|
||||
) {
|
||||
throw badRequest(plannedTimeLimitMessage(stages));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MDT-01: the stage ends sit at stops between start and end, as the contract
|
||||
* holds a request to; checked here too for a write that did not come through
|
||||
* it (an assistant's city walk), so no surface can store one out of place.
|
||||
*/
|
||||
function validateStageEnds(input: TourCreateCommand): void {
|
||||
const [issue] = tourStageIssues(input.waypoints);
|
||||
if (issue) throw badRequest(issue.message);
|
||||
}
|
||||
|
||||
/** Every check a write passes before its transaction: its texts, its stage ends and its planned times. */
|
||||
export function checkTourCommand(input: TourCreateCommand): void {
|
||||
validateInformationalMetadata(input);
|
||||
validateStageEnds(input);
|
||||
validatePlannedTimes(input);
|
||||
}
|
||||
|
||||
/** Where a tour without a line starts (CW-11): at its first point. */
|
||||
export function firstPointOf(points: readonly { lat: number; lng: number }[]): [number, number] | undefined {
|
||||
const point = points[0];
|
||||
return point ? [point.lat, point.lng] : undefined;
|
||||
}
|
||||
|
||||
/** The file name a file import keeps: the name as uploaded, without a path, cut to what the column shows. */
|
||||
export function importFileName(name: string | undefined): string | null {
|
||||
const base = name?.split('/').pop()?.trim();
|
||||
return base ? base.slice(0, 255) : null;
|
||||
}
|
||||
@@ -128,10 +128,7 @@ export class ToursMcp {
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
})
|
||||
async getTourPlan(
|
||||
{ tripId, placeId, dayId }: { tripId: number; placeId: number; dayId?: number },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
async getTourPlan({ tripId, placeId, dayId }: { tripId: number; placeId: number; dayId?: number }, ctx: McpContext) {
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
let read: PlanRead;
|
||||
try {
|
||||
|
||||
@@ -4,32 +4,19 @@ import { TourStationVisits } from '../../db/entities/TourStationVisits.entity';
|
||||
import { TourTypes } from '../../db/entities/TourTypes.entity';
|
||||
import { TourWaypoints } from '../../db/entities/TourWaypoints.entity';
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import { TOUR_HIKING_PREFERENCES, TOUR_TRACK_MARKS } from '../../db/json-columns';
|
||||
import type { PlacesRepository } from '../../db/repositories/Places.repository';
|
||||
import type { TourActivitiesRepository, TourActivityRefRow } from '../../db/repositories/TourActivities.repository';
|
||||
import type { TourActivitiesRepository } from '../../db/repositories/TourActivities.repository';
|
||||
import type { TourStationVisitsRepository } from '../../db/repositories/TourStationVisits.repository';
|
||||
import type { TourTypesRepository } from '../../db/repositories/TourTypes.repository';
|
||||
import type {
|
||||
TourWaypointRow,
|
||||
TourWaypointsRepository,
|
||||
TourWaypointWithPlaceRow,
|
||||
} from '../../db/repositories/TourWaypoints.repository';
|
||||
import type {
|
||||
TourDayRow,
|
||||
TourListRow,
|
||||
TourProvenanceRow,
|
||||
ToursRepository,
|
||||
TourUpdate,
|
||||
} from '../../db/repositories/Tours.repository';
|
||||
import { utcSuffix } from '../../db/types';
|
||||
import { decodeJson, encodeJson } from '../../utils/json-column';
|
||||
import type { TourWaypointsRepository, TourWaypointWithPlaceRow } from '../../db/repositories/TourWaypoints.repository';
|
||||
import type { TourDayRow, ToursRepository, TourUpdate } from '../../db/repositories/Tours.repository';
|
||||
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';
|
||||
import { PlaceImportService } from '../place-import/place-import.service';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { activityFacts, toActivity, trackTimesFor } from './tour-activities.helpers';
|
||||
import { toActivity, trackTimesFor } from './tour-activities.helpers';
|
||||
import {
|
||||
copiedFacet,
|
||||
copyStart,
|
||||
@@ -39,24 +26,15 @@ import {
|
||||
type TourCopySource,
|
||||
} from './tour-copy.helpers';
|
||||
import { readTourFile } from './tour-file-import.helpers';
|
||||
import { provenanceSource, tourItemOf } from './tour-item.helpers';
|
||||
import { TourPaddleCheckService } from './tour-paddle-check.service';
|
||||
import { advanceTourRevision } from './tour-revision';
|
||||
import { toVisit } from './tour-visits.service';
|
||||
import {
|
||||
computeTourMetrics,
|
||||
localHashOf,
|
||||
parseRouteGeometry,
|
||||
LOW_CONFIDENCE_THRESHOLD,
|
||||
type GeometryPoint,
|
||||
} from './tours.helpers';
|
||||
import { checkTourCommand, firstPointOf, importFileName, keyedWaypoints, tourRouteFields } from './tour-write.helpers';
|
||||
import { computeTourMetrics, localHashOf, parseRouteGeometry, type GeometryPoint } from './tours.helpers';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
hikingPreferencesOf,
|
||||
maxTourMinutesFor,
|
||||
plannedTimeLimitMessage,
|
||||
plannedTourDurationMinutesSchema,
|
||||
tourWebsiteSchema,
|
||||
type BikeProfile,
|
||||
type CityWalkCreateRequest,
|
||||
type PaddleBoatType,
|
||||
@@ -73,21 +51,13 @@ import {
|
||||
type TourListItem,
|
||||
type TourOrigin,
|
||||
type TourProvenance,
|
||||
type TourProvenanceSource,
|
||||
type TourWaypoint,
|
||||
tourStageCountOf,
|
||||
tourStageIssues,
|
||||
tourStageKeysOf,
|
||||
tourStageStopsOf,
|
||||
type WandererDifficulty,
|
||||
type WinterActivity,
|
||||
walkStationFacts,
|
||||
walkStationsOf,
|
||||
wandererDifficultyToSac,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export interface ImportGpxAsTourResult {
|
||||
tours: TourListItem[];
|
||||
caution: boolean;
|
||||
@@ -108,10 +78,13 @@ const HIKE_IMPORT: TourGpxImportFields = { tour_type: 'hike' };
|
||||
* What a create writes: the request as the contract reads it, or for a city
|
||||
* walk an assistant created from its stations (CW-11) the way the server's
|
||||
* router planned on foot, which has no heights, or no line when it planned
|
||||
* none, which the planner then plans when the walk is opened.
|
||||
* none, which the planner then plans when the walk is opened. Its points are
|
||||
* the tour's points as the rest of the domain reads them, so a change saved
|
||||
* or taken back from MCP (MCP-T03/T04) hands in its points unconverted.
|
||||
*/
|
||||
export type TourCreateCommand = Omit<TourCreateRequest, 'route_geometry'> & {
|
||||
export type TourCreateCommand = Omit<TourCreateRequest, 'route_geometry' | 'waypoints'> & {
|
||||
route_geometry: GeometryPoint[] | null;
|
||||
waypoints: TourWaypoint[];
|
||||
};
|
||||
|
||||
/** A route write checked and ready for its transaction: the write, where the tour starts and its fields. */
|
||||
@@ -133,52 +106,6 @@ interface CityWalkFootRoute {
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
/** MF-08: a save's hiking preferences as stored: a hike's switched-on ones as JSON, else NULL. */
|
||||
function hikingFields(input: TourCreateCommand): string | null {
|
||||
const preferences = input.tour_type === 'hike' ? hikingPreferencesOf(input.hiking_preferences) : null;
|
||||
return preferences ? encodeJson(TOUR_HIKING_PREFERENCES, preferences) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* HIK-01: a save's winter label as stored. Only a hike keeps one; another type
|
||||
* takes it off. A hike's save that leaves the field out (a client from before
|
||||
* the label) keeps the one the tour has, rather than taking it off unasked.
|
||||
*/
|
||||
function winterField(input: TourCreateCommand): WinterActivity | null | undefined {
|
||||
return input.tour_type === 'hike' ? input.winter_activity : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* RUN-01: a save's run profile and pace as stored. Only a run keeps them;
|
||||
* another type takes them off. A run without a pace keeps none: its time stays
|
||||
* unknown rather than reckoned at a pace nobody chose.
|
||||
*/
|
||||
function runFields(input: TourCreateCommand): Pick<TourUpdate, 'run_profile' | 'run_pace_seconds_per_km'> {
|
||||
if (input.tour_type !== 'run') return { run_profile: null, run_pace_seconds_per_km: null };
|
||||
return { run_profile: input.run_profile ?? null, run_pace_seconds_per_km: input.run_pace_seconds_per_km ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-03: every point with its key, kept from the write where it came with one,
|
||||
* so the visits of a city walk's stations outlast a reorder or a new point. A
|
||||
* key sent twice stays with its first point; a later one gets a fresh key, so
|
||||
* checking one station off never marks another.
|
||||
*/
|
||||
function keyedWaypoints(waypoints: TourWaypoint[]): Array<TourWaypoint & { key: string }> {
|
||||
const taken = new Set<string>();
|
||||
return waypoints.map((point) => {
|
||||
const key = point.key && !taken.has(point.key) ? point.key : randomUUID();
|
||||
taken.add(key);
|
||||
return { ...point, 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);
|
||||
@@ -197,102 +124,6 @@ export class ToursService {
|
||||
private readonly assignments: AssignmentsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* A tour as it is listed; a city walk with the stations its points make and their stays (CW-02, CW-03),
|
||||
* every tour with the recordings assigned to it and the tour it documents itself (ACT-01).
|
||||
*/
|
||||
private toItem(
|
||||
r: TourListRow,
|
||||
points: readonly TourWaypointRow[] = [],
|
||||
activities: readonly TourActivityRefRow[] = [],
|
||||
): TourListItem {
|
||||
return {
|
||||
place_id: r.place_id,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
website: r.website,
|
||||
tour_type: r.tour_type as TourListItem['tour_type'],
|
||||
distance: r.distance,
|
||||
elevation_gain: r.elevation_gain,
|
||||
elevation_loss: r.elevation_loss,
|
||||
duration: r.duration,
|
||||
planned_duration_minutes: r.planned_duration_minutes,
|
||||
break_additional_minutes: r.break_additional_minutes,
|
||||
difficulty: r.difficulty,
|
||||
wanderer_ref: r.wanderer_ref,
|
||||
match_confidence: r.match_confidence,
|
||||
max_hiking_difficulty: r.max_hiking_difficulty,
|
||||
derived_from_place_id: r.derived_from_place_id,
|
||||
origin: r.origin as TourOrigin,
|
||||
bike_profile: r.bike_profile as BikeProfile | null,
|
||||
winter_activity: r.winter_activity as WinterActivity | null,
|
||||
run_profile: r.run_profile as RunProfile | null,
|
||||
run_pace_seconds_per_km: r.run_pace_seconds_per_km,
|
||||
ski_discipline: r.ski_discipline as SkiDiscipline | null,
|
||||
ski_technique: r.ski_technique as SkiTechnique | null,
|
||||
boat_type: r.boat_type as PaddleBoatType | null,
|
||||
water_context: r.water_context as PaddleWaterContext | null,
|
||||
paddle_speed_kmh: r.paddle_speed_kmh,
|
||||
portage_speed_kmh: r.portage_speed_kmh,
|
||||
paddle_lake_style: r.paddle_lake_style as PaddleLakeStyle | null,
|
||||
track_marks: decodeJson(TOUR_TRACK_MARKS, r.track_marks, 'tour'),
|
||||
has_recorded_track: Boolean(r.has_recorded_track),
|
||||
planned: Boolean(r.planned),
|
||||
caution: r.match_confidence !== null && r.match_confidence < LOW_CONFIDENCE_THRESHOLD,
|
||||
has_waypoints: Boolean(r.has_waypoints),
|
||||
revision: r.revision,
|
||||
hiking_preferences: decodeJson(TOUR_HIKING_PREFERENCES, r.hiking_preferences, 'tour'),
|
||||
...walkStationFacts(r.tour_type, points),
|
||||
route_pending: Boolean(r.route_missing) && Boolean(r.has_waypoints),
|
||||
track_started_at: utcSuffix(r.track_started_at),
|
||||
track_ended_at: utcSuffix(r.track_ended_at),
|
||||
...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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The route a create or an update writes: metrics derived from the geometry, duration stored in minutes.
|
||||
* Without a line (CW-11) distance, heights and walking time stay unknown until the planner plans the way.
|
||||
*/
|
||||
private routeFields(input: TourCreateCommand): TourUpdate {
|
||||
const metrics = input.route_geometry ? computeTourMetrics(input.route_geometry) : null;
|
||||
// A paddle tour drawn here has its heights read off the banks at the points set, not
|
||||
// off the water: its ascent and descent stay unknown rather than wrong.
|
||||
const heightsKnown = input.tour_type !== 'kayak';
|
||||
return {
|
||||
tour_type: input.tour_type,
|
||||
distance: metrics?.distanceKm ?? null,
|
||||
elevation_gain: heightsKnown ? (metrics?.elevationGainM ?? null) : null,
|
||||
elevation_loss: heightsKnown ? (metrics?.elevationLossM ?? null) : null,
|
||||
duration: input.duration_seconds == null ? null : Math.round(input.duration_seconds / 60),
|
||||
...(input.planned_duration_minutes !== undefined
|
||||
? { planned_duration_minutes: input.planned_duration_minutes }
|
||||
: {}),
|
||||
...(input.break_additional_minutes !== undefined
|
||||
? { break_additional_minutes: input.break_additional_minutes }
|
||||
: {}),
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: input.max_hiking_difficulty,
|
||||
bike_profile: input.tour_type === 'bike' ? (input.bike_profile ?? null) : null,
|
||||
winter_activity: winterField(input),
|
||||
...runFields(input),
|
||||
// SKI-02: TREK plans no ski track, so a planned save is never one and takes its classification off.
|
||||
ski_discipline: null,
|
||||
ski_technique: null,
|
||||
boat_type: input.boat_type ?? null,
|
||||
water_context: input.water_context ?? null,
|
||||
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
|
||||
portage_speed_kmh: input.portage_speed_kmh ?? null,
|
||||
paddle_lake_style: input.paddle_lake_style ?? null,
|
||||
// MF-08: only a hike keeps preferences, and only those switched on; none is NULL.
|
||||
hiking_preferences: hikingFields(input),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `tour_types` knows every key the contract accepts, most of them disabled.
|
||||
* Checked before the write, so a disabled type is a 400 and not a tour the
|
||||
@@ -303,42 +134,6 @@ export class ToursService {
|
||||
if (!(await this.tourTypesRepo.isEnabled(key))) throw badRequest('Tour type is not available');
|
||||
}
|
||||
|
||||
private validateInformationalMetadata(input: TourCreateCommand): void {
|
||||
if (input.description != null && input.description.length > 2000) {
|
||||
throw badRequest('description must be 2000 characters or less');
|
||||
}
|
||||
if (input.website != null && !tourWebsiteSchema.safeParse(input.website).success) {
|
||||
throw badRequest('website must be a valid HTTPS URL without credentials');
|
||||
}
|
||||
}
|
||||
|
||||
private validatePlannedTimes(input: TourCreateCommand): void {
|
||||
for (const value of [input.planned_duration_minutes, input.break_additional_minutes]) {
|
||||
if (value !== undefined && !plannedTourDurationMinutesSchema.safeParse(value).success) {
|
||||
throw badRequest('planned Tour times must be whole minutes from 0 to 1440');
|
||||
}
|
||||
}
|
||||
// MDT-01: a multi-day tour is held to a day a stage, a day tour to one day.
|
||||
const stages = tourStageCountOf(input.waypoints);
|
||||
if (
|
||||
input.planned_duration_minutes == null &&
|
||||
input.duration_seconds != null &&
|
||||
Math.round(input.duration_seconds / 60) + (input.break_additional_minutes ?? 0) > maxTourMinutesFor(stages)
|
||||
) {
|
||||
throw badRequest(plannedTimeLimitMessage(stages));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MDT-01: the stage ends sit at stops between start and end, as the contract
|
||||
* holds a request to; checked here too for a write that did not come through
|
||||
* it (an assistant's city walk), so no surface can store one out of place.
|
||||
*/
|
||||
private validateStageEnds(input: TourCreateCommand): void {
|
||||
const [issue] = tourStageIssues(input.waypoints);
|
||||
if (issue) throw badRequest(issue.message);
|
||||
}
|
||||
|
||||
/** All tours (the facet + owning place) for a trip, newest first; a paddle tour with its check bench (PAD-09). */
|
||||
async listTours(tripId: string): Promise<TourListItem[]> {
|
||||
const tid = toRowId(tripId);
|
||||
@@ -348,7 +143,7 @@ export class ToursService {
|
||||
const activities = await this.activitiesRepo.listForTrip(tid);
|
||||
return await this.paddleChecks.forTrip(
|
||||
tid,
|
||||
rows.map((r) => this.toItem(r, listedPoints.get(r.place_id) ?? [], activities)),
|
||||
rows.map((r) => tourItemOf(r, listedPoints.get(r.place_id) ?? [], activities)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -362,9 +157,7 @@ export class ToursService {
|
||||
rows: readonly { place_id: number; tour_type: string; stage_ends?: number }[],
|
||||
): Promise<Map<number, TourWaypointWithPlaceRow[]>> {
|
||||
const byPlace = new Map<number, TourWaypointWithPlaceRow[]>();
|
||||
const read = rows
|
||||
.filter((row) => row.tour_type === 'walk' || (row.stage_ends ?? 0) > 0)
|
||||
.map((row) => row.place_id);
|
||||
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);
|
||||
@@ -419,7 +212,7 @@ export class ToursService {
|
||||
const documents = await this.activitiesRepo.findByTrack(row.place_id);
|
||||
const refs = documents ? [...activities, documents] : activities;
|
||||
const checked = await this.paddleChecks.forTour(
|
||||
this.toItem(row, stored, refs),
|
||||
tourItemOf(row, stored, refs),
|
||||
stored,
|
||||
place?.route_geometry ?? null,
|
||||
);
|
||||
@@ -470,14 +263,12 @@ export class ToursService {
|
||||
// The controller's TripAccessGuard already resolved this trip id.
|
||||
const tid = toRowId(tripId)!;
|
||||
await this.assertTourTypeEnabled(input.tour_type);
|
||||
this.validateInformationalMetadata(input);
|
||||
this.validateStageEnds(input);
|
||||
this.validatePlannedTimes(input);
|
||||
checkTourCommand(input);
|
||||
const start = input.route_geometry ? input.route_geometry[0] : firstPointOf(input.waypoints);
|
||||
if (!start || (input.route_geometry === null && input.waypoints.length < 2)) {
|
||||
throw badRequest('A tour needs at least two points');
|
||||
}
|
||||
const fields = this.routeFields(input);
|
||||
const fields = tourRouteFields(input);
|
||||
const derivedFrom = input.derived_from_place_id ?? null;
|
||||
|
||||
const placeId = await this.uow.transactional(async () => {
|
||||
@@ -523,7 +314,7 @@ export class ToursService {
|
||||
this.logger.warn(`Committed Tour ${placeId}: place notification failed`);
|
||||
}
|
||||
const checked = await this.paddleChecks.forTour(
|
||||
this.toItem(row, waypoints),
|
||||
tourItemOf(row, waypoints),
|
||||
waypoints,
|
||||
place.route_geometry ?? null,
|
||||
);
|
||||
@@ -806,12 +597,10 @@ export class ToursService {
|
||||
*/
|
||||
async routeWrite(input: TourCreateCommand): Promise<TourRouteWrite> {
|
||||
await this.assertTourTypeEnabled(input.tour_type);
|
||||
this.validateInformationalMetadata(input);
|
||||
this.validateStageEnds(input);
|
||||
this.validatePlannedTimes(input);
|
||||
checkTourCommand(input);
|
||||
const start = input.route_geometry ? input.route_geometry[0] : firstPointOf(input.waypoints);
|
||||
if (!start) throw badRequest('A tour needs at least two points');
|
||||
return { input, start, fields: this.routeFields(input) };
|
||||
return { input, start, fields: tourRouteFields(input) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1021,7 +810,7 @@ export class ToursService {
|
||||
track_ended_at: recorded?.ended ?? null,
|
||||
});
|
||||
tours.push(
|
||||
this.toItem({
|
||||
tourItemOf({
|
||||
place_id: place.id,
|
||||
name: place.name,
|
||||
description: place.description ?? null,
|
||||
@@ -1046,6 +835,7 @@ export class ToursService {
|
||||
has_recorded_track: 0,
|
||||
planned: 0,
|
||||
has_waypoints: 0,
|
||||
stage_ends: 0,
|
||||
route_missing: 0,
|
||||
revision: 1,
|
||||
hiking_preferences: null,
|
||||
@@ -1104,38 +894,3 @@ export class ToursService {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a tour without a line starts (CW-11): at its first point. */
|
||||
function firstPointOf(points: readonly { lat: number; lng: number }[]): [number, number] | undefined {
|
||||
const point = points[0];
|
||||
return point ? [point.lat, point.lng] : undefined;
|
||||
}
|
||||
|
||||
/** The file name a file import keeps: the name as uploaded, without a path, cut to what the column shows. */
|
||||
function importFileName(name: string | undefined): string | null {
|
||||
const base = name?.split('/').pop()?.trim();
|
||||
return base ? base.slice(0, 255) : null;
|
||||
}
|
||||
|
||||
/** What the source of a tour carried, read from the tour's own record and its import's snapshot row. */
|
||||
function provenanceSource(origin: TourOrigin, row: TourProvenanceRow): TourProvenanceSource {
|
||||
if (origin === 'gpx') return { kind: 'gpx', fileName: row.origin_file_name };
|
||||
if (origin === 'wanderer') {
|
||||
// The reference `instance#trail` is on the tour itself, so a snapshot whose row is missing still names its source.
|
||||
const [refInstance, refTrail] = splitWandererRef(row.wanderer_ref);
|
||||
return {
|
||||
kind: 'wanderer',
|
||||
instance: row.source_instance ?? refInstance,
|
||||
externalId: row.source_external_id ?? refTrail,
|
||||
externalUrl: row.source_external_url,
|
||||
externalUpdated: row.source_external_updated,
|
||||
importedAt: row.source_imported_at,
|
||||
};
|
||||
}
|
||||
return { kind: 'trek' };
|
||||
}
|
||||
|
||||
function splitWandererRef(ref: string | null): [string, string] {
|
||||
const at = ref?.lastIndexOf('#') ?? -1;
|
||||
return ref && at > 0 ? [ref.slice(0, at), ref.slice(at + 1)] : ['', ''];
|
||||
}
|
||||
|
||||
@@ -84,7 +84,13 @@ export class WandererWatchService {
|
||||
@InjectRepository(TourSourceHints) private readonly hints: TourSourceHintsRepository,
|
||||
) {}
|
||||
|
||||
/** The cron's entry: every connection that may be asked now, one after another. */
|
||||
/**
|
||||
* The cron's entry: every connection that may be asked now, one after another.
|
||||
*
|
||||
* @txIndependent one transaction per user: each user's hints land whole with
|
||||
* the note of the run, after a look at their own instance over the network, and
|
||||
* one user that fails keeps no other from theirs.
|
||||
*/
|
||||
async run(now: Date = new Date()): Promise<void> {
|
||||
if (this.running || !(await this.addons.isWandererAvailable())) return;
|
||||
this.running = true;
|
||||
@@ -137,7 +143,10 @@ export class WandererWatchService {
|
||||
);
|
||||
await this.wanderer.noteWatchRun(userId, { lastRunAt: dbNow(now), failures: 0, pausedUntil: null });
|
||||
});
|
||||
await this.announce(userId, findings.filter(({ source, verdict }) => isNewHint(source, verdict)));
|
||||
await this.announce(
|
||||
userId,
|
||||
findings.filter(({ source, verdict }) => isNewHint(source, verdict)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -733,9 +733,7 @@ export class TripsService {
|
||||
if (newPlaceId) await this.tagsRepo.insertIgnore(newPlaceId, [t.tag_id]); // TP47
|
||||
}
|
||||
|
||||
// A Tour is its place plus the `tours` facet, its import record, its obstacle decisions, its packing link and
|
||||
// the waypoints its route was planned through. The facet row goes first, since the others reference it;
|
||||
// without them it is no Tour.
|
||||
// The `tours` facet goes first: the import record, obstacle decisions, packing link and waypoints need it.
|
||||
const oldTours = await this.toursRepo.listRowsForTrip(Number(sourceTripId));
|
||||
const copiedTours = new Map<number, number>();
|
||||
for (const { place_id, ...tour } of oldTours) {
|
||||
@@ -744,7 +742,9 @@ export class TripsService {
|
||||
await this.toursRepo.insertCopy(newPlaceId, tour, placeMap);
|
||||
copiedTours.set(place_id, newPlaceId);
|
||||
}
|
||||
for (const [from, to] of copiedTours) await this.toursRepo.copyTourRelations(from, to);
|
||||
await Promise.all([...copiedTours].map(([from, to]) => this.toursRepo.copySource(from, to)));
|
||||
await Promise.all([...copiedTours].map(([from, to]) => this.toursRepo.copyObstacleReviews(from, to)));
|
||||
await Promise.all([...copiedTours].map(([from, to]) => this.toursRepo.copyPackingLink(from, to)));
|
||||
|
||||
const waypointsByPlace = new Map<number, TourWaypointRow[]>();
|
||||
for (const { place_id, ...waypoint } of await this.tourWaypointsRepo.listForTrip(Number(sourceTripId))) {
|
||||
@@ -767,8 +767,7 @@ export class TripsService {
|
||||
day_id: newDayId, place_id: newPlaceId, order_index: a.order_index, notes: a.notes,
|
||||
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,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -49,16 +49,14 @@ export interface WandererTrailDownload {
|
||||
* refused or is no longer the user's own.
|
||||
*/
|
||||
export type WandererTrailLook =
|
||||
| { kind: 'record'; name: string; description: string | null; updated: string | null }
|
||||
| { kind: 'gone' };
|
||||
{ kind: 'record'; name: string; description: string | null; updated: string | null } | { kind: 'gone' };
|
||||
|
||||
/**
|
||||
* WAN-02: what one look of the watch read: a look per trail, or why the
|
||||
* instance itself could not be asked, for the watch to back off from.
|
||||
*/
|
||||
export type WandererWatchLooks =
|
||||
| { ok: true; looks: Map<string, WandererTrailLook> }
|
||||
| { ok: false; code: WandererErrorCode };
|
||||
{ ok: true; looks: Map<string, WandererTrailLook> } | { ok: false; code: WandererErrorCode };
|
||||
|
||||
/** WAN-02: the background watch of one user's connection. */
|
||||
interface WandererWatchState {
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
* Fixtures: user 1 owns trip 5 (two dated days, one without a date), user 2 is
|
||||
* a member, user 3 has no access.
|
||||
*/
|
||||
import { db } from '../../src/db/database';
|
||||
import { AssignmentParticipants } from '../../src/db/entities/AssignmentParticipants.entity';
|
||||
import { Days } from '../../src/db/entities/Days.entity';
|
||||
import { Places } from '../../src/db/entities/Places.entity';
|
||||
@@ -20,6 +19,7 @@ import { JourneyDomainService } from '../../src/nest/journey/journey-domain.serv
|
||||
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 { makeDayAssignment } from '../helpers/factories/itinerary';
|
||||
import { countRows, deleteRows, insertRow, insertRows } from '../helpers/factories/rows';
|
||||
import { setAddonEnabled } from '../helpers/factories/settings';
|
||||
@@ -36,10 +36,6 @@ import type { Server } from 'http';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
||||
return buildDbMock(createSnapshotTestDb());
|
||||
});
|
||||
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
|
||||
LOG_LEVEL: 'error',
|
||||
logInfo: vi.fn(),
|
||||
@@ -48,6 +44,7 @@ vi.mock('../../src/nest/audit/audit-log.logger', () => ({
|
||||
logWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
const journeyHooks = {
|
||||
reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined),
|
||||
onPlaceCreated: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
* Fixtures: user 1 owns trip 5 (two days), user 2 is a member whom the
|
||||
* permission spy denies 'place_edit', user 3 has no access.
|
||||
*/
|
||||
import { db } from '../../src/db/database';
|
||||
import { Days } from '../../src/db/entities/Days.entity';
|
||||
import { Places } from '../../src/db/entities/Places.entity';
|
||||
import { TourActivities } from '../../src/db/entities/TourActivities.entity';
|
||||
@@ -20,6 +19,7 @@ import { PermissionsService } from '../../src/nest/permissions/permissions.servi
|
||||
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 { makeDayAssignment } from '../helpers/factories/itinerary';
|
||||
import { countRows, deleteRows, findRow, insertRows } from '../helpers/factories/rows';
|
||||
import { setAddonEnabled } from '../helpers/factories/settings';
|
||||
@@ -36,10 +36,6 @@ import type { Server } from 'http';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
|
||||
|
||||
vi.mock('../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
||||
return buildDbMock(createSnapshotTestDb());
|
||||
});
|
||||
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
|
||||
LOG_LEVEL: 'error',
|
||||
logInfo: vi.fn(),
|
||||
@@ -48,6 +44,7 @@ vi.mock('../../src/nest/audit/audit-log.logger', () => ({
|
||||
logWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
const journeyHooks = {
|
||||
reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined),
|
||||
onPlaceCreated: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
@@ -325,7 +325,12 @@ describe('Horse tours e2e (real guards + temp SQLite)', () => {
|
||||
.field('tour_type', 'horse')
|
||||
.attach('file', Buffer.from(GPX), { filename: 'ride.gpx', contentType: 'application/gpx+xml' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.tours[0]).toMatchObject({ tour_type: 'horse', origin: 'gpx', duration: null, bike_profile: null });
|
||||
expect(res.body.tours[0]).toMatchObject({
|
||||
tour_type: 'horse',
|
||||
origin: 'gpx',
|
||||
duration: null,
|
||||
bike_profile: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -172,7 +172,11 @@ describe('Runs e2e (real guards + temp SQLite)', () => {
|
||||
});
|
||||
const list = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(VIEWER));
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.body.tours[0]).toMatchObject({ place_id: placeId, run_profile: 'running', run_pace_seconds_per_km: 360 });
|
||||
expect(list.body.tours[0]).toMatchObject({
|
||||
place_id: placeId,
|
||||
run_profile: 'running',
|
||||
run_pace_seconds_per_km: 360,
|
||||
});
|
||||
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER));
|
||||
expect(detail.body.tour).toMatchObject({ tour_type: 'run', run_profile: 'running', duration: 48 });
|
||||
expect(detail.body.waypoints).toHaveLength(2);
|
||||
|
||||
@@ -195,16 +195,17 @@ describe('Ski tracks e2e (real guards + temp SQLite)', () => {
|
||||
expect(opened.provenance).toMatchObject({ origin: 'gpx', source: { kind: 'gpx', fileName: 'hut.gpx' } });
|
||||
});
|
||||
|
||||
it('TOURS-SKI-E2E-002: a cross-country track keeps its technique, unknown included', async () => {
|
||||
for (const technique of ['classic', 'skating', 'unknown']) {
|
||||
it.each(['classic', 'skating', 'unknown'])(
|
||||
'TOURS-SKI-E2E-002: a cross-country track keeps its technique, unknown included: %s',
|
||||
async (technique) => {
|
||||
const placeId = await importSki({ ski_discipline: 'cross_country', ski_technique: technique });
|
||||
expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({
|
||||
ski_discipline: 'cross_country',
|
||||
ski_technique: technique,
|
||||
});
|
||||
await deleteRows(orm, Places);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('TOURS-SKI-E2E-003: refuses a ski import without its discipline or with a technique it has not, and ski fields on a hike', async () => {
|
||||
const refused = await Promise.all(
|
||||
|
||||
@@ -388,7 +388,9 @@ export function createDayAssignment(
|
||||
};
|
||||
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, tour_stage_key) VALUES (?, ?, ?, ?, ?)')
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -448,14 +448,20 @@ describe('ToursRepository writes', () => {
|
||||
const place = createPlace(testDb, trip.id, { name: 'River loop' });
|
||||
const run = { ...METRICS, tour_type: 'run', max_hiking_difficulty: null };
|
||||
await repo.insertTour({ place_id: place.id, ...run, run_profile: 'running', run_pace_seconds_per_km: 360 });
|
||||
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ run_profile: 'running', run_pace_seconds_per_km: 360 });
|
||||
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({
|
||||
run_profile: 'running',
|
||||
run_pace_seconds_per_km: 360,
|
||||
});
|
||||
|
||||
const faster = { ...HIKE_UPDATE, ...run, run_profile: 'running' as const, run_pace_seconds_per_km: 300 };
|
||||
expect(await repo.updateInTrip(trip.id, place.id, faster)).toBe(true);
|
||||
expect((await repo.listForTrip(trip.id))[0]).toMatchObject({ tour_type: 'run', run_pace_seconds_per_km: 300 });
|
||||
|
||||
expect(await repo.updateInTrip(trip.id, place.id, HIKE_UPDATE)).toBe(true);
|
||||
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ run_profile: null, run_pace_seconds_per_km: null });
|
||||
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({
|
||||
run_profile: null,
|
||||
run_pace_seconds_per_km: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('TOURREPO-024: a ski track keeps its classification, is classified again only in its own trip, and a route edit takes it off (SKI-02)', async () => {
|
||||
@@ -682,8 +688,8 @@ describe('ToursRepository writes', () => {
|
||||
const plainCopy = createPlace(testDb, other.id);
|
||||
await repo.insertTour({ ...METRICS, place_id: plainCopy.id });
|
||||
|
||||
await repo.copyTourRelations(linked.id, linkedCopy.id);
|
||||
await repo.copyTourRelations(plain.id, plainCopy.id);
|
||||
await repo.copyPackingLink(linked.id, linkedCopy.id);
|
||||
await repo.copyPackingLink(plain.id, plainCopy.id);
|
||||
|
||||
const source = await findRow(t, TourPackingLinks, { place: linked.id });
|
||||
expect(await findRow(t, TourPackingLinks, { place: linkedCopy.id })).toMatchObject({
|
||||
|
||||
@@ -13,8 +13,8 @@ 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 { TourWaypoints } from '../../../src/db/entities/TourWaypoints.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';
|
||||
@@ -416,7 +416,12 @@ describe('createAssignment with a stage of a multi-day tour (MDT-02)', () => {
|
||||
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 () => {
|
||||
it.each([
|
||||
['its last stop', 'tour', 'stop-end'],
|
||||
['a stop ending none', 'tour', 'stop-unknown'],
|
||||
['a plain place', 'plain', 'stop-start'],
|
||||
['a day tour', 'dayTour', 'day-start'],
|
||||
] as const)('ASG-SVC-053: refuses a stage the tour does not have: %s', async (_label, which, key) => {
|
||||
const { trip, day, place } = fixture();
|
||||
await hutToHut(place.id);
|
||||
const plain = createPlace(testDb, trip.id, { name: 'Ordinary place' });
|
||||
@@ -426,18 +431,12 @@ describe('createAssignment with a stage of a multi-day tour (MDT-02)', () => {
|
||||
{ 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',
|
||||
]);
|
||||
}
|
||||
const placeId = { tour: place.id, plain: plain.id, dayTour: dayTour.id }[which];
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -467,7 +466,7 @@ describe('createAssignment with a stage of a multi-day tour (MDT-02)', () => {
|
||||
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);
|
||||
const { assignment } = await svc.moveAssignment(stage.id, secondDay.id, 0);
|
||||
expect(assignment).toMatchObject({ day_id: secondDay.id, tour_stage_key: 'stop-hut-two' });
|
||||
});
|
||||
|
||||
@@ -477,9 +476,7 @@ describe('createAssignment with a stage of a multi-day tour (MDT-02)', () => {
|
||||
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 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -329,9 +329,7 @@ describe('CollectionToursService', () => {
|
||||
// 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);
|
||||
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,
|
||||
|
||||
@@ -9,8 +9,8 @@ import { TourActivities } from '../../../src/db/entities/TourActivities.entity';
|
||||
import type { McpContext } from '../../../src/nest-mcp';
|
||||
import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service';
|
||||
import { TourActivitiesMcp } from '../../../src/nest/tours/tour-activities.mcp';
|
||||
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
||||
import { ToursMcp } from '../../../src/nest/tours/tours.mcp';
|
||||
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { makeDayAssignment } from '../../helpers/factories/itinerary';
|
||||
import { makePlace } from '../../helpers/factories/places';
|
||||
|
||||
@@ -48,23 +48,24 @@ function read(result: { content: Array<{ type: string; text: string }>; isError?
|
||||
return result.isError ? { error: text } : JSON.parse(text);
|
||||
}
|
||||
|
||||
/** A tour of the trip on a day of its own, with 500 m up and down; answers its place. */
|
||||
async function tourOnDay(tripId: number, name: string, type: 'hike' | 'kayak', distance: number, date: string) {
|
||||
const day = await makeDay(orm(), tripId, { date });
|
||||
const place = await makePlace(orm(), tripId, { name });
|
||||
await makeTour(orm(), place.id, { tourTypeRef: type, distance, elevation_gain: 500, elevation_loss: 500 });
|
||||
await makeDayAssignment(orm(), day.id, place.id);
|
||||
return place.id;
|
||||
}
|
||||
|
||||
/** A hike and a paddle tour done in July, a hike still ahead, and a trip somebody else owns. */
|
||||
async function fixture() {
|
||||
const { user } = await makeUser(orm());
|
||||
const { user: stranger } = await makeUser(orm());
|
||||
const trip = await makeTrip(orm(), user.id, { title: 'Alps' });
|
||||
const tours: Record<string, number> = {};
|
||||
for (const [name, type, distance, date] of [
|
||||
['Ridge', 'hike', 12, '2026-07-02'],
|
||||
['Lake', 'kayak', 9, '2026-07-04'],
|
||||
['Summit', 'hike', 15, '2026-07-20'],
|
||||
] as const) {
|
||||
const day = await makeDay(orm(), trip.id, { date });
|
||||
const place = await makePlace(orm(), trip.id, { name });
|
||||
await makeTour(orm(), place.id, { tourTypeRef: type, distance, elevation_gain: 500, elevation_loss: 500 });
|
||||
await makeDayAssignment(orm(), day.id, place.id);
|
||||
tours[name] = place.id;
|
||||
}
|
||||
tours.Ridge = await tourOnDay(trip.id, 'Ridge', 'hike', 12, '2026-07-02');
|
||||
tours.Lake = await tourOnDay(trip.id, 'Lake', 'kayak', 9, '2026-07-04');
|
||||
tours.Summit = await tourOnDay(trip.id, 'Summit', 'hike', 15, '2026-07-20');
|
||||
const closed = await makeTrip(orm(), stranger.id);
|
||||
const hidden = await makePlace(orm(), closed.id, { name: 'Hidden' });
|
||||
await makeTour(orm(), hidden.id);
|
||||
|
||||
@@ -288,8 +288,8 @@ describe('TourChangeMcp.saveTourChange (MCP-T03)', () => {
|
||||
{ ...request, expected_revision: 2 },
|
||||
{ ...request, proposal_id: 'f'.repeat(32) },
|
||||
];
|
||||
for (const body of altered) {
|
||||
const err = await refusal(saveAs(f.trip.id, f.walk.id, body, f.user.id));
|
||||
const refusals = await Promise.all(altered.map((body) => refusal(saveAs(f.trip.id, f.walk.id, body, f.user.id))));
|
||||
for (const err of refusals) {
|
||||
expect([err.getStatus(), err.code]).toEqual([422, TOUR_CHANGE_NOT_PREPARED]);
|
||||
expect(err.publicMessage).toMatch(/^These changes are not the proposal that was prepared\. Nothing was saved/);
|
||||
}
|
||||
|
||||
@@ -303,8 +303,9 @@ describe('TourChangeUndoMcp (MCP-T04)', () => {
|
||||
[first.step.id, 'later_change'],
|
||||
]);
|
||||
const before = await untouched(f.walk.id);
|
||||
for (const attempt of [() => prepared(f, first.step.id), () => undone(f, confirmed(first.step.id, 5))]) {
|
||||
const err = await refusal(attempt());
|
||||
const asPrepared = await refusal(prepared(f, first.step.id));
|
||||
const asUndone = await refusal(undone(f, confirmed(first.step.id, 5)));
|
||||
for (const err of [asPrepared, asUndone]) {
|
||||
expect([err.getStatus(), err.code, err.details?.revision]).toEqual([409, TOUR_REVISION_CONFLICT, 5]);
|
||||
expect(err.publicMessage).toContain('(revision 4 then, 5 now). Nothing changed: only the latest change');
|
||||
}
|
||||
@@ -349,8 +350,9 @@ describe('TourChangeUndoMcp (MCP-T04)', () => {
|
||||
await markStationVisit(orm(), f.onDay1.id, bakeryKey, 'visited');
|
||||
const before = await untouched(f.walk.id);
|
||||
|
||||
for (const attempt of [() => prepared(f, save.step.id), () => undone(f, confirmed(save.step.id, 4))]) {
|
||||
const err = await refusal(attempt());
|
||||
const asPrepared = await refusal(prepared(f, save.step.id));
|
||||
const asUndone = await refusal(undone(f, confirmed(save.step.id, 4)));
|
||||
for (const err of [asPrepared, asUndone]) {
|
||||
expect([err.getStatus(), err.code]).toEqual([409, TOUR_CHANGE_UNDO_REFUSED]);
|
||||
expect(err.details?.refused).toEqual([
|
||||
{
|
||||
@@ -486,7 +488,7 @@ describe('TourChangeUndoMcp (MCP-T04)', () => {
|
||||
['tripId', 'placeId', 'step_id', 'expected_revision', 'confirmed'],
|
||||
],
|
||||
] as const;
|
||||
for (const [method, name, readOnly, mode, fields] of tools) {
|
||||
const gates = tools.map(([method, name, readOnly, mode, fields]) => {
|
||||
const entry = getEntry(TourChangeUndoMcp as unknown as ClassRef, method);
|
||||
if (!entry || entry.kind !== 'tool') throw new Error(`${method} is no MCP tool`);
|
||||
const { options } = entry;
|
||||
@@ -498,9 +500,10 @@ describe('TourChangeUndoMcp (MCP-T04)', () => {
|
||||
expect(Object.keys(options.inputSchema as object)).toEqual([...fields]);
|
||||
expect(options.description).toContain('planner');
|
||||
const isAddonEnabled = vi.fn(async (id: string) => id === 'tours');
|
||||
expect(await options.when?.(ctx(1), { addons: { isAddonEnabled } })).toBe(true);
|
||||
expect(isAddonEnabled).toHaveBeenCalledWith('tours');
|
||||
}
|
||||
return { isAddonEnabled, open: options.when?.(ctx(1), { addons: { isAddonEnabled } }) };
|
||||
});
|
||||
expect(await Promise.all(gates.map((gate) => gate.open))).toEqual([true, true, true]);
|
||||
for (const { isAddonEnabled } of gates) expect(isAddonEnabled).toHaveBeenCalledWith('tours');
|
||||
const entry = getEntry(TourChangeUndoMcp as unknown as ClassRef, 'undoTourChange');
|
||||
if (!entry || entry.kind !== 'tool') throw new Error('undoTourChange is no MCP tool');
|
||||
// The fields are zod schemas from @trek/shared, read here by what they answer.
|
||||
|
||||
@@ -51,14 +51,16 @@ beforeAll(async () => {
|
||||
beforeEach(() => {
|
||||
resetTestDb(db);
|
||||
// Every leg the router plans is 500 m and 400 s on foot.
|
||||
route.mockReset().mockImplementation(
|
||||
async (_userId: number, _tripId: number, _dayId: number, points: Array<{ lat: number; lng: number }>) => ({
|
||||
leg: { line: points.map((point): [number, number] => [point.lat, point.lng]) },
|
||||
parts: points.slice(1).map(() => ({ distance: 500, duration: 400 })),
|
||||
snapped: points.map(() => ({ meters: 4 })),
|
||||
avoidMissed: [],
|
||||
}),
|
||||
);
|
||||
route
|
||||
.mockReset()
|
||||
.mockImplementation(
|
||||
async (_userId: number, _tripId: number, _dayId: number, points: Array<{ lat: number; lng: number }>) => ({
|
||||
leg: { line: points.map((point): [number, number] => [point.lat, point.lng]) },
|
||||
parts: points.slice(1).map(() => ({ distance: 500, duration: 400 })),
|
||||
snapped: points.map(() => ({ meters: 4 })),
|
||||
avoidMissed: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
|
||||
@@ -4,10 +4,7 @@ import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator'
|
||||
import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard';
|
||||
import { RESPONSE_CONTRACT_KEY } from '../../../src/nest/common/response-contract';
|
||||
import { TripAccessGuard } from '../../../src/nest/permissions/trip-access.guard';
|
||||
import {
|
||||
parseHorseRoutesQuery,
|
||||
TourHorseRoutesController,
|
||||
} from '../../../src/nest/tours/tour-horse-routes.controller';
|
||||
import { parseHorseRoutesQuery, TourHorseRoutesController } from '../../../src/nest/tours/tour-horse-routes.controller';
|
||||
import type { TourHorseRoutesService } from '../../../src/nest/tours/tour-horse-routes.service';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { GUARDS_METADATA } from '@nestjs/common/constants';
|
||||
|
||||
@@ -92,7 +92,8 @@ describe('TourHorseRoutesService (HOR-01)', () => {
|
||||
it('HORSE-SVC-005: keeps a bounded number of answers, letting the one kept longest go', async () => {
|
||||
query.mockResolvedValue([]);
|
||||
const nth = (n: number) => ({ south: 40 + n * 0.1, west: 5, north: 40.05 + n * 0.1, east: 5.05 });
|
||||
for (let n = 0; n <= HORSE_ROUTES_KEPT_MAX; n += 1) await service.list(nth(n), NOW);
|
||||
// Asked all at once, the answers are kept in the order they were asked for.
|
||||
await Promise.all(Array.from({ length: HORSE_ROUTES_KEPT_MAX + 1 }, (_, n) => service.list(nth(n), NOW)));
|
||||
expect(query).toHaveBeenCalledTimes(HORSE_ROUTES_KEPT_MAX + 1);
|
||||
await service.list(nth(HORSE_ROUTES_KEPT_MAX), NOW);
|
||||
await service.list(nth(1), NOW);
|
||||
@@ -107,7 +108,12 @@ describe('TourHorseRoutesService (HOR-01)', () => {
|
||||
const wayCount = Math.ceil((HORSE_ROUTES_KEPT_POINTS_MAX / 2 + 1) / perWay);
|
||||
const ids = Array.from({ length: wayCount }, (_, w) => w + 1);
|
||||
const dense = [
|
||||
{ type: 'relation', id: 7, tags: { route: 'horse' }, members: ids.map((ref) => ({ type: 'way', ref, role: '' })) },
|
||||
{
|
||||
type: 'relation',
|
||||
id: 7,
|
||||
tags: { route: 'horse' },
|
||||
members: ids.map((ref) => ({ type: 'way', ref, role: '' })),
|
||||
},
|
||||
...ids.map((id) => ({
|
||||
type: 'way',
|
||||
id,
|
||||
|
||||
@@ -117,11 +117,14 @@ describe('TourPackingLinkService', () => {
|
||||
|
||||
it('TPL-SVC-003: a category the user cannot see an item of is refused, a private one of another member included', async () => {
|
||||
const f = await fixture();
|
||||
for (const category of ['Climbing', 'hiking gear', 'Secret']) {
|
||||
await expect(
|
||||
links.link(String(f.trip.id), String(f.hike.id), { category }, f.owner.id),
|
||||
).rejects.toMatchObject({ status: 400, publicMessage: 'Packing category not found' });
|
||||
}
|
||||
const refused = (category: string) =>
|
||||
expect(links.link(String(f.trip.id), String(f.hike.id), { category }, f.owner.id)).rejects.toMatchObject({
|
||||
status: 400,
|
||||
publicMessage: 'Packing category not found',
|
||||
});
|
||||
await refused('Climbing');
|
||||
await refused('hiking gear');
|
||||
await refused('Secret');
|
||||
// The member who keeps it may link it.
|
||||
const own = await links.link(String(f.trip.id), String(f.hike.id), { category: 'Secret' }, f.member.id);
|
||||
expect(own.packing_link?.category).toBe('Secret');
|
||||
@@ -163,18 +166,15 @@ describe('TourPackingLinkService', () => {
|
||||
|
||||
it('TPL-SVC-006: a tour of another trip, a plain place or a malformed id is not found', async () => {
|
||||
const f = await fixture();
|
||||
const attempts = [
|
||||
() => links.read(f.trip.id, f.otherTour.id),
|
||||
() => links.read(f.trip.id, f.cafe.id),
|
||||
() => links.read('abc', f.hike.id),
|
||||
() => links.read(f.trip.id, '1.5'),
|
||||
() => links.link(String(f.trip.id), String(f.otherTour.id), { category: null }, f.owner.id),
|
||||
() => links.unlink(String(f.trip.id), String(f.cafe.id)),
|
||||
() => links.view(f.other.id, f.hike.id, f.owner.id),
|
||||
];
|
||||
for (const attempt of attempts) {
|
||||
await expect(attempt()).rejects.toMatchObject({ status: 404, publicMessage: 'Tour not found' });
|
||||
}
|
||||
const notFound = (attempt: Promise<unknown>) =>
|
||||
expect(attempt).rejects.toMatchObject({ status: 404, publicMessage: 'Tour not found' });
|
||||
await notFound(links.read(f.trip.id, f.otherTour.id));
|
||||
await notFound(links.read(f.trip.id, f.cafe.id));
|
||||
await notFound(links.read('abc', f.hike.id));
|
||||
await notFound(links.read(f.trip.id, '1.5'));
|
||||
await notFound(links.link(String(f.trip.id), String(f.otherTour.id), { category: null }, f.owner.id));
|
||||
await notFound(links.unlink(String(f.trip.id), String(f.cafe.id)));
|
||||
await notFound(links.view(f.other.id, f.hike.id, f.owner.id));
|
||||
expect(await countRows(orm(), TourPackingLinks)).toBe(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -115,9 +115,9 @@ describe('TourPackingMcp', () => {
|
||||
await expect(
|
||||
mcp.linkTourPacking({ tripId: f.trip.id, placeId: f.hike.id, category: 'Climbing' }, ctx(f.user.id)),
|
||||
).rejects.toThrow('Packing category not found');
|
||||
await expect(
|
||||
mcp.getTourPacking({ tripId: f.trip.id, placeId: 999_999 }, ctx(f.user.id)),
|
||||
).rejects.toThrow('Tour not found');
|
||||
await expect(mcp.getTourPacking({ tripId: f.trip.id, placeId: 999_999 }, ctx(f.user.id))).rejects.toThrow(
|
||||
'Tour not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('TPL-MCP-004: the tools are there only while Tours and Packing are on, and only for the scopes they need', async () => {
|
||||
@@ -127,15 +127,14 @@ describe('TourPackingMcp', () => {
|
||||
return entry.options;
|
||||
};
|
||||
const enabled = vi.spyOn(t.get(AddonsService), 'isAddonEnabled').mockResolvedValue(true);
|
||||
for (const name of ['getTourPacking', 'linkTourPacking', 'unlinkTourPacking']) {
|
||||
const gate = options(name).when;
|
||||
enabled.mockResolvedValue(true);
|
||||
expect(await gate?.(ctx(1), mcp)).toBe(true);
|
||||
enabled.mockImplementation(async (id: string) => id !== 'packing');
|
||||
expect(await gate?.(ctx(1), mcp)).toBe(false);
|
||||
enabled.mockImplementation(async (id: string) => id !== 'tours');
|
||||
expect(await gate?.(ctx(1), mcp)).toBe(false);
|
||||
}
|
||||
const gates = ['getTourPacking', 'linkTourPacking', 'unlinkTourPacking'].map((name) => options(name).when);
|
||||
const open = () => Promise.all(gates.map((gate) => gate?.(ctx(1), mcp)));
|
||||
enabled.mockResolvedValue(true);
|
||||
expect(await open()).toEqual([true, true, true]);
|
||||
enabled.mockImplementation(async (id: string) => id !== 'packing');
|
||||
expect(await open()).toEqual([false, false, false]);
|
||||
enabled.mockImplementation(async (id: string) => id !== 'tours');
|
||||
expect(await open()).toEqual([false, false, false]);
|
||||
|
||||
const allowed = (name: string, scopes: string[]) => {
|
||||
const access = options(name).access;
|
||||
|
||||
@@ -200,7 +200,9 @@ afterAll(() => {
|
||||
|
||||
describe('Tours GPX atomic persistence and postcommit publication', () => {
|
||||
async function expectEmpty() {
|
||||
for (const table of ['places', 'tours', 'tour_waypoints'] as const) expect(await count(table)).toBe(0);
|
||||
expect(await Promise.all((['places', 'tours', 'tour_waypoints'] as const).map((table) => count(table)))).toEqual([
|
||||
0, 0, 0,
|
||||
]);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
@@ -451,7 +453,7 @@ describe('Tours KML import through the same persistence (IO-02)', () => {
|
||||
</Document></kml>`);
|
||||
|
||||
it('stores each line as a tour of its own, heights only where the file has them', async () => {
|
||||
const result = (await tours.importGpxAsTour(tripId, twoLines, 'alps.kml', 'socket'))!;
|
||||
const result = await tours.importGpxAsTour(tripId, twoLines, 'alps.kml', 'socket');
|
||||
|
||||
expect(result.tours.map((tour) => [tour.name, tour.origin, tour.elevation_gain])).toEqual([
|
||||
['Coast', 'gpx', null],
|
||||
@@ -466,7 +468,9 @@ describe('Tours KML import through the same persistence (IO-02)', () => {
|
||||
testDb.exec(`CREATE TRIGGER fail_place BEFORE INSERT ON places WHEN NEW.name = 'Ridge'
|
||||
BEGIN SELECT RAISE(ABORT, 'place failure'); END;`);
|
||||
await expect(tours.importGpxAsTour(tripId, twoLines, 'alps.kml')).rejects.toThrow('place failure');
|
||||
for (const table of ['places', 'tours', 'tour_waypoints'] as const) expect(await count(table)).toBe(0);
|
||||
expect(await Promise.all((['places', 'tours', 'tour_waypoints'] as const).map((table) => count(table)))).toEqual([
|
||||
0, 0, 0,
|
||||
]);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -378,7 +378,9 @@ describe('ToursMcp', () => {
|
||||
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([
|
||||
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'],
|
||||
@@ -586,7 +588,11 @@ describe('ToursMcp', () => {
|
||||
await orm.close();
|
||||
const { tours } = read(await mcp.listTours({ tripId: trip.id }, ctx(user.id)));
|
||||
const byId = new Map(tours.map((tour: { place_id: number }) => [tour.place_id, tour]));
|
||||
expect(byId.get(ridge.id)).toMatchObject({ tour_type: 'run', run_profile: 'running', run_pace_seconds_per_km: 360 });
|
||||
expect(byId.get(ridge.id)).toMatchObject({
|
||||
tour_type: 'run',
|
||||
run_profile: 'running',
|
||||
run_pace_seconds_per_km: 360,
|
||||
});
|
||||
expect(byId.get(gorge.id)).toMatchObject({ run_profile: null, run_pace_seconds_per_km: null });
|
||||
const entry = getEntry(ToursMcp as unknown as ClassRef, 'listTours');
|
||||
const description = entry?.kind === 'tool' ? (entry.options.description ?? '') : '';
|
||||
|
||||
@@ -685,7 +685,6 @@ describe('ToursService runs (RUN-01)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('ToursService multi-day tours (MDT-01)', () => {
|
||||
/** Hut to hut over three days: two stops between start and end each end a stage. */
|
||||
const hutToHut: TourCreateRequest = {
|
||||
|
||||
@@ -11,9 +11,9 @@ import { maybe_encrypt_api_key } from '../../../src/nest/common/crypto/apiKeyCry
|
||||
import { DomainError } from '../../../src/nest/common/domain-error';
|
||||
import { NotificationsService } from '../../../src/nest/notifications/notifications.service';
|
||||
import { contentHashOf } from '../../../src/nest/tours/tours.helpers';
|
||||
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
||||
import { WATCH_BACKOFF_BASE_MS } from '../../../src/nest/tours/wanderer-watch.helpers';
|
||||
import { WandererWatchService } from '../../../src/nest/tours/wanderer-watch.service';
|
||||
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
||||
import { WandererClient, WandererError } from '../../../src/nest/wanderer/wanderer.client';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { makePlace } from '../../helpers/factories/places';
|
||||
|
||||
@@ -19,8 +19,15 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
type Role = 'start' | 'via' | 'end';
|
||||
|
||||
/** What a test sets on a point beyond its place and role. */
|
||||
interface PointExtra {
|
||||
stage_end?: boolean;
|
||||
shaping?: boolean;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
/** A straight run of points along a meridian, each a stop unless told otherwise. */
|
||||
function points(count: number, extra: (index: number) => Record<string, unknown> = () => ({})) {
|
||||
function points(count: number, extra: (index: number) => PointExtra = () => ({})) {
|
||||
return Array.from({ length: count }, (_, sequence) => ({
|
||||
lat: 47 + sequence * 0.01,
|
||||
lng: 11,
|
||||
|
||||
Reference in New Issue
Block a user