mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
Tours: read the saved tours of a trip over MCP (P2D-MCP-01)
This commit is contained in:
@@ -74,7 +74,16 @@ export type TourUpdate = Omit<TourInsert, 'place_id' | 'planned_duration_minutes
|
||||
break_additional_minutes?: number | null;
|
||||
};
|
||||
|
||||
type ToursKyselyDB = Pick<DB, 'tours' | 'places' | 'day_assignments' | 'tour_waypoints' | 'tour_sources'>;
|
||||
type ToursKyselyDB = Pick<DB, 'tours' | 'places' | 'day_assignments' | 'tour_waypoints' | 'tour_sources' | 'days'>;
|
||||
|
||||
/** One day a tour of the trip sits on, in the order of the trip. */
|
||||
export interface TourDayRow {
|
||||
place_id: number;
|
||||
day_id: number;
|
||||
day_number: number;
|
||||
date: string | null;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where one tour came from, as the provenance contract reads it: the facet's
|
||||
@@ -243,6 +252,26 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** The days every tour of a trip sits on, each tour and day once, in day order. */
|
||||
async listTourDays(trip_id: number): Promise<TourDayRow[]> {
|
||||
return await this.db()
|
||||
.selectFrom('day_assignments as da')
|
||||
.innerJoin('tours as t', 't.place_id', 'da.place_id')
|
||||
.innerJoin('days as d', 'd.id', 'da.day_id')
|
||||
.select([
|
||||
'da.place_id as place_id',
|
||||
'd.id as day_id',
|
||||
'd.day_number as day_number',
|
||||
'd.date as date',
|
||||
'd.title as title',
|
||||
])
|
||||
.where('d.trip_id', '=', trip_id)
|
||||
.distinct()
|
||||
.orderBy('d.day_number', 'asc')
|
||||
.orderBy('da.place_id', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** TO3 — `SELECT 1 FROM tours WHERE place_id = ?`: whether the place is a tour. */
|
||||
async existsForPlace(place_id: number): Promise<boolean> {
|
||||
const row = await this.db()
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import type { TourDayRow } from '../../db/repositories/Tours.repository';
|
||||
import { noAccess } from '../../mcp/tools/_shared';
|
||||
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, errorResult, ok, type McpContext } from '../../nest-mcp';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { TripAccessService } from '../trip-membership/trip-access.service';
|
||||
import { ToursService } from './tours.service';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { idSchema, type TourListItem } from '@trek/shared';
|
||||
|
||||
type TourDay = Pick<TourDayRow, 'day_id' | 'day_number' | 'date' | 'title'>;
|
||||
|
||||
/** A tour as a client reads it: its list facts and the days of the trip it is on. */
|
||||
function withDays(tour: TourListItem, days: ReadonlyMap<number, TourDay[]>) {
|
||||
return { ...tour, days: days.get(tour.place_id) ?? [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* P2D-MCP-01: the saved tours of a trip, read only. A tool reads what the
|
||||
* tours list and the tour detail answer over REST, through the same service,
|
||||
* so a client sees the same facts the app shows: the tours of a trip, one
|
||||
* tour's plan (its control points, measures and provenance; the full line
|
||||
* stays out, it runs to thousands of points), and the tours of one day.
|
||||
* Nothing here writes. Drafts in a browser are never visible here: only
|
||||
* saved tours exist on the server.
|
||||
*/
|
||||
@McpController()
|
||||
export class ToursMcp {
|
||||
constructor(
|
||||
private readonly tours: ToursService,
|
||||
private readonly access: TripAccessService,
|
||||
// Read by the addon gate of every tool here.
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
/** Each tour of the trip with the days it is on, each day once, in day order. */
|
||||
private async daysByTour(tripId: number): Promise<Map<number, TourDay[]>> {
|
||||
const byTour = new Map<number, TourDay[]>();
|
||||
for (const { place_id, ...day } of await this.tours.listTourDays(tripId)) {
|
||||
byTour.set(place_id, [...(byTour.get(place_id) ?? []), day]);
|
||||
}
|
||||
return byTour;
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'list_tours',
|
||||
description:
|
||||
'List the saved tours of a trip (hikes and other tours planned or imported in the Tours addon): name, type, where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit (max_hiking_difficulty, SAC T1 to T6), and the days of the trip each tour is on.',
|
||||
inputSchema: { tripId: idSchema },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
})
|
||||
async listTours({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
const [tours, days] = await Promise.all([this.tours.listTours(String(tripId)), this.daysByTour(tripId)]);
|
||||
return ok({ tours: tours.map((tour) => withDays(tour, days)) });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_tour_plan',
|
||||
description:
|
||||
'Read one saved tour of a trip: its facts, its ordered control points (start, via and end; an import without its own shows the two ends of its track), where it came from (provenance) and the days it is on. The full track geometry is not included.',
|
||||
inputSchema: { tripId: idSchema, placeId: idSchema.describe('The place id of the tour, as list_tours gives it') },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
})
|
||||
async getTourPlan({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) {
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
try {
|
||||
const [detail, days] = await Promise.all([
|
||||
this.tours.getTour(String(tripId), String(placeId)),
|
||||
this.daysByTour(tripId),
|
||||
]);
|
||||
return ok({ ...detail, tour: withDays(detail.tour, days) });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof NotFoundException) return errorResult('Tour not found.');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'find_tours_for_day',
|
||||
description: 'List the saved tours put on one day of a trip, with the same facts as list_tours.',
|
||||
inputSchema: { tripId: idSchema, dayId: idSchema },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
})
|
||||
async findToursForDay({ tripId, dayId }: { tripId: number; dayId: number }, ctx: McpContext) {
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
const [tours, days] = await Promise.all([this.tours.listTours(String(tripId)), this.daysByTour(tripId)]);
|
||||
const onDay = tours.filter((tour) => (days.get(tour.place_id) ?? []).some((day) => day.day_id === dayId));
|
||||
return ok({ tours: onDay.map((tour) => withDays(tour, days)) });
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,13 @@ import { AddonsModule } from '../addons/addons.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { PlaceImportModule } from '../place-import/place-import.module';
|
||||
import { PlacesModule } from '../places/places.module';
|
||||
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
|
||||
import { WandererModule } from '../wanderer/wanderer.module';
|
||||
import { ToursImportController } from './tours-import.controller';
|
||||
import { ToursWandererController } from './tours-wanderer.controller';
|
||||
import { ToursWandererService } from './tours-wanderer.service';
|
||||
import { ToursController } from './tours.controller';
|
||||
import { ToursMcp } from './tours.mcp';
|
||||
import { ToursService } from './tours.service';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { Module } from '@nestjs/common';
|
||||
@@ -28,6 +30,7 @@ import { Module } from '@nestjs/common';
|
||||
PlacesModule,
|
||||
PlaceImportModule,
|
||||
PermissionsModule,
|
||||
TripMembershipModule,
|
||||
|
||||
AddonsModule,
|
||||
// The wanderer import reads trails through the caller's own connection.
|
||||
@@ -35,6 +38,6 @@ import { Module } from '@nestjs/common';
|
||||
MikroOrmModule.forFeature([Tours, TourTypes, TourWaypoints, TourSources, Places]),
|
||||
],
|
||||
controllers: [ToursController, ToursImportController, ToursWandererController],
|
||||
providers: [ToursService, ToursWandererService],
|
||||
providers: [ToursService, ToursWandererService, ToursMcp],
|
||||
})
|
||||
export class ToursModule {}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { PlacesRepository } from '../../db/repositories/Places.repository';
|
||||
import type { TourTypesRepository } from '../../db/repositories/TourTypes.repository';
|
||||
import type { TourWaypointsRepository } from '../../db/repositories/TourWaypoints.repository';
|
||||
import type {
|
||||
TourDayRow,
|
||||
TourListRow,
|
||||
TourProvenanceRow,
|
||||
ToursRepository,
|
||||
@@ -159,6 +160,11 @@ export class ToursService {
|
||||
}
|
||||
|
||||
/** One saved tour plus the persisted routing controls needed by the editor. */
|
||||
/** The days every tour of the trip sits on, for the MCP read tools. */
|
||||
async listTourDays(tripId: number): Promise<TourDayRow[]> {
|
||||
return await this.toursRepo.listTourDays(tripId);
|
||||
}
|
||||
|
||||
async getTour(tripId: string, placeId: string): Promise<TourDetailResponse> {
|
||||
const tid = toRowId(tripId);
|
||||
const pid = toRowId(placeId);
|
||||
|
||||
Reference in New Issue
Block a user