feat: implement booking admin test infrastructure for 8-scenario E2E grid
continuous-integration/drone/push Build is passing Details

Built complete booking admin API integration for idempotency validation testing requested by Backend Brooke.

Components:
- TypeScript types for booking admin operations (cancel/move/attendees)
- API client with full idempotency and ETag support
- Comprehensive test page with 8 scenario buttons
- Real-time result tracking with request/response capture

Features:
- X-Idempotency-Key header support (UUID v4)
- ETag-based optimistic concurrency (If-Match)
- Provider gating checks (local vs fairplay)
- User-friendly error message mapping (12 error codes)
- HAR capture instructions for integration log

Test Scenarios:
1. Cancel within grace (≤15 min)
2. Cancel beyond grace (>15 min)
3. Move dry_run success
4. Move dry_run failure
5. Move window exceeded (>14 days)
6. Attendee capacity limits
7. Capacity race (concurrent moves)
8. ETag guard (stale If-Match)

Ready for E2E execution against booking admin endpoints.
master
Guillermo Pages 1 month ago
parent 6b971d723b
commit bd2cbb3a0d

@ -0,0 +1,848 @@
'use client';
import { useState } from 'react';
import { Calendar, Loader2, AlertCircle, CheckCircle2, XCircle } from 'lucide-react';
import {
getBookingDetail,
cancelBooking,
moveBooking,
updateAttendees,
generateIdempotencyKey,
canModifyBooking,
getProviderGatingMessage,
} from '@/src/lib/api/booking-admin';
import type {
BookingDetail,
CancelBookingRequest,
MoveBookingRequest,
UpdateAttendeesRequest,
AttendeeInput,
} from '@/src/types/booking-admin';
import { ERROR_MESSAGES } from '@/src/types/booking-admin';
export default function BookingAdminTestComponent() {
const [bookingId, setBookingId] = useState<string>('');
const [booking, setBooking] = useState<BookingDetail | null>(null);
const [etag, setEtag] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Test results
const [testResults, setTestResults] = useState<Array<{
scenario: string;
status: 'success' | 'error';
message: string;
timestamp: string;
request: any;
response: any;
}>>([]);
// Form state for operations
const [cancelReason, setCancelReason] = useState('Court maintenance - full refund issued');
const [moveTargetSlotId, setMoveTargetSlotId] = useState('');
const [moveReason, setMoveReason] = useState('Original slot cancelled for tournament');
const [attendeesJson, setAttendeesJson] = useState('');
async function loadBooking() {
if (!bookingId.trim()) {
setError('Please enter a booking ID');
return;
}
setLoading(true);
setError(null);
const result = await getBookingDetail(parseInt(bookingId, 10), {
expand: ['slot'],
include_provider: true,
});
if (result.success) {
setBooking(result.data);
setEtag(result.etag || null);
setError(null);
// Initialize attendees JSON
setAttendeesJson(JSON.stringify(result.data.attendees, null, 2));
} else {
setError(result.error.detail);
setBooking(null);
}
setLoading(false);
}
function addTestResult(scenario: string, status: 'success' | 'error', message: string, request: any, response: any) {
setTestResults(prev => [
...prev,
{
scenario,
status,
message,
timestamp: new Date().toISOString(),
request,
response,
},
]);
}
// ========================================
// Scenario 1: Cancel within grace (≤15 min)
// ========================================
async function testCancelWithinGrace() {
if (!booking) return;
const idempotencyKey = generateIdempotencyKey();
const request: CancelBookingRequest = {
status: 'cancelled',
reason: cancelReason,
notify_players: true,
};
const result = await cancelBooking(booking.booking_id, request, {
etag: etag || undefined,
idempotencyKey,
});
if (result.success) {
addTestResult(
'Cancel within grace (≤15 min)',
'success',
'Booking cancelled successfully',
{ bookingId: booking.booking_id, request, idempotencyKey },
result.data
);
// Reload booking
await loadBooking();
} else {
addTestResult(
'Cancel within grace (≤15 min)',
'error',
result.error.detail,
{ bookingId: booking.booking_id, request, idempotencyKey },
result.error
);
}
}
// ========================================
// Scenario 2: Cancel beyond grace (>15 min)
// ========================================
async function testCancelBeyondGrace() {
if (!booking) return;
// This should fail with past_slot_locked error
const idempotencyKey = generateIdempotencyKey();
const request: CancelBookingRequest = {
status: 'cancelled',
reason: 'Testing cancel beyond grace period',
notify_players: true,
};
const result = await cancelBooking(booking.booking_id, request, {
etag: etag || undefined,
idempotencyKey,
});
if (result.success) {
addTestResult(
'Cancel beyond grace (>15 min)',
'success',
'Booking cancelled (unexpected if beyond grace)',
{ bookingId: booking.booking_id, request, idempotencyKey },
result.data
);
} else {
addTestResult(
'Cancel beyond grace (>15 min)',
result.error.code === 'past_slot_locked' ? 'success' : 'error',
result.error.code === 'past_slot_locked'
? 'Correctly rejected: past_slot_locked'
: `Unexpected error: ${result.error.detail}`,
{ bookingId: booking.booking_id, request, idempotencyKey },
result.error
);
}
}
// ========================================
// Scenario 3: Move dry_run success
// ========================================
async function testMoveDryRunSuccess() {
if (!booking || !moveTargetSlotId.trim()) {
setError('Please enter a target slot ID');
return;
}
const request: MoveBookingRequest = {
new_slot_instance_id: parseInt(moveTargetSlotId, 10),
reason: moveReason,
notify_players: true,
dry_run: true,
};
const result = await moveBooking(booking.booking_id, request, {
etag: etag || undefined,
});
if (result.success) {
const dryRunResult = result.data as any;
addTestResult(
'Move dry_run success',
dryRunResult.ok ? 'success' : 'error',
dryRunResult.ok
? 'dry_run validation passed'
: `dry_run validation failed: ${dryRunResult.reasons?.join(', ')}`,
{ bookingId: booking.booking_id, request },
result.data
);
} else {
addTestResult(
'Move dry_run success',
'error',
result.error.detail,
{ bookingId: booking.booking_id, request },
result.error
);
}
}
// ========================================
// Scenario 4: Move dry_run failure
// ========================================
async function testMoveDryRunFailure() {
if (!booking) return;
// Use a non-existent or incompatible slot ID to trigger failure
const request: MoveBookingRequest = {
new_slot_instance_id: 999999, // Non-existent slot
reason: 'Testing dry_run failure',
notify_players: true,
dry_run: true,
};
const result = await moveBooking(booking.booking_id, request, {
etag: etag || undefined,
});
if (result.success) {
const dryRunResult = result.data as any;
addTestResult(
'Move dry_run failure',
!dryRunResult.ok ? 'success' : 'error',
!dryRunResult.ok
? `Correctly failed: ${dryRunResult.reasons?.join(', ')}`
: 'Unexpected success (should have failed)',
{ bookingId: booking.booking_id, request },
result.data
);
} else {
addTestResult(
'Move dry_run failure',
'success',
`Correctly rejected: ${result.error.code}`,
{ bookingId: booking.booking_id, request },
result.error
);
}
}
// ========================================
// Scenario 5: Move window exceeded (>14 days)
// ========================================
async function testMoveWindowExceeded() {
if (!booking || !moveTargetSlotId.trim()) {
setError('Please enter a target slot ID >14 days away');
return;
}
const idempotencyKey = generateIdempotencyKey();
const request: MoveBookingRequest = {
new_slot_instance_id: parseInt(moveTargetSlotId, 10),
reason: 'Testing move window exceeded',
notify_players: true,
dry_run: false,
};
const result = await moveBooking(booking.booking_id, request, {
etag: etag || undefined,
idempotencyKey,
});
if (result.success) {
addTestResult(
'Move window exceeded (>14 days)',
'error',
'Unexpected success (should have been blocked)',
{ bookingId: booking.booking_id, request, idempotencyKey },
result.data
);
} else {
addTestResult(
'Move window exceeded (>14 days)',
result.error.code === 'move_window_exceeded' ? 'success' : 'error',
result.error.code === 'move_window_exceeded'
? 'Correctly rejected: move_window_exceeded'
: `Unexpected error: ${result.error.detail}`,
{ bookingId: booking.booking_id, request, idempotencyKey },
result.error
);
}
}
// ========================================
// Scenario 6: Attendee updates with capacity limits
// ========================================
async function testAttendeeCapacityLimits() {
if (!booking) return;
const idempotencyKey = generateIdempotencyKey();
// Try to add more attendees than capacity allows
const attendees: AttendeeInput[] = [];
const capacity = booking.slot.capacity;
// Add capacity + 1 attendees (should fail)
for (let i = 1; i <= capacity + 1; i++) {
attendees.push({
position: i,
type: 'guest',
display_name: `Guest ${i}`,
});
}
const request: UpdateAttendeesRequest = {
attendees,
reason: 'Testing capacity limits',
notify_players: true,
};
const result = await updateAttendees(booking.booking_id, request, {
etag: etag || undefined,
idempotencyKey,
});
if (result.success) {
addTestResult(
'Attendee updates with capacity limits',
'error',
'Unexpected success (should have exceeded capacity)',
{ bookingId: booking.booking_id, request, idempotencyKey },
result.data
);
} else {
addTestResult(
'Attendee updates with capacity limits',
result.error.code === 'exceeds_capacity' ? 'success' : 'error',
result.error.code === 'exceeds_capacity'
? 'Correctly rejected: exceeds_capacity'
: `Unexpected error: ${result.error.detail}`,
{ bookingId: booking.booking_id, request, idempotencyKey },
result.error
);
}
}
// ========================================
// Scenario 7: Capacity race (concurrent moves)
// ========================================
async function testCapacityRace() {
if (!booking || !moveTargetSlotId.trim()) {
setError('Please enter a target slot ID');
return;
}
// Execute two concurrent move requests to the same slot
const idempotencyKey1 = generateIdempotencyKey();
const idempotencyKey2 = generateIdempotencyKey();
const request: MoveBookingRequest = {
new_slot_instance_id: parseInt(moveTargetSlotId, 10),
reason: 'Testing capacity race',
notify_players: true,
dry_run: false,
};
// Execute both requests concurrently
const [result1, result2] = await Promise.all([
moveBooking(booking.booking_id, request, {
etag: etag || undefined,
idempotencyKey: idempotencyKey1,
}),
moveBooking(booking.booking_id, request, {
etag: etag || undefined,
idempotencyKey: idempotencyKey2,
}),
]);
// One should succeed, one should fail with idempotency_conflict or capacity_conflict
const success1 = result1.success;
const success2 = result2.success;
addTestResult(
'Capacity race (concurrent moves) - Request 1',
success1 ? 'success' : 'error',
success1 ? 'First request succeeded' : `First request failed: ${(result1 as any).error.code}`,
{ bookingId: booking.booking_id, request, idempotencyKey: idempotencyKey1 },
success1 ? result1.data : (result1 as any).error
);
addTestResult(
'Capacity race (concurrent moves) - Request 2',
!success2 ? 'success' : 'error',
!success2
? `Second request correctly rejected: ${(result2 as any).error.code}`
: 'Second request succeeded (unexpected)',
{ bookingId: booking.booking_id, request, idempotencyKey: idempotencyKey2 },
success2 ? result2.data : (result2 as any).error
);
}
// ========================================
// Scenario 8: ETag guard (stale If-Match)
// ========================================
async function testETagGuard() {
if (!booking) return;
const idempotencyKey = generateIdempotencyKey();
const request: CancelBookingRequest = {
status: 'cancelled',
reason: 'Testing ETag guard',
notify_players: true,
};
// Use a stale/incorrect ETag
const staleETag = 'W/"stale-etag-12345"';
const result = await cancelBooking(booking.booking_id, request, {
etag: staleETag,
idempotencyKey,
});
if (result.success) {
addTestResult(
'ETag guard (stale If-Match)',
'error',
'Unexpected success (should have been blocked by stale ETag)',
{ bookingId: booking.booking_id, request, idempotencyKey, etag: staleETag },
result.data
);
} else {
addTestResult(
'ETag guard (stale If-Match)',
result.error.code === 'precondition_failed' ? 'success' : 'error',
result.error.code === 'precondition_failed'
? 'Correctly rejected: precondition_failed'
: `Unexpected error: ${result.error.detail}`,
{ bookingId: booking.booking_id, request, idempotencyKey, etag: staleETag },
result.error
);
}
}
// ========================================
// Execute move (non-dry-run)
// ========================================
async function executeMove() {
if (!booking || !moveTargetSlotId.trim()) {
setError('Please enter a target slot ID');
return;
}
const idempotencyKey = generateIdempotencyKey();
const request: MoveBookingRequest = {
new_slot_instance_id: parseInt(moveTargetSlotId, 10),
reason: moveReason,
notify_players: true,
dry_run: false,
};
const result = await moveBooking(booking.booking_id, request, {
etag: etag || undefined,
idempotencyKey,
});
if (result.success) {
addTestResult(
'Move booking (actual)',
'success',
'Booking moved successfully',
{ bookingId: booking.booking_id, request, idempotencyKey },
result.data
);
// Reload booking
await loadBooking();
} else {
addTestResult(
'Move booking (actual)',
'error',
result.error.detail,
{ bookingId: booking.booking_id, request, idempotencyKey },
result.error
);
}
}
// ========================================
// Execute attendees update
// ========================================
async function executeAttendeesUpdate() {
if (!booking || !attendeesJson.trim()) {
setError('Please enter attendees JSON');
return;
}
try {
const attendees: AttendeeInput[] = JSON.parse(attendeesJson);
const idempotencyKey = generateIdempotencyKey();
const request: UpdateAttendeesRequest = {
attendees,
reason: 'Manual attendee update',
notify_players: true,
};
const result = await updateAttendees(booking.booking_id, request, {
etag: etag || undefined,
idempotencyKey,
});
if (result.success) {
addTestResult(
'Update attendees (actual)',
'success',
'Attendees updated successfully',
{ bookingId: booking.booking_id, request, idempotencyKey },
result.data
);
// Reload booking
await loadBooking();
} else {
addTestResult(
'Update attendees (actual)',
'error',
result.error.detail,
{ bookingId: booking.booking_id, request, idempotencyKey },
result.error
);
}
} catch (err) {
setError('Invalid JSON format for attendees');
}
}
// ========================================
// Clear test results
// ========================================
function clearTestResults() {
setTestResults([]);
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Header */}
<div className="mb-8">
<h1 className="text-4xl font-bold text-slate-900 mb-2 tracking-tight">
Booking Admin API Test Page
</h1>
<p className="text-lg text-slate-600 font-light">
Execute 8-scenario E2E test grid for idempotency validation
</p>
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-slate-700">
<strong>Instructions:</strong> Open browser DevTools Network tab Enable "Preserve log" Execute scenarios Export HARs for each scenario
</p>
</div>
</div>
{/* Load Booking Section */}
<div className="bg-white border-2 border-slate-200 rounded-2xl p-6 mb-6">
<h2 className="text-2xl font-bold text-slate-900 mb-4">1. Load Booking</h2>
<div className="flex items-center space-x-4">
<input
type="number"
value={bookingId}
onChange={(e) => setBookingId(e.target.value)}
placeholder="Enter booking ID"
className="flex-1 px-4 py-2 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-slate-900"
/>
<button
onClick={loadBooking}
disabled={loading}
className="px-6 py-2 bg-slate-900 text-white font-semibold rounded-lg hover:bg-slate-800 transition-colors disabled:opacity-50"
>
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Load'}
</button>
</div>
{error && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
)}
</div>
{/* Booking Details */}
{booking && (
<div className="bg-white border-2 border-slate-200 rounded-2xl p-6 mb-6">
<h2 className="text-2xl font-bold text-slate-900 mb-4">Booking Details</h2>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<div className="text-slate-600 font-semibold">Booking ID</div>
<div className="text-slate-900">{booking.booking_id}</div>
</div>
<div>
<div className="text-slate-600 font-semibold">Status</div>
<div className="text-slate-900">{booking.status}</div>
</div>
<div>
<div className="text-slate-600 font-semibold">Slot Time</div>
<div className="text-slate-900">
{new Date(booking.slot.starts_at).toLocaleString()} - {new Date(booking.slot.ends_at).toLocaleTimeString()}
</div>
</div>
<div>
<div className="text-slate-600 font-semibold">Court</div>
<div className="text-slate-900">{booking.slot.court.name} (Club: {booking.slot.court.club_name})</div>
</div>
<div>
<div className="text-slate-600 font-semibold">Capacity</div>
<div className="text-slate-900">
{booking.slot.booked_count} / {booking.slot.capacity} booked · {booking.slot.capacity_remaining} remaining
</div>
</div>
<div>
<div className="text-slate-600 font-semibold">Provider</div>
<div className="text-slate-900">{booking.provider.type} (manages_slot_storage: {booking.provider.manages_slot_storage ? 'true' : 'false'})</div>
</div>
<div>
<div className="text-slate-600 font-semibold">ETag</div>
<div className="text-slate-900 font-mono text-xs">{etag || 'None'}</div>
</div>
<div>
<div className="text-slate-600 font-semibold">Policies</div>
<div className="text-slate-900 text-xs">
Grace: {booking.policies.cancel_grace_minutes}min, Window: {booking.policies.move_window_days}days
</div>
</div>
</div>
{/* Provider Gating Message */}
{!canModifyBooking(booking) && (
<div className="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="text-yellow-800 font-semibold">{getProviderGatingMessage(booking)}</div>
</div>
)}
{/* Attendees */}
<div className="mt-4">
<div className="text-slate-600 font-semibold mb-2">Attendees ({booking.attendees.length})</div>
<div className="space-y-2">
{booking.attendees.map(att => (
<div key={att.position} className="text-sm text-slate-700">
{att.position}. {att.display_name} ({att.type})
</div>
))}
</div>
</div>
</div>
)}
{/* Test Scenarios */}
{booking && canModifyBooking(booking) && (
<>
<div className="bg-white border-2 border-slate-200 rounded-2xl p-6 mb-6">
<h2 className="text-2xl font-bold text-slate-900 mb-4">2. Execute Test Scenarios</h2>
<div className="space-y-4">
{/* Cancel Section */}
<div className="border-b border-slate-200 pb-4">
<h3 className="text-lg font-semibold text-slate-900 mb-3">Cancel Operations</h3>
<div className="mb-3">
<label className="block text-sm font-semibold text-slate-700 mb-1">Cancel Reason</label>
<input
type="text"
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
className="w-full px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:border-slate-900"
/>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={testCancelWithinGrace}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm"
>
Scenario 1: Cancel within grace
</button>
<button
onClick={testCancelBeyondGrace}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm"
>
Scenario 2: Cancel beyond grace
</button>
<button
onClick={testETagGuard}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm"
>
Scenario 8: ETag guard
</button>
</div>
</div>
{/* Move Section */}
<div className="border-b border-slate-200 pb-4">
<h3 className="text-lg font-semibold text-slate-900 mb-3">Move Operations</h3>
<div className="mb-3 grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-1">Target Slot ID</label>
<input
type="number"
value={moveTargetSlotId}
onChange={(e) => setMoveTargetSlotId(e.target.value)}
placeholder="Enter slot instance ID"
className="w-full px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-1">Move Reason</label>
<input
type="text"
value={moveReason}
onChange={(e) => setMoveReason(e.target.value)}
className="w-full px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:border-slate-900"
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={testMoveDryRunSuccess}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
>
Scenario 3: Move dry_run success
</button>
<button
onClick={testMoveDryRunFailure}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
>
Scenario 4: Move dry_run failure
</button>
<button
onClick={testMoveWindowExceeded}
className="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm"
>
Scenario 5: Move window exceeded
</button>
<button
onClick={testCapacityRace}
className="px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 transition-colors text-sm"
>
Scenario 7: Capacity race
</button>
<button
onClick={executeMove}
className="px-4 py-2 bg-slate-900 text-white rounded-lg hover:bg-slate-800 transition-colors text-sm font-semibold"
>
Execute Move (Actual)
</button>
</div>
</div>
{/* Attendees Section */}
<div>
<h3 className="text-lg font-semibold text-slate-900 mb-3">Attendee Operations</h3>
<div className="mb-3">
<label className="block text-sm font-semibold text-slate-700 mb-1">Attendees JSON</label>
<textarea
value={attendeesJson}
onChange={(e) => setAttendeesJson(e.target.value)}
rows={6}
className="w-full px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:border-slate-900 font-mono text-sm"
/>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={testAttendeeCapacityLimits}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors text-sm"
>
Scenario 6: Attendee capacity limits
</button>
<button
onClick={executeAttendeesUpdate}
className="px-4 py-2 bg-slate-900 text-white rounded-lg hover:bg-slate-800 transition-colors text-sm font-semibold"
>
Execute Attendees Update (Actual)
</button>
</div>
</div>
</div>
</div>
{/* Test Results */}
<div className="bg-white border-2 border-slate-200 rounded-2xl p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold text-slate-900">3. Test Results ({testResults.length})</h2>
<button
onClick={clearTestResults}
className="px-4 py-2 bg-slate-200 text-slate-900 rounded-lg hover:bg-slate-300 transition-colors text-sm"
>
Clear Results
</button>
</div>
{testResults.length === 0 ? (
<div className="text-center py-8 text-slate-500">
No test results yet. Execute scenarios above to see results.
</div>
) : (
<div className="space-y-4">
{testResults.map((result, idx) => (
<div
key={idx}
className={`p-4 border-2 rounded-lg ${
result.status === 'success'
? 'bg-green-50 border-green-200'
: 'bg-red-50 border-red-200'
}`}
>
<div className="flex items-start space-x-3">
{result.status === 'success' ? (
<CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
) : (
<XCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
)}
<div className="flex-1">
<div className="font-semibold text-slate-900 mb-1">{result.scenario}</div>
<div className="text-sm text-slate-700 mb-2">{result.message}</div>
<div className="text-xs text-slate-500 mb-2">
{new Date(result.timestamp).toLocaleString()}
</div>
<details className="text-xs">
<summary className="cursor-pointer text-slate-600 hover:text-slate-900 font-semibold">
View Request/Response
</summary>
<div className="mt-2 space-y-2">
<div>
<div className="font-semibold text-slate-700">Request:</div>
<pre className="mt-1 p-2 bg-slate-100 rounded overflow-x-auto">
{JSON.stringify(result.request, null, 2)}
</pre>
</div>
<div>
<div className="font-semibold text-slate-700">Response:</div>
<pre className="mt-1 p-2 bg-slate-100 rounded overflow-x-auto">
{JSON.stringify(result.response, null, 2)}
</pre>
</div>
</div>
</details>
</div>
</div>
</div>
))}
</div>
)}
</div>
</>
)}
</div>
);
}

