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.

293 lines
7.5 KiB
TypeScript

/**
* Booking Admin TypeScript Types
*
* Based on: Booking Admin API Contract v1.1
* Date: 2025-11-07
* Phase: Phase 3 (Booking Management)
*/
// ============================================================================
// Core Booking Types
// ============================================================================
export interface BookingDetail {
booking_id: number;
status: BookingStatus;
slot: SlotInfo;
provider: ProviderInfo;
booked_by: UserInfo;
attendees: Attendee[];
policies: BookingPolicies;
created_at: string; // ISO8601
updated_at: string; // ISO8601
etag: string;
cancelled_reason?: string;
}
export type BookingStatus = 'confirmed' | 'cancelled' | 'no_show';
export interface SlotInfo {
slot_instance_id: number;
starts_at: string; // ISO8601
ends_at: string; // ISO8601
capacity: number;
booked_count: number;
capacity_remaining: number;
status: SlotStatus;
court: CourtInfo;
}
export type SlotStatus = 'open' | 'booked' | 'held' | 'cancelled';
export interface CourtInfo {
court_id: number;
name: string;
facility_id: number;
facility_name: string;
}
export interface ProviderInfo {
type: 'local' | 'fairplay';
manages_slot_storage: boolean;
}
export interface UserInfo {
app_user_id: number;
email: string;
display_name: string;
}
export interface Attendee {
position: number; // 1-based
type: AttendeeType;
app_user_id?: number;
email?: string;
display_name?: string;
remote_member_id?: number;
}
export type AttendeeType = 'app_user' | 'guest' | 'remote_member';
export interface BookingPolicies {
cancel_grace_minutes: number;
cancel_grace_anchor: 'start' | 'end';
move_window_days: number;
}
// ============================================================================
// Request/Response Types
// ============================================================================
/**
* Cancel Booking Request
*/
export interface CancelBookingRequest {
status: 'cancelled';
reason?: string; // max 500 chars
notify_players?: boolean; // default: true
prev_updated_at?: string; // ISO8601 (fallback for If-Match)
}
export interface CancelBookingResponse {
booking_id: number;
status: 'cancelled';
slot: SlotInfo;
cancelled_reason?: string;
updated_at: string; // ISO8601
etag: string;
}
/**
* Move Booking Request
*/
export interface MoveBookingRequest {
new_slot_instance_id: number;
reason?: string; // max 500 chars
notify_players?: boolean; // default: true
dry_run?: boolean; // default: false
prev_updated_at?: string; // ISO8601 (fallback for If-Match)
}
export interface MoveBookingResponse {
booking_id: number;
status: 'confirmed';
old_slot: SlotInfo;
new_slot: SlotInfo;
move_reason?: string;
updated_at: string; // ISO8601
etag: string;
}
export interface MoveDryRunResponse {
ok: boolean;
reasons?: string[]; // error codes if ok=false
}
/**
* Update Attendees Request
*/
export interface UpdateAttendeesRequest {
attendees: AttendeeInput[];
reason?: string; // max 500 chars
notify_players?: boolean; // default: true
prev_updated_at?: string; // ISO8601 (fallback for If-Match)
}
export interface AttendeeInput {
position: number; // 1-based
type: AttendeeType;
app_user_id?: number;
remote_member_id?: number;
display_name?: string;
}
export interface UpdateAttendeesResponse {
booking_id: number;
status: 'confirmed';
attendees: Attendee[];
slot: SlotInfo;
updated_at: string; // ISO8601
etag: string;
}
// ============================================================================
// Error Types (RFC-7807)
// ============================================================================
export interface BookingAdminError {
type: string;
title: string;
status: number;
detail: string;
code: BookingErrorCode;
errors?: ValidationError[];
meta?: Record<string, any>;
}
export interface ValidationError {
field: string;
message: string;
}
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'
| 'slot_in_past'
| 'exceeds_capacity'
| 'past_slot_locked'
| 'remote_provider_managed'
| 'capacity_conflict'
| 'booking_already_exists'
| 'duplicate_attendee'
| 'precondition_failed'
| 'precondition_required'
| 'idempotency_conflict'
| 'move_window_exceeded'
| 'internal_error';
// ============================================================================
// API Result Wrapper
// ============================================================================
export type BookingApiResult<T> =
| { success: true; data: T; etag?: string }
| { success: false; error: BookingAdminError };
// ============================================================================
// User-Friendly Error Messages
// ============================================================================
export const ERROR_MESSAGES: Record<BookingErrorCode, { title: string; hint: string }> = {
remote_provider_managed: {
title: 'Edits are disabled for this club.',
hint: "This club is managed by an external provider; changes must be made in the provider's system.",
},
capacity_conflict: {
title: 'Someone just took that spot.',
hint: "The slot's capacity changed while you were editing. Pick another slot and try again.",
},
booking_already_exists: {
title: 'This slot already has a booking.',
hint: 'Choose a different time or court.',
},
slot_different_sport: {
title: 'Wrong sport for this booking.',
hint: "Select a slot that matches the booking's sport.",
},
slot_different_club: {
title: 'Different club.',
hint: 'You can only move bookings within the same club.',
},
slot_wrong_status: {
title: "That slot isn't open.",
hint: 'Choose an open slot.',
},
slot_full: {
title: 'That slot is full.',
hint: 'Select a different time.',
},
past_slot_locked: {
title: "This booking can't be changed anymore.",
hint: "It's beyond the allowed time window.",
},
precondition_failed: {
title: 'This booking changed in the meantime.',
hint: 'Refresh and try again.',
},
precondition_required: {
title: 'Outdated data.',
hint: 'Please retry from the latest booking details.',
},
validation_error: {
title: 'Please fix the highlighted fields.',
hint: '',
},
not_admin_for_club: {
title: 'No admin access.',
hint: "You don't have permission for this club.",
},
booking_already_cancelled: {
title: 'This booking is already cancelled.',
hint: '',
},
invalid_status_transition: {
title: 'Cannot change booking status.',
hint: '',
},
slot_in_past: {
title: 'That slot already started.',
hint: 'Select a future time.',
},
exceeds_capacity: {
title: 'Too many attendees.',
hint: 'Reduce the number of attendees to fit the slot capacity.',
},
duplicate_attendee: {
title: 'Duplicate attendee.',
hint: 'Each person can only be added once.',
},
idempotency_conflict: {
title: 'Request conflict.',
hint: 'Please retry your action.',
},
move_window_exceeded: {
title: 'Move window exceeded.',
hint: 'The target slot is too far from the original booking date.',
},
not_found: {
title: 'Not found.',
hint: 'The booking or slot you requested does not exist.',
},
internal_error: {
title: 'Something went wrong.',
hint: 'Please try again later.',
},
};