diff --git a/frontend/src/lib/components/account/EditRequestModal.svelte b/frontend/src/lib/components/account/EditRequestModal.svelte index 20492f0..33cb05e 100644 --- a/frontend/src/lib/components/account/EditRequestModal.svelte +++ b/frontend/src/lib/components/account/EditRequestModal.svelte @@ -502,8 +502,16 @@ const endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(daysInEndMonth).padStart(2, '0')}`; const [whRes, ahRes] = await Promise.all([ - fetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`), - fetch(`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`) + fetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`, { + headers: authStore.currentToken + ? { Authorization: `Bearer ${authStore.currentToken}` } + : {} + }), + fetch(`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`, { + headers: authStore.currentToken + ? { Authorization: `Bearer ${authStore.currentToken}` } + : {} + }) ]); if (whRes.ok && ahRes.ok) { @@ -570,8 +578,16 @@ const endStr = endOfMonth.toString(); const [whRes, ahRes] = await Promise.all([ - fetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`), - fetch(`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`) + fetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`, { + headers: authStore.currentToken + ? { Authorization: `Bearer ${authStore.currentToken}` } + : {} + }), + fetch(`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`, { + headers: authStore.currentToken + ? { Authorization: `Bearer ${authStore.currentToken}` } + : {} + }) ]); if (whRes.ok && ahRes.ok) { diff --git a/frontend/src/lib/components/admin/WalkInBooking.svelte b/frontend/src/lib/components/admin/WalkInBooking.svelte index edb9921..c46d127 100644 --- a/frontend/src/lib/components/admin/WalkInBooking.svelte +++ b/frontend/src/lib/components/admin/WalkInBooking.svelte @@ -4,7 +4,7 @@ import { authStore } from '$lib/stores/auth.svelte'; import { CalendarDate } from '@internationalized/date'; import { SvelteDate } from 'svelte/reactivity'; - import { onMount } from 'svelte'; + import { onMount, onDestroy } from 'svelte'; import { toast } from 'svelte-sonner'; import type { AvailableHoursDay, Service } from '$lib/types/booking'; @@ -66,6 +66,43 @@ return () => clearInterval(interval); }); + // Release any held reservation if the component unmounts while a slot + // is still reserved (e.g. admin navigates away from the page). Best-effort + // — TTL cleanup will eventually run if this fails. + onDestroy(() => { + if (typeof window !== 'undefined' && window.__walkInCountdownInterval) { + clearInterval(window.__walkInCountdownInterval); + } + if (_reservationId) { + releaseWalkInReservation(); + } + }); + + async function releaseWalkInReservation() { + if (!_reservationId) return; + const idToRelease = _reservationId; + // Clear local state first so a slow DELETE doesn't block the UI. + _reservationId = null; + reservationExpiresAt = null; + _reservationCountdown = ''; + reservedDuration = 0; + reservedStartTime = null; + if (window.__walkInCountdownInterval) { + clearInterval(window.__walkInCountdownInterval); + } + try { + const res = await fetch('/api/admin/bookings/reserve', { + method: 'DELETE', + headers: { Authorization: `Bearer ${authStore.currentToken}` } + }); + if (!res.ok && res.status !== 404) { + console.warn('Failed to release walk-in reservation', idToRelease, res.status); + } + } catch (e) { + console.warn('Error releasing walk-in reservation', idToRelease, e); + } + } + function timeToMinutes(time: string): number { const parts = time.split(':').map(Number); return parts[0] * 60 + parts[1]; @@ -375,14 +412,7 @@ function handleModalClose() { showCreateModal = false; - _reservationId = null; - reservationExpiresAt = null; - _reservationCountdown = ''; - reservedDuration = 0; - reservedStartTime = null; - if (window.__walkInCountdownInterval) { - clearInterval(window.__walkInCountdownInterval); - } + releaseWalkInReservation(); } diff --git a/frontend/src/lib/components/booking/BookingFlow.svelte b/frontend/src/lib/components/booking/BookingFlow.svelte index 03edca9..a94f2da 100644 --- a/frontend/src/lib/components/booking/BookingFlow.svelte +++ b/frontend/src/lib/components/booking/BookingFlow.svelte @@ -40,6 +40,7 @@ import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte'; import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection'; import { formatLocalDateTime, getLondonTodayCalendarDate } from '$lib/utils/timeSlots'; + import { generateUUID } from '$lib/utils/uuid'; import type { Service, @@ -88,6 +89,11 @@ let selectedPaymentMethod = $state(null); let showNewCardForm = $state(false); let isProcessingPayment = $state(false); + // Synchronous double-click guard. Svelte 5 reactivity is async (effects run + // on the next microtask), so `isProcessingPayment` may not propagate to the + // button's `disabled` binding before a fast second click fires. This non- + // reactive flag is checked synchronously at the start of processPayment. + let isProcessingPaymentSync = false; // New card form fields let newCardNumber = $state(''); @@ -282,6 +288,11 @@ } async function processPayment(amount: number) { + // Synchronous double-click guard — set BEFORE any await so a rapid second + // click is rejected immediately, even before the reactive `disabled` has + // propagated to the button. + if (isProcessingPaymentSync) return; + isProcessingPaymentSync = true; isProcessingPayment = true; paymentAttempted = false; try { @@ -296,7 +307,7 @@ const body: Record = { payment_type: 'deposit', amount: amountCents, - idempotency_key: crypto.randomUUID?.() ?? Date.now().toString() + idempotency_key: generateUUID() }; if (selectedPaymentMethod) { @@ -321,9 +332,16 @@ if (response.ok) { depositPaid = true; - confirmedBooking.deposit_paid = true; - confirmedBooking.amount_paid = (confirmedBooking.amount_paid || 0) + amount; - confirmedBooking.amount_due = Math.max(0, (confirmedBooking.amount_due || 0) - amount); + // Immutable update — avoid mutating the existing object so + // concurrent renders (e.g. a stale fetch) can't observe partial + // state. (See audit: HIGH issue #3 — confirmedBooking mutated + // in place, potential overcharge on double-click race.) + confirmedBooking = { + ...confirmedBooking, + deposit_paid: true, + amount_paid: (confirmedBooking.amount_paid || 0) + amount, + amount_due: Math.max(0, (confirmedBooking.amount_due || 0) - amount) + }; toast.success('Payment successful!'); } else { const text = await response.text(); @@ -335,6 +353,7 @@ ); } finally { isProcessingPayment = false; + isProcessingPaymentSync = false; } } @@ -416,6 +435,34 @@ } } + // Best-effort release of the currently held reservation. Idempotent: safe + // to call when no reservation exists. Clears all reservation state so a + // subsequent reservation must be re-acquired. + async function releaseReservation() { + if (!_reservationId) return; + const idToRelease = _reservationId; + // Clear local state first so a slow DELETE doesn't block the UI. + _reservationId = null; + reservationExpiresAt = null; + reservationCountdown = ''; + reservationExpired = false; + _reservedSlotTime = null; + _reservedSlotDate = null; + if (window.__bookingFlowCountdownInterval) { + clearInterval(window.__bookingFlowCountdownInterval); + window.__bookingFlowCountdownInterval = null; + } + try { + const res = await fetch('/api/bookings/reserve', { + method: 'DELETE', + headers: authStore.currentToken ? { Authorization: `Bearer ${authStore.currentToken}` } : {} + }); + if (!res.ok) console.warn('Failed to release reservation', idToRelease, res.status); + } catch (e) { + console.warn('Error releasing reservation', idToRelease, e); + } + } + function startCountdown() { if (!reservationExpiresAt) return; @@ -1153,6 +1200,12 @@ selectedServices = [...selectedServices, service]; } + // Service change invalidates any held reservation (duration may have changed). + // Release before re-fetching hours so the next reservation starts fresh. + if (currentStep >= 2 && _reservationId) { + releaseReservation(); + } + // Only clear if we're on the date/time selection step if (currentStep === 2 && selectedDate) { const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`; @@ -1193,14 +1246,30 @@ // Select a time slot with server-side re-validation async function selectTimeWithValidation(time: string) { + // Race-condition guard: capture the user's click and pass it explicitly + // to the validator. If the user clicks a different time before this + // validation completes, the stale result must not clobber the newer + // selection. selectedTime = time; - await refreshAndValidateSlot(); + + // Time change invalidates any held reservation (slot is now stale). + // Only release if the new time differs from the reserved one — if the + // user is re-clicking the same reserved time, keep the reservation. + if (_reservationId && time !== _reservedSlotTime) { + await releaseReservation(); + } + + await refreshAndValidateSlot(time); } - // Re-fetch available hours silently and check if selectedTime is still available + // Re-fetch available hours silently and check if the requested time is still + // available. `validateForTime` should be the time the user clicked, NOT the + // current selectedTime — this prevents a slow validation for a stale click + // from clearing a newer selection. // Uses skipLoadingFlags=true to prevent UI judder (loading spinners hide DatePicker/TimeSlotPicker) - async function refreshAndValidateSlot() { - if (!selectedDate || !selectedTime) return; + async function refreshAndValidateSlot(validateForTime: string | null = null) { + const timeToCheck = validateForTime ?? selectedTime; + if (!selectedDate || !timeToCheck) return false; const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`; delete availableHoursCache[monthKey]; @@ -1210,12 +1279,12 @@ const dayAvailable = availableHours?.[dateStr]?.slots; if (!dayAvailable || dayAvailable.length === 0) { toast.error('Sorry, this slot is no longer available. Please choose a different time.'); - selectedTime = null; + if (selectedTime === timeToCheck) selectedTime = null; return false; } const duration = getTotalDuration(); - const [selHour, selMinute] = selectedTime.split(':').map(Number); + const [selHour, selMinute] = timeToCheck.split(':').map(Number); const selStart = selHour * 60 + selMinute; const selEnd = selStart + duration; @@ -1227,7 +1296,7 @@ if (!stillAvailable) { toast.error('Sorry, this slot was just taken. Please choose a different time.'); - selectedTime = null; + if (selectedTime === timeToCheck) selectedTime = null; return false; } @@ -1317,29 +1386,7 @@ currentStep = 3; } else { // Different slot or no reservation — release old one first, then reserve new - if (_reservationId) { - try { - const res = await fetch('/api/bookings/reserve', { - method: 'DELETE', - headers: authStore.currentToken - ? { Authorization: `Bearer ${authStore.currentToken}` } - : {} - }); - if (!res.ok) console.warn('Failed to release old reservation', res.status); - } catch (e) { - console.warn('Error releasing old reservation', e); - } - _reservationId = null; - reservationExpiresAt = null; - reservationCountdown = ''; - reservationExpired = false; - _reservedSlotTime = null; - _reservedSlotDate = null; - if (window.__bookingFlowCountdownInterval) { - clearInterval(window.__bookingFlowCountdownInterval); - window.__bookingFlowCountdownInterval = null; - } - } + await releaseReservation(); const slotStillFree = await refreshAndValidateSlot(); if (!slotStillFree) return; @@ -1379,24 +1426,12 @@ async function submitAndProceed() { _isSubmitting = true; try { - if (!idempotencyKey) { - // Generate UUID v4 manually for environments where crypto.randomUUID() is unavailable - const array = new Uint8Array(16); - if (typeof window !== 'undefined' && window.crypto) { - window.crypto.getRandomValues(array); - } else { - for (let i = 0; i < 16; i++) array[i] = Math.floor(Math.random() * 256); - } - array[6] = (array[6] & 0x0f) | 0x40; // version 4 - array[8] = (array[8] & 0x3f) | 0x80; // variant 1 - idempotencyKey = [...array] - .map((b, i) => { - const hex = b.toString(16).padStart(2, '0'); - if (i === 4 || i === 6 || i === 8 || i === 10) return '-' + hex; - return hex; - }) - .join(''); - } + // Always generate a fresh idempotency key per submission attempt. The + // previous behavior kept the key across the component lifetime, which + // meant a second submission (after a success, back-nav, change) would + // hit the backend with the same key and get the ORIGINAL booking back, + // making the user believe they made a new booking when they didn't. + idempotencyKey = generateUUID(); if (!selectedDate || !selectedTime) { toast.error('Please select a date and time'); @@ -1479,6 +1514,12 @@ total_amount: booking.total_amount || getTotalPrice(), duration_minutes: booking.duration_minutes || getTotalDuration() }; + // Booking is now persisted — release the temporary reservation + // (best-effort, no UI block). Also clear the idempotency key so + // any future submission in this component lifetime gets a fresh + // key and is treated as a NEW booking attempt. + releaseReservation(); + idempotencyKey = ''; currentStep = finalStep; fetchDiscountPreview(); setTimeout(() => { @@ -1531,6 +1572,12 @@ function prevStep() { if (currentStep > 1) { + // If leaving step 2 (date/time) and a reservation is still held, release + // it — the user is abandoning this selection. Going from step 3 back to + // step 2 keeps the reservation (the user is just reviewing). + if (currentStep === 2 && _reservationId) { + releaseReservation(); + } currentStep--; setTimeout(() => { window.scrollTo({ top: 0, behavior: 'smooth' }); @@ -1764,6 +1811,10 @@ maxValue={maxCalendarDate} {isDateUnavailable} onchange={(newDate) => { + // Date change invalidates any held reservation (slot is now stale). + if (_reservationId) { + releaseReservation(); + } selectedDate = newDate; selectedTime = null; }}