Files
oysqn.app/app/pages/reservations/create.vue
Patrick Toal 108c042921 fix(edge-fn): replace getClaims with adminClient.auth.getUser(token)
fix(edge-fn): use user.id instead of claims.sub; fixes 500s and false cert_required
fix(migrations): drop broad reservations SELECT policy; add reservation_slots view with security_invoker=false
fix(tests): correct weekSlot() keys from start/end to start_time/end_time
fix(tests): spread overlap test slots across separate ISO weeks
fix(tests): update e2e assertion to match actual authenticated home text
fix(app): hide IonMenu before user is authenticated
feat(dx): add test:all script running unit, integration, and e2e in sequence
docs(claude-md): document SELinux fix, Edge Function auth pattern, security_invoker behaviour
2026-04-20 14:32:37 -04:00

392 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<IonPage>
<IonHeader>
<IonToolbar color="primary">
<IonButtons slot="start">
<IonBackButton default-href="/" />
</IonButtons>
<IonTitle>New Reservation</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<!-- Step 1: Pick a date and select a slot -->
<template v-if="step === 1">
<h3 class="section-title">Select Date</h3>
<!-- Horizontal date strip -->
<div class="date-strip">
<button
v-for="d in dateOptions"
:key="d.iso"
class="date-chip"
:class="{ active: selectedDate === d.iso }"
@click="selectedDate = d.iso"
>
<span class="day-name">{{ d.dayName }}</span>
<span class="day-num">{{ d.dayNum }}</span>
<span class="month">{{ d.month }}</span>
</button>
</div>
<h3 class="section-title ion-margin-top">Available Slots</h3>
<div v-if="loadingSlots" class="ion-text-center ion-padding">
<IonSpinner name="crescent" />
</div>
<p v-else-if="availableByBoat.length === 0" class="empty-text">
No available slots for this date.
</p>
<template v-else>
<IonCard
v-for="entry in availableByBoat"
:key="entry.boat.id"
class="boat-card"
>
<IonCardHeader>
<IonCardTitle>{{ entry.boat.display_name || entry.boat.name }}</IonCardTitle>
<IonCardSubtitle v-if="entry.boat.class">{{ entry.boat.class }}</IonCardSubtitle>
</IonCardHeader>
<IonCardContent>
<div v-if="!entry.certified" class="cert-warning">
<IonIcon :icon="warningOutline" color="warning" />
<span>You are not certified for this boat.</span>
</div>
<div class="slot-chips">
<button
v-for="slot in entry.slots"
:key="slot.id"
class="slot-chip"
:disabled="!entry.certified"
@click="selectSlot(entry.boat, slot)"
>
{{ formatSlotTime(slot.start_time) }}{{ formatSlotTime(slot.end_time) }}
</button>
</div>
</IonCardContent>
</IonCard>
</template>
</template>
<!-- Step 2: Confirm details -->
<template v-else>
<IonCard>
<IonCardHeader>
<IonCardTitle>{{ selectedBoat?.display_name || selectedBoat?.name }}</IonCardTitle>
<IonCardSubtitle v-if="selectedBoat?.class">{{ selectedBoat.class }}</IonCardSubtitle>
</IonCardHeader>
<IonCardContent>
<div class="time-summary">
<div class="time-row">
<IonIcon :icon="timeOutline" />
<span>{{ formatFull(selectedSlot!.start_time) }} {{ formatSlotTime(selectedSlot!.end_time) }}</span>
</div>
</div>
</IonCardContent>
</IonCard>
<IonList lines="full" class="ion-margin-top">
<IonItem>
<IonLabel position="stacked">Reason</IonLabel>
<IonSelect v-model="form.reason" placeholder="Select reason" interface="action-sheet">
<IonSelectOption v-for="r in reasonOptions" :key="r" :value="r">{{ r }}</IonSelectOption>
</IonSelect>
</IonItem>
<IonItem>
<IonLabel position="stacked">Additional Comments (optional)</IonLabel>
<IonTextarea v-model="form.comment" :rows="3" placeholder="Any notes..." />
</IonItem>
</IonList>
<div class="form-actions">
<IonButton fill="outline" @click="step = 1">Back</IonButton>
<IonButton
:disabled="!form.reason || submitting"
@click="submitReservation"
>
<IonSpinner v-if="submitting" name="crescent" slot="start" />
Confirm Booking
</IonButton>
</div>
<IonToast
v-model:is-open="toast.show"
:message="toast.message"
:color="toast.color"
:duration="3000"
position="bottom"
/>
</template>
</IonContent>
</IonPage>
</template>
<script setup lang="ts">
import {
IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButtons,
IonBackButton, IonCard, IonCardHeader, IonCardTitle, IonCardSubtitle,
IonCardContent, IonList, IonItem, IonLabel, IonSelect, IonSelectOption,
IonTextarea, IonButton, IonSpinner, IonIcon, IonToast,
} from '@ionic/vue'
import { timeOutline, warningOutline } from 'ionicons/icons'
import { useAuthStore } from '~/stores/auth'
import type { Database } from '~/types/supabase'
type Boat = Database['public']['Tables']['boats']['Row']
type Interval = Database['public']['Tables']['intervals']['Row']
definePageMeta({ layout: false })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supabase = useSupabaseClient() as any
const auth = useAuthStore()
const router = useRouter()
const step = ref<1 | 2>(1)
const loadingSlots = ref(false)
const submitting = ref(false)
const selectedDate = ref(todayIso())
const selectedBoat = ref<Boat | null>(null)
const selectedSlot = ref<Interval | null>(null)
const toast = reactive({ show: false, message: '', color: 'success' })
const form = reactive({ reason: '', comment: '' })
const reasonOptions = ['Open Sail', 'Private Sail', 'Racing', 'Training', 'Other']
// 14-day date strip starting today
const dateOptions = computed(() => {
const out = []
const base = new Date()
for (let i = 0; i < 14; i++) {
const d = new Date(base)
d.setDate(base.getDate() + i)
out.push({
iso: toIso(d),
dayName: d.toLocaleDateString('en-CA', { weekday: 'short' }),
dayNum: d.getDate(),
month: d.toLocaleDateString('en-CA', { month: 'short' }),
})
}
return out
})
interface BoatSlotEntry {
boat: Boat
slots: Interval[]
certified: boolean
}
const availableByBoat = ref<BoatSlotEntry[]>([])
watch(selectedDate, loadSlots, { immediate: true })
async function loadSlots() {
loadingSlots.value = true
availableByBoat.value = []
const dayStart = selectedDate.value + 'T00:00:00Z'
const dayEnd = selectedDate.value + 'T23:59:59Z'
// Load available intervals + boats (booking_available = true) for selected date
const { data: intervals, error } = await supabase
.from('intervals')
.select('*, boats!inner(id, name, display_name, class, img_src, booking_available, required_certs, max_passengers, defects, year, icon_src, created_at)')
.gte('start_time', dayStart)
.lte('start_time', dayEnd)
.eq('boats.booking_available', true)
.order('start_time', { ascending: true })
if (error) { loadingSlots.value = false; return }
// Load booked slots via reservation_slots view (hides personal details)
const { data: bookedSlots } = await supabase
.from('reservation_slots')
.select('boat_id, start_time, end_time')
.gte('start_time', dayStart)
.lte('start_time', dayEnd)
const booked = new Set<string>()
for (const r of (bookedSlots ?? []) as { boat_id: string; start_time: string; end_time: string }[]) {
booked.add(`${r.boat_id}|${r.start_time}|${r.end_time}`)
}
const memberCerts: string[] = auth.member?.certifications ?? []
// Group by boat, filtering out booked slots
const byBoat = new Map<string, BoatSlotEntry>()
for (const row of intervals ?? []) {
const boat = (row as unknown as { boats: Boat }).boats
const key = `${boat.id}|${row.start_time}|${row.end_time}`
if (booked.has(key)) continue
if (!byBoat.has(boat.id)) {
byBoat.set(boat.id, {
boat,
slots: [],
certified: boat.required_certs.length === 0 ||
boat.required_certs.every(c => memberCerts.includes(c)),
})
}
byBoat.get(boat.id)!.slots.push(row as Interval)
}
availableByBoat.value = Array.from(byBoat.values())
loadingSlots.value = false
}
function selectSlot(boat: Boat, slot: Interval) {
selectedBoat.value = boat
selectedSlot.value = slot
form.reason = ''
form.comment = ''
step.value = 2
}
async function submitReservation() {
if (!auth.user || !selectedBoat.value || !selectedSlot.value) return
submitting.value = true
const { data: sessionData } = await supabase.auth.getSession()
const accessToken = sessionData.session?.access_token ?? ''
const response = await supabase.functions.invoke('create-reservation', {
body: {
boat_id: selectedBoat.value.id,
start_time: selectedSlot.value.start_time,
end_time: selectedSlot.value.end_time,
reason: form.reason,
comment: form.comment,
},
headers: { Authorization: `Bearer ${accessToken}` },
})
submitting.value = false
if (response.error || (response.data as { error?: { code: string; message: string } })?.error) {
const apiError = (response.data as { error?: { code: string; message: string } })?.error
const codeMessages: Record<string, string> = {
cert_required: 'You are not certified for this boat.',
slot_taken: 'This slot was just booked by someone else.',
booking_limit_weekly: apiError?.message ?? 'Weekly booking limit reached.',
booking_limit_weekend: apiError?.message ?? 'Weekend booking limit reached.',
boat_unavailable: 'This boat is currently out of service.',
}
toast.message = (apiError?.code && codeMessages[apiError.code]) || apiError?.message || 'Failed to create reservation.'
toast.color = 'danger'
toast.show = true
return
}
toast.message = 'Reservation created!'
toast.color = 'success'
toast.show = true
setTimeout(() => router.push('/'), 1500)
}
function todayIso() {
return toIso(new Date())
}
function toIso(d: Date) {
return d.toISOString().slice(0, 10)
}
function formatSlotTime(iso: string) {
return new Date(iso).toLocaleTimeString('en-CA', { hour: '2-digit', minute: '2-digit', hour12: false })
}
function formatFull(iso: string) {
return new Date(iso).toLocaleDateString('en-CA', { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
</script>
<style scoped>
.section-title {
font-size: 1rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.date-strip {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.5rem;
scrollbar-width: none;
}
.date-strip::-webkit-scrollbar { display: none; }
.date-chip {
display: flex;
flex-direction: column;
align-items: center;
min-width: 52px;
padding: 0.4rem 0.6rem;
border: 1px solid var(--ion-color-light-shade);
border-radius: 0.5rem;
background: var(--ion-color-light);
cursor: pointer;
font-size: 0.75rem;
gap: 2px;
flex-shrink: 0;
}
.date-chip.active {
background: var(--ion-color-primary);
color: white;
border-color: var(--ion-color-primary);
}
.day-name { font-weight: 600; text-transform: uppercase; font-size: 0.65rem; }
.day-num { font-size: 1.1rem; font-weight: 700; }
.month { font-size: 0.65rem; }
.boat-card { margin: 0 0 1rem; }
.cert-warning {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--ion-color-warning-shade);
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
.slot-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.slot-chip {
padding: 0.4rem 0.75rem;
border-radius: 1rem;
border: 1px solid var(--ion-color-primary);
background: transparent;
color: var(--ion-color-primary);
font-size: 0.85rem;
cursor: pointer;
font-weight: 500;
}
.slot-chip:disabled {
border-color: var(--ion-color-medium);
color: var(--ion-color-medium);
cursor: not-allowed;
}
.slot-chip:not(:disabled):hover {
background: var(--ion-color-primary);
color: white;
}
.time-summary { display: flex; flex-direction: column; gap: 0.4rem; }
.time-row { display: flex; align-items: center; gap: 0.5rem; font-size: 0.95rem; }
.empty-text { color: var(--ion-color-medium); text-align: center; padding: 2rem 0; }
.form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
</style>