You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

305 lines
7.7 KiB
TypeScript

/**
* 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<T> =
| { success: true; data: T; etag?: string }
| { success: false; error: BookingError };
/**
* User-friendly error messages for booking error codes
*/
export const BOOKING_ERROR_MESSAGES: Record<BookingErrorCode, (error?: BookingError) => 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';
}