/** * Booking Admin Types * * Types for booking management operations (cancel, move, attendees). * Based on Booking Admin API v1.1 contract. */ /** * Provider information for club/booking */ export interface ProviderInfo { type: 'local' | 'fairplay'; manages_slot_storage: boolean; } /** * Attendee in a booking */ export interface Attendee { position: number; // 1, 2, 3, 4... type: 'app_user' | 'guest' | 'remote_member'; app_user_id?: number; // if type=app_user remote_member_id?: number; // if type=remote_member email?: string; display_name: string; } /** * Slot information within booking */ export interface BookingSlot { slot_instance_id: number; starts_at: string; // ISO 8601 ends_at: string; // ISO 8601 capacity: number; booked_count: number; capacity_remaining: number; status: 'open' | 'held' | 'booked' | 'cancelled'; court: { court_id: number; name: string; club_id: number; club_name: string; }; } /** * User who created the booking */ export interface BookedBy { app_user_id: number; email: string; display_name: string; } /** * Complete booking details */ export interface BookingDetail { booking_id: number; status: 'confirmed' | 'cancelled' | 'no_show'; slot: BookingSlot; booked_by: BookedBy; attendees: Attendee[]; created_at: string; // ISO 8601 updated_at: string; // ISO 8601 provider?: ProviderInfo; etag?: string; // from ETag response header // Optional policy fields (if backend adds them) policies?: { cancel_grace_minutes?: number; cancel_grace_anchor?: 'start' | 'end'; move_window_days?: number; }; } /** * Request to cancel a booking */ export interface CancelBookingRequest { status: 'cancelled'; reason?: string; notify_players?: boolean; // default: true prev_updated_at?: string; // for optimistic concurrency (fallback to ETag) } /** * Response from cancel operation */ export interface CancelBookingResponse { booking_id: number; status: 'cancelled'; cancelled_reason?: string; updated_at: string; } /** * Request to move a booking to a different slot */ export interface MoveBookingRequest { new_slot_instance_id: number; reason?: string; notify_players?: boolean; // default: true prev_updated_at?: string; // for optimistic concurrency (fallback to ETag) // Note: dry_run is sent as query parameter ?dry_run=true, not in body } /** * Response from move operation */ export interface MoveBookingResponse { booking_id: number; status: 'confirmed'; old_slot: { slot_instance_id: number; starts_at: string; ends_at: string; }; new_slot: { slot_instance_id: number; starts_at: string; ends_at: string; court: { court_id: number; name: string; }; }; move_reason?: string; updated_at: string; } /** * Response from move dry-run validation */ export interface MoveDryRunResponse { ok: boolean; reasons?: string[]; // error codes if validation failed } /** * Request to update booking attendees */ export interface UpdateAttendeesRequest { attendees: Attendee[]; reason?: string; notify_players?: boolean; // default: true prev_updated_at?: string; // for optimistic concurrency (fallback to ETag) } /** * Response from attendees update operation */ export interface UpdateAttendeesResponse { booking_id: number; status: 'confirmed'; attendees: Attendee[]; updated_at: string; } /** * Booking-specific error codes */ export type BookingErrorCode = | 'validation_error' | 'not_found' | 'not_admin_for_club' | 'booking_already_cancelled' | 'invalid_status_transition' | 'slot_full' | 'slot_wrong_status' | 'slot_different_club' | 'slot_different_sport' // product-type mismatch (singles/doubles) | 'product_type_mismatch' // alternative naming | 'slot_in_past' | 'exceeds_capacity' | 'capacity_exceeded' // alternative naming | 'past_slot_locked' // beyond cancel grace period | 'remote_provider_managed' // FairPlay clubs (read-only) | 'capacity_conflict' // race condition (retry signal) | 'booking_already_exists' // unique constraint | 'duplicate_attendee' | 'invalid_attendee' // user not found | 'move_window_exceeded' // beyond move day limit | 'precondition_failed' // ETag mismatch (412) | 'precondition_required' // missing concurrency token | 'insufficient_capacity' // target slot full | 'internal_error'; /** * RFC-7807 Problem Details for booking errors */ export interface BookingError { type: string; title: string; status: number; detail: string; code: BookingErrorCode; errors?: Array<{ field: string; message: string; }>; // Extension fields for specific errors grace_minutes?: number; // for past_slot_locked limit_days?: number; // for move_window_exceeded } /** * API result wrapper for booking operations */ export type BookingApiResult = | { success: true; data: T; etag?: string } | { success: false; error: BookingError }; /** * User-friendly error messages for booking error codes */ export const BOOKING_ERROR_MESSAGES: Record string> = { remote_provider_managed: () => 'Edits are disabled for this club. This club is managed by an external provider; changes must be made in the provider\'s system.', capacity_conflict: () => 'Someone just took that spot. The slot\'s capacity changed while you were editing. Pick another slot and try again.', booking_already_exists: () => 'This slot already has a booking. Choose a different time or court.', slot_different_sport: () => 'Wrong format for this booking. Select a slot that matches the booking\'s format (singles/doubles).', product_type_mismatch: () => 'Wrong format for this booking. Select a slot that matches the booking\'s format (singles/doubles).', slot_different_club: () => 'Different club. You can only move bookings within the same club.', slot_wrong_status: () => 'That slot isn\'t open. Choose an open slot.', slot_full: () => 'That slot is full. Select a different time.', insufficient_capacity: () => 'That slot is full. Select a different time.', past_slot_locked: (error) => { const minutes = error?.grace_minutes || 15; return `Too late to change this booking. Changes allowed until ${minutes} minutes after start.`; }, move_window_exceeded: (error) => { const days = error?.limit_days || 14; return `Move not allowed that far out. You can move within the next ${days} days.`; }, precondition_failed: () => 'This booking changed while you were editing. We reloaded the latest details.', precondition_required: () => 'Outdated data. Please retry from the latest booking details.', validation_error: () => 'Please fix the highlighted fields.', not_admin_for_club: () => 'No admin access. You don\'t have permission for this club.', booking_already_cancelled: () => 'This booking is already cancelled.', invalid_status_transition: () => 'Invalid operation for this booking status.', slot_in_past: () => 'This slot is in the past and cannot be modified.', exceeds_capacity: () => 'Too many attendees for this slot\'s capacity.', capacity_exceeded: () => 'Too many attendees for this slot\'s capacity.', duplicate_attendee: () => 'Duplicate attendee detected.', invalid_attendee: () => 'One or more attendees could not be found.', not_found: () => 'Booking not found.', internal_error: () => 'An unexpected error occurred. Please try again.', }; /** * Get user-friendly error message from booking error */ export function getBookingErrorMessage(error: BookingError): string { const messageGetter = BOOKING_ERROR_MESSAGES[error.code]; if (messageGetter) { return messageGetter(error); } return error.detail || 'An error occurred'; }