feat(assistant): booking and stay cards, and a read of the bookings with their details

This commit is contained in:
Maurice
2026-10-22 15:50:50 +02:00
parent 51e5dbef03
commit b391ddcc43
67 changed files with 6336 additions and 103 deletions
@@ -7,13 +7,19 @@ import {
TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_READONLY,
ok,
} from '../../nest-mcp';
import { UnitOfWork } from '../database/unit-of-work';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { PlacesService } from '../places/places.service';
import { AccommodationsService } from './accommodations.service';
import { idSchema, placeWebsiteSchema } from '@trek/shared';
import {
idSchema,
listAccommodationsRequestSchema,
placeWebsiteSchema,
type ListAccommodationsRequest,
} from '@trek/shared';
import { z } from 'zod';
@@ -328,6 +334,19 @@ export class AccommodationsMcp {
return ok({ success: true, linkedReservationId, linkedReservationIds, removedAssignments: mirror.removed });
}
@Tool({
name: 'list_accommodations',
description:
'List where a trip sleeps, stay by stay: the place, its address and position, the check-in and check-out days with their times, and the booking linked to it, its reference included, as the trip-accommodations resource reads them.',
inputSchema: listAccommodationsRequestSchema.shape,
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'trips', mode: 'read' },
})
async listAccommodations({ tripId }: ListAccommodationsRequest, ctx: McpContext) {
if (!(await this.accommodations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
return ok({ accommodations: await this.accommodations.listAccommodations(tripId) });
}
@ResourceTemplate({
name: 'trip-accommodations',
uriTemplate: 'trek://trips/{tripId}/accommodations',
@@ -0,0 +1,52 @@
import type { AnswerInput } from '../card-types';
import { recordOf, type Row } from '../card-values';
import { rowsOf } from '../undo-snapshot';
import { bookingCard, bookingItemOf } from './booking-item';
import { idOf, rowOfReservation, rowOfStay, rowOfUpcoming, scopeOfCard, type BookingRow } from './booking-row';
import type { TrekCard } from '@trek/shared';
/**
* The cards of the booking reads, from the tool's raw answer: the first eight
* bookings in the order the answer lists them, the rest counted. The trip
* summary draws none: it runs on most turns and would repeat its card under
* every answer.
*/
const SHOWN = 8;
function listCard(input: AnswerInput, rows: Row[], toRow: (raw: Row) => BookingRow): TrekCard | null {
const scope = scopeOfCard(input.card);
return bookingCard('none', rows.slice(0, SHOWN).map((raw) => bookingItemOf(toRow(raw), scope)), rows.length);
}
/** A stay listed once: the list joins each booking that links it, so one stay can come twice. */
function distinctStays(rows: Row[]): Row[] {
const seen = new Set<number>();
return rows.filter((row) => {
const id = idOf(row.id);
if (id === null || seen.has(id)) return id === null;
seen.add(id);
return true;
});
}
function reservationsCard(input: AnswerInput): TrekCard | null {
const rows = rowsOf(recordOf(input.value).reservations);
return listCard(input, rows, (raw) => rowOfReservation(raw, input.card.tripId, input.card));
}
function staysCard(input: AnswerInput): TrekCard | null {
const rows = distinctStays(rowsOf(recordOf(input.value).accommodations));
return listCard(input, rows, (raw) => rowOfStay(raw, input.card.tripId, input.card));
}
function upcomingCard(input: AnswerInput): TrekCard | null {
const rows = rowsOf(recordOf(input.value).reservations);
return listCard(input, rows, (raw) => rowOfUpcoming(raw, input.card.tripId));
}
export const BOOKING_ANSWERS = {
list_reservations: reservationsCard,
list_accommodations: staysCard,
list_upcoming_reservations: upcomingCard,
};
@@ -0,0 +1,116 @@
import type { ToolArgs } from '../card-types';
import { recordOf, type Row } from '../card-values';
import { rowsOf } from '../undo-snapshot';
import { bookingCard, bookingItemOf, travelersOf } from './booking-item';
import { idOf, rowOfReservation, rowOfStay, scopeOfPreview } from './booking-row';
import type { AssistantChangePreview, BookingBody, BookingItem, TrekCard } from '@trek/shared';
/**
* The cards of applied booking writes, in place of the preview card: the
* booking as it was saved (its real status, coordinates resolved, legs
* normalised, the stay it links). Decisions run before any trip card is read,
* so the days and the people come from what the preview card named; what the
* saved row does not carry (a stay's times, a photo) is kept from the preview.
*/
interface AppliedInput {
args: ToolArgs;
result: unknown;
preview: AssistantChangePreview | null;
}
function previewItem(preview: AssistantChangePreview | null): BookingItem | null {
const card = preview?.card;
return card?.kind === 'booking' ? (card.items[0] ?? null) : null;
}
type StayBody = Extract<BookingBody, { shape: 'stay' }>;
/** A saved stay, the times and the picture the saved row leaves out kept from the preview. */
function filledStay(saved: StayBody, shown: StayBody): StayBody {
const checkIn = saved.checkIn && {
...saved.checkIn,
from: saved.checkIn.from ?? shown.checkIn?.from ?? null,
until: saved.checkIn.until ?? shown.checkIn?.until ?? null,
};
return {
...saved,
address: saved.address ?? shown.address,
lat: saved.lat ?? shown.lat,
lng: saved.lng ?? shown.lng,
photoKey: saved.photoKey ?? shown.photoKey,
checkIn,
checkOut: saved.checkOut && { ...saved.checkOut, by: saved.checkOut.by ?? shown.checkOut?.by ?? null },
nights: saved.nights ?? shown.nights,
};
}
/** The saved body, its gaps filled from what the preview showed. */
function filled(saved: BookingBody, shown: BookingBody | undefined): BookingBody {
if (saved.shape === 'stay' && shown?.shape === 'stay') return filledStay(saved, shown);
if (saved.shape === 'venue' && shown?.shape === 'venue') {
return {
...saved,
location: saved.location ?? shown.location,
lat: saved.lat ?? shown.lat,
lng: saved.lng ?? shown.lng,
};
}
return saved;
}
function savedCard(shown: BookingItem | null, item: BookingItem | null): TrekCard | null {
if (!item) return null;
return bookingCard('none', [{ ...item, body: filled(item.body, shown?.body), changed: shown?.changed ?? [] }]);
}
function tripIdOf(shown: BookingItem | null, raw: Row): number | null {
return shown?.tripId ?? idOf(raw.trip_id);
}
/** A booking a write answered with (`{ reservation }`). */
export function appliedReservation({ result, preview }: AppliedInput): TrekCard | null {
const raw = recordOf(recordOf(result).reservation);
const shown = previewItem(preview);
const tripId = tripIdOf(shown, raw);
if (idOf(raw.id) === null || tripId === null) return null;
const scope = scopeOfPreview(shown ? [shown] : [], tripId);
return savedCard(shown, bookingItemOf(rowOfReservation(raw, tripId), scope));
}
/**
* A booking a file's import created. Its answer names the id alone (no route,
* times or stay dates), so the card is the preview's, now saved: the id it got
* and, with the id, the route it can show on the map.
*/
export function appliedImport({ result, preview }: AppliedInput): TrekCard | null {
const shown = previewItem(preview);
const id = idOf(recordOf(recordOf(result).reservation).id);
if (!shown || id === null) return null;
const body = shown.body;
const located = body.shape === 'ticket' ? body.stops.filter((s) => s.lat !== null && s.lng !== null).length : 0;
const saved = body.shape === 'ticket' ? { ...body, mappable: located >= 2 } : body;
return bookingCard('none', [{ ...shown, reservationId: id, changed: [], body: saved }]);
}
/** A stay a write answered with (`{ accommodation }`, with `place` when the write created it too). */
export function appliedStay({ result, preview }: AppliedInput): TrekCard | null {
const answer = recordOf(result);
const raw = recordOf(answer.accommodation);
const shown = previewItem(preview);
const tripId = tripIdOf(shown, raw);
if (idOf(raw.id) === null || tripId === null) return null;
const place = answer.place && typeof answer.place === 'object' ? recordOf(answer.place) : null;
const scope = scopeOfPreview(shown ? [shown] : [], tripId);
const item = bookingItemOf(rowOfStay(raw, tripId, null, place), scope);
// A stay row knows no booking; the preview's title and status stand where it named them.
return savedCard(shown, item && shown ? { ...item, title: shown.title, status: item.status ?? shown.status } : item);
}
/** The travellers a write set (`{ travelers }`) on the booking the preview showed. */
export function appliedTravelers({ result, preview }: AppliedInput): TrekCard | null {
const shown = previewItem(preview);
if (!shown) return null;
const { people, more } = travelersOf(rowsOf(recordOf(result).travelers));
return bookingCard('none', [{ ...shown, travelers: people, moreTravelers: more }]);
}
@@ -0,0 +1,291 @@
import type { PreviewContext, ToolArgs } from '../card-types';
import { argId, finite, recordOf, type Row } from '../card-values';
import { rowsOf } from '../undo-snapshot';
import { bookingCard, bookingItemOf, changedBetween } from './booking-item';
import { idOf, rowOfReservation, rowOfStay, scopeOfCard, type BookingRow, type StaySource } from './booking-row';
import { bookingRowOf, dateOf, stayRowOf } from './booking-trip';
import type { BookingCard, CardMoney, TrekCard } from '@trek/shared';
/**
* The cards of held booking writes: what will be saved, drawn from the
* call's arguments and the trip as this turn read it. A create shows the new
* booking, an update the booking as it will read afterwards with the fields
* that change marked, a removal the booking as it is. A write about a booking
* the trip card does not hold draws no card; the row of words stays.
*/
const CURRENCY = /^[A-Z]{3}$/;
/** A stay moved to another place: what the old place named is dropped, so the new one's shows. */
const UNPLACED = { name: null, address: null, lat: null, lng: null, photoKey: null } as const;
/** A booking with nothing known yet, for a create to fill from its arguments. */
function blankRow(tripId: number): BookingRow {
return {
tripId,
tripTitle: null,
id: null,
type: null,
title: null,
status: null,
needsReview: false,
confirmation: null,
reservationTime: null,
reservationEndTime: null,
dayId: null,
endDayId: null,
dayDate: null,
location: null,
placeId: null,
placeName: null,
assignmentId: null,
meta: {},
endpoints: [],
travelers: [],
stay: null,
};
}
function blankStay(): StaySource {
return {
accommodationId: null,
placeId: null,
startDayId: null,
endDayId: null,
startDate: null,
endDate: null,
checkIn: null,
checkInEnd: null,
checkOut: null,
name: null,
address: null,
lat: null,
lng: null,
photoKey: null,
hasReference: false,
};
}
/** A price argument in its currency (the trip's unless the tool takes one). */
function argPrice(amount: unknown, currency: unknown, ctx: PreviewContext): CardMoney | null {
const value = finite(amount);
const code = typeof currency === 'string' ? currency.toUpperCase() : ctx.card.currency;
if (value === null || value <= 0 || value > 1e9 || !code || !CURRENCY.test(code)) return null;
return { amount: value, currency: code };
}
/** The plain-string metadata a transport tool takes. */
function stringsOf(value: unknown): Row {
return Object.fromEntries(Object.entries(recordOf(value)).filter(([, inner]) => typeof inner === 'string'));
}
/** Endpoints as the tool will store them: a flight's code-only stop placed at its airport, as the tool does. */
function endpointsOf(ctx: PreviewContext, value: unknown): Row[] {
return rowsOf(value).map((ep) => {
if (finite(ep.lat) !== null && finite(ep.lng) !== null) return ep;
const airport = typeof ep.code === 'string' ? ctx.airport(ep.code) : null;
return airport ? { ...ep, lat: airport.lat, lng: airport.lng, timezone: ep.timezone ?? airport.tz } : ep;
});
}
/** A transport write's arguments over the booking they change (a blank one for a create). */
function withTransportArgs(ctx: PreviewContext, base: BookingRow, args: ToolArgs): BookingRow {
let meta = base.meta;
if (args.metadata !== undefined) {
// The tool replaces the metadata whole, but a price mirrored from an expense survives unless it is named.
const { price, priceCurrency } = base.meta;
meta = { ...(price !== undefined ? { price, priceCurrency } : {}), ...stringsOf(args.metadata) };
}
if (Array.isArray(args.legs)) meta = { ...meta, legs: rowsOf(args.legs) };
return {
...base,
type: args.type ?? base.type,
title: args.title ?? base.title,
status: args.status ?? base.status,
dayId: args.start_day_id ?? base.dayId,
endDayId: args.end_day_id ?? (base.id === null ? args.start_day_id : base.endDayId),
reservationTime: args.reservation_time ?? base.reservationTime,
reservationEndTime: args.reservation_end_time ?? base.reservationEndTime,
confirmation: args.confirmation_number ?? base.confirmation,
needsReview: typeof args.needs_review === 'boolean' ? args.needs_review : base.needsReview,
meta,
endpoints: args.endpoints !== undefined ? endpointsOf(ctx, args.endpoints) : base.endpoints,
...(args.price !== undefined ? { price: argPrice(args.price, null, ctx) } : {}),
};
}
/** A booking write's arguments over the booking they change; a hotel's place is its stay's. */
function withBookingArgs(base: BookingRow, args: ToolArgs): BookingRow {
const type = args.type ?? base.type;
const row: BookingRow = {
...base,
type,
title: args.title ?? base.title,
status: args.status ?? base.status,
reservationTime: args.reservation_time ?? base.reservationTime,
reservationEndTime: args.reservation_end_time ?? base.reservationEndTime,
location: args.location ?? base.location,
confirmation: args.confirmation_number ?? base.confirmation,
assignmentId: args.assignment_id !== undefined ? args.assignment_id : base.assignmentId,
// A hotel turned into another type no longer reads as a stay, whatever it still links.
stay: type === 'hotel' ? base.stay : null,
};
if (args.place_id === undefined) return row;
if (type !== 'hotel') return { ...row, placeId: args.place_id };
return { ...row, stay: { ...(base.stay ?? blankStay()), ...UNPLACED, placeId: idOf(args.place_id) } };
}
function created(ctx: PreviewContext, row: BookingRow): BookingCard | null {
return bookingCard('create', [bookingItemOf(row, scopeOfCard(ctx.card))]);
}
/** The update's card: the booking as it will read, every field whose card value changes marked. */
function updated(ctx: PreviewContext, before: BookingRow, after: BookingRow): BookingCard | null {
const scope = scopeOfCard(ctx.card);
const old = bookingItemOf(before, scope);
const next = bookingItemOf(after, scope);
if (!old || !next) return null;
return bookingCard('update', [{ ...next, changed: changedBetween(old, next) }]);
}
function currentBooking(ctx: PreviewContext, args: ToolArgs): BookingRow | null {
const raw = bookingRowOf(ctx.card, argId(args, 'reservationId'));
return raw ? rowOfReservation(raw, ctx.card.tripId, ctx.card) : null;
}
function currentStay(ctx: PreviewContext, args: ToolArgs): BookingRow | null {
const raw = stayRowOf(ctx.card, argId(args, 'accommodationId'));
return raw ? rowOfStay(raw, ctx.card.tripId, ctx.card) : null;
}
function removed(ctx: PreviewContext, row: BookingRow | null): BookingCard | null {
return row ? bookingCard('remove', [bookingItemOf(row, scopeOfCard(ctx.card))]) : null;
}
/** A stay from a stay tool's arguments: the place, the nights, the three times. */
function stayOfArgs(args: ToolArgs, base: StaySource = blankStay()): StaySource {
return {
...base,
placeId: argId(args, 'place_id') ?? base.placeId,
startDayId: argId(args, 'start_day_id') ?? base.startDayId,
endDayId: argId(args, 'end_day_id') ?? base.endDayId,
checkIn: args.check_in ?? base.checkIn,
checkInEnd: args.check_in_end ?? base.checkInEnd,
checkOut: args.check_out ?? base.checkOut,
hasReference: typeof args.confirmation === 'string' ? args.confirmation.trim() !== '' : base.hasReference,
};
}
type ChangeBuilder = (ctx: PreviewContext, args: ToolArgs) => TrekCard | null;
export const BOOKING_CHANGE_CARDS: Readonly<Record<string, ChangeBuilder>> = {
create_transport: (ctx, args) =>
created(ctx, withTransportArgs(ctx, { ...blankRow(ctx.card.tripId), status: 'pending' }, args)),
create_reservation: (ctx, args) => {
const hotel =
args.type === 'hotel' &&
argId(args, 'place_id') !== null &&
argId(args, 'start_day_id') !== null &&
argId(args, 'end_day_id') !== null;
const base: BookingRow = { ...blankRow(ctx.card.tripId), status: 'pending', type: args.type };
const row = withBookingArgs(base, { ...args, place_id: hotel ? undefined : args.place_id });
return created(ctx, {
...row,
dayId: args.day_id,
price: argPrice(args.price, null, ctx),
stay: hotel ? stayOfArgs({ ...args, confirmation: args.confirmation_number }) : null,
});
},
update_reservation: (ctx, args) => {
const before = currentBooking(ctx, args);
return before ? updated(ctx, before, withBookingArgs(before, args)) : null;
},
update_transport: (ctx, args) => {
const before = currentBooking(ctx, args);
return before ? updated(ctx, before, withTransportArgs(ctx, before, args)) : null;
},
delete_reservation: (ctx, args) => removed(ctx, currentBooking(ctx, args)),
delete_transport: (ctx, args) => removed(ctx, currentBooking(ctx, args)),
set_reservation_travelers: (ctx, args) => {
const before = currentBooking(ctx, args);
if (!before) return null;
const ids = Array.isArray(args.user_ids) ? args.user_ids : [];
// Somebody not on the trip is left out, as the tool leaves them out.
const travelers = ids.flatMap((id: unknown) => {
const person = typeof id === 'number' ? ctx.card.members.get(id) : undefined;
return person ? [{ user_id: id, username: person.name, avatar: person.avatarKey }] : [];
});
return updated(ctx, before, { ...before, travelers });
},
link_hotel_accommodation: (ctx, args) => {
const before = currentBooking(ctx, args);
if (!before) return null;
const stay = stayOfArgs(args, before.stay ?? blankStay());
return updated(ctx, before, { ...before, stay: { ...stay, ...UNPLACED } });
},
create_accommodation: (ctx, args) => {
const place = ctx.card.places.get(argId(args, 'place_id') ?? 0);
if (!place) return null;
return created(ctx, { ...blankRow(ctx.card.tripId), type: 'hotel', title: place.name, stay: stayOfArgs(args) });
},
create_place_accommodation: (ctx, args) =>
created(ctx, {
...blankRow(ctx.card.tripId),
type: 'hotel',
title: args.name,
price: argPrice(args.price, args.currency, ctx),
stay: {
...stayOfArgs({ ...args, place_id: undefined }),
name: args.name,
address: args.address,
lat: args.lat,
lng: args.lng,
photoKey: args.google_place_id,
},
}),
update_accommodation: (ctx, args) => {
const before = currentStay(ctx, args);
if (!before?.stay) return null;
const placeId = argId(args, 'place_id');
const moved = placeId !== null && placeId !== before.stay.placeId;
// Another place: its name, address and position come from the trip's place, not the old stay's.
const base = moved ? { ...before.stay, ...UNPLACED } : before.stay;
const title = moved ? (ctx.card.places.get(placeId)?.name ?? before.title) : before.title;
return updated(ctx, before, { ...before, title, stay: stayOfArgs(args, base) });
},
delete_accommodation: (ctx, args) => removed(ctx, currentStay(ctx, args)),
};
/** A booking read from an attached file (a BookingImportPreviewItem), as it will be saved. */
export function importedBookingCard(ctx: PreviewContext, args: ToolArgs): TrekCard | null {
const stay = recordOf(args._accommodation);
const venue = recordOf(args._venue);
const isStay = args.type === 'hotel' || args._accommodation !== undefined;
const row: BookingRow = {
...blankRow(ctx.card.tripId),
type: isStay ? 'hotel' : args.type,
title: args.title,
needsReview: args.needs_review === true,
confirmation: args.confirmation_number,
reservationTime: args.reservation_time,
reservationEndTime: args.reservation_end_time,
location: args.location ?? venue.name,
meta: recordOf(args.metadata),
endpoints: rowsOf(args.endpoints),
point: { lat: venue.lat, lng: venue.lng },
stay: isStay
? {
...blankStay(),
startDate: dateOf(stay.check_in ?? args.reservation_time),
endDate: dateOf(stay.check_out ?? args.reservation_end_time),
checkIn: stay.check_in,
checkOut: stay.check_out,
address: venue.address,
lat: venue.lat,
lng: venue.lng,
hasReference: typeof stay.confirmation === 'string' && stay.confirmation.trim() !== '',
}
: null,
};
return created(ctx, row);
}
@@ -0,0 +1,189 @@
import type { Described, PreviewContext, ToolArgs } from '../card-types';
import { argId, recordOf, type Row } from '../card-values';
import { clip, dayLabel, formatDateTime, memberNames, money, placeName, Rows, text } from '../preview-format';
import { rowsOf } from '../undo-snapshot';
import { bookingRowOf, stayRowOf } from './booking-trip';
import type { AssistantChangeRow } from '@trek/shared';
/**
* The words of the curated booking changes: the row a receipt, a share text
* or a thread stored before the cards still reads. The card draws the rest;
* the booking reference is left out here as on the card.
*/
type Describer = (ctx: PreviewContext, args: ToolArgs) => Described;
/** A date and time as the card writes it; a bare clock as it came. */
function when(ctx: PreviewContext, value: unknown): string {
if (typeof value !== 'string') return '';
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value) ? formatDateTime(ctx, value) : text(value);
}
/** "Frankfurt (FRA) → New York (JFK)" from endpoints, empty without both ends. */
function routeText(endpoints: unknown): string {
const stops = rowsOf(endpoints);
const end = (role: string) => {
const found = stops.find((ep) => ep.role === role);
const name = text(found?.name);
const code = text(found?.code);
if (!name) return code;
return code && code !== name ? `${name} (${code})` : name;
};
const from = end('from');
const to = end('to');
return from && to ? clip(`${from} → ${to}`) : '';
}
/** A stay's nights in words: the place, check-in day to check-out day. */
function stayText(ctx: PreviewContext, placeId: number | null, start: number | null, end: number | null): string {
if (start === null || end === null) return '';
const place = placeName(ctx, placeId);
const range = `${dayLabel(ctx, start)} → ${dayLabel(ctx, end)}`;
return place ? `${place}, ${range}` : range;
}
function price(ctx: PreviewContext, amount: unknown, currency: unknown): string {
if (typeof amount !== 'number' || amount <= 0) return '';
return money(ctx, amount, typeof currency === 'string' ? currency : ctx.card.currency);
}
/** A status change, under the plain field name the generic card uses. */
function statusRow(rows: Rows, before: unknown, after: unknown): void {
if (after === undefined) return;
const row: AssistantChangeRow = {
op: 'change',
labelKey: 'field',
field: 'status',
before: text(before) || null,
after: text(after),
};
rows.rows.push(row);
}
/** The changes an update makes to a booking, each against what the trip holds now. */
function bookingChangeRows(ctx: PreviewContext, current: Row, args: ToolArgs): Rows {
const rows = new Rows();
if (args.title !== undefined) rows.change('name', text(current.title), text(args.title));
if (args.type !== undefined) rows.change('type', text(current.type), text(args.type));
statusRow(rows, current.status, args.status);
if (args.start_day_id !== undefined) {
rows.change('day', dayLabel(ctx, argId(current, 'day_id')), dayLabel(ctx, argId(args, 'start_day_id')));
}
if (args.reservation_time !== undefined) {
rows.change('time', when(ctx, current.reservation_time), when(ctx, args.reservation_time));
}
if (args.reservation_end_time !== undefined) {
rows.change('endTime', when(ctx, current.reservation_end_time), when(ctx, args.reservation_end_time));
}
if (args.endpoints !== undefined) rows.change('route', routeText(current.endpoints), routeText(args.endpoints));
if (args.location !== undefined) rows.change('location', text(current.location), text(args.location));
if (args.notes !== undefined) rows.change('notes', text(current.notes), text(args.notes));
return rows;
}
function updateBooking(tool: string): Describer {
return (ctx, args) => {
const current = recordOf(bookingRowOf(ctx.card, argId(args, 'reservationId')));
return {
subject: text(current.title) || text(args.title),
rows: bookingChangeRows(ctx, current, args),
dayId: argId(current, 'day_id'),
placeIds: [],
tool,
};
};
}
export const BOOKING_DESCRIBERS: Readonly<Record<string, Describer>> = {
create_transport: (ctx, args) => {
const dayId = argId(args, 'start_day_id');
const rows = new Rows()
.add('type', text(args.type))
.add('route', routeText(args.endpoints))
.add('day', dayLabel(ctx, dayId))
.add('time', when(ctx, args.reservation_time))
.add('endTime', when(ctx, args.reservation_end_time))
.add('price', price(ctx, args.price, null))
.add('notes', text(args.notes));
return { subject: text(args.title), rows, dayId, placeIds: [], tool: 'create_transport' };
},
update_reservation: updateBooking('update_reservation'),
update_transport: updateBooking('update_transport'),
set_reservation_travelers: (ctx, args) => {
const current = recordOf(bookingRowOf(ctx.card, argId(args, 'reservationId')));
const before = rowsOf(current.travelers).map((traveler) => traveler.user_id);
const rows = new Rows().change('members', memberNames(ctx, before), memberNames(ctx, args.user_ids));
return {
subject: text(current.title),
rows,
dayId: argId(current, 'day_id'),
placeIds: [],
tool: 'set_reservation_travelers',
};
},
link_hotel_accommodation: (ctx, args) => {
const current = recordOf(bookingRowOf(ctx.card, argId(args, 'reservationId')));
const placeId = argId(args, 'place_id');
const start = argId(args, 'start_day_id');
const rows = new Rows()
.add('stay', stayText(ctx, placeId, start, argId(args, 'end_day_id')))
.add('checkIn', text(args.check_in))
.add('checkOut', text(args.check_out));
return {
subject: text(current.title),
rows,
dayId: start,
placeIds: placeId ? [placeId] : [],
tool: 'link_hotel_accommodation',
};
},
create_accommodation: (ctx, args) => {
const placeId = argId(args, 'place_id');
const start = argId(args, 'start_day_id');
const rows = new Rows()
.add('stay', stayText(ctx, null, start, argId(args, 'end_day_id')))
.add('checkIn', text(args.check_in))
.add('checkOut', text(args.check_out))
.add('notes', text(args.notes));
return {
subject: placeName(ctx, placeId),
rows,
dayId: start,
placeIds: placeId ? [placeId] : [],
tool: 'create_accommodation',
};
},
create_place_accommodation: (ctx, args) => {
const start = argId(args, 'start_day_id');
const rows = new Rows()
.add('address', text(args.address))
.add('stay', stayText(ctx, null, start, argId(args, 'end_day_id')))
.add('checkIn', text(args.check_in))
.add('checkOut', text(args.check_out))
.add('price', price(ctx, args.price, args.currency))
.add('notes', text(args.accommodation_notes));
return { subject: text(args.name), rows, dayId: start, placeIds: [] };
},
update_accommodation: (ctx, args) => {
const current = recordOf(stayRowOf(ctx.card, argId(args, 'accommodationId')));
const placeId = argId(args, 'place_id') ?? argId(current, 'place_id');
const rows = new Rows();
if (args.place_id !== undefined) {
rows.change('stay', placeName(ctx, argId(current, 'place_id')), placeName(ctx, argId(args, 'place_id')));
}
if (args.start_day_id !== undefined || args.end_day_id !== undefined) {
const before = stayText(ctx, null, argId(current, 'start_day_id'), argId(current, 'end_day_id'));
const start = argId(args, 'start_day_id') ?? argId(current, 'start_day_id');
rows.change('day', before, stayText(ctx, null, start, argId(args, 'end_day_id') ?? argId(current, 'end_day_id')));
}
if (args.check_in !== undefined) rows.change('checkIn', text(current.check_in), text(args.check_in));
if (args.check_out !== undefined) rows.change('checkOut', text(current.check_out), text(args.check_out));
if (args.notes !== undefined) rows.change('notes', text(current.notes), text(args.notes));
return {
subject: text(current.place_name) || placeName(ctx, placeId),
rows,
dayId: argId(args, 'start_day_id') ?? argId(current, 'start_day_id'),
placeIds: placeId ? [placeId] : [],
};
},
};
@@ -0,0 +1,361 @@
import { resolveTimeZone, wallClockInstant } from '../../../common/timezoneService';
import { clipped, clockOf, fileKeyOf, finite, isLat, isLng, recordOf, type Row } from '../card-values';
import { idOf, legsHaveReference, textOf, type BookingRow, type BookingScope, type StaySource } from './booking-row';
import { dateOf } from './booking-trip';
import {
BOOKING_CHANGED_FIELDS,
cardFileKeySchema,
cardPhotoKeySchema,
isTransportReservationType,
LEG_RESERVATION_TYPES,
RESERVATION_STATUSES,
type BookingCard,
type BookingChangedField,
type BookingItem,
type BookingLeg,
type BookingStay,
type BookingStop,
type BookingTicket,
type BookingVenue,
type CardDay,
type CardMoney,
type CardPerson,
} from '@trek/shared';
/**
* One booking as its card shows it, from a `BookingRow` and its scope. The
* reference number is never copied, only that one exists; a price, a time or
* a coordinate that does not read cleanly is left out rather than guessed. A
* duration needs both ends' zones (stored, or from the coordinates): wall
* clocks alone across zones would lie.
*/
const MAX_ITEMS = 8;
const MAX_STOPS = 9;
const MAX_TRAVELERS = 8;
const LEG_MAX_MINUTES = 10_080;
const TRIP_MAX_MINUTES = 20_160;
const STOP_CODE = /^[A-Z0-9]{2,5}$/;
const TYPE_KEY = /^[a-z_]{1,30}$/;
const CURRENCY = /^[A-Z]{3}$/;
/** A stop with what the card does not show but needs for the times: its zone and its local clock. */
interface RouteStop {
stop: BookingStop;
zone: string | null;
localTime: unknown;
localDate: string | null;
}
function coord(value: unknown, isValid: (n: number | null) => n is number): number | null {
const n = finite(value);
return isValid(n) ? n : null;
}
function typeOf(row: BookingRow): string {
return typeof row.type === 'string' && TYPE_KEY.test(row.type) ? row.type : 'other';
}
function statusOf(row: BookingRow, type: string): BookingItem['status'] {
const status = RESERVATION_STATUSES.find((known) => known === row.status);
return status && type !== 'transit' ? status : null;
}
/** The stored price (`metadata.price`, text or number) in its currency, else the trip's. */
function priceOf(row: BookingRow, scope: BookingScope): CardMoney | null {
if (row.price !== undefined) return row.price;
const raw = row.meta.price;
const amount = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : Number.NaN;
if (!Number.isFinite(amount) || amount <= 0 || amount > 1e9) return null;
const own = row.meta.priceCurrency;
const currency = typeof own === 'string' && CURRENCY.test(own) ? own : scope.currency;
return currency && CURRENCY.test(currency) ? { amount, currency } : null;
}
/** The stored file name of an avatar; a picture on another host (a sign-in provider's) gives none. */
function avatarKeyOf(raw: unknown): string | null {
if (typeof raw === 'string' && cardFileKeySchema.safeParse(raw).success) return raw;
return fileKeyOf(raw);
}
export function travelersOf(rows: Row[]): { people: CardPerson[]; more: number } {
const people = rows.flatMap((row): CardPerson[] => {
const id = idOf(row.user_id);
const name = clipped(row.username, 80);
return id !== null && name !== null ? [{ id, name, avatarKey: avatarKeyOf(row.avatar) }] : [];
});
return { people: people.slice(0, MAX_TRAVELERS), more: Math.min(500, Math.max(0, people.length - MAX_TRAVELERS)) };
}
/** Whether the row has a confirmation of its own, on a leg or on its stay; never the value. */
function hasReference(row: BookingRow): boolean {
return textOf(row.confirmation) !== null || legsHaveReference(row.meta) || row.stay?.hasReference === true;
}
/** A day by id where the row belongs to this trip, else by its date. */
function dayFor(row: BookingRow, scope: BookingScope, id: unknown, date: string | null): CardDay | null {
if (row.tripId !== scope.tripId) return date ? { id: null, number: null, date } : null;
return scope.day(id) ?? scope.dayOn(date);
}
/** The minutes from one wall clock to another, each in its zone; null without both ends or past `max`. */
function minutesBetween(
from: { date: string | null; time: string | null; zone: string | null },
to: { date: string | null; time: string | null; zone: string | null },
max: number,
): number | null {
if (!from.date || !from.time || !from.zone || !to.date || !to.time || !to.zone) return null;
const start = wallClockInstant(from.date, from.time, from.zone);
const end = wallClockInstant(to.date, to.time, to.zone);
if (start === null || end === null) return null;
const minutes = Math.round((end - start) / 60_000);
return minutes > 0 && minutes <= max ? minutes : null;
}
function routeOf(endpoints: Row[]): RouteStop[] {
const sorted = [...endpoints].sort((a, b) => (finite(a.sequence) ?? 0) - (finite(b.sequence) ?? 0));
return sorted.flatMap((ep): RouteStop[] => {
const name = clipped(ep.name, 80) ?? (typeof ep.code === 'string' ? clipped(ep.code, 80) : null);
if (name === null) return [];
const code = typeof ep.code === 'string' ? ep.code.trim().toUpperCase() : '';
const lat = coord(ep.lat, isLat);
const lng = coord(ep.lng, isLng);
const zone = textOf(ep.timezone) ?? (lat !== null && lng !== null ? resolveTimeZone(lat, lng) : null);
return [
{
stop: { name, code: STOP_CODE.test(code) ? code : null, lat, lng },
zone,
localTime: ep.local_time,
localDate: dateOf(ep.local_date),
},
];
});
}
/** The stops a card shows: past nine, the first eight and the last. */
function shownStops(route: RouteStop[]): BookingStop[] {
const stops = route.map((entry) => entry.stop);
return stops.length > MAX_STOPS ? [...stops.slice(0, MAX_STOPS - 1), stops[stops.length - 1] as BookingStop] : stops;
}
function storedLegs(row: BookingRow, type: string, route: RouteStop[]): Row[] | null {
const legs = Array.isArray(row.meta.legs) ? row.meta.legs.map(recordOf) : null;
const hasLegs = (LEG_RESERVATION_TYPES as readonly string[]).includes(type);
return hasLegs && legs && legs.length >= 1 && legs.length <= 8 && legs.length === route.length - 1 ? legs : null;
}
function legOf(
row: BookingRow,
scope: BookingScope,
leg: Row,
from: RouteStop | undefined,
to: RouteStop | undefined,
last: boolean,
): BookingLeg {
const departs = clockOf(leg.dep_time);
const arrives = clockOf(leg.arr_time);
const departsDay = dayFor(row, scope, leg.dep_day_id, from?.localDate ?? null);
const arrivesDay = dayFor(row, scope, leg.arr_day_id, last ? (to?.localDate ?? null) : null);
return {
carrier: clipped(leg.airline, 60),
number: clipped(leg.flight_number ?? leg.train_number, 20),
departs,
departsDay,
arrives,
arrivesDay,
minutes: minutesBetween(
{ date: departsDay?.date ?? null, time: departs, zone: from?.zone ?? null },
{ date: arrivesDay?.date ?? null, time: arrives, zone: to?.zone ?? null },
LEG_MAX_MINUTES,
),
seat: clipped(leg.seat, 20),
platform: clipped(leg.platform, 20),
};
}
/** The one leg of a direct booking, or of one whose segments do not line up with its stops. */
function singleLeg(row: BookingRow, scope: BookingScope, route: RouteStop[]): BookingLeg {
const from = route[0];
const to = route.length >= 2 ? route[route.length - 1] : undefined;
const departs = clockOf(textOf(from?.localTime) ?? row.reservationTime);
const arrives = clockOf(textOf(to?.localTime) ?? row.reservationEndTime);
const departsDay = dayFor(
row,
scope,
row.dayId,
from?.localDate ?? dateOf(row.reservationTime) ?? dateOf(row.dayDate),
);
const arrivesDay = dayFor(row, scope, row.endDayId, to?.localDate ?? dateOf(row.reservationEndTime));
return {
carrier: clipped(row.meta.airline ?? row.meta.carrier, 60),
number: clipped(row.meta.flight_number ?? row.meta.train_number, 20),
departs,
departsDay,
arrives,
arrivesDay,
minutes: minutesBetween(
{ date: departsDay?.date ?? null, time: departs, zone: from?.zone ?? null },
{ date: arrivesDay?.date ?? null, time: arrives, zone: to?.zone ?? null },
LEG_MAX_MINUTES,
),
seat: clipped(row.meta.seat, 20),
platform: clipped(row.meta.platform, 20),
};
}
function ticketOf(row: BookingRow, scope: BookingScope, type: string): BookingTicket {
const route = routeOf(row.endpoints);
const stored = storedLegs(row, type, route);
const legs = stored
? stored.map((leg, i) => legOf(row, scope, leg, route[i], route[i + 1], i === stored.length - 1))
: [singleLeg(row, scope, route)];
const first = legs[0] as BookingLeg;
const last = legs[legs.length - 1] as BookingLeg;
const located = route.filter((entry) => entry.stop.lat !== null && entry.stop.lng !== null).length;
return {
shape: 'ticket',
stops: shownStops(route),
legs,
minutes: minutesBetween(
{ date: first.departsDay?.date ?? null, time: first.departs, zone: route[0]?.zone ?? null },
{ date: last.arrivesDay?.date ?? null, time: last.arrives, zone: route[route.length - 1]?.zone ?? null },
TRIP_MAX_MINUTES,
),
mappable: row.id !== null && located >= 2,
};
}
function nightsOf(start: CardDay | null, end: CardDay | null): number | null {
if (start?.number != null && end?.number != null) {
const nights = end.number - start.number;
return nights >= 0 && nights <= 366 ? nights : null;
}
if (start?.date && end?.date) {
const span = Date.parse(`${end.date}T00:00:00Z`) - Date.parse(`${start.date}T00:00:00Z`);
const nights = Math.round(span / 86_400_000);
return Number.isFinite(nights) && nights >= 0 && nights <= 366 ? nights : null;
}
return null;
}
function photoKeyOf(value: unknown): string | null {
return typeof value === 'string' && cardPhotoKeySchema.safeParse(value).success ? value : null;
}
function stayOf(row: BookingRow, scope: BookingScope, stay: StaySource | null): BookingStay {
const placeId = stay?.placeId ?? null;
const place = row.tripId === scope.tripId ? scope.place(placeId) : null;
const start = stay ? dayFor(row, scope, stay.startDayId, stay.startDate) : null;
const end = stay ? dayFor(row, scope, stay.endDayId, stay.endDate) : null;
return {
shape: 'stay',
accommodationId: stay?.accommodationId ?? null,
placeId,
address: clipped(stay?.address ?? place?.address, 200),
lat: coord(stay?.lat ?? place?.lat, isLat),
lng: coord(stay?.lng ?? place?.lng, isLng),
photoKey: photoKeyOf(stay?.photoKey ?? place?.googlePlaceId),
checkIn: start ? { day: start, from: clockOf(stay?.checkIn), until: clockOf(stay?.checkInEnd) } : null,
checkOut: end ? { day: end, by: clockOf(stay?.checkOut) } : null,
nights: nightsOf(start, end),
};
}
function venueOf(row: BookingRow, scope: BookingScope): BookingVenue {
const local = row.tripId === scope.tripId;
const placeId = idOf(row.placeId) ?? (local ? scope.assignmentPlace(row.assignmentId) : null);
const place = local ? scope.place(placeId) : null;
return {
shape: 'venue',
day: dayFor(row, scope, row.dayId, dateOf(row.reservationTime) ?? dateOf(row.dayDate)),
starts: clockOf(row.reservationTime),
ends: clockOf(row.reservationEndTime),
location: clipped(row.location, 200) ?? clipped(row.placeName, 200) ?? clipped(place?.name, 200),
placeId,
lat: coord(place?.lat ?? row.point?.lat, isLat),
lng: coord(place?.lng ?? row.point?.lng, isLng),
};
}
/** The booking's card; null for one without a title (nor a place to name it by). */
export function bookingItemOf(
row: BookingRow,
scope: BookingScope,
changed: readonly BookingChangedField[] = [],
): BookingItem | null {
const type = typeOf(row);
const title = clipped(row.title, 200) ?? (row.stay ? clipped(row.stay.name, 200) : null);
if (title === null) return null;
const { people, more } = travelersOf(row.travelers);
const body = isTransportReservationType(type)
? ticketOf(row, scope, type)
: type === 'hotel' || row.stay
? stayOf(row, scope, row.stay)
: venueOf(row, scope);
return {
tripId: row.tripId,
tripTitle: row.tripId === scope.tripId ? null : clipped(row.tripTitle, 120),
reservationId: row.id,
type,
title,
status: statusOf(row, type),
needsReview: row.needsReview,
hasReference: hasReference(row),
price: priceOf(row, scope),
travelers: people,
moreTravelers: more,
changed: [...changed],
body,
};
}
/** A card of up to eight bookings, the rest counted; null when none of them reads. */
export function bookingCard(
change: BookingCard['change'],
items: ReadonlyArray<BookingItem | null>,
total?: number,
): BookingCard | null {
const readable = items.filter((item): item is BookingItem => item !== null);
if (readable.length === 0) return null;
const shown = readable.slice(0, MAX_ITEMS);
return { kind: 'booking', change, items: shown, more: Math.max(0, (total ?? readable.length) - shown.length) };
}
/** What a field of the card reads as, for telling a changed one from an unchanged one. */
function facet(item: BookingItem, field: BookingChangedField): unknown {
const body = item.body;
switch (field) {
case 'title':
case 'type':
case 'status':
case 'price':
return item[field];
case 'travelers':
return item.travelers.map((person) => person.id).concat(item.moreTravelers);
case 'reference':
return item.hasReference;
case 'day':
if (body.shape === 'ticket') return body.legs.map((leg) => [leg.departsDay?.id, leg.departsDay?.date]);
return body.shape === 'venue' ? [body.day?.id, body.day?.date] : null;
case 'time':
if (body.shape === 'ticket') return body.legs.map((leg) => [leg.departs, leg.arrives, leg.arrivesDay?.date]);
return body.shape === 'venue' ? [body.starts, body.ends] : null;
case 'route':
return body.shape === 'ticket' ? body.stops.map((stop) => [stop.name, stop.code]) : null;
case 'stay':
return body.shape === 'stay' ? [body.placeId, body.checkIn?.day.id, body.checkOut?.day.id, body.nights] : null;
case 'checkIn':
return body.shape === 'stay' ? [body.checkIn?.from, body.checkIn?.until] : null;
case 'checkOut':
return body.shape === 'stay' ? (body.checkOut?.by ?? null) : null;
case 'location':
return body.shape === 'venue' ? [body.location, body.placeId] : null;
}
}
/** The fields whose card value an update changes: the card before against the card after. */
export function changedBetween(before: BookingItem, after: BookingItem): BookingChangedField[] {
return BOOKING_CHANGED_FIELDS.filter(
(field) => JSON.stringify(facet(before, field)) !== JSON.stringify(facet(after, field)),
);
}
@@ -0,0 +1,342 @@
import { RESERVATION_METADATA } from '../../../../db/json-columns';
import { decodeJson } from '../../../../utils/json-column';
import type { TripCard } from '../../assistant-context';
import { argId, recordOf, type Row } from '../card-values';
import { rowsOf } from '../undo-snapshot';
import { bookingRowOf, dateOf, dayOf, dayOnDate, stayRowOf } from './booking-trip';
import type { BookingItem, CardDay, CardMoney } from '@trek/shared';
/**
* Every booking card is drawn from one shape: a booking as the tools speak of
* it, whatever told of it (a saved row, a summary row, an upcoming row, a
* file's import, the arguments of a held write). `BookingScope` is what the
* card can look up beside it: the trip card on a turn, or, for an applied
* change (decided before any trip card is read), what the stored preview card
* already named.
*/
/** A stay as a booking card reads it: the place, the nights, the times as stored. */
export interface StaySource {
accommodationId: number | null;
placeId: number | null;
startDayId: number | null;
endDayId: number | null;
/** The dates when no day id is known (a file's import, another trip's row). */
startDate: string | null;
endDate: string | null;
checkIn: unknown;
checkInEnd: unknown;
checkOut: unknown;
name: unknown;
address: unknown;
lat: unknown;
lng: unknown;
/** A Google place id, for TREK's photo proxy. */
photoKey: unknown;
hasReference: boolean;
}
export interface BookingRow {
tripId: number;
/** Only for a row of another trip. */
tripTitle: unknown;
id: number | null;
type: unknown;
title: unknown;
status: unknown;
needsReview: boolean;
confirmation: unknown;
reservationTime: unknown;
reservationEndTime: unknown;
dayId: unknown;
endDayId: unknown;
/** The date of the booking's day, where only that is known. */
dayDate: unknown;
location: unknown;
placeId: unknown;
placeName: unknown;
assignmentId: unknown;
meta: Row;
endpoints: Row[];
travelers: Row[];
stay: StaySource | null;
/** A price the source names itself (a write's argument); undefined reads it from the metadata. */
price?: CardMoney | null;
/** Where a venue is when no trip place says so (a file's import names it). */
point?: { lat: unknown; lng: unknown };
}
/** A place as a booking card completes a stay or a venue from it. */
interface ScopePlace {
name: string | null;
address: string | null;
lat: number | null;
lng: number | null;
googlePlaceId: string | null;
}
/** What a booking card looks up beside its source. */
export interface BookingScope {
tripId: number;
currency: string | null;
day(id: unknown): CardDay | null;
dayOn(date: string | null): CardDay | null;
place(id: number | null): ScopePlace | null;
assignmentPlace(id: unknown): number | null;
}
export function idOf(value: unknown): number | null {
return argId({ value }, 'value');
}
/** The stay a booking links: stored as text, read back as `"14"` or `"14.0"` by older rows. */
export function accommodationIdOf(raw: Row): number | null {
const value = raw.accommodation_id;
return idOf(typeof value === 'string' ? Math.trunc(Number(value)) : value);
}
export function textOf(value: unknown): string | null {
return typeof value === 'string' && value.trim() !== '' ? value : null;
}
/** The scope of a turn: everything the trip card holds. */
export function scopeOfCard(card: TripCard): BookingScope {
return {
tripId: card.tripId,
currency: card.currency,
day: (id) => dayOf(card, id),
dayOn: (date) => dayOnDate(card, date),
place: (id) => (id === null ? null : (card.places.get(id) ?? null)),
assignmentPlace: (id) => card.assignments.get(idOf(id) ?? 0)?.placeId ?? null,
};
}
/** The days a stored card named, by id. */
function daysOfItems(items: readonly BookingItem[]): Map<number, CardDay> {
const days = new Map<number, CardDay>();
const add = (day: CardDay | null | undefined) => {
if (day?.id != null && !days.has(day.id)) days.set(day.id, day);
};
for (const item of items) {
const body = item.body;
if (body.shape === 'ticket') {
for (const leg of body.legs) {
add(leg.departsDay);
add(leg.arrivesDay);
}
} else if (body.shape === 'stay') {
add(body.checkIn?.day);
add(body.checkOut?.day);
} else add(body.day);
}
return days;
}
/**
* The scope of an applied change: the days its preview card named. A day
* the preview did not name keeps its id alone, so the card still links it.
*/
export function scopeOfPreview(items: readonly BookingItem[], tripId: number): BookingScope {
const days = daysOfItems(items);
const first = items[0];
return {
tripId,
currency: first?.price?.currency ?? null,
day: (id) => {
const known = idOf(id);
return known === null ? null : (days.get(known) ?? { id: known, number: null, date: null });
},
dayOn: (date) => (date === null ? null : { id: null, number: null, date }),
place: () => null,
assignmentPlace: () => null,
};
}
/** A booking's metadata: the readable details of a `list_reservations` row, else the stored column. */
function metaOf(raw: Row): Row {
if (raw.details && typeof raw.details === 'object') return recordOf(raw.details);
return typeof raw.metadata === 'string' ? decodeJson(RESERVATION_METADATA, raw.metadata) : recordOf(raw.metadata);
}
/**
* The stay a hotel booking links, completed from the trip's own stay row where
* the trip card has it. Another type reads as itself even while it still links
* the stay it had as a hotel.
*/
function linkedStay(raw: Row, meta: Row, card: TripCard | null): StaySource | null {
if (raw.type !== 'hotel') return null;
const accommodationId = accommodationIdOf(raw);
const placeId = idOf(raw.accommodation_place_id) ?? idOf(raw.place_id);
const stay = card ? stayRowOf(card, accommodationId) : null;
return {
accommodationId,
placeId: idOf(stay?.place_id) ?? placeId,
startDayId: idOf(stay?.start_day_id) ?? idOf(raw.accommodation_start_day_id),
endDayId: idOf(stay?.end_day_id) ?? idOf(raw.accommodation_end_day_id),
startDate: null,
endDate: null,
checkIn: stay?.check_in ?? meta.check_in_time,
checkInEnd: stay?.check_in_end ?? meta.check_in_end_time,
checkOut: stay?.check_out ?? meta.check_out_time,
name: stay?.place_name ?? raw.accommodation_name,
address: stay?.place_address,
lat: stay?.place_lat,
lng: stay?.place_lng,
photoKey: null,
hasReference: textOf(stay?.confirmation) !== null,
};
}
/** A saved booking: a summary row, a `list_reservations` row or what a write answered. */
export function rowOfReservation(raw: Row, tripId: number, card: TripCard | null = null): BookingRow {
const meta = metaOf(raw);
return {
tripId: idOf(raw.trip_id) ?? tripId,
tripTitle: null,
id: idOf(raw.id),
type: raw.type,
title: raw.title,
status: raw.status,
needsReview: raw.needs_review === 1 || raw.needs_review === true,
confirmation: raw.confirmation_number,
reservationTime: raw.reservation_time,
reservationEndTime: raw.reservation_end_time,
dayId: raw.day_id,
endDayId: raw.end_day_id,
dayDate: null,
location: raw.location,
placeId: raw.type === 'hotel' ? null : raw.place_id,
placeName: raw.place_name,
assignmentId: raw.assignment_id,
meta,
endpoints: rowsOf(raw.endpoints),
travelers: rowsOf(raw.travelers),
stay: linkedStay(raw, meta, card),
};
}
/** The booking of this trip that links a stay, from the trip card. */
function bookingOfStay(card: TripCard | null, accommodationId: number | null): Row | null {
if (!card || accommodationId === null) return null;
const summary = recordOf(recordOf(card.sources).summary);
const found = rowsOf(summary.reservations).find((row) => accommodationIdOf(row) === accommodationId);
return found ? (bookingRowOf(card, idOf(found.id)) ?? found) : null;
}
/** The price a place created with its stay carries, as booking metadata reads it. */
function placePrice(place: Row | null): Row {
return place && place.price != null ? { price: place.price, priceCurrency: place.currency } : {};
}
/**
* A stay: an accommodation row with its place joined (`list_accommodations`,
* the summary, what a write answered). `place` overrides the joined place
* (a place created with the stay). The booking linking it gives the status.
*/
export function rowOfStay(
raw: Row,
tripId: number,
card: TripCard | null = null,
place: Row | null = null,
): BookingRow {
const accommodationId = idOf(raw.id);
const booking = bookingOfStay(card, accommodationId);
return {
tripId: idOf(raw.trip_id) ?? tripId,
tripTitle: null,
id: idOf(booking?.id),
type: 'hotel',
title: raw.reservation_title ?? booking?.title ?? place?.name ?? raw.place_name,
status: booking?.status ?? null,
needsReview: booking?.needs_review === 1 || booking?.needs_review === true,
confirmation: booking?.confirmation_number,
reservationTime: null,
reservationEndTime: null,
dayId: null,
endDayId: null,
dayDate: null,
location: null,
placeId: null,
placeName: null,
assignmentId: null,
meta: { ...placePrice(place), ...(booking ? metaOf(booking) : {}) },
endpoints: [],
travelers: booking ? rowsOf(booking.travelers) : [],
stay: {
accommodationId,
placeId: idOf(place?.id) ?? idOf(raw.place_id),
startDayId: idOf(raw.start_day_id),
endDayId: idOf(raw.end_day_id),
startDate: null,
endDate: null,
checkIn: raw.check_in,
checkInEnd: raw.check_in_end,
checkOut: raw.check_out,
name: place?.name ?? raw.place_name,
address: place?.address ?? raw.place_address,
lat: place?.lat ?? raw.place_lat,
lng: place?.lng ?? raw.place_lng,
photoKey: place?.google_place_id ?? null,
hasReference: textOf(raw.confirmation) !== null,
},
};
}
/**
* A row of `list_upcoming_reservations`, which spans every trip: no endpoints
* and no metadata; a stay comes as its check-in or its check-out moment.
*/
export function rowOfUpcoming(raw: Row, tripId: number): BookingRow {
const moment = raw.type === 'checkin' || raw.type === 'checkout' ? raw.type : null;
const date = dateOf(raw.day_date) ?? dateOf(raw.reservation_time);
const rowTrip = idOf(raw.trip_id) ?? tripId;
return {
tripId: rowTrip,
tripTitle: rowTrip === tripId ? null : raw.trip_title,
id: moment ? null : idOf(raw.id),
type: moment ? 'hotel' : raw.type,
title: raw.title,
status: moment ? null : raw.status,
needsReview: false,
confirmation: raw.confirmation_number,
reservationTime: raw.reservation_time,
reservationEndTime: null,
dayId: null,
endDayId: null,
dayDate: date,
location: raw.location ?? raw.place_name,
placeId: null,
placeName: null,
assignmentId: null,
meta: {},
endpoints: [],
travelers: [],
stay: moment
? {
accommodationId: idOf(raw.id),
placeId: null,
startDayId: null,
endDayId: null,
startDate: moment === 'checkin' ? date : null,
endDate: moment === 'checkout' ? date : null,
checkIn: moment === 'checkin' ? raw.reservation_time : null,
checkInEnd: null,
checkOut: moment === 'checkout' ? raw.reservation_time : null,
name: raw.place_name,
address: null,
lat: null,
lng: null,
photoKey: null,
hasReference: false,
}
: null,
};
}
/** Whether a booking's legs carry a reference of their own (`list_reservations` says so without the value). */
export function legsHaveReference(meta: Row): boolean {
if (meta.has_leg_reference === true) return true;
return (
Array.isArray(meta.legs) && meta.legs.some((leg: unknown) => textOf(recordOf(leg).confirmation_number) !== null)
);
}
@@ -0,0 +1,66 @@
import type { TripCard } from '../../assistant-context';
import { recordOf, type Row } from '../card-values';
import { rowsOf } from '../undo-snapshot';
import type { CardDay } from '@trek/shared';
/**
* The trip's bookings and stays as `get_trip_summary` answered them on this
* turn, by id, read once per trip card. The trip card itself keeps only a
* booking's title and day for the prompt; a booking card needs the whole row
* (its endpoints, travellers, metadata and the stay it links).
*/
interface TripBookings {
reservations: ReadonlyMap<number, Row>;
stays: ReadonlyMap<number, Row>;
}
const PARSED = new WeakMap<TripCard, TripBookings>();
function byId(rows: Row[]): Map<number, Row> {
const out = new Map<number, Row>();
for (const row of rows) {
if (typeof row.id === 'number' && Number.isInteger(row.id) && row.id > 0 && !out.has(row.id)) out.set(row.id, row);
}
return out;
}
function parsed(card: TripCard): TripBookings {
let found = PARSED.get(card);
if (!found) {
const summary = recordOf(recordOf(card.sources).summary);
found = { reservations: byId(rowsOf(summary.reservations)), stays: byId(rowsOf(summary.accommodations)) };
PARSED.set(card, found);
}
return found;
}
/** A booking of this trip as the summary holds it, joins included. */
export function bookingRowOf(card: TripCard, id: number | null): Row | null {
return id === null ? null : (parsed(card).reservations.get(id) ?? null);
}
/** A stay of this trip as the summary holds it, its place joined. */
export function stayRowOf(card: TripCard, id: number | null): Row | null {
return id === null ? null : (parsed(card).stays.get(id) ?? null);
}
/** A day of this trip by its id; null for one the trip does not hold. */
export function dayOf(card: TripCard, id: unknown): CardDay | null {
const day = typeof id === 'number' ? card.days.find((d) => d.id === id) : undefined;
return day ? { id: day.id, number: day.number, date: day.date } : null;
}
const ISO_DATE = /^(\d{4}-\d{2}-\d{2})/;
/** The date part of a date or a date and time, as written. */
export function dateOf(value: unknown): string | null {
return typeof value === 'string' ? (ISO_DATE.exec(value.trim())?.[1] ?? null) : null;
}
/** The trip day on a date; a date the trip has no day for still names its date. */
export function dayOnDate(card: TripCard | null, date: string | null): CardDay | null {
if (date === null) return null;
const day = card?.days.find((d) => d.date === date);
return day ? { id: day.id, number: day.number, date } : { id: null, number: null, date };
}
@@ -0,0 +1,135 @@
import type { PreviewContext, ToolArgs, ToolCards, UndoSnapshot } from '../card-types';
import { argId, clipped, finite, idIn, isLat, isLng, recordOf, type Row } from '../card-values';
import { byId, rowsOf, same, snapshotAssignments, snapshotPlaces, STABLE_FIELDS } from '../undo-snapshot';
import { accommodationIdOf } from './booking-row';
import { stayRowOf } from './booking-trip';
import { categoryIdForKind, type AssistantChangeEffect } from '@trek/shared';
/**
* The way back from a created booking or stay, and what a held stay does to
* the day plan. A stay puts its place on its check-in day, so the pending
* stay shows as a ghost pin and in the day's preview like any new stop; undo
* removes the booking (a hotel takes its stay along) or the stay (a stay
* takes its stop along), or the place a stay was created with.
*/
type Undo = NonNullable<ToolCards['undo']>;
/** What still says a booking is the one the change created. */
const BOOKING_FIELDS = [
'title',
'type',
'status',
'reservation_time',
'reservation_end_time',
'day_id',
'end_day_id',
'location',
] as const;
/** What still says a stay is the one the change created. */
const STAY_FIELDS = ['place_id', 'start_day_id', 'end_day_id', 'check_in', 'check_in_end', 'check_out'] as const;
function summaryRows(snapshot: UndoSnapshot, key: string): Row[] {
return rowsOf(recordOf(snapshot.summary)[key]);
}
/** A created booking, still as it was created. */
function bookingUnchanged(result: unknown, snapshot: UndoSnapshot): boolean {
const created = recordOf(recordOf(result).reservation);
return same(created, byId(summaryRows(snapshot, 'reservations'), created.id), BOOKING_FIELDS);
}
/** A created stay, still as it was created. */
function stayUnchanged(result: unknown, snapshot: UndoSnapshot): boolean {
const created = recordOf(recordOf(result).accommodation);
return same(created, byId(summaryRows(snapshot, 'accommodations'), created.id), STAY_FIELDS);
}
/**
* A place created with its stay, untouched and used by nothing but that stay:
* not planned on another day, in no other booking, stay or expense. The stay
* itself and the booking that links it do not count; they go with the place.
*/
function placeOfStayUnchanged(result: unknown, snapshot: UndoSnapshot): boolean {
const answer = recordOf(result);
const created = recordOf(answer.place);
if (!same(created, byId(snapshotPlaces(snapshot), created.id), STABLE_FIELDS.place)) return false;
const stayId = recordOf(answer.accommodation).id;
const ownStop = recordOf(answer.assignment).id;
const plannedElsewhere = snapshotAssignments(snapshot).some((a) => a.place_id === created.id && a.id !== ownStop);
const otherBooking = summaryRows(snapshot, 'reservations').some(
(r) => r.place_id === created.id && accommodationIdOf(r) !== stayId,
);
const otherStay = summaryRows(snapshot, 'accommodations').some((a) => a.place_id === created.id && a.id !== stayId);
const paid = rowsOf(recordOf(recordOf(snapshot.summary).budget).items).some((item) => item.place_id === created.id);
return !plannedElsewhere && !otherBooking && !otherStay && !paid && stayUnchanged(result, snapshot);
}
function byResultId(key: string, arg: string) {
return (_args: ToolArgs, result: unknown): ToolArgs | null => {
const id = idIn(result, key);
return id ? { [arg]: id } : null;
};
}
export const BOOKING_UNDO: Readonly<Record<string, Undo>> = {
create_reservation: {
tool: 'delete_reservation',
args: byResultId('reservation', 'reservationId'),
unchanged: bookingUnchanged,
},
create_transport: {
tool: 'delete_transport',
args: byResultId('reservation', 'reservationId'),
unchanged: bookingUnchanged,
},
create_accommodation: {
tool: 'delete_accommodation',
args: byResultId('accommodation', 'accommodationId'),
unchanged: stayUnchanged,
},
create_place_accommodation: {
tool: 'delete_place',
args: byResultId('place', 'placeId'),
unchanged: placeOfStayUnchanged,
},
};
/** A change to the day plan, with nothing of what the kind does not name. */
function effect(
kind: AssistantChangeEffect['kind'],
dayId: number | null,
fields: Partial<Omit<AssistantChangeEffect, 'kind' | 'dayId'>>,
): AssistantChangeEffect | null {
if (dayId === null) return null;
return { kind, dayId, fromDayId: null, placeId: null, assignmentId: null, place: null, ...fields };
}
type EffectBuilder = (ctx: PreviewContext, args: ToolArgs) => AssistantChangeEffect | null;
export const BOOKING_EFFECTS: Readonly<Record<string, EffectBuilder>> = {
create_accommodation: (_ctx, args) =>
effect('add', argId(args, 'start_day_id'), { placeId: argId(args, 'place_id') }),
create_place_accommodation: (ctx, args) => {
const lat = finite(args.lat);
const lng = finite(args.lng);
const name = clipped(args.name, 200);
const start = argId(args, 'start_day_id');
// Without a position there is no pin to show, so no stop is previewed.
if (!isLat(lat) || !isLng(lng) || name === null) return null;
const categories = [...ctx.categories].map(([id, label]) => ({ id, name: label }));
return effect('add', start, { place: { name, lat, lng, categoryId: categoryIdForKind('lodging', categories) } });
},
create_reservation: (_ctx, args) => {
const placeId = argId(args, 'place_id');
const hotel = args.type === 'hotel' && placeId !== null && argId(args, 'end_day_id') !== null;
return hotel ? effect('add', argId(args, 'start_day_id'), { placeId }) : null;
},
update_accommodation: (ctx, args) => {
const current = recordOf(stayRowOf(ctx.card, argId(args, 'accommodationId')));
const from = argId(current, 'start_day_id');
const to = argId(args, 'start_day_id');
if (to === null || to === from) return null;
return effect('move', to, { fromDayId: from, placeId: argId(args, 'place_id') ?? argId(current, 'place_id') });
},
};
@@ -1,4 +1,125 @@
import type { DomainCards } from '../card-types';
import { IMPORT_BOOKING_TOOL } from '../../assistant-tools.constants';
import type { TripCard } from '../../assistant-context';
import type { DomainCards, PreviewContext, ToolArgs } from '../card-types';
import { argId, clipped, idIn } from '../card-values';
import { BOOKING_ANSWERS } from './booking-answer';
import { appliedImport, appliedReservation, appliedStay, appliedTravelers } from './booking-applied';
import { BOOKING_CHANGE_CARDS, importedBookingCard } from './booking-change';
import { BOOKING_DESCRIBERS } from './booking-describe';
import { stayRowOf } from './booking-trip';
import { BOOKING_EFFECTS, BOOKING_UNDO } from './booking-undo';
import type { TrekCard } from '@trek/shared';
/** The cards of the bookings: flights, trains, stays and other reservations. None yet: this package fills them in. */
export const BOOKING_CARDS: DomainCards = { id: 'booking', tools: {} };
/**
* The bookings in the assistant chat: tickets (flights, trains, hire cars),
* stays and venues, as the planner's booking cards show them. Every booking
* write and read gets its card; the curated writes name their own verb
* ("Add accommodation" instead of a bare "Add") and the created ones can be
* undone. `create_reservation` and a file's `import_booking` keep the core's
* words; their cards come from here.
*/
type ChangeOf = (ctx: PreviewContext, args: ToolArgs) => TrekCard | null;
const change = (tool: string): ChangeOf => BOOKING_CHANGE_CARDS[tool] ?? (() => null);
/** The stay an id argument names: its place, else the booking that links it. */
function stayName(key: string, value: number, card: TripCard): string | null {
if (key !== 'accommodationId') return null;
const stay = stayRowOf(card, value);
return stay ? clipped(stay.place_name ?? stay.reservation_title, 160) : null;
}
/** The stay a stay write touched, by the key its answer names it under (the place and the stop come too). */
const stayIds = (_args: ToolArgs, result: unknown): number[] => {
const id = idIn(result, 'accommodation');
return id ? [id] : [];
};
const ADD_STAY = { verbKey: 'addStay', entity: 'accommodation' } as const;
const UPDATE_RESERVATION = { verbKey: 'updateReservation', entity: 'reservation' } as const;
export const BOOKING_CARDS: DomainCards = {
id: 'booking',
prompt: [
'Bookings and stays are drawn as cards beside your answer: do not repeat their times, route or reference. Read them with list_reservations (flight numbers, seats, check-in times) and list_accommodations; get_trip_summary leaves those details out.',
'A hotel not yet in the trip: search_place, then create_place_accommodation; one that is: create_accommodation.',
],
tools: {
list_reservations: { answer: BOOKING_ANSWERS.list_reservations },
list_accommodations: { answer: BOOKING_ANSWERS.list_accommodations },
list_upcoming_reservations: { answer: BOOKING_ANSWERS.list_upcoming_reservations },
create_reservation: {
change: change('create_reservation'),
effect: BOOKING_EFFECTS.create_reservation,
applied: appliedReservation,
undo: BOOKING_UNDO.create_reservation,
},
create_transport: {
write: { verbKey: 'addReservation', entity: 'reservation' },
describe: BOOKING_DESCRIBERS.create_transport,
change: change('create_transport'),
applied: appliedReservation,
undo: BOOKING_UNDO.create_transport,
},
update_reservation: {
write: UPDATE_RESERVATION,
describe: BOOKING_DESCRIBERS.update_reservation,
change: change('update_reservation'),
applied: appliedReservation,
},
update_transport: {
write: UPDATE_RESERVATION,
describe: BOOKING_DESCRIBERS.update_transport,
change: change('update_transport'),
applied: appliedReservation,
},
set_reservation_travelers: {
write: UPDATE_RESERVATION,
describe: BOOKING_DESCRIBERS.set_reservation_travelers,
change: change('set_reservation_travelers'),
applied: appliedTravelers,
appliedIds: (args) => {
const id = argId(args, 'reservationId');
return id ? [id] : [];
},
},
link_hotel_accommodation: {
write: { verbKey: 'updateStay', entity: 'reservation' },
describe: BOOKING_DESCRIBERS.link_hotel_accommodation,
change: change('link_hotel_accommodation'),
applied: appliedReservation,
},
delete_reservation: { change: change('delete_reservation') },
delete_transport: { change: change('delete_transport') },
create_accommodation: {
write: ADD_STAY,
describe: BOOKING_DESCRIBERS.create_accommodation,
effect: BOOKING_EFFECTS.create_accommodation,
change: change('create_accommodation'),
applied: appliedStay,
appliedIds: stayIds,
undo: BOOKING_UNDO.create_accommodation,
},
create_place_accommodation: {
write: ADD_STAY,
describe: BOOKING_DESCRIBERS.create_place_accommodation,
effect: BOOKING_EFFECTS.create_place_accommodation,
change: change('create_place_accommodation'),
applied: appliedStay,
appliedIds: stayIds,
undo: BOOKING_UNDO.create_place_accommodation,
},
update_accommodation: {
write: { verbKey: 'updateStay', entity: 'accommodation' },
describe: BOOKING_DESCRIBERS.update_accommodation,
effect: BOOKING_EFFECTS.update_accommodation,
change: change('update_accommodation'),
applied: appliedStay,
appliedIds: stayIds,
nameArg: stayName,
},
delete_accommodation: { change: change('delete_accommodation'), nameArg: stayName },
[IMPORT_BOOKING_TOOL]: { change: importedBookingCard, applied: appliedImport },
},
};
+1 -25
View File
@@ -9,7 +9,7 @@ import type { ReservationsRepository } from '../../db/repositories/Reservations.
import type { TripsRepository } from '../../db/repositories/Trips.repository';
import { decodeJson } from '../../utils/json-column';
import { NotFoundError } from '../common/domain-errors';
import { resolveTimeZone } from '../common/timezoneService';
import { offsetMinutesAt, resolveTimeZone } from '../common/timezoneService';
import { addDays } from '../days/days.service';
import { ReservationsService } from '../reservations/reservations.service';
import { InjectRepository } from '@mikro-orm/nestjs';
@@ -87,30 +87,6 @@ function isValidTimeZone(zone: string): boolean {
return ok;
}
// One formatter per zone: the transition scan below asks it about once a day
// across the calendar's span.
const _offsetFormatters = new Map<string, Intl.DateTimeFormat>();
function offsetFormatter(zone: string): Intl.DateTimeFormat {
let f = _offsetFormatters.get(zone);
if (!f) {
f = new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: 'longOffset' });
if (_offsetFormatters.size >= 1000) _offsetFormatters.clear();
_offsetFormatters.set(zone, f);
}
return f;
}
/** The zone's UTC offset at instant `ms`, in minutes east of UTC. */
function offsetMinutesAt(zone: string, ms: number): number {
const raw =
offsetFormatter(zone)
.formatToParts(new Date(ms))
.find((p) => p.type === 'timeZoneName')?.value ?? 'GMT';
const m = raw.match(/GMT([+-])(\d{2}):?(\d{2})?/);
if (!m) return 0; // "GMT" (UTC) has no offset digits
return (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3] ?? 0));
}
/** An offset in minutes as iCalendar writes it: "+0200", "-0330". */
function formatOffset(minutes: number): string {
const abs = Math.abs(minutes);
+47
View File
@@ -37,6 +37,53 @@ export function resolveTimeZone(lat: unknown, lng: unknown): string | null {
}
}
// One formatter per zone: the calendar's transition scan asks it about once a
// day across its span, and every booking card asks it for both ends of a leg.
const offsetFormatters = new Map<string, Intl.DateTimeFormat>();
function offsetFormatter(zone: string): Intl.DateTimeFormat {
let f = offsetFormatters.get(zone);
if (!f) {
f = new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: 'longOffset' });
if (offsetFormatters.size >= 1000) offsetFormatters.clear();
offsetFormatters.set(zone, f);
}
return f;
}
/** The zone's UTC offset at instant `ms`, in minutes east of UTC. Throws a RangeError on an unknown zone. */
export function offsetMinutesAt(zone: string, ms: number): number {
const raw =
offsetFormatter(zone)
.formatToParts(new Date(ms))
.find((p) => p.type === 'timeZoneName')?.value ?? 'GMT';
const m = raw.match(/GMT([+-])(\d{2}):?(\d{2})?/);
if (!m) return 0; // "GMT" (UTC) has no offset digits
return (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3] ?? 0));
}
/**
* The instant a wall clock (`YYYY-MM-DD`, `HH:MM`) reads in an IANA zone, in
* ms since the epoch; null for a malformed or impossible date, a malformed time
* or an unknown zone. A clock a spring-forward skips lands an hour later, as a
* clock set by hand would.
*/
export function wallClockInstant(date: string, time: string, zone: string): number | null {
const d = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
const t = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(time);
if (!d || !t) return null;
const asUtc = Date.UTC(Number(d[1]), Number(d[2]) - 1, Number(d[3]), Number(t[1]), Number(t[2]));
// A day the month does not have (31 February) rolls over into the next month; refuse it instead.
if (Number.isNaN(asUtc) || new Date(asUtc).toISOString().slice(0, 10) !== date) return null;
try {
const first = asUtc - offsetMinutesAt(zone, asUtc) * 60_000;
// The offset in force at the first guess can differ from the one at the
// instant itself across a change of offset; one more step settles it.
return asUtc - offsetMinutesAt(zone, first) * 60_000;
} catch {
return null;
}
}
/**
* Today's date and the clock time where the instance runs, in the TZ the crons
* are scheduled in (CronRegistrarService). A UTC "today" disagreed with the
@@ -0,0 +1,120 @@
import { RESERVATION_METADATA } from '../../db/json-columns';
import { noAccess } from '../../mcp/tools/_shared';
import { McpController, ok, Tool, TOOL_ANNOTATIONS_READONLY, type McpContext } from '../../nest-mcp';
import { decodeJson } from '../../utils/json-column';
import { ReservationsService, type ReservationRow } from './reservations.service';
import {
isTransportReservationType,
listReservationsRequestSchema,
type ListReservationsRequest,
type ReservationReadKind,
} from '@trek/shared';
type Row = Record<string, unknown>;
/** The metadata keys a person reads on a booking; the rest (provider ids, a transit itinerary) stays stored. */
const DETAIL_KEYS = [
'airline',
'flight_number',
'train_number',
'platform',
'seat',
'check_in_time',
'check_in_end_time',
'check_out_time',
'price',
'priceCurrency',
] as const;
/** What a leg says about itself; its own booking reference stays with the booking. */
const LEG_KEYS = [
'from',
'to',
'airline',
'flight_number',
'train_number',
'platform',
'seat',
'dep_day_id',
'dep_time',
'arr_day_id',
'arr_time',
] as const;
function recordOf(value: unknown): Row {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Row) : {};
}
function picked(source: Row, keys: readonly string[]): Row {
const out: Row = {};
for (const key of keys) {
const value = source[key];
if (typeof value === 'string' || typeof value === 'number') out[key] = value;
}
return out;
}
/**
* A booking's readable details out of its stored metadata: what the planner
* shows on it. A reference kept on a leg stays stored too; `has_leg_reference`
* says only that one exists.
*/
function bookingDetails(raw: unknown): Row {
const meta = typeof raw === 'string' ? decodeJson(RESERVATION_METADATA, raw) : recordOf(raw);
const details = picked(meta, DETAIL_KEYS);
const legs = Array.isArray(meta.legs) ? meta.legs.map(recordOf) : [];
if (legs.length > 1) details.legs = legs.map((leg) => picked(leg, LEG_KEYS));
const ref = (leg: Row) => typeof leg.confirmation_number === 'string' && leg.confirmation_number.trim() !== '';
if (legs.some(ref)) details.has_leg_reference = true;
return details;
}
/** The days a booking touches: where it starts and ends, a leg's departure and arrival, a stay's check-in. */
function daysOf(row: ReservationRow, details: Row): number[] {
const legs = Array.isArray(details.legs) ? details.legs.map(recordOf) : [];
return [
row.day_id,
row.end_day_id,
row.accommodation_start_day_id,
...legs.flatMap((leg) => [leg.dep_day_id, leg.arr_day_id]),
].filter((value): value is number => typeof value === 'number');
}
function kindOf(row: ReservationRow): ReservationReadKind {
return isTransportReservationType(row.type) ? 'transport' : 'booking';
}
/**
* The trip's bookings as an assistant reads them, beside the summary every
* turn loads. The summary hands the provider no metadata (it may hold
* provider ids and a whole transit itinerary), so a flight number, a seat or
* a check-in time never reached the model; this read carries them as
* `details`, the booking reference still hidden by the assistant's redaction.
* Read-only, core (offered on every step) and gated like the trip's other
* booking reads.
*/
@McpController()
export class ReservationsReadMcp {
constructor(private readonly reservations: ReservationsService) {}
@Tool({
name: 'list_reservations',
description:
'List the bookings of a trip with their readable details: flights, trains and hire cars (kinds ["transport"]), hotels, tables, tickets and tours (kinds ["booking"]), or both. Each carries its days, times, route endpoints, status, travellers and `details` (airline, flight or train number, platform, seat, legs of a stopover booking, check-in and check-out times, price). Use it to answer about bookings instead of get_trip_summary, which leaves these details out. dayId keeps the bookings on that day.',
inputSchema: listReservationsRequestSchema.shape,
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'reservations', mode: 'read' },
})
async listReservations({ tripId, kinds, dayId }: ListReservationsRequest, ctx: McpContext) {
if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
const wanted = new Set<ReservationReadKind>(kinds ?? ['transport', 'booking']);
const reservations = (await this.reservations.list(tripId)).flatMap((row) => {
const { metadata, ...rest } = row;
const details = bookingDetails(metadata);
if (!wanted.has(kindOf(row))) return [];
if (dayId !== undefined && !daysOf(row, details).includes(dayId)) return [];
return [{ ...rest, details }];
});
return ok({ reservations });
}
}
@@ -21,6 +21,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { RealtimeModule } from '../realtime/realtime.module';
import { ReservationWritesService } from './reservation-writes.service';
import { ReservationsReadMcp } from './reservations-read.mcp';
import { ReservationsReadModule } from './reservations-read.module';
import { ReservationsController } from './reservations.controller';
import { ReservationsMcp } from './reservations.mcp';
@@ -81,7 +82,8 @@ import { Module } from '@nestjs/common';
],
controllers: [ReservationsController, UpcomingReservationsController],
// ReservationWritesService: the booking use cases the controller and the MCP tools share.
providers: [ReservationsService, ReservationWritesService, ReservationsMcp, ReservationsRpc],
// ReservationsReadMcp: the assistant's read of the bookings with their readable details.
providers: [ReservationsService, ReservationWritesService, ReservationsMcp, ReservationsReadMcp, ReservationsRpc],
// For in-container consumers (ReservationsRpc, TripsService, BookingImportService).
exports: [ReservationsService],
})