mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
feat(assistant): booking and stay cards, and a read of the bookings with their details
This commit is contained in:
@@ -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 },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user