feat: implement booking admin test infrastructure for 8-scenario E2E grid
continuous-integration/drone/push Build is passing
Details
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
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…
Reference in New Issue