Files
TREK/shared/src/reservation/reservation.schema.ts
T
Maurice d6ed7a60a0 feat(bookings): add a dedicated URL field to reservations (#935)
Bookings get a first-class url column (migration) instead of users pasting links into notes. It's editable in the booking modal and rendered as a clickable link on the reservation card. The reservation request schemas are open passthroughs, so only the entity schema + service SQL enumerate it.
2026-06-30 15:03:57 +02:00

221 lines
9.3 KiB
TypeScript

import { z } from 'zod';
/**
* Reservation + accommodation API contract — single source of truth for the
* /api/trips/:tripId/reservations and /api/trips/:tripId/accommodations endpoints.
*
* Trip-scoped. Reservations use the 'reservation_edit' permission; accommodations
* use 'day_edit' (they live in the day/accommodation service). The legacy routes
* (server/src/routes/reservations.ts + the accommodations sub-router in
* routes/days.ts) carry several side effects — auto-creating/updating/deleting a
* linked budget item, accommodation broadcasts and booking notifications — which
* the Nest service reproduces 1:1. Reservation bodies are wide and provider-ish,
* so the create/update payloads stay mostly open with `title` pinned.
*/
const open = z.record(z.string(), z.unknown());
/**
* A reservation endpoint (flight/train leg terminal) — row of the
* reservation_endpoints table (server/src/services/reservationService.ts).
*/
export const reservationEndpointSchema = z.object({
id: z.number().optional(),
reservation_id: z.number().optional(),
role: z.enum(['from', 'to', 'stop']),
sequence: z.number(),
name: z.string(),
code: z.string().nullable(),
lat: z.number(),
lng: z.number(),
timezone: z.string().nullable(),
local_time: z.string().nullable(),
local_date: z.string().nullable(),
});
export type ReservationEndpoint = z.infer<typeof reservationEndpointSchema>;
/**
* Reservation entity as returned by the reservation list endpoint
* (server/src/services/reservationService.ts -> listReservations). Columns of
* the `reservations` table plus the joined day_number / place_name / linked
* accommodation fields and the computed `day_positions` + `endpoints`.
* `accommodation_id` is stored as TEXT in the DB.
*/
export const reservationSchema = z.object({
id: z.number(),
trip_id: z.number(),
day_id: z.number().nullable().optional(),
end_day_id: z.number().nullable().optional(),
place_id: z.number().nullable().optional(),
assignment_id: z.number().nullable().optional(),
title: z.string(),
reservation_time: z.string().nullable().optional(),
reservation_end_time: z.string().nullable().optional(),
location: z.string().nullable().optional(),
confirmation_number: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
url: z.string().nullable().optional(),
status: z.string(),
type: z.string(),
accommodation_id: z.union([z.number(), z.string()]).nullable().optional(),
metadata: z.string().nullable().optional(),
needs_review: z.number().optional(),
day_plan_position: z.number().nullable().optional(),
created_at: z.string().optional(),
// AirTrail (or future provider) linkage — drives the "synced" badge (#214).
external_source: z.string().nullable().optional(),
external_id: z.string().nullable().optional(),
external_owner_user_id: z.number().nullable().optional(),
external_synced_at: z.string().nullable().optional(),
sync_enabled: z.number().nullable().optional(),
// joined / computed in listReservations
day_number: z.number().nullable().optional(),
place_name: z.string().nullable().optional(),
accommodation_place_id: z.number().nullable().optional(),
accommodation_name: z.string().nullable().optional(),
accommodation_start_day_id: z.number().nullable().optional(),
accommodation_end_day_id: z.number().nullable().optional(),
day_positions: z.record(z.string(), z.number()).nullable().optional(),
endpoints: z.array(reservationEndpointSchema).optional(),
});
export type Reservation = z.infer<typeof reservationSchema>;
/**
* Accommodation entity as returned by listAccommodations / getAccommodationWithPlace
* (server/src/services/dayService.ts). Columns of the day_accommodations table
* plus the joined place fields and (on list) the linked reservation_title.
*/
export const accommodationSchema = z.object({
id: z.number(),
trip_id: z.number(),
place_id: z.number().nullable().optional(),
start_day_id: z.number(),
end_day_id: z.number(),
check_in: z.string().nullable().optional(),
check_in_end: z.string().nullable().optional(),
check_out: z.string().nullable().optional(),
confirmation: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
created_at: z.string().optional(),
// joined in listAccommodations / getAccommodationWithPlace
place_name: z.string().nullable().optional(),
place_address: z.string().nullable().optional(),
place_image: z.string().nullable().optional(),
place_lat: z.number().nullable().optional(),
place_lng: z.number().nullable().optional(),
reservation_title: z.string().nullable().optional(),
});
export type Accommodation = z.infer<typeof accommodationSchema>;
/** Reservation create: title is required; the many optional fields stay open. */
export const reservationCreateRequestSchema = open.and(z.object({ title: z.string().min(1) }));
export type ReservationCreateRequest = z.infer<typeof reservationCreateRequestSchema>;
export const reservationUpdateRequestSchema = open;
export type ReservationUpdateRequest = z.infer<typeof reservationUpdateRequestSchema>;
export const reservationPositionsRequestSchema = z.object({
positions: z.array(z.object({ id: z.number(), day_plan_position: z.number() })),
day_id: z.union([z.number(), z.string()]).nullable().optional(),
});
export type ReservationPositionsRequest = z.infer<typeof reservationPositionsRequestSchema>;
export const accommodationCreateRequestSchema = z.object({
place_id: z.union([z.number(), z.string()]),
start_day_id: z.union([z.number(), z.string()]),
end_day_id: z.union([z.number(), z.string()]),
check_in: z.string().nullable().optional(),
check_in_end: z.string().nullable().optional(),
check_out: z.string().nullable().optional(),
confirmation: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
});
export type AccommodationCreateRequest = z.infer<typeof accommodationCreateRequestSchema>;
export const accommodationUpdateRequestSchema = open;
export type AccommodationUpdateRequest = z.infer<typeof accommodationUpdateRequestSchema>;
// ---------------------------------------------------------------------------
// Booking import (KItinerary)
// ---------------------------------------------------------------------------
const bookingImportEndpointSchema = z.object({
role: z.enum(['from', 'to', 'stop']),
sequence: z.number(),
name: z.string(),
code: z.string().nullable(),
// Nullable: the mapper emits named endpoints without coords; confirm() geocodes
// them, and only the coord'd ones are persisted.
lat: z.number().nullable(),
lng: z.number().nullable(),
timezone: z.string().nullable(),
local_time: z.string().nullable(),
local_date: z.string().nullable(),
});
const bookingImportVenueSchema = z.object({
name: z.string(),
lat: z.number().optional(),
lng: z.number().optional(),
address: z.string().optional(),
website: z.string().optional(),
phone: z.string().optional(),
});
const bookingImportAccommodationSchema = z.object({
check_in: z.string().optional(),
check_out: z.string().optional(),
confirmation: z.string().optional(),
});
export const bookingImportPreviewItemSchema = z.object({
type: z.string(),
title: z.string().min(1),
reservation_time: z.string().nullable().optional(),
reservation_end_time: z.string().nullable().optional(),
confirmation_number: z.string().nullable().optional(),
location: z.string().nullable().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
endpoints: z.array(bookingImportEndpointSchema).optional(),
needs_review: z.boolean().optional(),
_venue: bookingImportVenueSchema.optional(),
_accommodation: bookingImportAccommodationSchema.optional(),
source: z.object({ fileName: z.string(), index: z.number() }),
});
export type BookingImportPreviewItem = z.infer<typeof bookingImportPreviewItemSchema>;
/**
* How the preview endpoint should treat the LLM fallback:
* - `no-ai` — kitinerary only (default; existing behaviour)
* - `fallback-on-empty` — run the LLM for files kitinerary returns nothing for
* - `force-ai` — run the LLM on every submitted file (skip kitinerary)
*/
export const bookingImportModeSchema = z.enum(['no-ai', 'fallback-on-empty', 'force-ai']);
export type BookingImportMode = z.infer<typeof bookingImportModeSchema>;
/** Per-file AI report so the preview UI can offer "Try AI parsing" only where it applies. */
export const bookingImportFileReportSchema = z.object({
fileName: z.string(),
aiAvailable: z.boolean(),
aiUsed: z.boolean(),
});
export type BookingImportFileReport = z.infer<typeof bookingImportFileReportSchema>;
export const bookingImportPreviewResponseSchema = z.object({
items: z.array(bookingImportPreviewItemSchema),
warnings: z.array(z.string()),
// Optional so existing/no-AI responses stay byte-compatible.
files: z.array(bookingImportFileReportSchema).optional(),
});
export type BookingImportPreviewResponse = z.infer<typeof bookingImportPreviewResponseSchema>;
export const bookingImportConfirmRequestSchema = z.object({
items: z.array(bookingImportPreviewItemSchema).min(1),
});
export type BookingImportConfirmRequest = z.infer<typeof bookingImportConfirmRequestSchema>;
export const bookingImportConfirmResponseSchema = z.object({
created: z.array(reservationSchema),
});
export type BookingImportConfirmResponse = z.infer<typeof bookingImportConfirmResponseSchema>;