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
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';
|
|
}
|