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 b52990bf27
commit b4e188fbfc
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],
})
@@ -0,0 +1,111 @@
/**
* The assistant's booking reads over a real MCP session against the booted
* container: list_reservations answers the bookings with their readable
* details and no raw metadata, list_accommodations the stays with their place,
* and neither answers for a trip the caller cannot see.
*/
import { buildApp } from '../../src/bootstrap';
import { createLocalMcpServer, McpRegistryService } from '../../src/nest-mcp';
import { withRequestContext } from '../../src/nest/database/request-context';
import { makeDayAccommodation } from '../helpers/factories/itinerary';
import { makePlace } from '../helpers/factories/places';
import { makeReservation, makeReservationEndpoint } from '../helpers/factories/reservations';
import { makeDay, makeTrip } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { MikroORM } from '@mikro-orm/core';
import { Client } from '@modelcontextprotocol/sdk/client/index';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
import type { INestApplication } from '@nestjs/common';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
let app: INestApplication;
let orm: MikroORM;
beforeAll(async () => {
app = await buildApp();
orm = app.get(MikroORM);
}, 60_000);
afterAll(async () => {
await app?.close();
});
/** One tool call as `userId` over an MCP session of its own, with every scope; the JSON it answered or its error. */
async function callTool(userId: number, name: string, args: Record<string, unknown>) {
const server = createLocalMcpServer('TREK booking reads e2e');
const around = (_info: unknown, call: () => unknown): unknown => withRequestContext(orm, call);
await withRequestContext(orm, () =>
app
.get(McpRegistryService)
.attach(
server,
{ userId, scopes: null, isStaticToken: false },
{ include: (kind, tool) => kind === 'tool' && tool === name, around },
),
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: 'booking-reads-e2e', version: '1.0.0' });
await server.connect(serverTransport);
await client.connect(clientTransport);
try {
const result = await client.callTool({ name, arguments: args });
const content = Array.isArray(result.content) ? (result.content as Array<{ text?: string }>) : [];
const text = content[0]?.text ?? '';
return result.isError === true ? { error: text } : JSON.parse(text);
} finally {
await client.close();
await server.close();
}
}
describe('the booking reads an assistant makes over MCP', () => {
it('RESREAD-E2E-001: answers the bookings with their details and the stays with their place', async () => {
const { user } = await makeUser(orm);
const trip = await makeTrip(orm, user.id, { title: 'Berlin' });
const day1 = await makeDay(orm, trip.id, { day_number: 1, date: '2026-06-03' });
const day2 = await makeDay(orm, trip.id, { day_number: 2, date: '2026-06-04' });
const flight = await makeReservation(orm, trip.id, {
title: 'Frankfurt to Berlin',
type: 'flight',
day: day1.id,
confirmation_number: 'ABC123',
metadata: JSON.stringify({ airline: 'Lufthansa', flight_number: 'LH 170', airtrail_ids: [1] }),
});
await makeReservationEndpoint(orm, flight.id, { role: 'from', sequence: 0, name: 'Frankfurt', code: 'FRA' });
await makeReservationEndpoint(orm, flight.id, { role: 'to', sequence: 1, name: 'Berlin', code: 'BER' });
const hotel = await makePlace(orm, trip.id, { name: 'Mercure', lat: 52.53, lng: 13.38 });
await makeDayAccommodation(orm, trip.id, hotel.id, day1.id, day2.id, { check_in: '15:00', check_out: '11:00' });
const read = await callTool(user.id, 'list_reservations', { tripId: trip.id, kinds: ['transport'] });
expect(read.reservations).toHaveLength(1);
expect(read.reservations[0]).toMatchObject({
id: flight.id,
title: 'Frankfurt to Berlin',
details: { airline: 'Lufthansa', flight_number: 'LH 170' },
endpoints: [expect.objectContaining({ code: 'FRA' }), expect.objectContaining({ code: 'BER' })],
});
expect(read.reservations[0]).not.toHaveProperty('metadata');
expect(read.reservations[0].details).not.toHaveProperty('airtrail_ids');
const stays = await callTool(user.id, 'list_accommodations', { tripId: trip.id });
expect(stays.accommodations).toEqual([
expect.objectContaining({
place_id: hotel.id,
place_name: 'Mercure',
start_day_id: day1.id,
end_day_id: day2.id,
check_in: '15:00',
check_out: '11:00',
}),
]);
});
it('RESREAD-E2E-002: answers neither read for a trip the caller is not on', async () => {
const { user } = await makeUser(orm);
const { user: stranger } = await makeUser(orm);
const trip = await makeTrip(orm, user.id);
expect(await callTool(stranger.id, 'list_reservations', { tripId: trip.id })).toHaveProperty('error');
expect(await callTool(stranger.id, 'list_accommodations', { tripId: trip.id })).toHaveProperty('error');
});
});
@@ -1155,3 +1155,179 @@ describe('Tool: list_upcoming_reservations', () => {
});
});
});
// ---------------------------------------------------------------------------
// list_reservations / list_accommodations
//
// The assistant's booking reads: the bookings with their readable details,
// never the raw metadata (provider ids, a leg's own reference), and the stays
// with their place, each behind the trip's access check and its scope.
// ---------------------------------------------------------------------------
async function withScopedHarness(userId: number, fn: (h: McpHarness) => Promise<void>, scopes: string[] | null = null) {
const h = await createMcpHarness({ realtime, userId, withResources: false, scopes });
try {
await fn(h);
} finally {
await h.cleanup();
}
}
interface ListedBooking {
id: number;
title: string;
type: string;
metadata?: unknown;
details: Record<string, unknown>;
}
async function listed(userId: number, args: Record<string, unknown>): Promise<ListedBooking[]> {
let out: ListedBooking[] = [];
await withScopedHarness(userId, async (h) => {
const data = parseToolResult(await h.client.callTool({ name: 'list_reservations', arguments: args })) as {
reservations: ListedBooking[];
};
out = data.reservations;
});
return out;
}
/** A trip with a flight with stopover legs on day 1, a dinner on day 2 and a hotel stay. */
async function seedTrip(userId: number) {
const trip = createTrip(testDb, userId);
const day1 = createDay(testDb, trip.id, { date: '2026-06-03' });
const day2 = createDay(testDb, trip.id, { date: '2026-06-04' });
const flight = await insertRow(orm, Reservations, {
trip: trip.id,
day: day1.id,
title: 'Frankfurt to New York',
type: 'flight',
status: 'confirmed',
confirmation_number: 'ABC123',
metadata: JSON.stringify({
airline: 'Lufthansa',
flight_number: 'LH 400',
seat: '12A',
airtrail_ids: [4, 5],
legs: [
{ from: 'FRA', to: 'MUC', flight_number: 'LH 101', confirmation_number: 'LEG-REF', dep_time: '08:15' },
{ from: 'MUC', to: 'JFK', flight_number: 'LH 410', arr_day_id: day2.id, arr_time: '13:05' },
],
}),
});
const dinner = await insertRow(orm, Reservations, {
trip: trip.id,
day: day2.id,
title: 'Dinner',
type: 'restaurant',
metadata: JSON.stringify({ price: '80' }),
});
return { trip, day1, day2, flight, dinner };
}
describe('Tool: list_reservations', () => {
it('RESREAD-MCP-001: lists the bookings with their readable details and without the raw metadata', async () => {
const { user } = createUser(testDb);
const { trip } = await seedTrip(user.id);
const bookings = await listed(user.id, { tripId: trip.id });
expect(bookings.map((b) => b.title).sort()).toEqual(['Dinner', 'Frankfurt to New York']);
const flight = bookings.find((b) => b.type === 'flight');
expect(flight).not.toHaveProperty('metadata');
expect(flight?.details).toMatchObject({ airline: 'Lufthansa', flight_number: 'LH 400', seat: '12A' });
expect(flight?.details.legs).toEqual([
{ from: 'FRA', to: 'MUC', flight_number: 'LH 101', dep_time: '08:15' },
expect.objectContaining({ from: 'MUC', to: 'JFK', flight_number: 'LH 410', arr_time: '13:05' }),
]);
// A provider id and a leg's own reference stay with the booking.
expect(JSON.stringify(flight?.details)).not.toContain('airtrail_ids');
expect(JSON.stringify(flight?.details)).not.toContain('LEG-REF');
expect(bookings.find((b) => b.type === 'restaurant')?.details).toEqual({ price: '80' });
});
it('RESREAD-MCP-005: says that a leg holds a reference, never the reference itself', async () => {
const { user } = createUser(testDb);
const { trip } = await seedTrip(user.id);
const bookings = await listed(user.id, { tripId: trip.id });
expect(bookings.find((b) => b.type === 'flight')?.details.has_leg_reference).toBe(true);
expect(bookings.find((b) => b.type === 'restaurant')?.details).not.toHaveProperty('has_leg_reference');
});
it('RESREAD-MCP-002: keeps one kind, or the bookings on one day (a leg arriving on it counts)', async () => {
const { user } = createUser(testDb);
const { trip, day1, day2 } = await seedTrip(user.id);
expect((await listed(user.id, { tripId: trip.id, kinds: ['transport'] })).map((b) => b.type)).toEqual(['flight']);
expect((await listed(user.id, { tripId: trip.id, kinds: ['booking'] })).map((b) => b.type)).toEqual(['restaurant']);
expect((await listed(user.id, { tripId: trip.id, dayId: day1.id })).map((b) => b.type)).toEqual(['flight']);
expect((await listed(user.id, { tripId: trip.id, dayId: day2.id })).map((b) => b.type).sort()).toEqual([
'flight',
'restaurant',
]);
});
it('RESREAD-MCP-003: refuses a trip the user cannot see', async () => {
const { user } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const { trip } = await seedTrip(user.id);
await withScopedHarness(stranger.id, async (h) => {
const result = await h.client.callTool({ name: 'list_reservations', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
it('RESREAD-MCP-004: rides reservations:read and is hidden from a token without it', async () => {
const { user } = createUser(testDb);
await withScopedHarness(
user.id,
async (h) => {
expect((await h.client.listTools()).tools.map((t) => t.name)).toContain('list_reservations');
},
['reservations:read'],
);
await withScopedHarness(
user.id,
async (h) => {
expect((await h.client.listTools()).tools.map((t) => t.name)).not.toContain('list_reservations');
},
['places:read'],
);
});
});
describe('Tool: list_accommodations', () => {
it('RESREAD-MCP-010: lists the stays with their place and check-in and check-out', async () => {
const { user } = createUser(testDb);
const { trip, day1, day2 } = await seedTrip(user.id);
const hotel = createPlace(testDb, trip.id, { name: 'Mercure', lat: 52.53, lng: 13.38 });
createDayAccommodation(testDb, trip.id, hotel.id, day1.id, day2.id, { check_in: '15:00', confirmation: 'H-1' });
await withScopedHarness(user.id, async (h) => {
const data = parseToolResult(
await h.client.callTool({ name: 'list_accommodations', arguments: { tripId: trip.id } }),
) as { accommodations: Array<Record<string, unknown>> };
expect(data.accommodations).toHaveLength(1);
expect(data.accommodations[0]).toMatchObject({
place_id: hotel.id,
place_name: 'Mercure',
start_day_id: day1.id,
end_day_id: day2.id,
check_in: '15:00',
});
});
});
it('RESREAD-MCP-011: refuses a trip the user cannot see, and is hidden from a token without trip reads', async () => {
const { user } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const { trip } = await seedTrip(user.id);
await withScopedHarness(stranger.id, async (h) => {
const result = await h.client.callTool({ name: 'list_accommodations', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
await withScopedHarness(
user.id,
async (h) => {
expect((await h.client.listTools()).tools.map((t) => t.name)).not.toContain('list_accommodations');
},
['places:read'],
);
});
});
@@ -0,0 +1,766 @@
/**
* The booking cards: a ticket, a stay or a venue from every booking read and
* write, as the planner shows them. The reference never travels, only that
* one exists; times cross zones only where both ends know theirs.
*/
import { buildTripCard } from '../../../../../../src/nest/assistant/assistant-context';
import { unchangedSinceApplied } from '../../../../../../src/nest/assistant/assistant-undo.helpers';
import type { CardAirport, PreviewContext } from '../../../../../../src/nest/assistant/cards/card-types';
import {
answerCardOf,
appliedCardOf,
ASSISTANT_UNDO_MAP,
ASSISTANT_WRITE_TOOLS,
CARD_PROMPT_LINES,
} from '../../../../../../src/nest/assistant/cards/registry';
import { appliedChange, describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers';
import { MAX_CARD_CHARS, trekCardSchema, type BookingCard, type BookingItem, type TrekCard } from '@trek/shared';
import { describe, expect, it } from 'vitest';
const FLIGHT = {
id: 501,
trip_id: 1,
type: 'flight',
status: 'confirmed',
title: 'Frankfurt to New York',
day_id: 31,
end_day_id: 31,
reservation_time: '2026-06-03T08:15',
reservation_end_time: '2026-06-03T11:05',
confirmation_number: 'ABC123',
needs_review: 0,
metadata: JSON.stringify({ airline: 'Lufthansa', flight_number: 'LH 400', seat: '12A', price: '420' }),
endpoints: [
{
role: 'from',
sequence: 0,
name: 'Frankfurt',
code: 'fra',
lat: 50.03,
lng: 8.57,
timezone: 'Europe/Berlin',
local_time: '08:15',
local_date: '2026-06-03',
},
{
role: 'to',
sequence: 1,
name: 'New York',
code: 'JFK',
lat: 40.64,
lng: -73.78,
timezone: 'America/New_York',
local_time: '11:05',
local_date: '2026-06-03',
},
],
travelers: [{ user_id: 7, username: 'mia', avatar: 'mia.png', avatar_url: 'https://cdn.example/mia.png' }],
};
const HOTEL = {
id: 502,
trip_id: 1,
type: 'hotel',
status: 'pending',
title: 'Mercure stay',
accommodation_id: '601.0',
accommodation_place_id: 12,
accommodation_start_day_id: 31,
accommodation_end_day_id: 33,
needs_review: 1,
metadata: null,
endpoints: [],
travelers: [],
};
const DINNER = {
id: 503,
trip_id: 1,
type: 'restaurant',
status: 'pending',
title: 'Dinner at Borchardt',
day_id: 32,
reservation_time: '2026-06-04T19:00',
reservation_end_time: '2026-06-04T21:30',
assignment_id: 301,
location: null,
metadata: '{"price":"0"}',
endpoints: [],
travelers: [],
};
const STAY = {
id: 601,
trip_id: 1,
place_id: 12,
start_day_id: 31,
end_day_id: 33,
check_in: '15:00',
check_in_end: '20:00',
check_out: '11:00',
confirmation: 'HOTEL-XYZ',
place_name: 'Mercure Hotel Berlin City',
place_address: 'Invalidenstraße 38',
place_image: '/uploads/places/mercure.jpg',
place_lat: 52.53,
place_lng: 13.38,
reservation_title: 'Mercure stay',
};
const SUMMARY = {
trip: { title: 'Berlin', currency: 'EUR' },
members: {
owner: { id: 7, username: 'mia', avatar_url: '/uploads/avatars/mia.png' },
collaborators: [{ id: 8, username: 'jon', avatar_url: null }],
},
days: [
{ id: 31, day_number: 1, date: '2026-06-03', assignments: [], notes: [] },
{
id: 32,
day_number: 2,
date: '2026-06-04',
assignments: [{ id: 301, place: { id: 13, name: 'Borchardt', lat: 52.51, lng: 13.39 } }],
notes: [],
},
{ id: 33, day_number: 3, date: '2026-06-05', assignments: [], notes: [] },
],
reservations: [FLIGHT, HOTEL, DINNER],
accommodations: [STAY],
};
const PLACES = {
places: [
{
id: 12,
name: 'Mercure Hotel Berlin City',
address: 'Invalidenstraße 38',
lat: 52.53,
lng: 13.38,
google_place_id: 'ChIJmercure',
image_url: 'https://maps.example/photo.jpg',
},
{ id: 13, name: 'Borchardt', lat: 52.51, lng: 13.39 },
],
};
const card = buildTripCard(1, 7, SUMMARY, PLACES, null);
const AIRPORTS: Record<string, CardAirport> = {
MUC: { city: 'Munich', lat: 48.35, lng: 11.79, tz: 'Europe/Berlin' },
LIS: { city: 'Lisbon', lat: 38.77, lng: -9.13, tz: 'Europe/Lisbon' },
};
const ctx: PreviewContext = {
card,
categories: new Map([
[2, 'Food'],
[5, 'Hotel'],
]),
locale: 'en-GB',
today: '2026-05-01',
airport: (code) => AIRPORTS[code] ?? null,
};
/** The card checked against the union, as the registry checks it before it streams. */
function parsed(value: TrekCard | null | undefined): BookingCard {
const result = trekCardSchema.safeParse(value);
expect(result.success).toBe(true);
expect(value?.kind).toBe('booking');
return value as BookingCard;
}
function answer(tool: string, value: unknown): BookingCard {
return parsed(answerCardOf(tool, { toolCallId: 'tc-r', args: {}, value, card, today: '2026-05-01' }));
}
function changeCard(tool: string, args: Record<string, unknown>, context: PreviewContext = ctx) {
const preview = describeToolCall(tool, 'tc-w', args, context);
return { preview, card: preview.card ?? null };
}
function first(value: BookingCard): BookingItem {
return value.items[0] as BookingItem;
}
/** Nothing secret or remote anywhere in a card's JSON. */
function expectClean(value: unknown): void {
const json = JSON.stringify(value);
for (const secret of ['ABC123', 'HOTEL-XYZ', 'LEG-REF', 'FILE-REF', 'http', '/uploads']) {
expect(json).not.toContain(secret);
}
}
describe('booking answer cards', () => {
it('ASTBOOK-001: a flight is a ticket with codes, times, carrier, seat, price and 8 h 50 across zones', () => {
const list = answer('list_reservations', { reservations: [FLIGHT, HOTEL, DINNER] });
expect(list).toMatchObject({ kind: 'booking', change: 'none', more: 0 });
expect(list.items).toHaveLength(3);
const flight = first(list);
expect(flight).toMatchObject({
reservationId: 501,
type: 'flight',
status: 'confirmed',
hasReference: true,
price: { amount: 420, currency: 'EUR' },
travelers: [{ id: 7, name: 'mia', avatarKey: 'mia.png' }],
});
expect(flight.body).toMatchObject({
shape: 'ticket',
stops: [
{ name: 'Frankfurt', code: 'FRA' },
{ name: 'New York', code: 'JFK' },
],
legs: [
{
carrier: 'Lufthansa',
number: 'LH 400',
departs: '08:15',
arrives: '11:05',
departsDay: { id: 31, number: 1, date: '2026-06-03' },
minutes: 530,
seat: '12A',
},
],
minutes: 530,
mappable: true,
});
expectClean(list);
});
it('ASTBOOK-002: a hotel is a stay with its nights, check-in window, check-out and the place photo key', () => {
const stay = answer('list_reservations', { reservations: [HOTEL] });
expect(first(stay)).toMatchObject({ type: 'hotel', status: 'pending', needsReview: true, hasReference: true });
expect(first(stay).body).toEqual({
shape: 'stay',
accommodationId: 601,
placeId: 12,
address: 'Invalidenstraße 38',
lat: 52.53,
lng: 13.38,
photoKey: 'ChIJmercure',
checkIn: { day: { id: 31, number: 1, date: '2026-06-03' }, from: '15:00', until: '20:00' },
checkOut: { day: { id: 33, number: 3, date: '2026-06-05' }, by: '11:00' },
nights: 2,
});
const listed = answer('list_accommodations', { accommodations: [STAY, { ...STAY, reservation_title: 'Twice' }] });
expect(listed.items).toHaveLength(1);
expect(first(listed)).toMatchObject({ title: 'Mercure stay', reservationId: 502, status: 'pending' });
expectClean([stay, listed]);
});
it('ASTBOOK-003: a table is a venue on its day with its time range and the planned place', () => {
const dinner = answer('list_reservations', { reservations: [DINNER] });
expect(first(dinner)).toMatchObject({ price: null, hasReference: false, needsReview: false });
expect(first(dinner).body).toEqual({
shape: 'venue',
day: { id: 32, number: 2, date: '2026-06-04' },
starts: '19:00',
ends: '21:30',
location: 'Borchardt',
placeId: 13,
lat: 52.51,
lng: 13.39,
});
});
it('ASTBOOK-004: without zones there is no duration; legs line up with the stops or fall back to one', () => {
const unplaced = (name: string, code: string, sequence: number, time: string) => ({
role: sequence === 0 ? 'from' : 'to',
sequence,
name,
code,
local_time: time,
local_date: '2026-06-03',
});
const bare = {
...FLIGHT,
endpoints: [unplaced('Frankfurt', 'FRA', 0, '08:15'), unplaced('New York', 'JFK', 1, '11:05')],
};
const noZone = first(answer('list_reservations', { reservations: [bare] }));
expect(noZone.body).toMatchObject({ minutes: null, mappable: false, legs: [{ minutes: null }] });
const stop = { role: 'stop', sequence: 1, name: 'Munich', code: 'MUC', lat: 48.35, lng: 11.79 };
const via = [FLIGHT.endpoints[0], stop, { ...FLIGHT.endpoints[1], sequence: 2 }];
const legs = [
{ flight_number: 'LH 101', dep_day_id: 31, dep_time: '08:15', arr_day_id: 31, arr_time: '09:10' },
{ flight_number: 'LH 410', dep_day_id: 31, dep_time: '10:30', arr_day_id: 31, arr_time: '13:05' },
];
const twoLegs = { ...FLIGHT, endpoints: via, details: { legs } };
const multi = first(answer('list_reservations', { reservations: [twoLegs] })).body;
expect(multi).toMatchObject({
legs: [
{ number: 'LH 101', departs: '08:15', arrives: '09:10', minutes: 55 },
{ number: 'LH 410', departs: '10:30', arrives: '13:05' },
],
});
const mismatch = { ...twoLegs, details: { legs: legs.slice(0, 1) } };
expect(first(answer('list_reservations', { reservations: [mismatch] })).body).toMatchObject({
legs: [{ departs: '08:15', arrives: '11:05' }],
});
});
it('ASTBOOK-005: a train keeps number, platform and seat; a hire car without a route is one leg', () => {
const train = {
...FLIGHT,
id: 504,
type: 'train',
endpoints: [],
details: { train_number: 'ICE 597', platform: '7', seat: '41' },
};
expect(first(answer('list_reservations', { reservations: [train] })).body).toMatchObject({
stops: [],
legs: [{ number: 'ICE 597', platform: '7', seat: '41', departs: '08:15', arrives: '11:05' }],
mappable: false,
});
const car = { id: 505, trip_id: 1, type: 'car', title: 'Hire car', reservation_time: '09:00', endpoints: [] };
expect(first(answer('list_reservations', { reservations: [car] })).body).toMatchObject({
legs: [{ departs: '09:00', arrives: null, departsDay: null, carrier: null }],
});
});
it('ASTBOOK-006: a price reads only when it is a positive amount; an odd currency takes the trip one', () => {
const priced = (metadata: unknown) =>
first(answer('list_reservations', { reservations: [{ ...DINNER, metadata: JSON.stringify(metadata) }] })).price;
expect(priced({ price: 'abc' })).toBeNull();
expect(priced({ price: '0' })).toBeNull();
expect(priced({ price: 12.5, priceCurrency: 'usd' })).toEqual({ amount: 12.5, currency: 'EUR' });
expect(priced({ price: '30', priceCurrency: 'CHF' })).toEqual({ amount: 30, currency: 'CHF' });
});
it('ASTBOOK-007: an upcoming row of another trip names that trip; a check-in is a stay; no picture travels', () => {
const upcoming = answer('list_upcoming_reservations', {
reservations: [
{
id: 900,
trip_id: 2,
title: 'Night train',
type: 'train',
status: 'confirmed',
reservation_time: '2026-07-01T22:10',
confirmation_number: 'ABC123',
trip_title: 'Vienna',
trip_cover: '/uploads/covers/vienna.jpg',
day_date: '2026-07-01',
place_name: null,
place_image: 'https://maps.example/x.jpg',
},
{
id: 601,
trip_id: 1,
title: 'Mercure stay',
type: 'checkin',
status: null,
reservation_time: '15:00',
trip_title: 'Berlin',
day_date: '2026-06-03',
place_name: 'Mercure Hotel Berlin City',
},
],
});
const [train, checkIn] = upcoming.items as [BookingItem, BookingItem];
expect(train).toMatchObject({ tripId: 2, tripTitle: 'Vienna', reservationId: 900, hasReference: true });
expect(train.body).toMatchObject({ legs: [{ departs: '22:10', departsDay: { id: null, date: '2026-07-01' } }] });
expect(checkIn).toMatchObject({ tripId: 1, tripTitle: null, type: 'hotel', status: null, reservationId: null });
expect(checkIn.body).toMatchObject({
shape: 'stay',
accommodationId: 601,
checkIn: { day: { id: 31, date: '2026-06-03' }, from: '15:00' },
checkOut: null,
});
expectClean(upcoming);
});
it('ASTBOOK-008: ten bookings show eight and count the rest, well under the card size cap', () => {
const many = Array.from({ length: 10 }, (_, i) => ({ ...FLIGHT, id: 700 + i }));
const list = answer('list_reservations', { reservations: many });
expect(list.items).toHaveLength(8);
expect(list.more).toBe(2);
expect(JSON.stringify(list).length).toBeLessThan(MAX_CARD_CHARS);
});
it('ASTBOOK-009: a reference kept only on a leg still counts, and a stay reads a review flag stored as true', () => {
const legRef = {
...FLIGHT,
confirmation_number: null,
metadata: undefined,
details: { airline: 'Lufthansa', has_leg_reference: true },
};
expect(first(answer('list_reservations', { reservations: [legRef] })).hasReference).toBe(true);
const flagged = { ...SUMMARY, reservations: [FLIGHT, { ...HOTEL, needs_review: true }, DINNER] };
const flaggedCard = buildTripCard(1, 7, flagged, PLACES, null);
const stays = parsed(
answerCardOf('list_accommodations', {
toolCallId: 'tc-s',
args: {},
value: { accommodations: [STAY] },
card: flaggedCard,
today: '2026-05-01',
}),
);
expect(first(stays)).toMatchObject({ reservationId: 502, needsReview: true });
});
});
describe('booking change cards', () => {
it('ASTBOOK-010: a transport places code-only stops at their airports and names its own action', () => {
const { preview, card: drawn } = changeCard('create_transport', {
type: 'flight',
title: 'Munich to Lisbon',
start_day_id: 32,
endpoints: [
{ role: 'from', sequence: 0, name: 'Munich', code: 'MUC', local_time: '07:00' },
{ role: 'to', sequence: 1, name: 'Lisbon', code: 'LIS', local_time: '09:10' },
],
confirmation_number: 'ABC123',
price: 99,
});
expect(preview).toMatchObject({ verbKey: 'addReservation', tool: 'create_transport', subject: 'Munich to Lisbon' });
const item = first(parsed(drawn));
expect(parsed(drawn).change).toBe('create');
expect(item).toMatchObject({ status: 'pending', reservationId: null, hasReference: true, price: { amount: 99 } });
expect(item.body).toMatchObject({
stops: [
{ code: 'MUC', lat: 48.35, lng: 11.79 },
{ code: 'LIS', lat: 38.77, lng: -9.13 },
],
legs: [{ departs: '07:00', arrives: '09:10', departsDay: { id: 32 }, minutes: 190 }],
mappable: false,
});
const unknown = changeCard('create_transport', {
type: 'flight',
title: 'Somewhere',
endpoints: [
{ role: 'from', sequence: 0, name: 'Nowhere', code: 'ZZZ' },
{ role: 'to', sequence: 1, name: 'Lisbon', code: 'LIS' },
],
});
expect(first(parsed(unknown.card)).body).toMatchObject({ stops: [{ lat: null, lng: null }, { lat: 38.77 }] });
expectClean([preview, drawn]);
});
it('ASTBOOK-011: a hotel booking with a place and both days draws the stay and puts it on its check-in day', () => {
const { preview, card: drawn } = changeCard('create_reservation', {
title: 'Mercure',
type: 'hotel',
place_id: 12,
start_day_id: 31,
end_day_id: 33,
check_in: '15:00',
check_out: '11:00',
});
expect(preview.effect).toEqual({
kind: 'add',
dayId: 31,
fromDayId: null,
placeId: 12,
assignmentId: null,
place: null,
});
expect(first(parsed(drawn)).body).toMatchObject({
shape: 'stay',
placeId: 12,
photoKey: 'ChIJmercure',
checkIn: { day: { id: 31 }, from: '15:00' },
checkOut: { day: { id: 33 }, by: '11:00' },
nights: 2,
});
const table = changeCard('create_reservation', { title: 'Table', type: 'restaurant', assignment_id: 301 });
expect(table.preview.effect).toBeNull();
expect(first(parsed(table.card)).body).toMatchObject({ shape: 'venue', placeId: 13, location: 'Borchardt' });
});
it('ASTBOOK-012: an update marks only the fields whose card value changes; an unknown booking draws none', () => {
const { preview, card: drawn } = changeCard('update_reservation', { reservationId: 503, status: 'confirmed' });
expect(preview).toMatchObject({ verbKey: 'updateReservation', tool: 'update_reservation' });
expect(preview.rows).toEqual([
{ op: 'change', labelKey: 'field', field: 'status', before: 'pending', after: 'confirmed' },
]);
expect(parsed(drawn)).toMatchObject({ change: 'update' });
expect(first(parsed(drawn))).toMatchObject({ status: 'confirmed', changed: ['status'] });
const same = changeCard('update_reservation', { reservationId: 503, title: 'Dinner at Borchardt' });
expect(first(parsed(same.card)).changed).toEqual([]);
const moved = changeCard('update_transport', {
reservationId: 501,
endpoints: [FLIGHT.endpoints[0], { role: 'to', sequence: 1, name: 'Lisbon', code: 'LIS' }],
});
expect(first(parsed(moved.card)).changed).toEqual(expect.arrayContaining(['route']));
expect(first(parsed(moved.card)).changed).not.toContain('status');
expect(changeCard('update_reservation', { reservationId: 999, status: 'confirmed' }).card).toBeNull();
});
it('ASTBOOK-013: a removal draws the booking as it is; new travellers leave out who is not on the trip', () => {
expect(parsed(changeCard('delete_transport', { reservationId: 501 }).card)).toMatchObject({ change: 'remove' });
expect(parsed(changeCard('delete_accommodation', { accommodationId: 601 }).card).change).toBe('remove');
const { preview, card: drawn } = changeCard('set_reservation_travelers', { reservationId: 503, user_ids: [8, 99] });
expect(preview).toMatchObject({ verbKey: 'updateReservation', tool: 'set_reservation_travelers' });
expect(first(parsed(drawn))).toMatchObject({
travelers: [{ id: 8, name: 'jon', avatarKey: null }],
changed: ['travelers'],
});
});
it('ASTBOOK-014: a stay names its verb, shows its place and lands on its check-in day', () => {
const { preview, card: drawn } = changeCard('create_accommodation', {
place_id: 12,
start_day_id: 31,
end_day_id: 32,
check_in: '14:00',
confirmation: 'HOTEL-XYZ',
});
expect(preview).toMatchObject({ verbKey: 'addStay', tool: 'create_accommodation', undoable: true });
expect(preview.effect).toMatchObject({ kind: 'add', dayId: 31, placeId: 12 });
expect(first(parsed(drawn))).toMatchObject({
title: 'Mercure Hotel Berlin City',
status: null,
hasReference: true,
});
expect(changeCard('create_accommodation', { place_id: 404, start_day_id: 31, end_day_id: 32 }).card).toBeNull();
const added = changeCard('create_place_accommodation', {
name: 'Hotel Adlon',
address: 'Unter den Linden 77',
lat: 52.516,
lng: 13.38,
google_place_id: 'ChIJadlon',
start_day_id: 32,
end_day_id: 33,
price: 840,
currency: 'eur',
});
expect(added.preview.verbKey).toBe('addStay');
expect(added.preview.effect).toMatchObject({
kind: 'add',
dayId: 32,
place: { name: 'Hotel Adlon', lat: 52.516, lng: 13.38, categoryId: 5 },
});
expect(first(parsed(added.card))).toMatchObject({ price: { amount: 840, currency: 'EUR' } });
expect(first(parsed(added.card)).body).toMatchObject({ placeId: null, photoKey: 'ChIJadlon', nights: 1 });
// Without a position there is no pin, so no stop is previewed on the day.
const unplaced = changeCard('create_place_accommodation', { name: 'Somewhere', start_day_id: 32, end_day_id: 33 });
expect(unplaced.preview.effect).toBeNull();
});
it('ASTBOOK-015: moving a stay to another check-in day moves its stop; linking a hotel marks the stay', () => {
const { preview, card: drawn } = changeCard('update_accommodation', { accommodationId: 601, start_day_id: 32 });
expect(preview).toMatchObject({ verbKey: 'updateStay', subject: 'Mercure Hotel Berlin City' });
expect(preview.effect).toMatchObject({ kind: 'move', dayId: 32, fromDayId: 31, placeId: 12 });
expect(first(parsed(drawn)).changed).toContain('stay');
const linked = changeCard('link_hotel_accommodation', {
reservationId: 502,
place_id: 12,
start_day_id: 31,
end_day_id: 32,
check_in: '16:00',
});
expect(linked.preview).toMatchObject({ verbKey: 'updateStay', tool: 'link_hotel_accommodation' });
expect(first(parsed(linked.card)).changed).toEqual(expect.arrayContaining(['stay', 'checkIn']));
});
it('ASTBOOK-016: a booking read from a file is drawn with its stay nights by date and no reference', () => {
const { preview, card: drawn } = changeCard('import_booking', {
type: 'hotel',
title: 'Mercure Hotel Berlin City',
confirmation_number: 'FILE-REF',
_accommodation: { check_in: '2026-06-03T15:00', check_out: '2026-06-05T11:00', confirmation: 'FILE-REF' },
_venue: { name: 'Mercure', address: 'Invalidenstraße 38', lat: 52.53, lng: 13.38 },
source: { fileName: 'mercure.pdf', index: 0 },
});
expect(preview.verbKey).toBe('importBooking');
expect(first(parsed(drawn))).toMatchObject({ hasReference: true, status: null });
expect(first(parsed(drawn)).body).toMatchObject({
checkIn: { day: { id: 31 }, from: '15:00' },
checkOut: { day: { id: 33 }, by: '11:00' },
nights: 2,
lat: 52.53,
});
expect(JSON.stringify(drawn)).not.toContain('FILE-REF');
// Applied, the import answers the id alone: the card is the preview's, saved under that id.
const slim = { reservation: { id: 502, title: 'Mercure Hotel Berlin City', type: 'hotel', day_id: null } };
const saved = parsed(appliedCardOf('import_booking', { args: {}, result: slim, preview }));
expect(saved.change).toBe('none');
expect(first(saved)).toMatchObject({ reservationId: 502, hasReference: true, changed: [] });
expect(first(saved).body).toMatchObject({
checkIn: { day: { id: 31 }, from: '15:00' },
checkOut: { day: { id: 33 }, by: '11:00' },
nights: 2,
});
expect(appliedCardOf('import_booking', { args: {}, result: 'Not added.', preview })).toBeNull();
});
it('ASTBOOK-018: an applied flight from a file keeps its route, times and price, and can show it on the map', () => {
const args = {
type: 'flight',
title: 'Frankfurt to New York',
reservation_time: '2026-06-03T08:15',
reservation_end_time: '2026-06-03T11:05',
confirmation_number: 'FILE-REF',
metadata: { airline: 'Lufthansa', flight_number: 'LH 400', price: '420' },
endpoints: FLIGHT.endpoints,
source: { fileName: 'lh400.pdf', index: 0 },
};
const { preview, card: drawn } = changeCard('import_booking', args);
expect(first(parsed(drawn)).body).toMatchObject({ mappable: false });
const slim = { reservation: { id: 520, title: args.title, type: 'flight', day_id: 31 } };
const saved = first(parsed(appliedCardOf('import_booking', { args: {}, result: slim, preview })));
expect(saved).toMatchObject({ reservationId: 520, hasReference: true, price: { amount: 420, currency: 'EUR' } });
expect(saved.body).toMatchObject({
stops: [{ code: 'FRA' }, { code: 'JFK' }],
legs: [{ departs: '08:15', arrives: '11:05', carrier: 'Lufthansa' }],
minutes: 530,
mappable: true,
});
expectClean(saved);
});
it('ASTBOOK-019: a hotel changed into another type reads as that type, not as the stay it still links', () => {
const { card: drawn } = changeCard('update_reservation', { reservationId: 502, type: 'restaurant' });
const item = first(parsed(drawn));
expect(item.body.shape).toBe('venue');
expect(item.changed).toEqual(expect.arrayContaining(['type']));
const preview = describeToolCall('update_reservation', 'tc-w', { reservationId: 502, type: 'restaurant' }, ctx);
const result = { reservation: { ...HOTEL, type: 'restaurant' } };
const saved = parsed(appliedCardOf('update_reservation', { args: {}, result, preview }));
expect(first(saved).body.shape).toBe('venue');
});
it('ASTBOOK-017: every curated booking write describes empty arguments with its own verb', () => {
for (const tool of [
'create_transport',
'update_reservation',
'update_transport',
'set_reservation_travelers',
'link_hotel_accommodation',
'create_accommodation',
'create_place_accommodation',
'update_accommodation',
]) {
expect(changeCard(tool, {}).preview.verbKey, tool).toBe(ASSISTANT_WRITE_TOOLS[tool].verbKey);
}
});
});
describe('applied booking cards and receipts', () => {
const previewOf = (tool: string, args: Record<string, unknown>) => describeToolCall(tool, 'tc-w', args, ctx);
it('ASTBOOK-020: an applied booking is the saved row, its days named from the preview', () => {
const args = { type: 'flight', title: 'Frankfurt to New York', start_day_id: 31, endpoints: FLIGHT.endpoints };
const preview = previewOf('create_transport', args);
const saved = parsed(appliedCardOf('create_transport', { args, result: { reservation: FLIGHT }, preview }));
expect(saved.change).toBe('none');
expect(first(saved)).toMatchObject({ reservationId: 501, status: 'confirmed', hasReference: true });
expect(first(saved).body).toMatchObject({ mappable: true, legs: [{ departsDay: { id: 31, number: 1 } }] });
expectClean(saved);
expect(appliedCardOf('create_transport', { args, result: 'Transport not found.', preview })).toBeNull();
});
it('ASTBOOK-021: an applied stay keeps the times and photo the preview showed; travellers are replaced', () => {
const args = {
name: 'Hotel Adlon',
google_place_id: 'ChIJadlon',
start_day_id: 32,
end_day_id: 33,
check_in: '15:00',
price: 840,
currency: 'EUR',
};
const preview = previewOf('create_place_accommodation', args);
const result = {
place: {
id: 77,
name: 'Hotel Adlon',
google_place_id: 'ChIJadlon',
lat: 52.516,
lng: 13.38,
price: 840,
currency: 'EUR',
},
accommodation: { id: 602, trip_id: 1, place_id: 77, start_day_id: 32, end_day_id: 33, check_in: null },
assignment: { id: 9 },
};
const saved = parsed(appliedCardOf('create_place_accommodation', { args, result, preview }));
// The price lives on the place the stay was created with.
expect(first(saved).price).toEqual({ amount: 840, currency: 'EUR' });
expect(first(saved).body).toMatchObject({
accommodationId: 602,
placeId: 77,
photoKey: 'ChIJadlon',
checkIn: { day: { id: 32, number: 2 }, from: '15:00' },
});
const travelers = previewOf('set_reservation_travelers', { reservationId: 503, user_ids: [8] });
const set = parsed(
appliedCardOf('set_reservation_travelers', {
args: {},
result: { travelers: [{ user_id: 7, username: 'mia', avatar: null }] },
preview: travelers,
}),
);
expect(first(set).travelers).toEqual([{ id: 7, name: 'mia', avatarKey: null }]);
});
it('ASTBOOK-022: a stay write files its receipt under the stay, a travellers write under the booking', () => {
const result = { accommodation: { id: 601 }, assignment: { id: 9 }, place: { id: 12 } };
expect(appliedChange('create_place_accommodation', 'tc-1', {}, result, 31, null)).toMatchObject({
entity: 'accommodation',
ids: [601],
});
expect(appliedChange('update_accommodation', 'tc-2', { accommodationId: 601 }, result, null, null)).toMatchObject({
entity: 'accommodation',
ids: [601],
});
expect(appliedChange('set_reservation_travelers', 'tc-3', { reservationId: 503 }, {}, null, null)).toMatchObject({
entity: 'reservation',
ids: [503],
});
expect(appliedChange('create_transport', 'tc-4', {}, { reservation: FLIGHT }, null, null)).toMatchObject({
entity: 'reservation',
ids: [501],
dayId: 31,
});
});
});
describe('booking undo, naming and prompt', () => {
const snapshot = (summary: Record<string, unknown> = SUMMARY, places: unknown = PLACES) => ({ summary, places });
it('ASTBOOK-030: a created booking or stay is taken back by its own removal', () => {
expect(ASSISTANT_UNDO_MAP.create_reservation.args({}, { reservation: { id: 503 } }, null)).toEqual({
reservationId: 503,
});
expect(ASSISTANT_UNDO_MAP.create_transport.tool).toBe('delete_transport');
expect(ASSISTANT_UNDO_MAP.create_accommodation.args({}, { accommodation: { id: 601 } }, null)).toEqual({
accommodationId: 601,
});
expect(ASSISTANT_UNDO_MAP.create_place_accommodation.args({}, { place: { id: 12 } }, null)).toEqual({
placeId: 12,
});
expect(ASSISTANT_UNDO_MAP.create_accommodation.args({}, 'Failed.', null)).toBeNull();
});
it('ASTBOOK-031: an undo holds while the booking or stay is as created, and stops once it changed', () => {
expect(unchangedSinceApplied('create_reservation', { reservation: DINNER }, snapshot())).toBe(true);
const confirmed = { ...SUMMARY, reservations: [FLIGHT, HOTEL, { ...DINNER, status: 'confirmed' }] };
expect(unchangedSinceApplied('create_reservation', { reservation: DINNER }, snapshot(confirmed))).toBe(false);
expect(unchangedSinceApplied('create_accommodation', { accommodation: STAY }, snapshot())).toBe(true);
const later = { ...SUMMARY, accommodations: [{ ...STAY, check_in: '18:00' }] };
expect(unchangedSinceApplied('create_accommodation', { accommodation: STAY }, snapshot(later))).toBe(false);
});
it('ASTBOOK-032: a place created with its stay can go while only that stay and its booking use it', () => {
const place = { id: 12, name: 'Mercure Hotel Berlin City', lat: 52.53, lng: 13.38 };
const result = { place, accommodation: STAY, assignment: { id: 301 } };
expect(unchangedSinceApplied('create_place_accommodation', result, snapshot())).toBe(true);
const booked = { ...SUMMARY, reservations: [...SUMMARY.reservations, { id: 510, place_id: 12, title: 'Spa' }] };
expect(unchangedSinceApplied('create_place_accommodation', result, snapshot(booked))).toBe(false);
const planned = {
...SUMMARY,
days: [{ id: 33, day_number: 3, assignments: [{ id: 302, day_id: 33, place_id: 12 }] }],
};
expect(unchangedSinceApplied('create_place_accommodation', result, snapshot(planned))).toBe(false);
});
it('ASTBOOK-033: a generic stay removal names the stay; the prompt tells the model to leave the cards alone', () => {
const preview = describeToolCall('delete_accommodation', 'tc-9', { accommodationId: 601 }, ctx);
expect(JSON.stringify(preview.rows)).toContain('Mercure Hotel Berlin City');
expect(CARD_PROMPT_LINES.some((line) => line.includes('list_reservations'))).toBe(true);
expect(CARD_PROMPT_LINES.some((line) => line.includes('create_place_accommodation'))).toBe(true);
});
});
@@ -1,4 +1,9 @@
import { localParts, resolveTimeZone } from '../../../../src/nest/common/timezoneService';
import {
localParts,
offsetMinutesAt,
resolveTimeZone,
wallClockInstant,
} from '../../../../src/nest/common/timezoneService';
import { describe, expect, it } from 'vitest';
@@ -14,4 +19,31 @@ describe('timezoneService', () => {
expect(localParts('invalid', 'Asia/Tokyo')).toEqual({ date: null, time: null });
expect(localParts(null, null)).toEqual({ date: null, time: null });
});
it('TZSVC-001: reads a zone offset in minutes east of UTC, summer and winter', () => {
expect(offsetMinutesAt('Europe/Berlin', Date.UTC(2026, 6, 1))).toBe(120);
expect(offsetMinutesAt('Europe/Berlin', Date.UTC(2026, 0, 1))).toBe(60);
expect(offsetMinutesAt('America/St_Johns', Date.UTC(2026, 0, 1))).toBe(-210);
expect(offsetMinutesAt('UTC', Date.UTC(2026, 0, 1))).toBe(0);
});
it('TZSVC-002: turns a wall clock in a zone into its instant', () => {
expect(wallClockInstant('2026-06-03', '08:15', 'Europe/Berlin')).toBe(Date.UTC(2026, 5, 3, 6, 15));
expect(wallClockInstant('2026-06-03', '11:05', 'America/New_York')).toBe(Date.UTC(2026, 5, 3, 15, 5));
// On the day the clocks go back, a midday clock already reads winter time.
expect(wallClockInstant('2026-10-25', '12:00', 'Europe/Berlin')).toBe(Date.UTC(2026, 9, 25, 11, 0));
});
it('TZSVC-003: refuses a malformed date, a malformed time and a zone that does not exist', () => {
expect(wallClockInstant('2026-6-3', '08:15', 'Europe/Berlin')).toBeNull();
expect(wallClockInstant('2026-06-03', '24:00', 'Europe/Berlin')).toBeNull();
expect(wallClockInstant('2026-06-03', '08:15', 'Mars/Olympus')).toBeNull();
});
it('TZSVC-004: refuses a day the month does not have instead of rolling it over', () => {
expect(wallClockInstant('2026-02-31', '08:15', 'Europe/Berlin')).toBeNull();
expect(wallClockInstant('2026-04-31', '08:15', 'Europe/Berlin')).toBeNull();
expect(wallClockInstant('2026-13-01', '08:15', 'Europe/Berlin')).toBeNull();
expect(wallClockInstant('2028-02-29', '08:15', 'UTC')).toBe(Date.UTC(2028, 1, 29, 8, 15));
});
});