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