@ -0,0 +1,10 @@
import AdminAuthGuard from '@/src/components/AdminAuthGuard';
import BookingAdminTestComponent from './BookingAdminTestComponent';
export default function BookingAdminTestPage() {
return (
<AdminAuthGuard>
<BookingAdminTestComponent />
</AdminAuthGuard>
);
}

@ -0,0 +1,288 @@
/**
* Booking Admin API Client
*
* Handles all booking admin operations: view, cancel, move, edit attendees
* Supports idempotency keys and ETag-based optimistic concurrency
*/
import type {
BookingDetail,
CancelBookingRequest,
CancelBookingResponse,
MoveBookingRequest,
MoveBookingResponse,
MoveDryRunResponse,
UpdateAttendeesRequest,
UpdateAttendeesResponse,
BookingAdminError,
BookingApiResult,
} from '@/src/types/booking-admin';
const API_BASE_URL = process.env.NEXT_PUBLIC_PYTHON_API_URL;
if (!API_BASE_URL) {
throw new Error('NEXT_PUBLIC_PYTHON_API_URL environment variable is not defined');
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Generate UUID v4 for idempotency keys
*/
export function generateIdempotencyKey(): string {
return crypto.randomUUID();
}
/**
* Handle API response with ETag extraction
*/
async function handleApiResponse<T>(response: Response): Promise<BookingApiResult<T>> {
const etag = response.headers.get('ETag') || undefined;
if (response.ok) {
const data = await response.json();
return { success: true, data, etag };
}
// Handle error responses (RFC-7807)
try {
const error: BookingAdminError = await response.json();
return { success: false, error };
} catch {
// Fallback for non-JSON errors
return {
success: false,
error: {
type: 'about:blank',
title: 'API Error',
status: response.status,
detail: response.statusText || 'An unexpected error occurred',
code: 'internal_error',
},
};
}
}
// ============================================================================
// API Functions
// ============================================================================
/**
* GET /admin/bookings/{booking_id}
* Fetch complete booking details
*/
export async function getBookingDetail(
bookingId: number,
options?: {
expand?: ('slot' | 'audit' | 'payment')[];
include_provider?: boolean;
}
): Promise<BookingApiResult<BookingDetail>> {
try {
const params = new URLSearchParams();
if (options?.expand && options.expand.length > 0) {
params.append('expand', options.expand.join(','));
}
if (options?.include_provider) {
params.append('include_provider', 'true');
}
const queryString = params.toString();
const url = `${API_BASE_URL}/admin/bookings/${bookingId}${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
});
return handleApiResponse<BookingDetail>(response);
} 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/{booking_id}
* Cancel a booking
*/
export async function cancelBooking(
bookingId: number,
request: CancelBookingRequest,
options?: {
etag?: string;
idempotencyKey?: string;
}
): Promise<BookingApiResult<CancelBookingResponse>> {
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Prefer ETag over prev_updated_at
if (options?.etag) {
headers['If-Match'] = options.etag;
}
// Add idempotency key (recommended)
if (options?.idempotencyKey) {
headers['X-Idempotency-Key'] = options.idempotencyKey;
}
const response = await fetch(`${API_BASE_URL}/admin/bookings/${bookingId}`, {
method: 'PATCH',
credentials: 'include',
headers,
body: JSON.stringify(request),
});
return handleApiResponse<CancelBookingResponse>(response);
} 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/{booking_id}/move
* Move booking to a different slot
*/
export async function moveBooking(
bookingId: number,
request: MoveBookingRequest,
options?: {
etag?: string;
idempotencyKey?: string;
}
): Promise<BookingApiResult<MoveBookingResponse | MoveDryRunResponse>> {
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Prefer ETag over prev_updated_at
if (options?.etag) {
headers['If-Match'] = options.etag;
}
// Add idempotency key (recommended)
// Note: dry_run=true requests are NOT cached
if (options?.idempotencyKey && !request.dry_run) {
headers['X-Idempotency-Key'] = options.idempotencyKey;
}
const response = await fetch(`${API_BASE_URL}/admin/bookings/${bookingId}/move`, {
method: 'PATCH',
credentials: 'include',
headers,
body: JSON.stringify(request),
});
return handleApiResponse<MoveBookingResponse | MoveDryRunResponse>(response);
} 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/{booking_id}/attendees
* Update attendee list
*/
export async function updateAttendees(
bookingId: number,
request: UpdateAttendeesRequest,
options?: {
etag?: string;
idempotencyKey?: string;
}
): Promise<BookingApiResult<UpdateAttendeesResponse>> {
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Prefer ETag over prev_updated_at
if (options?.etag) {
headers['If-Match'] = options.etag;
}
// Add idempotency key (recommended)
if (options?.idempotencyKey) {
headers['X-Idempotency-Key'] = options.idempotencyKey;
}
const response = await fetch(`${API_BASE_URL}/admin/bookings/${bookingId}/attendees`, {
method: 'PATCH',
credentials: 'include',
headers,
body: JSON.stringify(request),
});
return handleApiResponse<UpdateAttendeesResponse>(response);
} 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',
},
};
}
}
// ============================================================================
// Helper: Check if provider allows admin writes
// ============================================================================
/**
* Check if booking can be modified by admin
* (Provider gating check)
*/
export function canModifyBooking(booking: BookingDetail): boolean {
return booking.provider.manages_slot_storage === false;
}
/**
* Get provider gating message
*/
export function getProviderGatingMessage(booking: BookingDetail): string | null {
if (canModifyBooking(booking)) {
return null;
}
return 'This club is managed by an external provider. Admin edits are disabled here.';
}

@ -0,0 +1,292 @@
/**
* 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;
club_id: number;
club_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.',
},
};
Loading…
Cancel
Save