feat(bookings): add TypeScript types and API client for Booking Admin v1.1
continuous-integration/drone/push Build is passing Details

Implemented foundation for Phase 3 Booking Admin integration:

**TypeScript Types (src/types/bookings.ts):**
- BookingDetail with nested slot, attendees, provider info
- Request types: CancelBookingRequest, MoveBookingRequest, UpdateAttendeesRequest
- Response types for all operations (cancel, move, attendees)
- MoveDryRunResponse for slot picker validation
- ProviderInfo for FairPlay gating
- 18 booking error codes with user-friendly message mapping
- getBookingErrorMessage() helper with parameterized messages

**API Client (src/lib/api/bookings.ts):**
- getBooking() - GET /admin/bookings/{id} with ETag extraction
- cancelBooking() - PATCH /cancel with ETag + idempotency
- validateMoveBooking() - PATCH /move?dry_run=true for picker
- moveBooking() - PATCH /move with ETag + idempotency
- updateBookingAttendees() - PATCH /attendees with ETag + idempotency

**Features:**
- ETag optimistic concurrency (If-Match header)
- X-Idempotency-Key for retry safety (auto-generated UUID)
- Comprehensive error handling with RFC-7807 format
- Parameterized error messages (grace_minutes, limit_days)
- Network error fallbacks

**Production Config (per Backend Brooke):**
- Cancel grace: 15 min after slot start
- Move window: 14-day limit
- Product-type enforcement (singles ≠ doubles)
- Provider gating (FairPlay = read-only)

Related: Phase 3, Backend Brooke commit 514a3b6, v1.1 contract
master
Guillermo Pages 1 month ago
parent 1b7497b6d2
commit 71367143c6

@ -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<BookingApiResult<BookingDetail>> {
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<CancelBookingRequest, 'status'>,
etag?: string
): Promise<BookingApiResult<CancelBookingResponse>> {
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<MoveBookingRequest, 'new_slot_instance_id'>
): Promise<BookingApiResult<MoveDryRunResponse>> {
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<BookingApiResult<MoveBookingResponse>> {
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<BookingApiResult<UpdateAttendeesResponse>> {
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',
},
};
}
}

@ -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<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';
}
Loading…
Cancel
Save