feat(bookings): add TypeScript types and API client for Booking Admin v1.1
continuous-integration/drone/push Build is passing
Details
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
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…
Reference in New Issue