diff --git a/src/lib/api/bookings.ts b/src/lib/api/bookings.ts new file mode 100644 index 0000000..d5bbfa5 --- /dev/null +++ b/src/lib/api/bookings.ts @@ -0,0 +1,265 @@ +/** + * Booking Admin API Client + * + * API client for booking management operations (cancel, move, attendees). + * Supports ETag optimistic concurrency and idempotency headers. + */ + +import type { + BookingDetail, + CancelBookingRequest, + CancelBookingResponse, + MoveBookingRequest, + MoveBookingResponse, + MoveDryRunResponse, + UpdateAttendeesRequest, + UpdateAttendeesResponse, + BookingError, + BookingApiResult, +} from '@/src/types/bookings'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'https://api.playchoo.com'; + +/** + * Generate a unique idempotency key for request retries + */ +function generateIdempotencyKey(): string { + return crypto.randomUUID(); +} + +/** + * Extract ETag from response headers + */ +function extractETag(headers: Headers): string | undefined { + return headers.get('ETag') || undefined; +} + +/** + * GET /admin/bookings/{id} + * Fetch complete booking details + */ +export async function getBooking(bookingId: number): Promise> { + try { + const response = await fetch(`${API_BASE_URL}/admin/bookings/${bookingId}`, { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const error: BookingError = await response.json(); + return { success: false, error }; + } + + const data: BookingDetail = await response.json(); + const etag = extractETag(response.headers); + + return { + success: true, + data: { ...data, etag }, + etag, + }; + } catch (error) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Network Error', + status: 0, + detail: 'Failed to fetch booking details. Please check your connection.', + code: 'internal_error', + }, + }; + } +} + +/** + * PATCH /admin/bookings/{id}/cancel + * Cancel a booking with optional reason and notification control + */ +export async function cancelBooking( + bookingId: number, + request: Omit, + etag?: string +): Promise> { + try { + const headers: HeadersInit = { + 'Content-Type': 'application/json', + 'X-Idempotency-Key': generateIdempotencyKey(), + }; + + // Prefer ETag for optimistic concurrency + if (etag) { + headers['If-Match'] = etag; + } + + const response = await fetch(`${API_BASE_URL}/admin/bookings/${bookingId}/cancel`, { + method: 'PATCH', + credentials: 'include', + headers, + body: JSON.stringify({ + status: 'cancelled', + ...request, + }), + }); + + if (!response.ok) { + const error: BookingError = await response.json(); + return { success: false, error }; + } + + const data: CancelBookingResponse = await response.json(); + return { success: true, data }; + } catch (error) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Network Error', + status: 0, + detail: 'Failed to cancel booking. Please check your connection.', + code: 'internal_error', + }, + }; + } +} + +/** + * PATCH /admin/bookings/{id}/move?dry_run=true + * Validate move operation without committing (for slot picker) + */ +export async function validateMoveBooking( + bookingId: number, + request: Pick +): Promise> { + try { + const response = await fetch( + `${API_BASE_URL}/admin/bookings/${bookingId}/move?dry_run=true`, + { + method: 'PATCH', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + } + ); + + if (!response.ok) { + const error: BookingError = await response.json(); + return { success: false, error }; + } + + const data: MoveDryRunResponse = await response.json(); + return { success: true, data }; + } catch (error) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Network Error', + status: 0, + detail: 'Failed to validate move. Please check your connection.', + code: 'internal_error', + }, + }; + } +} + +/** + * PATCH /admin/bookings/{id}/move + * Move booking to a different slot + */ +export async function moveBooking( + bookingId: number, + request: MoveBookingRequest, + etag?: string +): Promise> { + try { + const headers: HeadersInit = { + 'Content-Type': 'application/json', + 'X-Idempotency-Key': generateIdempotencyKey(), + }; + + // Prefer ETag for optimistic concurrency + if (etag) { + headers['If-Match'] = etag; + } + + const response = await fetch(`${API_BASE_URL}/admin/bookings/${bookingId}/move`, { + method: 'PATCH', + credentials: 'include', + headers, + body: JSON.stringify(request), + }); + + if (!response.ok) { + const error: BookingError = await response.json(); + return { success: false, error }; + } + + const data: MoveBookingResponse = await response.json(); + return { success: true, data }; + } catch (error) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Network Error', + status: 0, + detail: 'Failed to move booking. Please check your connection.', + code: 'internal_error', + }, + }; + } +} + +/** + * PATCH /admin/bookings/{id}/attendees + * Update booking attendee list + */ +export async function updateBookingAttendees( + bookingId: number, + request: UpdateAttendeesRequest, + etag?: string +): Promise> { + try { + const headers: HeadersInit = { + 'Content-Type': 'application/json', + 'X-Idempotency-Key': generateIdempotencyKey(), + }; + + // Prefer ETag for optimistic concurrency + if (etag) { + headers['If-Match'] = etag; + } + + const response = await fetch(`${API_BASE_URL}/admin/bookings/${bookingId}/attendees`, { + method: 'PATCH', + credentials: 'include', + headers, + body: JSON.stringify(request), + }); + + if (!response.ok) { + const error: BookingError = await response.json(); + return { success: false, error }; + } + + const data: UpdateAttendeesResponse = await response.json(); + return { success: true, data }; + } catch (error) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Network Error', + status: 0, + detail: 'Failed to update attendees. Please check your connection.', + code: 'internal_error', + }, + }; + } +} diff --git a/src/types/bookings.ts b/src/types/bookings.ts new file mode 100644 index 0000000..4ea71c0 --- /dev/null +++ b/src/types/bookings.ts @@ -0,0 +1,304 @@ +/** + * 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'; +}