mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
refactor(reservations): one reservation type catalog in shared feeds the server's type lists
RESERVATION_TYPES lists every type with its flags (transport, carrier, rental, stay, legs, creatable through MCP), and the named subsets derive from it in picker order. The MCP transport and booking enums, the share view's travel filter, the road trip carrier query and the shared CARRIER_TYPES now read it. The reservation response types type and status as the known values or any stored string, so rows outside the catalog still pass; requests stay as tolerant as before.
This commit is contained in:
@@ -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<Reservations> {
|
||||
* 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<RoadtripCarrierRow[]> {
|
||||
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<RoadtripCarrierRow[]>('all', false);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string>; 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<string, string>; endpoints?: TransportEndpoint[]; legs?: TransportLegInput[]; needs_review?: boolean;
|
||||
},
|
||||
|
||||
@@ -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<string> = new Set(TRAVEL_RESERVATION_TYPES);
|
||||
|
||||
export function isTravelBooking(type: unknown): boolean {
|
||||
return typeof type === 'string' && TRAVEL_BOOKING_TYPES.has(type);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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<keyof ReservationTypeInfo, 'type'>;
|
||||
|
||||
/** The catalog's types whose flags match all of `match`, in catalog order. */
|
||||
function typesWhere<const M extends Partial<Record<Flag, boolean>>>(match: M): readonly Extract<Entry, M>['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<Entry, M>['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<string, ReservationTypeInfo> = 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];
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user