mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(mcp): keep transit out of the transport create enum
The transport form's picker has nine entries, not ten: a transit booking carries a provider itinerary in metadata.transit and create_transit_journey is what writes one, so letting a caller hand-make a bare transit row produces a shape the transit UI does not expect. Splits the one list in two - what may be asked for, and what counts as a transport for the update gate - so a stored transit booking stays editable through update_transport, which is where it was already reachable. Also reports the ids set_reservation_travelers dropped instead of returning a plain success with a shorter list.
This commit is contained in:
@@ -15,17 +15,26 @@ import type { EndpointInput } from './reservations.service';
|
||||
import { AssignmentsService } from '../assignments/assignments.service';
|
||||
import { transportLegsInputSchema, type TransportLegInput } from '@trek/shared';
|
||||
|
||||
// The type picker in the planner (client/src/components/Planner/TransportModal.tsx)
|
||||
// offers ten transport modes and ReservationsPanel renders all of them; the tools
|
||||
// below used to accept four, so a bus or a ferry could be planned in the UI and not
|
||||
// through an assistant. Both lists are the picker's, in the picker's order.
|
||||
// What counts as a transport booking, for the update_transport gate. Every value
|
||||
// ReservationsPanel renders with a transport icon, so a stored `transit` row is
|
||||
// editable through the transport tools like any other.
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'] as const;
|
||||
// What a caller may ASK for, which is the transport form's own picker
|
||||
// (client/src/components/Planner/TransportModal.tsx), in its order. The tools
|
||||
// below used to accept four of these nine, so a bus or a ferry could be planned
|
||||
// in the UI and not through an assistant.
|
||||
//
|
||||
// `transit` is deliberately absent: the picker does not offer it either. A transit
|
||||
// booking carries a provider itinerary in metadata.transit, and create_transit_journey
|
||||
// is what writes one — a hand-made `transit` row would be a shape the transit UI
|
||||
// does not expect.
|
||||
const CREATABLE_TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const;
|
||||
/** Only these two carry per-segment detail: the transport form writes metadata.legs for a flight or a train and for nothing else. */
|
||||
const LEG_TRANSPORT_TYPES = ['flight', 'train'] as const;
|
||||
/** Everything the picker offers that is not a transport — create_reservation's half. */
|
||||
const BOOKING_TYPES = ['hotel', 'restaurant', 'event', 'tour', 'activity', 'parking', 'other'] as const;
|
||||
|
||||
type TransportType = typeof TRANSPORT_TYPES[number];
|
||||
type TransportType = typeof CREATABLE_TRANSPORT_TYPES[number];
|
||||
type BookingType = typeof BOOKING_TYPES[number];
|
||||
|
||||
const endpointObjectSchema = z.object({
|
||||
@@ -309,7 +318,7 @@ export class ReservationsMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'create_reservation',
|
||||
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For anything travelled ON — flight, train, bus, car, taxi, bicycle, cruise, ferry, transit, transport_other — use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/parking/other → use assignment_id. Set price to record the cost; it will appear on the booking and in the Budget tab.',
|
||||
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For anything travelled ON — flight, train, bus, car, taxi, bicycle, cruise, ferry, transport_other — use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/parking/other → use assignment_id. Set price to record the cost; it will appear on the booking and in the Budget tab.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(200),
|
||||
@@ -388,7 +397,7 @@ export class ReservationsMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'update_reservation',
|
||||
description: 'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. For anything travelled ON — flight, train, bus, car, taxi, bicycle, cruise, ferry, transit, transport_other — use update_transport instead. Linking: hotel → use place_id to link to an accommodation place; restaurant/event/tour/activity/parking/other → use assignment_id to link to a day assignment.',
|
||||
description: 'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. For anything travelled ON — flight, train, bus, car, taxi, bicycle, cruise, ferry, transport_other — use update_transport instead. Linking: hotel → use place_id to link to an accommodation place; restaurant/event/tour/activity/parking/other → use assignment_id to link to a day assignment.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
@@ -482,7 +491,14 @@ export class ReservationsMcp {
|
||||
if (!result) return errorResult('Reservation not found.');
|
||||
|
||||
this.guards.safeBroadcast(tripId, 'reservation:travelers-updated', { reservationId, travelers: result.travelers });
|
||||
return ok({ travelers: result.travelers });
|
||||
// Report what the roster filter dropped. Silently succeeding with a shorter
|
||||
// list reads as "done" to a caller that guessed an id for a name it could
|
||||
// not resolve, which is exactly what an importer does.
|
||||
const attached = new Set(result.travelers.map(t => t.user_id));
|
||||
const ignored = [...new Set(user_ids)].filter(uid => !attached.has(uid));
|
||||
return ok(ignored.length > 0
|
||||
? { travelers: result.travelers, ignored_user_ids: ignored, note: 'Ignored ids are not on this trip. Add them with add_trip_member or create_trip_guest first.' }
|
||||
: { travelers: result.travelers });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
@@ -608,10 +624,10 @@ export class ReservationsMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'create_transport',
|
||||
description: 'Create a transport booking for a trip — flight, train, bus, car, taxi, bicycle, cruise, ferry, transit or transport_other. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). For a booking WITH STOPOVERS also pass legs[] (one entry per segment, one fewer than endpoints[]), otherwise every segment inherits the stop time as both its arrival and its departure. The top-level confirmation_number is the booking reference; when a single segment was booked under its own reference, put that one on the leg instead. Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
|
||||
description: 'Create a transport booking for a trip — flight, train, bus, car, taxi, bicycle, cruise, ferry or transport_other. For scheduled public transit use create_transit_journey, which attaches the provider itinerary. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). For a booking WITH STOPOVERS also pass legs[] (one entry per segment, one fewer than endpoints[]), otherwise every segment inherits the stop time as both its arrival and its departure. The top-level confirmation_number is the booking reference; when a single segment was booked under its own reference, put that one on the leg instead. Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
type: z.enum(TRANSPORT_TYPES),
|
||||
type: z.enum(CREATABLE_TRANSPORT_TYPES),
|
||||
title: z.string().min(1).max(200),
|
||||
status: z.enum(['pending', 'confirmed', 'cancelled']).optional().default('pending'),
|
||||
start_day_id: z.number().int().positive().optional().describe('Departure day'),
|
||||
@@ -720,7 +736,7 @@ export class ReservationsMcp {
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
type: z.enum(TRANSPORT_TYPES).optional(),
|
||||
type: z.enum(CREATABLE_TRANSPORT_TYPES).optional(),
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
status: z.enum(['pending', 'confirmed', 'cancelled']).optional(),
|
||||
start_day_id: z.number().int().positive().optional().describe('Departure day'),
|
||||
|
||||
@@ -592,6 +592,23 @@ describe('Tool: set_reservation_travelers', () => {
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.travelers.map((t: any) => t.user_id)).toEqual([owner.id]);
|
||||
// A caller that guessed an id for a name it could not resolve is told so
|
||||
// rather than getting a plain success with a shorter list.
|
||||
expect(data.ignored_user_ids).toEqual([outsider.id]);
|
||||
});
|
||||
});
|
||||
|
||||
it('says nothing about ignored ids when every id was on the trip', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
await withHarness(owner.id, async (h) => {
|
||||
const reservationId = await makeBooking(h, trip.id);
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_reservation_travelers',
|
||||
arguments: { tripId: trip.id, reservationId, user_ids: [owner.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.ignored_user_ids).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1052,11 +1052,11 @@ describe('Tool: update_transport (multi-leg)', () => {
|
||||
|
||||
/** client/src/components/Planner/TransportModal.tsx, in the picker's order. */
|
||||
const PICKER_TRANSPORT_TYPES = [
|
||||
'flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other',
|
||||
'flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other',
|
||||
] as const;
|
||||
|
||||
/** The six the tools used to reject outright. */
|
||||
const NEWLY_ACCEPTED = ['bus', 'taxi', 'bicycle', 'ferry', 'transit', 'transport_other'] as const;
|
||||
/** The five the tools used to reject outright. */
|
||||
const NEWLY_ACCEPTED = ['bus', 'taxi', 'bicycle', 'ferry', 'transport_other'] as const;
|
||||
|
||||
describe('Transport tools: the full type list', () => {
|
||||
it.each(PICKER_TRANSPORT_TYPES)('create_transport accepts %s', async (type) => {
|
||||
@@ -1172,6 +1172,36 @@ describe('Transport tools: the full type list', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses to hand-make a transit booking, which create_transit_journey owns', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_transport',
|
||||
arguments: { tripId: trip.id, type: 'transit', title: 'Tram 4' },
|
||||
});
|
||||
expect((result as { isError?: boolean }).isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('still lets update_transport reach a stored transit booking', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
// As create_transit_journey leaves it: a transport row carrying an itinerary.
|
||||
const reservationId = Number(testDb.prepare(
|
||||
"INSERT INTO reservations (trip_id, title, type, status) VALUES (?, 'Tram 4', 'transit', 'pending')"
|
||||
).run(trip.id).lastInsertRowid);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_transport',
|
||||
arguments: { tripId: trip.id, reservationId, status: 'confirmed' },
|
||||
});
|
||||
expect((result as { isError?: boolean }).isError).toBeFalsy();
|
||||
const row = testDb.prepare('SELECT status, type FROM reservations WHERE id = ?').get(reservationId) as any;
|
||||
expect(row).toMatchObject({ status: 'confirmed', type: 'transit' });
|
||||
});
|
||||
});
|
||||
|
||||
it('still refuses legs on a mode whose form never writes them', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -108,7 +108,7 @@ Requires `reservations:write` scope.
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `create_transport` | Create a transport booking (`flight`, `train`, `bus`, `car`, `taxi`, `bicycle`, `cruise`, `ferry`, `transit`, `transport_other`) with optional multi-stop endpoints, departure/arrival times, and confirmation details. |
|
||||
| `create_transport` | Create a transport booking — the nine types the transport form offers (`flight`, `train`, `bus`, `car`, `taxi`, `bicycle`, `cruise`, `ferry`, `transport_other`) — with optional multi-stop endpoints, departure/arrival times, and confirmation details. Scheduled public transit goes through `create_transit_journey` instead. |
|
||||
| `update_transport` | Update an existing transport booking. Pass `endpoints[]` to replace all stops. |
|
||||
| `delete_transport` | Delete a transport booking from a trip. |
|
||||
|
||||
@@ -132,7 +132,7 @@ Requires `reservations:read` or `reservations:write` scope.
|
||||
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). |
|
||||
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
|
||||
| `reorder_reservations` | Reorder reservations within a day. |
|
||||
| `set_reservation_travelers` | Set who is travelling on a booking, from the trip roster (members and guests). Replaces the list; an empty array clears it. |
|
||||
| `set_reservation_travelers` | Set who is travelling on a booking, from the trip roster (members and guests). Replaces the list; an empty array clears it. Ids that are not on the trip come back under `ignored_user_ids` rather than being attached. |
|
||||
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and place. |
|
||||
|
||||
### Budget
|
||||
|
||||
@@ -39,6 +39,10 @@ Reservations are grouped into two collapsible sections: **Pending** and **Confir
|
||||
|
||||
On desktop, a type filter bar lets you show only specific types. Filter selections are kept for the current browser session.
|
||||
|
||||
Travelers are set per booking from the trip roster, guests included.
|
||||
|
||||
> **AI / MCP:** `set_reservation_travelers` writes that list; it replaces it wholesale and ignores anybody who is not on the trip. See [MCP-Tools-and-Resources](MCP-Tools-and-Resources).
|
||||
|
||||
A traveler filter sits next to it — on desktop and mobile — once the trip has more than one member and at least one booking has travelers assigned. Click an avatar to show only that person's bookings; several can be active at once. On desktop the selection is kept for the browser session like the type filter; on mobile it resets when you leave the tab.
|
||||
|
||||
## Reservation card contents
|
||||
|
||||
@@ -27,6 +27,8 @@ Self-hosters can point the `TRANSIT_API_URL` environment variable at their own M
|
||||
|
||||
Nine types are available: **Flight**, **Train**, **Bus**, **Car**, **Taxi**, **Bicycle**, **Cruise**, **Ferry**, and **Other**.
|
||||
|
||||
> **AI / MCP:** `create_transport` accepts the same nine. Scheduled public transit is its own thing — `create_transit_journey` attaches the provider itinerary. See [MCP-Tools-and-Resources](MCP-Tools-and-Resources).
|
||||
|
||||
## Common fields
|
||||
|
||||
All transport types share these fields:
|
||||
|
||||
@@ -79,6 +79,8 @@ In the owner's Guests section, each guest row has a **Rename** (pencil) and a **
|
||||
|
||||
There is no limit on the number of guests per trip.
|
||||
|
||||
> **AI / MCP:** an assistant can do all three — `create_trip_guest`, `rename_trip_guest`, `delete_trip_guest` — and is held to the same owner-only rule. See [MCP-Tools-and-Resources](MCP-Tools-and-Resources).
|
||||
|
||||
## Public Share Link
|
||||
|
||||
The right column is only visible to users with the `share_manage` permission (default: trip owner).
|
||||
|
||||
Reference in New Issue
Block a user