/** * Booking Admin TypeScript Types * * Based on: Booking Admin API Contract v1.1 * Date: 2025-11-07 * Phase: Phase 3 (Booking Management) */ // ============================================================================ // Core Booking Types // ============================================================================ export interface BookingDetail { booking_id: number; status: BookingStatus; slot: SlotInfo; provider: ProviderInfo; booked_by: UserInfo; attendees: Attendee[]; policies: BookingPolicies; created_at: string; // ISO8601 updated_at: string; // ISO8601 etag: string; cancelled_reason?: string; } export type BookingStatus = 'confirmed' | 'cancelled' | 'no_show'; export interface SlotInfo { slot_instance_id: number; starts_at: string; // ISO8601 ends_at: string; // ISO8601 capacity: number; booked_count: number; capacity_remaining: number; status: SlotStatus; court: CourtInfo; } export type SlotStatus = 'open' | 'booked' | 'held' | 'cancelled'; export interface CourtInfo { court_id: number; name: string; facility_id: number; facility_name: string; } export interface ProviderInfo { type: 'local' | 'fairplay'; manages_slot_storage: boolean; } export interface UserInfo { app_user_id: number; email: string; display_name: string; } export interface Attendee { position: number; // 1-based type: AttendeeType; app_user_id?: number; email?: string; display_name?: string; remote_member_id?: number; } export type AttendeeType = 'app_user' | 'guest' | 'remote_member'; export interface BookingPolicies { cancel_grace_minutes: number; cancel_grace_anchor: 'start' | 'end'; move_window_days: number; } // ============================================================================ // Request/Response Types // ============================================================================ /** * Cancel Booking Request */ export interface CancelBookingRequest { status: 'cancelled'; reason?: string; // max 500 chars notify_players?: boolean; // default: true prev_updated_at?: string; // ISO8601 (fallback for If-Match) } export interface CancelBookingResponse { booking_id: number; status: 'cancelled'; slot: SlotInfo; cancelled_reason?: string; updated_at: string; // ISO8601 etag: string; } /** * Move Booking Request */ export interface MoveBookingRequest { new_slot_instance_id: number; reason?: string; // max 500 chars notify_players?: boolean; // default: true dry_run?: boolean; // default: false prev_updated_at?: string; // ISO8601 (fallback for If-Match) } export interface MoveBookingResponse { booking_id: number; status: 'confirmed'; old_slot: SlotInfo; new_slot: SlotInfo; move_reason?: string; updated_at: string; // ISO8601 etag: string; } export interface MoveDryRunResponse { ok: boolean; reasons?: string[]; // error codes if ok=false } /** * Update Attendees Request */ export interface UpdateAttendeesRequest { attendees: AttendeeInput[]; reason?: string; // max 500 chars notify_players?: boolean; // default: true prev_updated_at?: string; // ISO8601 (fallback for If-Match) } export interface AttendeeInput { position: number; // 1-based type: AttendeeType; app_user_id?: number; remote_member_id?: number; display_name?: string; } export interface UpdateAttendeesResponse { booking_id: number; status: 'confirmed'; attendees: Attendee[]; slot: SlotInfo; updated_at: string; // ISO8601 etag: string; } // ============================================================================ // Error Types (RFC-7807) // ============================================================================ export interface BookingAdminError { type: string; title: string; status: number; detail: string; code: BookingErrorCode; errors?: ValidationError[]; meta?: Record; } export interface ValidationError { field: string; message: string; } 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' | 'slot_in_past' | 'exceeds_capacity' | 'past_slot_locked' | 'remote_provider_managed' | 'capacity_conflict' | 'booking_already_exists' | 'duplicate_attendee' | 'precondition_failed' | 'precondition_required' | 'idempotency_conflict' | 'move_window_exceeded' | 'internal_error'; // ============================================================================ // API Result Wrapper // ============================================================================ export type BookingApiResult = | { success: true; data: T; etag?: string } | { success: false; error: BookingAdminError }; // ============================================================================ // User-Friendly Error Messages // ============================================================================ export const ERROR_MESSAGES: Record = { remote_provider_managed: { title: 'Edits are disabled for this club.', hint: "This club is managed by an external provider; changes must be made in the provider's system.", }, capacity_conflict: { title: 'Someone just took that spot.', hint: "The slot's capacity changed while you were editing. Pick another slot and try again.", }, booking_already_exists: { title: 'This slot already has a booking.', hint: 'Choose a different time or court.', }, slot_different_sport: { title: 'Wrong sport for this booking.', hint: "Select a slot that matches the booking's sport.", }, slot_different_club: { title: 'Different club.', hint: 'You can only move bookings within the same club.', }, slot_wrong_status: { title: "That slot isn't open.", hint: 'Choose an open slot.', }, slot_full: { title: 'That slot is full.', hint: 'Select a different time.', }, past_slot_locked: { title: "This booking can't be changed anymore.", hint: "It's beyond the allowed time window.", }, precondition_failed: { title: 'This booking changed in the meantime.', hint: 'Refresh and try again.', }, precondition_required: { title: 'Outdated data.', hint: 'Please retry from the latest booking details.', }, validation_error: { title: 'Please fix the highlighted fields.', hint: '', }, not_admin_for_club: { title: 'No admin access.', hint: "You don't have permission for this club.", }, booking_already_cancelled: { title: 'This booking is already cancelled.', hint: '', }, invalid_status_transition: { title: 'Cannot change booking status.', hint: '', }, slot_in_past: { title: 'That slot already started.', hint: 'Select a future time.', }, exceeds_capacity: { title: 'Too many attendees.', hint: 'Reduce the number of attendees to fit the slot capacity.', }, duplicate_attendee: { title: 'Duplicate attendee.', hint: 'Each person can only be added once.', }, idempotency_conflict: { title: 'Request conflict.', hint: 'Please retry your action.', }, move_window_exceeded: { title: 'Move window exceeded.', hint: 'The target slot is too far from the original booking date.', }, not_found: { title: 'Not found.', hint: 'The booking or slot you requested does not exist.', }, internal_error: { title: 'Something went wrong.', hint: 'Please try again later.', }, };