diff --git a/server/src/db/repositories/Reservations.repository.ts b/server/src/db/repositories/Reservations.repository.ts index fcad8bd5e..e5f41d71f 100644 --- a/server/src/db/repositories/Reservations.repository.ts +++ b/server/src/db/repositories/Reservations.repository.ts @@ -7,6 +7,7 @@ import { publicReservationExpr, publicStayExists, type ReservationVisibilityKyse import type { DayAssignmentRow } from './DayAssignments.repository'; import { TrekRepository } from './_shared/trek-repository'; import type { DB } from '../kysely/db'; +import { ROADTRIP_RESERVATION_TYPES } from '@trek/shared'; /** * RS18/RR1's shared joined projection — `SELECT r.*, d.day_number, p.name as @@ -1558,13 +1559,12 @@ export class ReservationsRepository extends TrekRepository { * RPL4 (`RoadtripPlanService.carriers`) — `SELECT id, type, title, day_id, * end_day_id, reservation_time, reservation_end_time, metadata, * day_plan_position FROM reservations WHERE trip_id = ? AND type IN - * ('flight', 'train', 'ferry', 'cruise', 'bus', 'car') AND day_id IS NOT - * NULL`: the bookings that seam the drive, and the hire cars whose desks - * stand on it. + * (ROADTRIP_RESERVATION_TYPES) AND day_id IS NOT NULL`: the bookings that + * seam the drive, and the hire cars whose desks stand on it. */ async listRoadtripCarriers(trip_id: number): Promise { return this.roadtripCarrierQuery() - .where({ trip: trip_id, type: { $in: ['flight', 'train', 'ferry', 'cruise', 'bus', 'car'] }, day: { $ne: null } }) + .where({ trip: trip_id, type: { $in: [...ROADTRIP_RESERVATION_TYPES] }, day: { $ne: null } }) .execute('all', false); } diff --git a/server/src/nest/reservations/reservations.mcp.ts b/server/src/nest/reservations/reservations.mcp.ts index 84816aceb..71ffbabc4 100644 --- a/server/src/nest/reservations/reservations.mcp.ts +++ b/server/src/nest/reservations/reservations.mcp.ts @@ -13,14 +13,21 @@ import { DaysService } from '../days/days.service'; import { findByIata } from '../airports/airports.data'; import type { EndpointInput } from './reservations.service'; import { AssignmentsService } from '../assignments/assignments.service'; -import { idSchema, transportLegInputSchema, reservationUrlSchema, type TransportLegInput } from '@trek/shared'; +import { + idSchema, transportLegInputSchema, reservationUrlSchema, type TransportLegInput, + TRANSPORT_RESERVATION_TYPES, MCP_CREATABLE_TRANSPORT_TYPES, LEG_RESERVATION_TYPES, BOOKING_RESERVATION_TYPES, + RESERVATION_STATUSES, type ReservationStatus, +} from '@trek/shared'; import { RESERVATION_METADATA } from '../../db/json-columns'; import { decodeJson } from '../../utils/json-column'; -// What counts as a transport booking, for the update_transport gate. Every value -// ReservationsPanel renders with a transport icon, so a stored `transit` row is -// editable through the transport tools like any other. -const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transit', 'transport_other'] as const; +// The type lists come from the shared reservation catalog (RESERVATION_TYPES), +// in the pickers' order, so the enums below list their values as the UI does. +// +// TRANSPORT_TYPES is what counts as a transport booking, for the update_transport +// gate: every value ReservationsPanel renders with a transport icon, so a stored +// `transit` row is editable through the transport tools like any other. +const TRANSPORT_TYPES = TRANSPORT_RESERVATION_TYPES; // What a caller may ASK for, which is the transport form's own picker // (client/src/components/Planner/TransportModal.tsx), in its order. The tools // below used to accept four of these nine, so a bus or a ferry could be planned @@ -30,11 +37,11 @@ const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cr // booking carries a provider itinerary in metadata.transit, and create_transit_journey // is what writes one. A hand-made `transit` row would be a shape the transit UI // does not expect. -const CREATABLE_TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transport_other'] as const; +const CREATABLE_TRANSPORT_TYPES = MCP_CREATABLE_TRANSPORT_TYPES; /** Only these two carry per-segment detail: the transport form writes metadata.legs for a flight or a train and for nothing else. */ -const LEG_TRANSPORT_TYPES = ['flight', 'train'] as const; +const LEG_TRANSPORT_TYPES = LEG_RESERVATION_TYPES; /** Everything the picker offers that is not a transport: create_reservation's half. */ -const BOOKING_TYPES = ['hotel', 'restaurant', 'event', 'tour', 'activity', 'parking', 'other'] as const; +const BOOKING_TYPES = BOOKING_RESERVATION_TYPES; /** * The booking link. Same refinement the REST contract applies @@ -412,7 +419,7 @@ export class ReservationsMcp { location: z.string().max(500).optional(), confirmation_number: z.string().max(100).optional(), notes: z.string().max(1000).optional(), - status: z.enum(['pending', 'confirmed', 'cancelled']).optional().describe('Reservation status: "pending", "confirmed", or "cancelled"'), + status: z.enum(RESERVATION_STATUSES).optional().describe('Reservation status: "pending", "confirmed", or "cancelled"'), place_id: idSchema.nullable().optional().describe('Link to a place (use for hotel type), or null to unlink'), assignment_id: idSchema.nullable().optional().describe('Link to a day assignment (use for restaurant, train, car, cruise, event, tour, activity, other), or null to unlink'), }, @@ -424,7 +431,7 @@ export class ReservationsMcp { tripId: number; reservationId: number; title?: string; type?: BookingType; reservation_time?: string; reservation_end_time?: string; url?: string; location?: string; confirmation_number?: string; notes?: string; - status?: 'pending' | 'confirmed' | 'cancelled'; place_id?: number | null; assignment_id?: number | null; + status?: ReservationStatus; place_id?: number | null; assignment_id?: number | null; }, ctx: McpContext, ) { @@ -657,7 +664,7 @@ export class ReservationsMcp { tripId: idSchema, type: z.enum(CREATABLE_TRANSPORT_TYPES), title: z.string().min(1).max(200), - status: z.enum(['pending', 'confirmed', 'cancelled']).optional().default('pending'), + status: z.enum(RESERVATION_STATUSES).optional().default('pending'), start_day_id: idSchema.optional().describe('Departure day'), end_day_id: idSchema.optional().describe('Arrival day (if different from departure)'), reservation_time: z.string().optional().describe('ISO 8601 datetime or time string for departure'), @@ -678,7 +685,7 @@ export class ReservationsMcp { async createTransport( { tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, url, notes, metadata, endpoints, legs, needs_review, price, budget_category }: { tripId: number; type: TransportType; title: string; - status?: 'pending' | 'confirmed' | 'cancelled'; start_day_id?: number; end_day_id?: number; + status?: ReservationStatus; start_day_id?: number; end_day_id?: number; reservation_time?: string; reservation_end_time?: string; confirmation_number?: string; url?: string; notes?: string; metadata?: Record; endpoints?: TransportEndpoint[]; legs?: TransportLegInput[]; needs_review?: boolean; price?: number; budget_category?: string; @@ -759,7 +766,7 @@ export class ReservationsMcp { reservationId: idSchema, type: z.enum(CREATABLE_TRANSPORT_TYPES).optional(), title: z.string().min(1).max(200).optional(), - status: z.enum(['pending', 'confirmed', 'cancelled']).optional(), + status: z.enum(RESERVATION_STATUSES).optional(), start_day_id: idSchema.optional().describe('Departure day'), end_day_id: idSchema.optional().describe('Arrival day (if different from departure)'), reservation_time: z.string().optional().describe('ISO 8601 datetime or time string for departure'), @@ -778,7 +785,7 @@ export class ReservationsMcp { async updateTransport( { tripId, reservationId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, url, notes, metadata, endpoints, legs, needs_review }: { tripId: number; reservationId: number; type?: TransportType; title?: string; - status?: 'pending' | 'confirmed' | 'cancelled'; start_day_id?: number; end_day_id?: number; + status?: ReservationStatus; start_day_id?: number; end_day_id?: number; reservation_time?: string; reservation_end_time?: string; confirmation_number?: string; url?: string; notes?: string; metadata?: Record; endpoints?: TransportEndpoint[]; legs?: TransportLegInput[]; needs_review?: boolean; }, diff --git a/server/src/nest/share/share-view.helpers.ts b/server/src/nest/share/share-view.helpers.ts index 1b5b0cc54..d7c22a5f9 100644 --- a/server/src/nest/share/share-view.helpers.ts +++ b/server/src/nest/share/share-view.helpers.ts @@ -1,13 +1,12 @@ +import { TRAVEL_RESERVATION_TYPES } from '@trek/shared'; + /** * The two narrowing options of a public share link (#1712), as pure functions over * the snapshot the service has already read, so they can be tested apart from SQL. */ -/** Getting there and sleeping there: every transport type, and the hotel. */ -const TRAVEL_BOOKING_TYPES = new Set([ - 'flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transit', 'transport_other', - 'hotel', -]); +/** Getting there and sleeping there: every transport type, and the hotel (the shared catalog's travel set). */ +const TRAVEL_BOOKING_TYPES: ReadonlySet = new Set(TRAVEL_RESERVATION_TYPES); export function isTravelBooking(type: unknown): boolean { return typeof type === 'string' && TRAVEL_BOOKING_TYPES.has(type); diff --git a/shared/CLAUDE.md b/shared/CLAUDE.md index 727b1928b..86501f60e 100644 --- a/shared/CLAUDE.md +++ b/shared/CLAUDE.md @@ -68,3 +68,7 @@ The parity rule governs **existing** routes; it is not a license to mint new deb ## Permissions (`src/permissions/permissions.ts`) `PERMISSION_ACTIONS` is the catalog of configurable trip permissions (key, default level, the levels an admin may pick), `PermissionKey` the union of its keys and `evaluatePermission(level, { isAdmin, isOwner, isMember })` the one decision rule. The server's `PermissionsService` and `@RequirePermission(action: PermissionKey)` are built on them, so a new action starts here and a misspelt key fails to compile. The client still evaluates in its own `useCanDo` (with "not configured = allow"); moving it onto `evaluatePermission` is client work. + +## Reservation types (`src/reservation/reservation-types.ts`) + +`RESERVATION_TYPES` is the catalog of reservation types in picker order, each with its flags (`isTransport`, `isCarrier`, `isRental`, `isStay`, `hasLegs`, `creatableViaMcp`). The named subsets (`TRANSPORT_RESERVATION_TYPES`, `MCP_CREATABLE_TRANSPORT_TYPES`, `BOOKING_RESERVATION_TYPES`, `CARRIER_RESERVATION_TYPES`, `TRAVEL_RESERVATION_TYPES`, …) are derived from it in that order, so an MCP enum built from one lists its values as the UI does. A new type is added here once; the spec pins every subset. The column stays free text: `reservationTypeSchema` and `reservationStatusSchema` accept the catalog values or any stored string, and the request schemas stay as tolerant as before. Inside `reservationSchema` the two fields are strings described by the catalog rather than the union, because Zod 4 types a union field as optional for a consumer compiled without strictNullChecks (the client), which would loosen `Reservation`. The client's own type lists (`TRANSPORT_TYPES` and the `cable_car` lists) have not moved onto it yet. diff --git a/shared/src/index.ts b/shared/src/index.ts index 874693380..25359ce53 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -32,6 +32,7 @@ export * from './todo/todo.schema'; export * from './budget/budget.schema'; export * from './budget/receipt-scan.schema'; export * from './reservation/reservation.schema'; +export * from './reservation/reservation-types'; export * from './reservation/ki-reservation.schema'; export * from './datetime/datetime-normalize'; export * from './airtrail/airtrail.schema'; diff --git a/shared/src/reservation/reservation-types.spec.ts b/shared/src/reservation/reservation-types.spec.ts new file mode 100644 index 000000000..50ee784d3 --- /dev/null +++ b/shared/src/reservation/reservation-types.spec.ts @@ -0,0 +1,110 @@ +import { CARRIER_TYPES, isCarrierType } from '../roadtrip/carriers'; +import { + BOOKING_RESERVATION_TYPES, + CARRIER_RESERVATION_TYPES, + LEG_RESERVATION_TYPES, + MCP_CREATABLE_TRANSPORT_TYPES, + RENTAL_RESERVATION_TYPES, + RESERVATION_STATUSES, + RESERVATION_TYPE_KEYS, + ROADTRIP_RESERVATION_TYPES, + TRANSPORT_RESERVATION_TYPES, + TRAVEL_RESERVATION_TYPES, + isTransportReservationType, + reservationTypeInfo, +} from './reservation-types'; +import { reservationSchema, reservationStatusSchema, reservationTypeSchema } from './reservation.schema'; + +import { describe, expect, it } from 'vitest'; + +// The subsets pin the hand-written lists they replaced, value for value and in +// order: the MCP tool enums are built from them and list their values as here. +describe('reservation type catalog', () => { + it('lists every type once', () => { + expect(new Set(RESERVATION_TYPE_KEYS).size).toBe(RESERVATION_TYPE_KEYS.length); + }); + + it('keeps the transport set of the update_transport gate', () => { + expect(TRANSPORT_RESERVATION_TYPES).toEqual([ + 'flight', + 'train', + 'bus', + 'car', + 'taxi', + 'bicycle', + 'cruise', + 'ferry', + 'cable_car', + 'transit', + 'transport_other', + ]); + }); + + it('keeps the transport picker an assistant may ask for, without transit', () => { + expect(MCP_CREATABLE_TRANSPORT_TYPES).toEqual([ + 'flight', + 'train', + 'bus', + 'car', + 'taxi', + 'bicycle', + 'cruise', + 'ferry', + 'cable_car', + 'transport_other', + ]); + }); + + it('keeps the booking picker', () => { + expect(BOOKING_RESERVATION_TYPES).toEqual(['hotel', 'restaurant', 'event', 'tour', 'activity', 'parking', 'other']); + }); + + it('gives legs to flights and trains only', () => { + expect(LEG_RESERVATION_TYPES).toEqual(['flight', 'train']); + }); + + it('calls the same rides carriers as the road trip did, without the hire car', () => { + expect([...CARRIER_RESERVATION_TYPES].sort()).toEqual(['bus', 'cruise', 'ferry', 'flight', 'train']); + expect(CARRIER_TYPES).toBe(CARRIER_RESERVATION_TYPES); + expect(isCarrierType('car')).toBe(false); + expect(RENTAL_RESERVATION_TYPES).toEqual(['car']); + expect([...ROADTRIP_RESERVATION_TYPES].sort()).toEqual(['bus', 'car', 'cruise', 'ferry', 'flight', 'train']); + }); + + it('counts every transport and the hotel as travel', () => { + expect(TRAVEL_RESERVATION_TYPES).toEqual([...TRANSPORT_RESERVATION_TYPES, 'hotel']); + }); + + it('looks a stored type up, and knows nothing about a value outside the catalog', () => { + expect(reservationTypeInfo('ferry')).toMatchObject({ isTransport: true, isCarrier: true, creatableViaMcp: true }); + expect(reservationTypeInfo('transit')).toMatchObject({ + isTransport: true, + isCarrier: false, + creatableViaMcp: false, + }); + expect(reservationTypeInfo('spaceship')).toBeUndefined(); + expect(reservationTypeInfo(null)).toBeUndefined(); + expect(isTransportReservationType('cable_car')).toBe(true); + expect(isTransportReservationType('hotel')).toBe(false); + expect(isTransportReservationType(undefined)).toBe(false); + }); + + it('keeps the three statuses', () => { + expect(RESERVATION_STATUSES).toEqual(['pending', 'confirmed', 'cancelled']); + }); +}); + +describe('reservation response type and status', () => { + it('accepts the catalog values and passes stored free text through', () => { + expect(reservationTypeSchema.parse('flight')).toBe('flight'); + expect(reservationTypeSchema.parse('legacy_kind')).toBe('legacy_kind'); + expect(reservationStatusSchema.parse('confirmed')).toBe('confirmed'); + expect(reservationStatusSchema.parse('waitlisted')).toBe('waitlisted'); + expect(reservationTypeSchema.safeParse(3).success).toBe(false); + }); + + it('still parses a row whose type is outside the catalog', () => { + const row = { id: 1, trip_id: 2, title: 'Old row', status: 'pending', type: 'balloon' }; + expect(reservationSchema.parse(row)).toMatchObject({ type: 'balloon' }); + }); +}); diff --git a/shared/src/reservation/reservation-types.ts b/shared/src/reservation/reservation-types.ts new file mode 100644 index 000000000..f4181caa0 --- /dev/null +++ b/shared/src/reservation/reservation-types.ts @@ -0,0 +1,137 @@ +/** + * The reservation types TREK knows, with what each one is. One table instead of + * the type lists the server and the client used to keep side by side (the MCP + * transport gate, the share view's "travel only" filter, the day plan's + * carriers), so a new type is added once and every named subset follows. + * + * The order is the transport form's picker, then the booking form's; every + * subset below keeps it, so an enum built from one lists its values in the + * order the UI offers them. + * + * The `type` column itself stays free text: rows written by older versions or + * by an import may hold a value outside this table, and the API returns them + * as they are (`reservationTypeSchema` accepts any string). + */ +export interface ReservationTypeInfo { + readonly type: string; + /** Getting from A to B; rendered with a transport icon and edited through the transport tools. */ + readonly isTransport: boolean; + /** + * A ride the traveller takes and leaves behind (the day plan's carrier, the + * road trip's seam). A hire car, a taxi or a transit hop is not one. + */ + readonly isCarrier: boolean; + /** A hire car: driven by the traveller, its desks are stops on the drive. */ + readonly isRental: boolean; + /** Where the trip sleeps. */ + readonly isStay: boolean; + /** Carries per-segment detail (metadata.legs). */ + readonly hasLegs: boolean; + /** + * An assistant may create one through the MCP tools. `transit` is not: its + * provider itinerary is written by create_transit_journey alone. + */ + readonly creatableViaMcp: boolean; +} + +const transport = { + isTransport: true, + isCarrier: false, + isRental: false, + isStay: false, + hasLegs: false, + creatableViaMcp: true, +} as const; +const booking = { + isTransport: false, + isCarrier: false, + isRental: false, + isStay: false, + hasLegs: false, + creatableViaMcp: true, +} as const; + +export const RESERVATION_TYPES = [ + { type: 'flight', ...transport, isCarrier: true, hasLegs: true }, + { type: 'train', ...transport, isCarrier: true, hasLegs: true }, + { type: 'bus', ...transport, isCarrier: true }, + { type: 'car', ...transport, isRental: true }, + { type: 'taxi', ...transport }, + { type: 'bicycle', ...transport }, + { type: 'cruise', ...transport, isCarrier: true }, + { type: 'ferry', ...transport, isCarrier: true }, + { type: 'cable_car', ...transport }, + { type: 'transit', ...transport, creatableViaMcp: false }, + { type: 'transport_other', ...transport }, + { type: 'hotel', ...booking, isStay: true }, + { type: 'restaurant', ...booking }, + { type: 'event', ...booking }, + { type: 'tour', ...booking }, + { type: 'activity', ...booking }, + { type: 'parking', ...booking }, + { type: 'other', ...booking }, +] as const satisfies readonly ReservationTypeInfo[]; + +type Entry = (typeof RESERVATION_TYPES)[number]; + +/** A reservation type from the catalog. */ +export type ReservationType = Entry['type']; + +type Flag = Exclude; + +/** The catalog's types whose flags match all of `match`, in catalog order. */ +function typesWhere>>(match: M): readonly Extract['type'][] { + const flags = Object.entries(match) as [Flag, boolean][]; + return RESERVATION_TYPES.filter((e) => flags.every(([flag, value]) => e[flag] === value)).map( + (e) => e.type, + ) as Extract['type'][]; +} + +/** Every type in the catalog, in picker order. */ +export const RESERVATION_TYPE_KEYS: readonly ReservationType[] = RESERVATION_TYPES.map((e) => e.type); + +/** Every transport type, `transit` included. */ +export const TRANSPORT_RESERVATION_TYPES = typesWhere({ isTransport: true }); + +/** The transport types an assistant may ask for: the transport picker, without `transit`. */ +export const MCP_CREATABLE_TRANSPORT_TYPES = typesWhere({ isTransport: true, creatableViaMcp: true }); + +/** The non-transport types an assistant may ask for: what create_reservation offers. */ +export const BOOKING_RESERVATION_TYPES = typesWhere({ isTransport: false, creatableViaMcp: true }); + +/** The transport types that carry per-segment legs. */ +export const LEG_RESERVATION_TYPES = typesWhere({ hasLegs: true }); + +/** The rides the traveller leaves behind (the day plan's carriers, the road trip's seams). */ +export const CARRIER_RESERVATION_TYPES = typesWhere({ isCarrier: true }); + +/** The rental types (a hire car). */ +export const RENTAL_RESERVATION_TYPES = typesWhere({ isRental: true }); + +/** What a road trip places on its route: the carriers it breaks at, and the hire cars whose desks are stops. */ +export const ROADTRIP_RESERVATION_TYPES: readonly ReservationType[] = [ + ...CARRIER_RESERVATION_TYPES, + ...RENTAL_RESERVATION_TYPES, +]; + +/** Getting there and sleeping there: every transport type, then the stays. */ +export const TRAVEL_RESERVATION_TYPES: readonly ReservationType[] = [ + ...TRANSPORT_RESERVATION_TYPES, + ...typesWhere({ isStay: true }), +]; + +const BY_TYPE: ReadonlyMap = new Map(RESERVATION_TYPES.map((e) => [e.type, e])); + +/** The catalog entry for a stored type, or undefined for a value outside the catalog. */ +export function reservationTypeInfo(type: string | null | undefined): ReservationTypeInfo | undefined { + return type == null ? undefined : BY_TYPE.get(type); +} + +/** Whether a stored type is a transport booking. False for anything outside the catalog. */ +export function isTransportReservationType(type: string | null | undefined): boolean { + return reservationTypeInfo(type)?.isTransport === true; +} + +/** The statuses a reservation can be in. A row without one reads as `pending`. */ +export const RESERVATION_STATUSES = ['pending', 'confirmed', 'cancelled'] as const; +export type ReservationStatus = (typeof RESERVATION_STATUSES)[number]; diff --git a/shared/src/reservation/reservation.schema.ts b/shared/src/reservation/reservation.schema.ts index b2349fdef..73fb136aa 100644 --- a/shared/src/reservation/reservation.schema.ts +++ b/shared/src/reservation/reservation.schema.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { RESERVATION_STATUSES, RESERVATION_TYPE_KEYS } from './reservation-types'; /** * Reservation + accommodation API contract — single source of truth for the @@ -15,6 +16,23 @@ import { z } from 'zod'; const open = z.record(z.string(), z.unknown()); +/** + * A reservation's `type` as the API returns it: one of the catalog's types + * (`RESERVATION_TYPES`), or whatever free text an older row or an import + * stored, which is passed through as it is. + */ +export const reservationTypeSchema = z.enum(RESERVATION_TYPE_KEYS).or(z.string()); + +/** A reservation's `status` as the API returns it: a known status, or a stored value outside them. */ +export const reservationStatusSchema = z.enum(RESERVATION_STATUSES).or(z.string()); + +// Inside reservationSchema the two fields stay z.string(), described by the +// catalog. Zod 4 types a union field as optional for a consumer compiled +// without strictNullChecks (the client), so the union there would loosen the +// inferred Reservation type; the runtime answer is the same, any string. +const reservationTypeField = z.string().describe('One of RESERVATION_TYPES, or free text an older row stored'); +const reservationStatusField = z.string().describe('One of RESERVATION_STATUSES, or a value an older row stored'); + /** * A reservation endpoint (flight/train leg terminal) — row of the * reservation_endpoints table (server/src/services/reservationService.ts). @@ -148,8 +166,8 @@ export const reservationSchema = z.object({ confirmation_number: z.string().nullable().optional(), notes: z.string().nullable().optional(), url: z.string().nullable().optional(), - status: z.string(), - type: z.string(), + status: reservationStatusField, + type: reservationTypeField, accommodation_id: z.union([z.number(), z.string()]).nullable().optional(), metadata: z.string().nullable().optional(), needs_review: z.number().optional(), diff --git a/shared/src/roadtrip/carriers.ts b/shared/src/roadtrip/carriers.ts index 5547e24ff..e6ad8640a 100644 --- a/shared/src/roadtrip/carriers.ts +++ b/shared/src/roadtrip/carriers.ts @@ -1,4 +1,5 @@ import { chronoOrder } from '../day/chrono-order'; +import { CARRIER_RESERVATION_TYPES } from '../reservation/reservation-types'; import { haversineKm, type LatLng } from './corridor'; import type { CarrierTerminal, RoadtripStop, RoutedLeg } from './planning-types'; import { formatClock, formatDurationShort, parseClock } from './roadtripModel'; @@ -12,8 +13,10 @@ import { formatClock, formatDurationShort, parseClock } from './roadtripModel'; * On the road trip such a booking is a seam: the road ends at the terminal it leaves * from and starts again at the one it lands at. Routing straight across it drew a * two-thousand-kilometre drive where a flight was booked (#2428). + * + * The set is the reservation catalog's `isCarrier` types (`reservation-types.ts`). */ -export const CARRIER_TYPES = ['flight', 'train', 'ferry', 'cruise', 'bus'] as const; +export const CARRIER_TYPES = CARRIER_RESERVATION_TYPES; export function isCarrierType(type: string | null | undefined): boolean { return !!type && (CARRIER_TYPES as readonly string[]).includes(type);