mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(mcp): set who is travelling on a booking
Reads have hydrated a booking's travellers since #1517; there was no way to write them, so an imported booking could not record who was on it - and creating a guest is only half an answer if the guest cannot then be attached to the flight they were on. Mirrors PUT /api/trips/:tripId/reservations/:id/travelers. The roster filter stays in the service, so an off-trip id is dropped rather than attached and the tool cannot be used to enumerate users.
This commit is contained in:
@@ -457,6 +457,34 @@ export class ReservationsMcp {
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'set_reservation_travelers',
|
||||
description: 'Set who is travelling on a booking, replacing the current list. Pass the user IDs of trip members or guests from list_trip_members; an empty array clears the list. Somebody who is not on the trip is ignored rather than added — use add_trip_member for a person with a TREK account, or create_trip_guest for one without.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
user_ids: z.array(z.number().int().positive()).describe('User IDs of the travellers, from list_trip_members. Replaces the whole list; [] clears it.'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'reservations', mode: 'write' },
|
||||
})
|
||||
async setReservationTravelers(
|
||||
{ tripId, reservationId, user_ids }: { tripId: number; reservationId: number; user_ids: number[] },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
|
||||
// The service filters the ids against the trip roster on its own, so an
|
||||
// off-trip id cannot be attached; a missing booking is the only failure.
|
||||
const result = this.reservations.setTravelers(String(reservationId), String(tripId), user_ids);
|
||||
if (!result) return errorResult('Reservation not found.');
|
||||
|
||||
this.guards.safeBroadcast(tripId, 'reservation:travelers-updated', { reservationId, travelers: result.travelers });
|
||||
return ok({ travelers: result.travelers });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'reorder_reservations',
|
||||
description: 'Update the display order of reservations within a day.',
|
||||
|
||||
@@ -36,7 +36,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, createReservation, createDayAssignment } from '../../helpers/factories';
|
||||
import { createUser, createTrip, createDay, createPlace, createReservation, createDayAssignment, addTripMember } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -482,3 +482,176 @@ describe('Resource: trek://trips/{tripId}/reservations', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_reservation_travelers (#1517)
|
||||
//
|
||||
// Reads already hydrate a booking's travellers; there was no way to write them,
|
||||
// so an imported booking could not record who was on it. The roster filter lives
|
||||
// in the service, so the cases below pin that an off-trip id is dropped rather
|
||||
// than attached, and that a guest — the only representation of a companion
|
||||
// without an account — is assignable like any member.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: set_reservation_travelers', () => {
|
||||
async function makeBooking(h: McpHarness, tripId: number): Promise<number> {
|
||||
const created = await h.client.callTool({
|
||||
name: 'create_reservation',
|
||||
arguments: { tripId, type: 'event', title: 'Concert' },
|
||||
});
|
||||
return (parseToolResult(created) as any).reservation.id;
|
||||
}
|
||||
|
||||
it('sets the travellers on a booking', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: friend } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, friend.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, friend.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.travelers.map((t: any) => t.user_id).sort()).toEqual([owner.id, friend.id].sort());
|
||||
const rows = testDb.prepare('SELECT user_id FROM reservation_travelers WHERE reservation_id = ?').all(reservationId) as any[];
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('assigns a guest, who has no account to look up', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
await withHarness(owner.id, async (h) => {
|
||||
const guestResult = await h.client.callTool({
|
||||
name: 'create_trip_guest',
|
||||
arguments: { tripId: trip.id, name: 'Anna' },
|
||||
});
|
||||
const guestId = (parseToolResult(guestResult) as any).member.id;
|
||||
const reservationId = await makeBooking(h, trip.id);
|
||||
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_reservation_travelers',
|
||||
arguments: { tripId: trip.id, reservationId, user_ids: [guestId] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.travelers).toHaveLength(1);
|
||||
expect(data.travelers[0].username).toBe('Anna');
|
||||
expect(data.travelers[0].is_guest).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces the previous list rather than adding to it', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: friend } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, friend.id);
|
||||
await withHarness(owner.id, async (h) => {
|
||||
const reservationId = await makeBooking(h, trip.id);
|
||||
await h.client.callTool({
|
||||
name: 'set_reservation_travelers',
|
||||
arguments: { tripId: trip.id, reservationId, user_ids: [owner.id, friend.id] },
|
||||
});
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_reservation_travelers',
|
||||
arguments: { tripId: trip.id, reservationId, user_ids: [friend.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.travelers.map((t: any) => t.user_id)).toEqual([friend.id]);
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the list on an empty array', 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);
|
||||
await h.client.callTool({
|
||||
name: 'set_reservation_travelers',
|
||||
arguments: { tripId: trip.id, reservationId, user_ids: [owner.id] },
|
||||
});
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_reservation_travelers',
|
||||
arguments: { tripId: trip.id, reservationId, user_ids: [] },
|
||||
});
|
||||
expect((parseToolResult(result) as any).travelers).toEqual([]);
|
||||
expect(testDb.prepare('SELECT user_id FROM reservation_travelers WHERE reservation_id = ?').all(reservationId)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a user who is not on the trip instead of attaching them', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: outsider } = 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, outsider.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.travelers.map((t: any) => t.user_id)).toEqual([owner.id]);
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts reservation:travelers-updated', 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);
|
||||
broadcastMock.mockClear();
|
||||
await h.client.callTool({
|
||||
name: 'set_reservation_travelers',
|
||||
arguments: { tripId: trip.id, reservationId, user_ids: [owner.id] },
|
||||
});
|
||||
expect(broadcastMock).toHaveBeenCalledWith(
|
||||
trip.id,
|
||||
'reservation:travelers-updated',
|
||||
expect.objectContaining({ reservationId }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('404s a booking of another trip', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const ownTrip = createTrip(testDb, owner.id);
|
||||
const otherTrip = createTrip(testDb, owner.id);
|
||||
await withHarness(owner.id, async (h) => {
|
||||
const reservationId = await makeBooking(h, otherTrip.id);
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_reservation_travelers',
|
||||
arguments: { tripId: ownTrip.id, reservationId, user_ids: [owner.id] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for a non-member', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: outsider } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
let reservationId = 0;
|
||||
await withHarness(owner.id, async (h) => { reservationId = await makeBooking(h, trip.id); });
|
||||
await withHarness(outsider.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_reservation_travelers',
|
||||
arguments: { tripId: trip.id, reservationId, user_ids: [outsider.id] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_reservation_travelers',
|
||||
arguments: { tripId: trip.id, reservationId: 1, user_ids: [] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,6 +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. |
|
||||
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and place. |
|
||||
|
||||
### Budget
|
||||
|
||||
Reference in New Issue
Block a user