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