feat(tours): bike tours in the contract and on the server (BIK-01 to BIK-06, RTE-01)

A tour can be a bike tour with a routing profile (touring, road, gravel,
mtb), stored per tour by a new migration. The server grades the ways of a
mountain bike route from OpenStreetMap (mtb:scale) through one shared
Overpass client, offers CyclOSM behind its own admin switch, and exports a
bike tour's GPX with the activity of its profile. A hike's route choices
are now exactly the three the card names: steep climbs, steps and ferries.
This commit is contained in:
Maurice
2026-10-19 21:33:40 +02:00
parent c3229afcba
commit adcaca3c5f
126 changed files with 3112 additions and 237 deletions
+11
View File
@@ -38,6 +38,7 @@ export class Tours {
hiking_preferences?: string | null;
taken_over_from_trip_title?: string | null;
taken_over_from_tour_name?: string | null;
bike_profile?: TToursBikeProfile | null;
tourTypeRef!: Ref<TourTypes>;
tour_obstacle_reviews_collection = new Collection<TourObstacleReviews>(this);
tour_sources: Ref<TourSources> | null = null;
@@ -75,6 +76,15 @@ export const ToursPaddleLakeStyle = {
export type TToursPaddleLakeStyle = (typeof ToursPaddleLakeStyle)[keyof typeof ToursPaddleLakeStyle];
export const ToursBikeProfile = {
TOURING: 'touring',
ROAD: 'road',
GRAVEL: 'gravel',
MTB: 'mtb',
} as const;
export type TToursBikeProfile = (typeof ToursBikeProfile)[keyof typeof ToursBikeProfile];
export const ToursSchema = defineEntity({
class: Tours,
repository: () => ToursRepository,
@@ -116,6 +126,7 @@ export const ToursSchema = defineEntity({
hiking_preferences: p.text().nullable(),
taken_over_from_trip_title: p.text().nullable(),
taken_over_from_tour_name: p.text().nullable(),
bike_profile: p.enum(() => ToursBikeProfile).nullable(),
tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_tours_tour_type'),
tour_obstacle_reviews_collection: () => p.oneToMany(TourObstacleReviews).mappedBy('place').hidden(),
tour_sources: () => p.oneToOne(TourSources).ref().mappedBy('place').hidden(),
+1
View File
@@ -30,4 +30,5 @@ export interface ToursTable {
hiking_preferences: InsertOptional<string | null>;
taken_over_from_trip_title: InsertOptional<string | null>;
taken_over_from_tour_name: InsertOptional<string | null>;
bike_profile: InsertOptional<string | null>;
}
@@ -0,0 +1,18 @@
import { Migration } from '@mikro-orm/migrations';
/**
* BIK-01: a tour can be a bike tour. The `bike` key has been in `tour_types`,
* disabled, since every contract type was seeded; it is switched on here. The
* profile it is ridden on (touring, road, gravel, mtb) is a column of the
* tour, NULL on every other type, so no existing tour changes its meaning.
*/
export class Migration20200101045200_a_tour_can_be_ridden extends Migration {
override name = 'Migration20200101045200_a_tour_can_be_ridden';
override up(): void {
this.addSql(
"ALTER TABLE tours ADD COLUMN bike_profile TEXT CHECK (bike_profile IS NULL OR bike_profile IN ('touring', 'road', 'gravel', 'mtb'))",
);
this.addSql("UPDATE tour_types SET enabled = 1 WHERE key = 'bike'");
}
}
+11 -1
View File
@@ -2,7 +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 type { PaddleBoatType, PaddleLakeStyle, PaddleWaterContext } from '@trek/shared';
import type { BikeProfile, PaddleBoatType, PaddleLakeStyle, PaddleWaterContext } from '@trek/shared';
/**
* A tour as the Tours list and detail read it: the `tours` facet joined with
@@ -28,6 +28,8 @@ export interface TourListRow {
max_hiking_difficulty: number | null;
derived_from_place_id: number | null;
origin: string;
/** BIK-01: a bike tour's profile; NULL on every other type. */
bike_profile: string | null;
boat_type: string | null;
water_context: string | null;
paddle_speed_kmh: number | null;
@@ -92,6 +94,7 @@ export interface TourRow {
hiking_preferences: string | null;
taken_over_from_trip_title: string | null;
taken_over_from_tour_name: string | null;
bike_profile: string | null;
}
const _tourRowKeys: AssertRowKeys<TourRow, Tours> = true;
@@ -107,6 +110,8 @@ export interface TourInsert {
break_additional_minutes?: number | null;
match_confidence: number | null;
max_hiking_difficulty: number | null;
/** BIK-01: a bike tour's profile; left out, NULL. */
bike_profile?: BikeProfile | null;
/** A paddle tour's boat, water and chosen speed; left out, a hike's NULLs. */
boat_type?: PaddleBoatType | null;
water_context?: PaddleWaterContext | null;
@@ -123,6 +128,7 @@ export type TourUpdate = Omit<
| 'place_id'
| 'planned_duration_minutes'
| 'break_additional_minutes'
| 'bike_profile'
| 'boat_type'
| 'water_context'
| 'paddle_speed_kmh'
@@ -131,6 +137,7 @@ export type TourUpdate = Omit<
> & {
planned_duration_minutes?: number | null;
break_additional_minutes?: number | null;
bike_profile: BikeProfile | null;
boat_type: PaddleBoatType | null;
water_context: PaddleWaterContext | null;
paddle_speed_kmh: number | null;
@@ -232,6 +239,7 @@ export class ToursRepository extends TrekRepository<Tours> {
't.max_hiking_difficulty as max_hiking_difficulty',
't.derived_from_place_id as derived_from_place_id',
't.origin as origin',
't.bike_profile as bike_profile',
't.boat_type as boat_type',
't.water_context as water_context',
't.paddle_speed_kmh as paddle_speed_kmh',
@@ -585,6 +593,7 @@ export class ToursRepository extends TrekRepository<Tours> {
break_additional_minutes: input.break_additional_minutes ?? null,
match_confidence: input.match_confidence,
max_hiking_difficulty: input.max_hiking_difficulty,
bike_profile: input.bike_profile ?? null,
boat_type: input.boat_type ?? null,
water_context: input.water_context ?? null,
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
@@ -669,6 +678,7 @@ export class ToursRepository extends TrekRepository<Tours> {
: {}),
match_confidence: input.match_confidence,
max_hiking_difficulty: input.max_hiking_difficulty,
bike_profile: input.bike_profile,
boat_type: input.boat_type,
water_context: input.water_context,
paddle_speed_kmh: input.paddle_speed_kmh,
@@ -328,6 +328,11 @@ export function applyGlobalMiddleware(
// OpenHikingMap, the hiking base map of the Tours planner (MAP-01), loaded only
// while a user shows it and the admin allows it; GL reads it through fetch.
'https://tile.openmaps.fr',
// CyclOSM, the cycling base map of a bike tour in the Tours planner (BIK-06), on the
// same terms; its three shards are named, so no wildcard opens the whole domain.
'https://a.tile-cyclosm.openstreetmap.fr',
'https://b.tile-cyclosm.openstreetmap.fr',
'https://c.tile-cyclosm.openstreetmap.fr',
// Amap's raster tiles, for an install whose users are in China, for the
// same reason as the hosts above (#2180). Road (webrd01..04) and
// satellite (webst01..04) are numbered shards of one domain, so the
+11
View File
@@ -91,6 +91,16 @@ export class AddonsService {
return this.writeFlag('tours_hiking_map_enabled', enabled);
}
/** BIK-06: the admin's switch for CyclOSM under Tours, as stored; fail-open like OpenHikingMap's. */
async getToursCycleMap() {
const value = await readAppSetting(this.appSettings, 'tours_cycle_map_enabled');
return { enabled: value !== 'false' };
}
async updateToursCycleMap(enabled: boolean) {
return this.writeFlag('tours_cycle_map_enabled', enabled);
}
/** wanderer may be used: Tours is on and the admin allowed it under Tours. */
async isWandererAvailable(): Promise<boolean> {
return (await this.isAddonEnabled(ADDON_IDS.TOURS)) && (await this.getToursWanderer()).enabled;
@@ -177,6 +187,7 @@ export class AddonsService {
bagTracking: (await this.getBagTracking()).enabled,
toursWanderer: await this.isWandererAvailable(),
toursHikingMap: (await this.isAddonEnabled(ADDON_IDS.TOURS)) && (await this.getToursHikingMap()).enabled,
toursCycleMap: (await this.isAddonEnabled(ADDON_IDS.TOURS)) && (await this.getToursCycleMap()).enabled,
addons: [
// The repository row carries every scalar column (description, config,
// sort_order included); the legacy statement selected only these five,
+19
View File
@@ -323,6 +323,25 @@ export class AdminController {
return result;
}
@Get('tours-cycle-map')
@ResponseContract(adminFeatureToggleResponseSchema)
getToursCycleMap() {
return this.addons.getToursCycleMap();
}
@Put('tours-cycle-map')
@ResponseContract(adminFeatureToggleResponseSchema)
async updateToursCycleMap(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = await this.addons.updateToursCycleMap(body.enabled);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.tours_cycle_map',
ip: getClientIp(req),
details: { enabled: result.enabled },
});
return result;
}
@Get('places-photos')
getPlacesPhotos() {
return this.addons.getPlacesPhotos();
@@ -112,6 +112,10 @@ export const APP_SETTINGS = {
// the installs TREK is made for; an admin whose install is commercial or larger switches it off
// until there is an agreement with the operator (openmaps.fr/tile-usage-policy.html).
tours_hiking_map_enabled: { schema: flag, default: 'true' },
// BIK-06: CyclOSM as a base map of a bike tour in the Tours planner, on the same terms: its
// tiles are OpenStreetMap France's, free for moderate use with a Referer; a heavy or commercial
// install switches it off (or reaches an agreement) under the operator's usage policy.
tours_cycle_map_enabled: { schema: flag, default: 'true' },
route_usage_enabled: { schema: flag, default: 'false' },
// PAD-11C: how TREK gets the water data of a place a paddler plans in (auto, ask, off) and the largest extract it loads by itself.
paddle_auto_load: { schema: z.enum(['auto', 'ask', 'off']), default: 'auto' },
@@ -202,11 +202,6 @@ type WayElement = z.infer<typeof wayElementSchema>;
type RelationElement = z.infer<typeof relationElementSchema>;
/** What an Overpass mirror answers with, at the boundary. */
export const overpassFerrataAnswerSchema = z.object({
elements: z.array(z.unknown()).max(50000),
remark: z.string().optional(),
});
function cleanName(value: string | undefined): string | null {
const name = value?.trim() ?? '';
return name === '' ? null : name.slice(0, NAME_MAX);
+5 -5
View File
@@ -1,6 +1,6 @@
import { FerrataTiles } from '../../db/entities/FerrataTiles.entity';
import { OverpassModule } from '../overpass/overpass.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { FerratasOverpassClient } from './ferratas-overpass.client';
import { FerratasController } from './ferratas.controller';
import { FerratasService } from './ferratas.service';
import { MikroOrmModule } from '@mikro-orm/nestjs';
@@ -8,14 +8,14 @@ import { Module } from '@nestjs/common';
/**
* Via ferratas around a trip, read from OpenStreetMap through Overpass.
* PermissionsModule brings TripAccessGuard; RuntimeEnvService (the Overpass
* endpoints and deadline) comes from the global AppConfigModule. The answers
* PermissionsModule brings TripAccessGuard; OverpassModule the client that asks
* the operator's Overpass mirrors. The answers
* are kept per grid cell in `ferrata_tiles`; UnitOfWork and MikroORM come from
* the global OrmModule.
*/
@Module({
imports: [PermissionsModule, MikroOrmModule.forFeature([FerrataTiles])],
imports: [PermissionsModule, OverpassModule, MikroOrmModule.forFeature([FerrataTiles])],
controllers: [FerratasController],
providers: [FerratasService, FerratasOverpassClient],
providers: [FerratasService],
})
export class FerratasModule {}
+3 -3
View File
@@ -6,7 +6,7 @@ import { logWarn } from '../audit/audit-log.logger';
import { badRequest, DomainError } from '../common/domain-error';
import { withRequestContext } from '../database/request-context';
import { UnitOfWork } from '../database/unit-of-work';
import { FerratasOverpassClient } from './ferratas-overpass.client';
import { OverpassElementsClient } from '../overpass/overpass-elements.client';
import {
buildFerrataQuery,
ferrataTileBlocks,
@@ -98,7 +98,7 @@ export class FerratasService {
private readonly waiting: Array<() => void> = [];
constructor(
private readonly overpass: FerratasOverpassClient,
private readonly overpass: OverpassElementsClient,
@InjectRepository(FerrataTiles) private readonly tiles: FerrataTilesRepository,
private readonly uow: UnitOfWork,
private readonly orm: MikroORM,
@@ -242,7 +242,7 @@ export class FerratasService {
private async queryOverpass(keys: readonly string[]): Promise<unknown[]> {
await this.turn();
try {
return await this.overpass.query(buildFerrataQuery(ferrataTilesBbox(keys)));
return await this.overpass.query(buildFerrataQuery(ferrataTilesBbox(keys)), 'Ferratas');
} catch {
// The client already logged why each mirror failed.
throw new DomainError(502, FERRATAS_UNAVAILABLE_MESSAGE, {
+3 -2
View File
@@ -367,8 +367,8 @@ export const CATEGORY_OSM_FILTERS: Record<string, string[]> = {
charging: ['amenity=charging_station'],
rest_area: ['highway=rest_area', 'highway=services'],
campsite: ['tourism=camp_site', 'tourism=caravan_site'],
// What a hike or a paddle tour stops for along its line (PLN-02). Asked for by the Tours
// search along a route, never offered by the explore pill.
// What a hike, a ride or a paddle tour stops for along its line (PLN-02). Asked for by the
// Tours search along a route, never offered by the explore pill.
viewpoint: ['tourism=viewpoint'],
peak: ['natural=peak'],
shelter: ['amenity=shelter', 'tourism=wilderness_hut', 'tourism=alpine_hut'],
@@ -377,6 +377,7 @@ export const CATEGORY_OSM_FILTERS: Record<string, string[]> = {
picnic: ['tourism=picnic_site', 'leisure=picnic_table'],
slipway: ['leisure=slipway', 'canoe=put_in'],
boat_rental: ['amenity=boat_rental'],
bike_repair: ['amenity=bicycle_repair_station', 'shop=bicycle'],
};
export const POI_CATEGORY_KEYS = Object.keys(CATEGORY_OSM_FILTERS);
@@ -3,12 +3,14 @@ import { readCappedJson } from '../../utils/cappedFetch';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { logWarn } from '../audit/audit-log.logger';
import { buildUserAgent, parseOverpassEndpoints } from '../maps/overpass.constants';
import { overpassFerrataAnswerSchema } from './ferratas.helpers';
import { Injectable } from '@nestjs/common';
import { z } from 'zod';
/**
* The outbound half of the via ferrata lookup: one Overpass query raced across
* the mirrors the operator configured.
* One Overpass query raced across the mirrors the operator configured, for the
* domains that read OpenStreetMap elements themselves: the via ferratas around
* a trip, the trail grades along a bike tour (BIK-05).
*
* It reads the same settings the maps domain's Overpass client reads
* (OVERPASS_URL, a comma-separated list that replaces the public mirrors, and
@@ -22,6 +24,12 @@ import { Injectable } from '@nestjs/common';
// megabytes means the mirror is misbehaving, not that the mountains got bigger.
const MAX_ANSWER_BYTES = 8 * 1024 * 1024;
/** An Overpass answer: its elements, and a remark when Overpass gave up on the query. */
export const overpassAnswerSchema = z.object({
elements: z.array(z.unknown()).max(50000),
remark: z.string().optional(),
});
/** Why every mirror failed, one reason per mirror, for the log line. */
function reasons(err: unknown): string {
const all = err instanceof AggregateError ? err.errors : [err];
@@ -29,7 +37,7 @@ function reasons(err: unknown): string {
}
@Injectable()
export class FerratasOverpassClient {
export class OverpassElementsClient {
constructor(private readonly env: RuntimeEnvService) {}
/**
@@ -37,8 +45,9 @@ export class FerratasOverpassClient {
* document. A `remark` (Overpass' own timeout or runtime error, sent with a
* 200) counts as a failure, so a fast but empty answer cannot win the race.
* Throws once every mirror failed; the caller decides what the client sees.
* `caller` names the domain in the log line.
*/
async query(query: string): Promise<unknown[]> {
async query(query: string, caller: string): Promise<unknown[]> {
const { overpassUrl, overpassTimeoutMs } = this.env.env().integrations;
const endpoints = parseOverpassEndpoints(overpassUrl);
const body = `data=${encodeURIComponent(query)}`;
@@ -52,7 +61,7 @@ export class FerratasOverpassClient {
await res.body?.cancel().catch(() => {});
throw new Error(`Overpass ${res.status} @ ${url}`);
}
const answer = overpassFerrataAnswerSchema.safeParse(await readCappedJson<unknown>(res, MAX_ANSWER_BYTES));
const answer = overpassAnswerSchema.safeParse(await readCappedJson<unknown>(res, MAX_ANSWER_BYTES));
if (!answer.success) throw new Error(`Overpass non-OSM or oversized body @ ${url}`);
if (answer.data.remark) throw new Error(`Overpass remark @ ${url}: ${answer.data.remark}`);
return answer.data.elements;
@@ -61,7 +70,7 @@ export class FerratasOverpassClient {
try {
return await Promise.any(endpoints.map(attempt));
} catch (err) {
logWarn(`[Ferratas] all ${endpoints.length} Overpass endpoint(s) failed: ${reasons(err)}`);
logWarn(`[${caller}] all ${endpoints.length} Overpass endpoint(s) failed: ${reasons(err)}`);
throw new Error('Could not reach any Overpass endpoint', { cause: err });
} finally {
// The losers are cancelled once one mirror answered (or all gave up).
@@ -0,0 +1,13 @@
import { OverpassElementsClient } from './overpass-elements.client';
import { Module } from '@nestjs/common';
/**
* The Overpass client shared by the domains that read OpenStreetMap elements
* themselves. RuntimeEnvService (the mirrors and the deadline) comes from the
* global AppConfigModule.
*/
@Module({
providers: [OverpassElementsClient],
exports: [OverpassElementsClient],
})
export class OverpassModule {}
@@ -0,0 +1,5 @@
import { tourTrailGradeRequestSchema } from '@trek/shared';
import { createZodDto } from 'nestjs-zod';
export class TourTrailGradeDto extends createZodDto(tourTrailGradeRequestSchema) {}
@@ -9,7 +9,7 @@ import { toRowId } from '../common/row-id';
import { PlaceImportService } from '../place-import/place-import.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable, NotFoundException } from '@nestjs/common';
import type { PaddleBoatType } from '@trek/shared';
import type { BikeProfile, PaddleBoatType } from '@trek/shared';
/** What GPX calls the activity in a track's <type>; a paddle tour says what it is paddled in. */
const PADDLE_ACTIVITY: Record<PaddleBoatType, string> = {
@@ -19,8 +19,18 @@ const PADDLE_ACTIVITY: Record<PaddleBoatType, string> = {
other: 'paddling',
};
function gpxActivityOf(type: string, boat: string | null): string {
/** A bike tour says what it is ridden on, in the words Garmin and Strava read. */
const BIKE_ACTIVITY: Record<BikeProfile, string> = {
touring: 'cycling',
road: 'road_biking',
gravel: 'gravel_cycling',
mtb: 'mountain_biking',
};
function gpxActivityOf(tour: { tour_type: string; boat_type?: string | null; bike_profile?: string | null }): string {
const { tour_type: type, boat_type: boat, bike_profile: bike } = tour;
if (type === 'kayak') return PADDLE_ACTIVITY[(boat ?? 'other') as PaddleBoatType] ?? PADDLE_ACTIVITY.other;
if (type === 'bike') return BIKE_ACTIVITY[(bike ?? 'touring') as BikeProfile] ?? BIKE_ACTIVITY.touring;
return type === 'hike' ? 'hiking' : type;
}
@@ -51,7 +61,7 @@ export class TourGpxExportService {
name: tour.name,
description: tour.description ?? null,
website: tour.website ?? null,
activity: gpxActivityOf(tour.tour_type, tour.boat_type),
activity: gpxActivityOf(tour),
route_geometry: place.route_geometry ?? null,
stops,
});
@@ -31,6 +31,7 @@ const MAPS_CATEGORIES: Record<TourPoiCategory, readonly string[]> = {
sights: ['sights'],
slipway: ['slipway'],
boat_rental: ['boat_rental'],
bike_repair: ['bike_repair'],
};
/**
@@ -0,0 +1,29 @@
import { ADDON_IDS } from '../../addons';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { ResponseContract } from '../common/response-contract';
import { TripAccessGuard } from '../permissions/trip-access.guard';
import { TourTrailGradeDto } from './dto/tour-trail-grade.dto';
import { TourTrailGradeService } from './tour-trail-grade.service';
import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common';
import { tourTrailGradeResponseSchema } from '@trek/shared';
/**
* BIK-05 POST /api/trips/:tripId/tours/trail-grades: the trail grades of the
* ways a mountain bike tour runs on. A read, so anyone who sees the trip may
* ask; the ways come in the body because the draft may not be saved yet.
*/
@Controller('api/trips/:tripId/tours')
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
export class TourTrailGradeController {
constructor(private readonly trailGrades: TourTrailGradeService) {}
@Post('trail-grades')
@HttpCode(200)
@ResponseContract(tourTrailGradeResponseSchema)
async grades(@Body() body: TourTrailGradeDto) {
return await this.trailGrades.grades(body);
}
}
@@ -0,0 +1,97 @@
import { OverpassElementsClient } from '../overpass/overpass-elements.client';
import { Injectable } from '@nestjs/common';
import type { TourTrailGrade, TourTrailGradeRequest, TourTrailGradeResponse } from '@trek/shared';
import { z } from 'zod';
/** Ways per Overpass question: a query names them all, and a shorter one answers sooner. */
const WAYS_PER_QUERY = 400;
/** How long a way's grades are kept: OpenStreetMap changes slowly, the mirrors are shared. */
const KEPT_MS = 24 * 60 * 60 * 1000;
/** The most ways kept at once; the oldest go first. */
const MAX_KEPT = 50_000;
const wayElementSchema = z.object({
type: z.literal('way'),
id: z.number().int().positive(),
tags: z.record(z.string(), z.string()).optional(),
});
/** A tag's value as a step of its scale (`S2` and ` 2 ` read as 2), or null when it is none. */
function step(value: string | undefined, top: number, signed: boolean): string | null {
const match = /^\s*S?([0-9])([+-])?\s*$/i.exec(value ?? '');
if (!match || Number(match[1]) > top) return null;
return signed ? `${match[1]}${match[2] ?? ''}` : (match[1] ?? null);
}
function gradeOf(id: number, tags: Record<string, string> = {}): TourTrailGrade | null {
const grade = {
id,
mtb_scale: step(tags['mtb:scale'], 6, true),
mtb_scale_uphill: step(tags['mtb:scale:uphill'], 5, true),
mtb_scale_imba: step(tags['mtb:scale:imba'], 4, false),
};
return grade.mtb_scale || grade.mtb_scale_uphill || grade.mtb_scale_imba ? grade : null;
}
/**
* BIK-05: the trail grades of the ways a mountain bike tour runs on, read from
* OpenStreetMap through the operator's Overpass mirrors. What was read is kept
* a day, so the same tour asked about again costs the mirrors nothing; ways a
* mirror could not answer are said, never guessed.
*/
@Injectable()
export class TourTrailGradeService {
private readonly kept = new Map<number, { grade: TourTrailGrade | null; at: number }>();
constructor(private readonly overpass: OverpassElementsClient) {}
async grades(request: TourTrailGradeRequest): Promise<TourTrailGradeResponse> {
const now = Date.now();
const ids = [...new Set(request.way_ids)];
const unknown = ids.filter((id) => {
const known = this.kept.get(id);
return !known || now - known.at > KEPT_MS;
});
const parts = Array.from({ length: Math.ceil(unknown.length / WAYS_PER_QUERY) }, (_, index) =>
unknown.slice(index * WAYS_PER_QUERY, (index + 1) * WAYS_PER_QUERY),
);
// One question after another, so a long tour does not flood the shared mirrors.
const unanswered = await parts.reduce<Promise<number[]>>(async (done, part) => {
const failed = await done;
return (await this.ask(part, now)) ? failed : [...failed, ...part];
}, Promise.resolve([]));
const ways = ids.flatMap((id) => {
const grade = this.kept.get(id)?.grade;
return grade ? [grade] : [];
});
return { ways, unanswered };
}
/** Reads one part's grades into what is kept; false when no mirror answered. */
private async ask(ids: number[], now: number): Promise<boolean> {
let elements: unknown[];
try {
elements = await this.overpass.query(`[out:json][timeout:25];way(id:${ids.join(',')});out tags;`, 'Tours');
} catch {
return false;
}
const tagged = new Map<number, Record<string, string> | undefined>();
for (const element of elements) {
const way = wayElementSchema.safeParse(element);
if (way.success) tagged.set(way.data.id, way.data.tags);
}
// A way the answer leaves out has no tags worth naming, or no longer exists: no grade either way.
for (const id of ids) this.keep(id, gradeOf(id, tagged.get(id)), now);
return true;
}
private keep(id: number, grade: TourTrailGrade | null, at: number): void {
this.kept.delete(id);
this.kept.set(id, { grade, at });
if (this.kept.size > MAX_KEPT) {
const oldest = this.kept.keys().next().value;
if (oldest !== undefined) this.kept.delete(oldest);
}
}
}
+2 -2
View File
@@ -52,7 +52,7 @@ export class ToursMcp {
@Tool({
name: 'list_tours',
description:
'List the saved tours of a trip (hikes and paddle tours planned or imported in the Tours addon): name, type (hike, or kayak for a paddle tour), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_steps, avoid_ferries, prefer_paths, shortest; weightings, never a grade of the path), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), the revision (raised by every saved change of the tour), and the days of the trip each tour is on.',
'List the saved tours of a trip (hikes, bike tours and paddle tours planned or imported in the Tours addon): name, type (hike, bike, or kayak for a paddle tour), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), the revision (raised by every saved change of the tour), and the days of the trip each tour is on.',
inputSchema: { tripId: idSchema },
annotations: TOOL_ANNOTATIONS_READONLY,
when: addonGate(ADDON_IDS.TOURS),
@@ -130,7 +130,7 @@ export class ToursMcp {
@Tool({
name: 'search_tour_pois',
description:
'Search places along the saved line of a tour: what a hike or a paddle tour stops for (viewpoint, peak, shelter, drinking_water, food, toilets, picnic, campsite, sights, slipway, boat_rental), within corridor_m metres of the line. Answers each place with its source, kind, address, website and phone where known, opening hours as the source has them (a hint to check, never a promise), distance_m (straight across to the line, not a detour) and along_m (from the start along the line), nearest the start first, up to 60. partial says some stretches could not be searched, capped that only the first part of a long line was. Nothing is added to the tour or the trip.',
'Search places along the saved line of a tour: what a hike, a bike tour or a paddle tour stops for (viewpoint, peak, shelter, drinking_water, food, toilets, picnic, campsite, sights, slipway, boat_rental, bike_repair for repair stations and bike shops), within corridor_m metres of the line. Answers each place with its source, kind, address, website and phone where known, opening hours as the source has them (a hint to check, never a promise), distance_m (straight across to the line, not a detour) and along_m (from the start along the line), nearest the start first, up to 60. partial says some stretches could not be searched, capped that only the first part of a long line was. Nothing is added to the tour or the trip.',
inputSchema: {
tripId: idSchema,
placeId: idSchema.describe('The place id of the tour, as list_tours gives it'),
+7 -1
View File
@@ -7,6 +7,7 @@ import { Tours } from '../../db/entities/Tours.entity';
import { AddonsModule } from '../addons/addons.module';
import { MapsModule } from '../maps/maps.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { OverpassModule } from '../overpass/overpass.module';
import { PaddleObstaclesModule } from '../paddle-obstacles/paddle-obstacles.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { PlaceImportModule } from '../place-import/place-import.module';
@@ -23,6 +24,8 @@ import { TourPaddleCheckService } from './tour-paddle-check.service';
import { TourPaddlePlanService } from './tour-paddle-plan.service';
import { TourPoiSearchController } from './tour-poi-search.controller';
import { TourPoiSearchService } from './tour-poi-search.service';
import { TourTrailGradeController } from './tour-trail-grade.controller';
import { TourTrailGradeService } from './tour-trail-grade.service';
import { ToursImportController } from './tours-import.controller';
import { ToursWandererController } from './tours-wanderer.controller';
import { ToursWandererService } from './tours-wanderer.service';
@@ -49,7 +52,8 @@ import { Module } from '@nestjs/common';
McpSharedModule,
// PLN-02 searches places along a tour through the map search.
MapsModule,
// BIK-05 reads the trail grades along a mountain bike tour from OpenStreetMap.
OverpassModule,
AddonsModule,
// The wanderer import reads trails through the caller's own connection.
WandererModule,
@@ -60,6 +64,7 @@ import { Module } from '@nestjs/common';
controllers: [
TourTakeOverController,
TourPoiSearchController,
TourTrailGradeController,
ToursController,
ToursImportController,
ToursWandererController,
@@ -75,6 +80,7 @@ import { Module } from '@nestjs/common';
ToursWandererService,
TourLibraryService,
TourPoiSearchService,
TourTrailGradeService,
ToursMcp,
TourLibraryMcp,
],
+10 -3
View File
@@ -35,6 +35,7 @@ import {
MAX_PLANNED_TOUR_DURATION_MINUTES,
plannedTourDurationMinutesSchema,
tourWebsiteSchema,
type BikeProfile,
type PaddleBoatType,
type PaddleLakeStyle,
type PaddleWaterContext,
@@ -108,6 +109,7 @@ export class ToursService {
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,
boat_type: r.boat_type as PaddleBoatType | null,
water_context: r.water_context as PaddleWaterContext | null,
paddle_speed_kmh: r.paddle_speed_kmh,
@@ -143,6 +145,7 @@ export class ToursService {
: {}),
match_confidence: 1,
max_hiking_difficulty: input.max_hiking_difficulty,
bike_profile: input.tour_type === 'bike' ? (input.bike_profile ?? null) : null,
boat_type: input.boat_type ?? null,
water_context: input.water_context ?? null,
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
@@ -381,6 +384,7 @@ export class ToursService {
row.wanderer_ref && row.tour_type === 'hike'
? wandererDifficultyToSac(row.difficulty as WandererDifficulty | null)
: null,
bike_profile: row.bike_profile as BikeProfile | null,
boat_type: row.boat_type as PaddleBoatType | null,
water_context: row.water_context as PaddleWaterContext | null,
paddle_speed_kmh: row.paddle_speed_kmh,
@@ -486,6 +490,7 @@ export class ToursService {
break_additional_minutes: row.break_additional_minutes,
match_confidence: row.match_confidence,
max_hiking_difficulty: row.max_hiking_difficulty,
bike_profile: row.bike_profile as BikeProfile | null,
boat_type: row.boat_type as PaddleBoatType | null,
water_context: row.water_context as PaddleWaterContext | null,
paddle_speed_kmh: row.paddle_speed_kmh,
@@ -647,7 +652,9 @@ export class ToursService {
const rows = this.readGpxTracks(fileBuffer, fileName);
if (rows.length === 0) return null;
await this.assertTourTypeEnabled(fields.tour_type);
const paddle = {
// What the type adds to each imported tour: a bike tour its profile, a paddle tour its boat and water.
const typeFields = {
bike_profile: fields.bike_profile ?? null,
boat_type: fields.boat_type ?? null,
water_context: fields.water_context ?? null,
paddle_speed_kmh: null,
@@ -663,7 +670,7 @@ export class ToursService {
await this.toursRepo.insertTour({
place_id: place.id,
tour_type: fields.tour_type,
...paddle,
...typeFields,
distance: metrics.distanceKm,
elevation_gain: metrics.elevationGainM,
elevation_loss: metrics.elevationLossM,
@@ -682,7 +689,7 @@ export class ToursService {
description: place.description ?? null,
website: place.website ?? null,
tour_type: fields.tour_type,
...paddle,
...typeFields,
distance: metrics.distanceKm,
elevation_gain: metrics.elevationGainM,
elevation_loss: metrics.elevationLossM,