Compare commits
2 Commits
5b4955f07e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
534d66c774
|
|||
|
7f1e82acc2
|
68
CLAUDE.md
68
CLAUDE.md
@@ -31,6 +31,26 @@ You work with Patrick, a Solutions Architect, on the OYS Borrow a Boat app (oysq
|
||||
- `app/middleware/auth.ts` — global auth guard via useSupabaseUser()
|
||||
- Auth pages use `definePageMeta({ layout: false })` (effectively a no-op with IonRouterOutlet, but documents intent)
|
||||
|
||||
### Ionic + Nuxt Watch-outs
|
||||
Source: https://ionic.nuxtjs.org/get-started/watch-outs
|
||||
|
||||
**Never use Nuxt navigation components** — `<NuxtPage>`, `<NuxtLayout>`, `<NuxtLink>` are not integrated with this module. Use `<IonRouterOutlet>` and `useIonRouter()` / `router-link` prop on Ionic components instead.
|
||||
|
||||
**Every page must have `<IonPage>` as its root element** — required for Ionic page transitions and stack navigation.
|
||||
|
||||
**Route params: always import `useRoute` from `vue-router` directly** — Nuxt's auto-imported `useRoute()` always returns `params: {}` when used with IonRouterOutlet. This is a known bug with no upstream fix yet.
|
||||
```typescript
|
||||
import { useRoute, useRouter } from 'vue-router' // NOT Nuxt auto-import
|
||||
```
|
||||
|
||||
**`onMounted` / `onBeforeMount` are unreliable** — IonRouterOutlet preserves DOM elements rather than unmounting them, so mounted hooks may not fire when expected. Use Ionic lifecycle hooks (`onIonViewWillEnter`) instead. See Data Fetching Pattern below.
|
||||
|
||||
**`useHead()` requires workarounds** — not compatible out of the box with IonRouterOutlet's component persistence.
|
||||
|
||||
**`<keep-alive>`, `<transition>`, `<router-view>` do not work as expected** — IonRouterOutlet manages its own lifecycle; don't layer Vue's built-in equivalents on top of it.
|
||||
|
||||
**No SSR** — this app targets mobile/PWA; SSR is disabled (`ssr: false` in nuxt.config). Do not add SSR-dependent patterns.
|
||||
|
||||
### Auth
|
||||
- Supabase Auth — magic link + OTP only (no password auth)
|
||||
- `useSupabaseUser()` composable (from @nuxtjs/supabase)
|
||||
@@ -55,6 +75,54 @@ You work with Patrick, a Solutions Architect, on the OYS Borrow a Boat app (oysq
|
||||
- Ionicons only (`ionicons/icons`) — no PrimeIcons
|
||||
- Always import individual icon names from `ionicons/icons` (tree-shakeable)
|
||||
|
||||
### Data Fetching Pattern
|
||||
Every page that reads from Supabase **must** use this pattern. Do NOT use `onMounted` for data fetches.
|
||||
|
||||
**Why**: `onMounted` (and `watch(user, { immediate: true })`) fire during component setup — before `IonRouterOutlet` has populated `route.params` and before `@nuxtjs/supabase` has restored the session into the Supabase JS client. The fetch runs unauthenticated, RLS returns 0 rows, and the page shows empty/error state.
|
||||
|
||||
```typescript
|
||||
// Route params: always computed, never destructured at setup time
|
||||
const id = computed(() => route.params.id as string)
|
||||
|
||||
const user = useSupabaseUser()
|
||||
|
||||
async function load() {
|
||||
if (!user.value) return // guard: session not ready
|
||||
if (!id.value) return // guard: param pages only
|
||||
// ... fetch data
|
||||
}
|
||||
|
||||
// Fires when session becomes available (handles direct URL load / page refresh)
|
||||
watch([user, id], ([u, rid]) => { if (u && rid) load() }, { immediate: true })
|
||||
// Fires on every Ionic page activation (handles IonRouterOutlet page cache)
|
||||
onIonViewWillEnter(() => { if (user.value) load() })
|
||||
```
|
||||
|
||||
- For pages **without** a route param: omit `id` from the watch array, keep `watch(user, ...)`.
|
||||
- Reactive filter deps (e.g. `selectedDate`, `filterDateFrom`) use a separate `watch(dep, () => { if (user.value) fetch() })` — not `watch(dep, fetch)`, so they don't fire before the session is ready.
|
||||
- `onMounted` is still valid for **non-auth DOM setup** (e.g., `window.addEventListener`, breakpoint detection).
|
||||
|
||||
### Offline Cache
|
||||
- **Rule**: Every table/view read from Supabase must be written to `useAppCache` on success and read from it when offline.
|
||||
- **Composables**: `useAppCache` (localStorage, 24h TTL), `useOfflineStatus` (reactive `isOnline`)
|
||||
- **Pattern** for any data fetch:
|
||||
```typescript
|
||||
const cache = useAppCache()
|
||||
const { isOnline } = useOfflineStatus()
|
||||
|
||||
if (!isOnline.value) {
|
||||
const cached = cache.peek<T>('my-key')
|
||||
if (cached) data.value = cached
|
||||
return
|
||||
}
|
||||
const { data: fresh } = await supabase.from('my_table').select('*')
|
||||
data.value = fresh ?? []
|
||||
if (fresh) cache.set('my-key', fresh)
|
||||
```
|
||||
- **Schedule data** is keyed by ISO week Monday: `cache.weekKey(utcIso)` → use keys `intervals:{monday}` and `slots:{monday}`.
|
||||
- **Realtime**: `app/app.vue` subscribes to `reservations`, `intervals`, and `boats` changes and patches the cache in real time. When adding a new table subscription, add it to the `app-cache-sync` channel in `app.vue`.
|
||||
- **Cross-page navigation state** (not persistence): use `useBookingDraft` as a pattern — module-level `ref` set by the source page, consumed (`take()`) by the destination page. Do not use query params for structured objects.
|
||||
|
||||
## Rules
|
||||
|
||||
1. Do not mix unrelated project contexts in one session.
|
||||
|
||||
75
app/app.vue
75
app/app.vue
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<IonApp>
|
||||
<!-- Offline indicator — floats above all pages -->
|
||||
<div v-if="!isOnline" class="offline-chip">
|
||||
<IonIcon :icon="cloudOfflineOutline" />
|
||||
<span>Offline</span>
|
||||
</div>
|
||||
|
||||
<IonSplitPane content-id="main-content">
|
||||
<IonMenu v-if="authStore.user" content-id="main-content" menu-id="main-menu">
|
||||
<IonHeader>
|
||||
@@ -91,11 +97,15 @@ import {
|
||||
import {
|
||||
homeOutline, calendarOutline, boatOutline, personOutline,
|
||||
bookOutline, calendarNumberOutline, peopleOutline, constructOutline, logOutOutline,
|
||||
layersOutline, settingsOutline, bookmarkOutline,
|
||||
layersOutline, settingsOutline, bookmarkOutline, cloudOfflineOutline,
|
||||
} from 'ionicons/icons'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useOfflineStatus } from '~/composables/useOfflineStatus'
|
||||
import { useAppCache } from '~/composables/useAppCache'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { isOnline } = useOfflineStatus()
|
||||
const cache = useAppCache()
|
||||
|
||||
async function closeMenu() {
|
||||
await menuController.close('main-menu')
|
||||
@@ -116,4 +126,67 @@ watch(() => authStore.user, (u) => {
|
||||
if (u) authStore.fetchMember()
|
||||
else authStore.member = null
|
||||
})
|
||||
|
||||
// ── Realtime: keep cache fresh while online ──────────────────────────────────
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const supabase = useSupabaseClient() as any
|
||||
|
||||
type ReservationPayload = {
|
||||
eventType: 'INSERT' | 'UPDATE' | 'DELETE'
|
||||
new: { id: string; boat_id: string; start_time: string; end_time: string; status: string } | null
|
||||
old: { id: string; boat_id: string; start_time: string; end_time: string; status: string } | null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
supabase
|
||||
.channel('app-cache-sync')
|
||||
.on('postgres_changes', { event: '*', schema: 'public', table: 'reservations' },
|
||||
(payload: ReservationPayload) => {
|
||||
const row = payload.new ?? payload.old
|
||||
if (!row?.start_time) return
|
||||
const wk = cache.weekKey(row.start_time)
|
||||
const slotsKey = `slots:${wk}`
|
||||
type SlotRow = { id: string; boat_id: string; start_time: string; end_time: string; status: string }
|
||||
const cached = cache.peek<SlotRow[]>(slotsKey) ?? []
|
||||
let updated: SlotRow[]
|
||||
if (payload.eventType === 'INSERT' && payload.new) {
|
||||
const { id, boat_id, start_time, end_time, status } = payload.new
|
||||
updated = [...cached, { id, boat_id, start_time, end_time, status }]
|
||||
} else if (payload.eventType === 'UPDATE' && payload.new) {
|
||||
updated = cached.map(s => s.id === payload.new!.id ? { ...s, status: payload.new!.status } : s)
|
||||
} else {
|
||||
updated = cached.filter(s => s.id !== (payload.old?.id))
|
||||
}
|
||||
cache.set(slotsKey, updated)
|
||||
})
|
||||
.on('postgres_changes', { event: '*', schema: 'public', table: 'intervals' },
|
||||
() => cache.invalidatePrefix('intervals:'))
|
||||
.on('postgres_changes', { event: '*', schema: 'public', table: 'boats' },
|
||||
async () => {
|
||||
cache.invalidate('boats')
|
||||
const { data } = await supabase.from('boats').select('*').order('name')
|
||||
if (data) cache.set('boats', data)
|
||||
})
|
||||
.subscribe()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.offline-chip {
|
||||
position: fixed;
|
||||
top: calc(env(safe-area-inset-top, 0px) + 8px);
|
||||
right: 12px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: var(--ion-color-warning);
|
||||
color: var(--ion-color-warning-contrast);
|
||||
border-radius: 12px;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
74
app/composables/useAppCache.ts
Normal file
74
app/composables/useAppCache.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { weekMonday } from '~/utils/toronto'
|
||||
|
||||
const TTL_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T
|
||||
ts: number
|
||||
}
|
||||
|
||||
function storageKey(key: string) {
|
||||
return `cache:${key}`
|
||||
}
|
||||
|
||||
export function useAppCache() {
|
||||
function set<T>(key: string, data: T): void {
|
||||
try {
|
||||
localStorage.setItem(storageKey(key), JSON.stringify({ data, ts: Date.now() } satisfies CacheEntry<T>))
|
||||
} catch { /* quota exceeded or unavailable */ }
|
||||
}
|
||||
|
||||
/** Returns data only if fresh (< 24 h). Returns null if stale or absent. */
|
||||
function get<T>(key: string): T | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey(key))
|
||||
if (!raw) return null
|
||||
const entry = JSON.parse(raw) as CacheEntry<T>
|
||||
if (Date.now() - entry.ts > TTL_MS) return null
|
||||
return entry.data
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
/** Returns data regardless of age — for offline fallback when cache is stale. */
|
||||
function peek<T>(key: string): T | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey(key))
|
||||
if (!raw) return null
|
||||
return (JSON.parse(raw) as CacheEntry<T>).data
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
/** Returns age in ms, or null if absent. */
|
||||
function age(key: string): number | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey(key))
|
||||
if (!raw) return null
|
||||
return Date.now() - (JSON.parse(raw) as CacheEntry<unknown>).ts
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function invalidate(key: string): void {
|
||||
localStorage.removeItem(storageKey(key))
|
||||
}
|
||||
|
||||
/** Removes all cache entries whose key starts with prefix. */
|
||||
function invalidatePrefix(prefix: string): void {
|
||||
const full = storageKey(prefix)
|
||||
const toRemove: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i)
|
||||
if (k?.startsWith(full)) toRemove.push(k)
|
||||
}
|
||||
toRemove.forEach(k => localStorage.removeItem(k))
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache key for schedule data (intervals or slots) for the ISO week containing
|
||||
* the given UTC ISO timestamp. Desktop and mobile both key to week-Monday.
|
||||
*/
|
||||
function weekKey(utcIso: string): string {
|
||||
return weekMonday(utcIso.slice(0, 10))
|
||||
}
|
||||
|
||||
return { set, get, peek, age, invalidate, invalidatePrefix, weekKey }
|
||||
}
|
||||
26
app/composables/useBookingDraft.ts
Normal file
26
app/composables/useBookingDraft.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Database } from '~/types/supabase'
|
||||
|
||||
type Boat = Database['public']['Tables']['boats']['Row']
|
||||
|
||||
export interface BookingDraft {
|
||||
boat: Boat
|
||||
startTime: string
|
||||
endTime: string
|
||||
}
|
||||
|
||||
const _draft = ref<BookingDraft | null>(null)
|
||||
|
||||
export function useBookingDraft() {
|
||||
function set(boat: Boat, startTime: string, endTime: string): void {
|
||||
_draft.value = { boat, startTime, endTime }
|
||||
}
|
||||
|
||||
/** Reads and clears the draft — call once in the destination page. */
|
||||
function take(): BookingDraft | null {
|
||||
const val = _draft.value
|
||||
_draft.value = null
|
||||
return val
|
||||
}
|
||||
|
||||
return { set, take }
|
||||
}
|
||||
10
app/composables/useOfflineStatus.ts
Normal file
10
app/composables/useOfflineStatus.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
const _isOnline = ref(typeof navigator !== 'undefined' ? navigator.onLine : true)
|
||||
|
||||
if (import.meta.client) {
|
||||
window.addEventListener('online', () => { _isOnline.value = true })
|
||||
window.addEventListener('offline', () => { _isOnline.value = false })
|
||||
}
|
||||
|
||||
export function useOfflineStatus() {
|
||||
return { isOnline: readonly(_isOnline) }
|
||||
}
|
||||
@@ -187,7 +187,7 @@ import {
|
||||
IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButtons,
|
||||
IonMenuButton, IonButton, IonIcon, IonCard, IonBadge,
|
||||
IonList, IonItem, IonLabel, IonInput, IonToggle, IonSpinner,
|
||||
IonModal, IonAlert, IonToast,
|
||||
IonModal, IonAlert, IonToast, onIonViewWillEnter,
|
||||
} from '@ionic/vue'
|
||||
import { addOutline, pencilOutline, trashOutline, boatOutline, cameraOutline } from 'ionicons/icons'
|
||||
import type { Database } from '~/types/supabase'
|
||||
@@ -224,7 +224,9 @@ const form = reactive({
|
||||
img_src: null as string | null,
|
||||
})
|
||||
|
||||
onMounted(fetchBoats)
|
||||
const user = useSupabaseUser()
|
||||
watch(user, (val) => { if (val) fetchBoats() }, { immediate: true })
|
||||
onIonViewWillEnter(() => { if (user.value) fetchBoats() })
|
||||
|
||||
async function fetchBoats() {
|
||||
loading.value = true
|
||||
|
||||
@@ -155,7 +155,7 @@ import {
|
||||
IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButtons,
|
||||
IonMenuButton, IonButton, IonIcon, IonCard, IonCardHeader, IonCardTitle,
|
||||
IonCardSubtitle, IonCardContent, IonList, IonItem, IonLabel, IonInput,
|
||||
IonSpinner, IonToast,
|
||||
IonSpinner, IonToast, onIonViewWillEnter,
|
||||
} from '@ionic/vue'
|
||||
import { saveOutline, trashOutline, addOutline } from 'ionicons/icons'
|
||||
import type { Database } from '~/types/supabase'
|
||||
@@ -184,10 +184,14 @@ const local = reactive<Record<ConfigKey, number>>({
|
||||
|
||||
const original = reactive<Record<ConfigKey, number>>({ ...local })
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadAll() {
|
||||
await Promise.all([fetchConfig(), fetchHolidays()])
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const user = useSupabaseUser()
|
||||
watch(user, (val) => { if (val) loadAll() }, { immediate: true })
|
||||
onIonViewWillEnter(() => { if (user.value) loadAll() })
|
||||
|
||||
async function fetchConfig() {
|
||||
const { data } = await supabase
|
||||
|
||||
@@ -193,6 +193,7 @@ import {
|
||||
IonMenuButton, IonButton, IonIcon, IonCard, IonCardHeader, IonCardTitle,
|
||||
IonCardSubtitle, IonCardContent, IonList, IonItem, IonLabel, IonToggle,
|
||||
IonSelect, IonSelectOption, IonSpinner, IonModal, IonInput, IonToast, IonCheckbox,
|
||||
onIonViewWillEnter,
|
||||
} from '@ionic/vue'
|
||||
import {
|
||||
addOutline, timeOutline, trashOutline, constructOutline, calendarOutline,
|
||||
@@ -252,12 +253,16 @@ const dateOptions = computed(() => {
|
||||
return out
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadAll() {
|
||||
await Promise.all([fetchBoats(), fetchTemplates()])
|
||||
await fetchSlots()
|
||||
})
|
||||
}
|
||||
|
||||
watch(selectedDate, fetchSlots)
|
||||
const user = useSupabaseUser()
|
||||
watch(user, (val) => { if (val) loadAll() }, { immediate: true })
|
||||
onIonViewWillEnter(() => { if (user.value) loadAll() })
|
||||
|
||||
watch(selectedDate, () => { if (user.value) fetchSlots() })
|
||||
|
||||
async function fetchBoats() {
|
||||
const { data } = await supabase.from('boats').select('*').order('name')
|
||||
|
||||
@@ -161,7 +161,7 @@ import {
|
||||
IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButtons,
|
||||
IonMenuButton, IonButton, IonIcon, IonCard, IonCardContent, IonBadge,
|
||||
IonList, IonItem, IonLabel, IonInput, IonTextarea, IonSelect, IonSelectOption,
|
||||
IonSpinner, IonModal, IonAlert, IonToast,
|
||||
IonSpinner, IonModal, IonAlert, IonToast, onIonViewWillEnter,
|
||||
} from '@ionic/vue'
|
||||
import { addOutline, pencilOutline, trashOutline } from 'ionicons/icons'
|
||||
import type { Database } from '~/types/supabase'
|
||||
@@ -215,12 +215,16 @@ const filteredReservations = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadAll() {
|
||||
await Promise.all([fetchBoats(), fetchMembers()])
|
||||
await fetchReservations()
|
||||
})
|
||||
}
|
||||
|
||||
watch([filterDateFrom, filterDateTo], fetchReservations)
|
||||
const user = useSupabaseUser()
|
||||
watch(user, (val) => { if (val) loadAll() }, { immediate: true })
|
||||
onIonViewWillEnter(() => { if (user.value) loadAll() })
|
||||
|
||||
watch([filterDateFrom, filterDateTo], () => { if (user.value) fetchReservations() })
|
||||
|
||||
async function fetchBoats() {
|
||||
const { data } = await supabase.from('boats').select('*').order('name')
|
||||
|
||||
@@ -128,7 +128,7 @@ import {
|
||||
IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButtons,
|
||||
IonMenuButton, IonButton, IonIcon, IonCard, IonCardHeader, IonCardTitle,
|
||||
IonCardContent, IonList, IonItem, IonLabel, IonInput, IonSpinner,
|
||||
IonModal, IonAlert, IonToast,
|
||||
IonModal, IonAlert, IonToast, onIonViewWillEnter,
|
||||
} from '@ionic/vue'
|
||||
import { addOutline, pencilOutline, trashOutline, closeOutline } from 'ionicons/icons'
|
||||
import type { Database, TimeTuple } from '~/types/supabase'
|
||||
@@ -153,7 +153,9 @@ const form = reactive<{ name: string; tuples: [string, string][] }>({
|
||||
tuples: [['08:00', '12:00']],
|
||||
})
|
||||
|
||||
onMounted(fetchTemplates)
|
||||
const user = useSupabaseUser()
|
||||
watch(user, (val) => { if (val) fetchTemplates() }, { immediate: true })
|
||||
onIonViewWillEnter(() => { if (user.value) fetchTemplates() })
|
||||
|
||||
async function fetchTemplates() {
|
||||
loading.value = true
|
||||
|
||||
@@ -35,7 +35,13 @@
|
||||
No upcoming reservations.
|
||||
</p>
|
||||
<IonList v-else lines="full">
|
||||
<IonItem v-for="r in upcomingReservations" :key="r.id">
|
||||
<IonItem
|
||||
v-for="r in upcomingReservations"
|
||||
:key="r.id"
|
||||
button
|
||||
detail
|
||||
@click="openActionSheet(r)"
|
||||
>
|
||||
<IonLabel>
|
||||
<h3>{{ boatName(r) }}</h3>
|
||||
<p>{{ formatDateRange(r.start_time, r.end_time) }}</p>
|
||||
@@ -71,9 +77,9 @@ import {
|
||||
IonPage, IonHeader, IonToolbar, IonTitle, IonContent,
|
||||
IonButtons, IonMenuButton, IonButton, IonCard, IonCardHeader,
|
||||
IonCardTitle, IonCardContent, IonList, IonItem, IonLabel,
|
||||
IonBadge, IonSpinner, IonIcon,
|
||||
IonBadge, IonSpinner, IonIcon, actionSheetController, onIonViewWillEnter,
|
||||
} from '@ionic/vue'
|
||||
import { addCircleOutline } from 'ionicons/icons'
|
||||
import { addCircleOutline, createOutline, trashOutline } from 'ionicons/icons'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import type { Database, ReservationStatus } from '~/types/supabase'
|
||||
|
||||
@@ -86,6 +92,7 @@ definePageMeta({ layout: false })
|
||||
const user = useSupabaseUser()
|
||||
const auth = useAuthStore()
|
||||
const supabase = useSupabaseClient<Database>()
|
||||
const router = useRouter()
|
||||
|
||||
const today = new Date()
|
||||
const loadingReservations = ref(true)
|
||||
@@ -99,13 +106,47 @@ async function fetchReservations() {
|
||||
.select('*, boats(name, display_name)')
|
||||
.eq('user_id', user.value.id)
|
||||
.gte('start_time', new Date().toISOString())
|
||||
.neq('status', 'cancelled')
|
||||
.order('start_time', { ascending: true })
|
||||
.limit(3)
|
||||
upcomingReservations.value = (data as ReservationWithBoat[]) ?? []
|
||||
loadingReservations.value = false
|
||||
}
|
||||
|
||||
// Initial load and re-fetch on every page visit (Ionic caches the page, so
|
||||
// watch(user) alone won't re-run when navigating back from create/edit).
|
||||
watch(user, (val) => { if (val) fetchReservations() }, { immediate: true })
|
||||
onIonViewWillEnter(() => { if (user.value) fetchReservations() })
|
||||
|
||||
async function openActionSheet(r: ReservationWithBoat) {
|
||||
const isFuture = new Date(r.start_time) > new Date()
|
||||
const sheet = await actionSheetController.create({
|
||||
header: `${boatName(r)} — ${formatDateRange(r.start_time, r.end_time)}`,
|
||||
buttons: [
|
||||
...(isFuture ? [
|
||||
{
|
||||
text: 'Edit',
|
||||
icon: createOutline,
|
||||
handler: () => void router.push(`/reservations/edit/${r.id}`),
|
||||
},
|
||||
{
|
||||
text: 'Cancel Reservation',
|
||||
role: 'destructive',
|
||||
icon: trashOutline,
|
||||
handler: () => void cancelReservation(r.id),
|
||||
},
|
||||
] : []),
|
||||
{ text: 'Dismiss', role: 'cancel' },
|
||||
],
|
||||
})
|
||||
await sheet.present()
|
||||
}
|
||||
|
||||
async function cancelReservation(id: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (supabase as any).from('reservations').update({ status: 'cancelled' }).eq('id', id)
|
||||
upcomingReservations.value = upcomingReservations.value.filter(r => r.id !== id)
|
||||
}
|
||||
|
||||
function boatName(r: ReservationWithBoat) {
|
||||
return r.boats?.display_name || r.boats?.name || 'Unknown boat'
|
||||
@@ -125,6 +166,7 @@ function statusColor(status: ReservationStatus): string {
|
||||
pending: 'warning',
|
||||
tentative: 'medium',
|
||||
confirmed: 'success',
|
||||
cancelled: 'danger',
|
||||
}
|
||||
return colors[status]
|
||||
}
|
||||
|
||||
@@ -88,6 +88,15 @@
|
||||
</IonCard>
|
||||
|
||||
<IonList lines="full" class="ion-margin-top">
|
||||
<IonItem v-if="auth.isAdmin">
|
||||
<IonLabel position="stacked">Member</IonLabel>
|
||||
<IonSelect v-model="form.targetUserId" placeholder="Self (admin)" interface="action-sheet">
|
||||
<IonSelectOption value="">Self (admin)</IonSelectOption>
|
||||
<IonSelectOption v-for="m in members" :key="m.user_id" :value="m.user_id">
|
||||
{{ m.first_name }} {{ m.last_name }}
|
||||
</IonSelectOption>
|
||||
</IonSelect>
|
||||
</IonItem>
|
||||
<IonItem>
|
||||
<IonLabel position="stacked">Reason</IonLabel>
|
||||
<IonSelect v-model="form.reason" placeholder="Select reason" interface="action-sheet">
|
||||
@@ -129,11 +138,16 @@ import {
|
||||
IonBackButton, IonCard, IonCardHeader, IonCardTitle, IonCardSubtitle,
|
||||
IonCardContent, IonList, IonItem, IonLabel, IonSelect, IonSelectOption,
|
||||
IonTextarea, IonButton, IonSpinner, IonIcon, IonToast,
|
||||
onIonViewWillEnter,
|
||||
} from '@ionic/vue'
|
||||
import { timeOutline, warningOutline } from 'ionicons/icons'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import type { Member } from '~/stores/auth'
|
||||
import { toDateToronto } from '~/utils/toronto'
|
||||
import type { Database } from '~/types/supabase'
|
||||
import { useBookingDraft } from '~/composables/useBookingDraft'
|
||||
import { useAppCache } from '~/composables/useAppCache'
|
||||
import { useOfflineStatus } from '~/composables/useOfflineStatus'
|
||||
|
||||
type Boat = Database['public']['Tables']['boats']['Row']
|
||||
type Interval = Database['public']['Tables']['intervals']['Row']
|
||||
@@ -144,7 +158,9 @@ definePageMeta({ layout: false })
|
||||
const supabase = useSupabaseClient() as any
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { take: takeDraft } = useBookingDraft()
|
||||
const cache = useAppCache()
|
||||
const { isOnline } = useOfflineStatus()
|
||||
|
||||
const step = ref<1 | 2>(1)
|
||||
const loadingSlots = ref(false)
|
||||
@@ -152,9 +168,10 @@ const submitting = ref(false)
|
||||
const selectedDate = ref(todayIso())
|
||||
const selectedBoat = ref<Boat | null>(null)
|
||||
const selectedSlot = ref<Interval | null>(null)
|
||||
const members = ref<Member[]>([])
|
||||
const toast = reactive({ show: false, message: '', color: 'success' })
|
||||
|
||||
const form = reactive({ reason: '', comment: '' })
|
||||
const form = reactive({ reason: '', comment: '', targetUserId: '' })
|
||||
const reasonOptions = ['Open Sail', 'Private Sail', 'Racing', 'Training', 'Other']
|
||||
|
||||
// 14-day date strip starting today
|
||||
@@ -182,28 +199,33 @@ interface BoatSlotEntry {
|
||||
|
||||
const availableByBoat = ref<BoatSlotEntry[]>([])
|
||||
|
||||
// Pre-populate from schedule deep-link: ?boatId=&startTime=&endTime=
|
||||
onMounted(async () => {
|
||||
const { boatId, startTime, endTime } = route.query as Record<string, string>
|
||||
if (boatId && startTime && endTime) {
|
||||
const { data: boat } = await supabase.from('boats').select('*').eq('id', boatId).single()
|
||||
if (boat) {
|
||||
selectedBoat.value = boat as Boat
|
||||
// onIonViewWillEnter fires every visit, including re-entry to a cached Ionic page
|
||||
onIonViewWillEnter(async () => {
|
||||
const draft = takeDraft()
|
||||
if (draft) {
|
||||
selectedBoat.value = draft.boat
|
||||
selectedSlot.value = {
|
||||
id: '',
|
||||
boat_id: boatId,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
boat_id: draft.boat.id,
|
||||
start_time: draft.startTime,
|
||||
end_time: draft.endTime,
|
||||
user_id: null,
|
||||
created_at: '',
|
||||
}
|
||||
// Set the date strip to the slot's date so Back returns to correct day
|
||||
selectedDate.value = toDateToronto(new Date(startTime))
|
||||
selectedDate.value = toDateToronto(new Date(draft.startTime))
|
||||
step.value = 2
|
||||
return
|
||||
}
|
||||
// Normal flow: reset to step 1 and load slots for today
|
||||
step.value = 1
|
||||
selectedBoat.value = null
|
||||
selectedSlot.value = null
|
||||
selectedDate.value = todayIso()
|
||||
form.targetUserId = ''
|
||||
if (auth.isAdmin) {
|
||||
const { data } = await supabase.from('members').select('*').order('last_name')
|
||||
members.value = data ?? []
|
||||
}
|
||||
// Normal flow: load slots for today
|
||||
await loadSlots()
|
||||
})
|
||||
|
||||
@@ -215,37 +237,50 @@ async function loadSlots() {
|
||||
|
||||
const dayStart = selectedDate.value + 'T00:00:00Z'
|
||||
const dayEnd = selectedDate.value + 'T23:59:59Z'
|
||||
const wk = cache.weekKey(selectedDate.value + 'T12:00:00Z')
|
||||
|
||||
// Load available intervals + boats (booking_available = true) for selected date
|
||||
const { data: intervals, error } = await supabase
|
||||
type BookedSlot = { boat_id: string; start_time: string; end_time: string }
|
||||
let intervals: unknown[] | null = null
|
||||
let bookedSlots: BookedSlot[] | null = null
|
||||
|
||||
if (!isOnline.value) {
|
||||
const cachedIntervals = cache.peek<unknown[]>(`intervals:${wk}`)
|
||||
const cachedSlots = cache.peek<BookedSlot[]>(`slots:${wk}`)
|
||||
intervals = cachedIntervals?.filter(r => {
|
||||
const row = r as { start_time: string }
|
||||
return row.start_time >= dayStart && row.start_time <= dayEnd
|
||||
}) ?? []
|
||||
bookedSlots = cachedSlots?.filter(s => s.start_time >= dayStart && s.start_time <= dayEnd) ?? []
|
||||
} else {
|
||||
const { data: intData, 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 }
|
||||
intervals = intData
|
||||
|
||||
// Load booked slots via reservation_slots view (hides personal details)
|
||||
const { data: bookedSlots } = await supabase
|
||||
const { data: slotData } = await supabase
|
||||
.from('reservation_slots')
|
||||
.select('boat_id, start_time, end_time')
|
||||
.gte('start_time', dayStart)
|
||||
.lte('start_time', dayEnd)
|
||||
bookedSlots = slotData
|
||||
}
|
||||
|
||||
const booked = new Set<string>()
|
||||
for (const r of (bookedSlots ?? []) as { boat_id: string; start_time: string; end_time: string }[]) {
|
||||
for (const r of (bookedSlots ?? []) as BookedSlot[]) {
|
||||
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}`
|
||||
const key = `${boat.id}|${(row as { start_time: string }).start_time}|${(row as { end_time: string }).end_time}`
|
||||
if (booked.has(key)) continue
|
||||
|
||||
if (!byBoat.has(boat.id)) {
|
||||
@@ -285,20 +320,32 @@ async function submitReservation() {
|
||||
end_time: selectedSlot.value.end_time,
|
||||
reason: form.reason,
|
||||
comment: form.comment,
|
||||
...(auth.isAdmin && form.targetUserId ? { target_user_id: form.targetUserId } : {}),
|
||||
},
|
||||
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
|
||||
type ApiErrorBody = { error?: { code: string; message: string } }
|
||||
// functions-js v2 sets data=null on non-2xx and stores the raw Response on error.context
|
||||
let apiError: { code: string; message: string } | undefined =
|
||||
(response.data as ApiErrorBody)?.error
|
||||
if (!apiError && response.error) {
|
||||
try {
|
||||
const body: ApiErrorBody = await (response.error as { context?: Response }).context?.json()
|
||||
apiError = body?.error
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (apiError || response.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.',
|
||||
historical_booking_not_allowed: apiError?.message ?? 'Can not book a reservation in the past.',
|
||||
}
|
||||
toast.message = (apiError?.code && codeMessages[apiError.code]) || apiError?.message || 'Failed to create reservation.'
|
||||
toast.color = 'danger'
|
||||
|
||||
211
app/pages/reservations/edit/[id].vue
Normal file
211
app/pages/reservations/edit/[id].vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<IonPage>
|
||||
<IonHeader>
|
||||
<IonToolbar color="primary">
|
||||
<IonButtons slot="start">
|
||||
<IonBackButton default-href="/" />
|
||||
</IonButtons>
|
||||
<IonTitle>Edit Reservation</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent class="ion-padding">
|
||||
<div v-if="loading" class="ion-text-center ion-padding">
|
||||
<IonSpinner name="crescent" />
|
||||
</div>
|
||||
|
||||
<template v-else-if="reservation">
|
||||
<IonCard>
|
||||
<IonCardHeader>
|
||||
<IonCardTitle>{{ boatName }}</IonCardTitle>
|
||||
</IonCardHeader>
|
||||
<IonCardContent>
|
||||
<div class="time-row">
|
||||
<IonIcon :icon="timeOutline" />
|
||||
<span>{{ formatDateRange(reservation.start_time, reservation.end_time) }}</span>
|
||||
</div>
|
||||
</IonCardContent>
|
||||
</IonCard>
|
||||
|
||||
<IonList lines="full" class="ion-margin-top">
|
||||
<IonItem v-if="authStore.isAdmin">
|
||||
<IonLabel position="stacked">Member</IonLabel>
|
||||
<IonSelect v-model="form.user_id" interface="action-sheet">
|
||||
<IonSelectOption v-for="m in members" :key="m.user_id" :value="m.user_id">
|
||||
{{ m.first_name }} {{ m.last_name }}
|
||||
</IonSelectOption>
|
||||
</IonSelect>
|
||||
</IonItem>
|
||||
<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="router.back()">Cancel</IonButton>
|
||||
<IonButton :disabled="!form.reason || saving" @click="save">
|
||||
<IonSpinner v-if="saving" name="crescent" slot="start" />
|
||||
Save Changes
|
||||
</IonButton>
|
||||
</div>
|
||||
|
||||
<IonButton
|
||||
v-if="isFuture"
|
||||
expand="block"
|
||||
fill="outline"
|
||||
color="danger"
|
||||
class="ion-margin-top"
|
||||
:disabled="saving"
|
||||
@click="confirmCancel"
|
||||
>
|
||||
<IonIcon slot="start" :icon="trashOutline" />
|
||||
Cancel Reservation
|
||||
</IonButton>
|
||||
</template>
|
||||
|
||||
<p v-else class="empty-text">Reservation not found.</p>
|
||||
|
||||
<IonToast
|
||||
v-model:is-open="toast.show"
|
||||
:message="toast.message"
|
||||
:color="toast.color"
|
||||
:duration="2500"
|
||||
position="bottom"
|
||||
/>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButtons,
|
||||
IonBackButton, IonCard, IonCardHeader, IonCardTitle, IonCardContent,
|
||||
IonList, IonItem, IonLabel, IonSelect, IonSelectOption, IonTextarea,
|
||||
IonButton, IonSpinner, IonIcon, IonToast, alertController, onIonViewWillEnter,
|
||||
} from '@ionic/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { timeOutline, trashOutline } from 'ionicons/icons'
|
||||
import type { Database } from '~/types/supabase'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
type ReservationWithBoat = Database['public']['Tables']['reservations']['Row'] & {
|
||||
boats: { name: string; display_name: string | null } | null
|
||||
}
|
||||
type Member = Database['public']['Tables']['members']['Row']
|
||||
|
||||
definePageMeta({ layout: false, middleware: ['auth'] })
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const supabase = useSupabaseClient() as any
|
||||
const user = useSupabaseUser()
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const id = computed(() => route.params.id as string)
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const reservation = ref<ReservationWithBoat | null>(null)
|
||||
const members = ref<Member[]>([])
|
||||
const toast = reactive({ show: false, message: '', color: 'success' })
|
||||
|
||||
const form = reactive({ reason: '', comment: '', user_id: '' })
|
||||
const reasonOptions = ['Open Sail', 'Private Sail', 'Racing', 'Training', 'Other']
|
||||
|
||||
const boatName = computed(() =>
|
||||
reservation.value?.boats?.display_name || reservation.value?.boats?.name || 'Unknown boat'
|
||||
)
|
||||
|
||||
const isFuture = computed(() =>
|
||||
reservation.value ? new Date(reservation.value.start_time) > new Date() : false
|
||||
)
|
||||
|
||||
async function loadReservation() {
|
||||
const reservationId = route.params.id as string
|
||||
if (!user.value || !reservationId) return
|
||||
loading.value = true
|
||||
const [{ data }, { data: memberData }] = await Promise.all([
|
||||
supabase.from('reservations').select('*, boats(name, display_name)').eq('id', reservationId).single(),
|
||||
authStore.isAdmin
|
||||
? supabase.from('members').select('*').order('last_name')
|
||||
: Promise.resolve({ data: [] }),
|
||||
])
|
||||
reservation.value = data ?? null
|
||||
members.value = memberData ?? []
|
||||
if (data) {
|
||||
form.reason = data.reason ?? ''
|
||||
form.comment = data.comment ?? ''
|
||||
form.user_id = data.user_id ?? ''
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
watch(user, (val) => { if (val) loadReservation() }, { immediate: true })
|
||||
onIonViewWillEnter(() => { if (user.value) loadReservation() })
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
const payload: Record<string, string> = { reason: form.reason, comment: form.comment }
|
||||
if (authStore.isAdmin && form.user_id) payload.user_id = form.user_id
|
||||
const { error } = await supabase
|
||||
.from('reservations')
|
||||
.update(payload)
|
||||
.eq('id', id.value)
|
||||
saving.value = false
|
||||
if (error) {
|
||||
toast.message = 'Failed to save changes.'
|
||||
toast.color = 'danger'
|
||||
toast.show = true
|
||||
return
|
||||
}
|
||||
toast.message = 'Reservation updated.'
|
||||
toast.color = 'success'
|
||||
toast.show = true
|
||||
setTimeout(() => router.back(), 1500)
|
||||
}
|
||||
|
||||
async function confirmCancel() {
|
||||
const alert = await alertController.create({
|
||||
header: 'Cancel Reservation',
|
||||
message: 'Are you sure you want to cancel this reservation?',
|
||||
buttons: [
|
||||
{ text: 'Keep it', role: 'cancel' },
|
||||
{
|
||||
text: 'Cancel Reservation',
|
||||
role: 'destructive',
|
||||
handler: () => void cancelReservation(),
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
async function cancelReservation() {
|
||||
saving.value = true
|
||||
await supabase.from('reservations').update({ status: 'cancelled' }).eq('id', id.value)
|
||||
saving.value = false
|
||||
router.back()
|
||||
}
|
||||
|
||||
function formatDateRange(start: string, end: string) {
|
||||
const s = new Date(start)
|
||||
const e = new Date(end)
|
||||
const date = s.toLocaleDateString('en-CA', { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
const startTime = s.toLocaleTimeString('en-CA', { hour: '2-digit', minute: '2-digit' })
|
||||
const endTime = e.toLocaleTimeString('en-CA', { hour: '2-digit', minute: '2-digit' })
|
||||
return `${date} · ${startTime}–${endTime}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.time-row { display: flex; align-items: center; gap: 0.5rem; font-size: 0.95rem; }
|
||||
.form-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 1.5rem; }
|
||||
.empty-text { color: var(--ion-color-medium); text-align: center; padding: 2rem 0; }
|
||||
</style>
|
||||
@@ -53,18 +53,19 @@
|
||||
v-for="slot in daySlots(boat.id, focusDate)"
|
||||
:key="slot.id"
|
||||
class="slot-block"
|
||||
:class="[slotClass(slot), slot.type === 'available' ? 'slot-tappable' : '']"
|
||||
:class="[slotClass(slot), (slot.type === 'available' || slot.isOwn || authStore.isAdmin) ? 'slot-tappable' : '']"
|
||||
:title="slotTitle(slot)"
|
||||
:role="slot.type === 'available' ? 'button' : undefined"
|
||||
:tabindex="slot.type === 'available' ? 0 : undefined"
|
||||
@click="slot.type === 'available' && bookSlot(slot)"
|
||||
@keydown.enter="slot.type === 'available' && bookSlot(slot)"
|
||||
:role="(slot.type === 'available' || slot.isOwn || authStore.isAdmin) ? 'button' : undefined"
|
||||
:tabindex="(slot.type === 'available' || slot.isOwn || authStore.isAdmin) ? 0 : undefined"
|
||||
@click="handleSlotClick(slot)"
|
||||
@keydown.enter="handleSlotClick(slot)"
|
||||
>
|
||||
<div class="slot-main-row">
|
||||
<span class="slot-time">{{ fmtTime(slot.startTime) }}–{{ fmtTime(slot.endTime) }}</span>
|
||||
<IonIcon v-if="slot.type === 'available'" :icon="chevronForwardOutline" class="slot-chevron" />
|
||||
</div>
|
||||
<span class="slot-status">{{ slotLabel(slot) }}</span>
|
||||
<span class="slot-member">{{ slot.memberName ?? '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,13 +108,16 @@
|
||||
v-for="slot in daySlots(boat.id, date)"
|
||||
:key="slot.id"
|
||||
class="slot-block slot-sm"
|
||||
:class="slotClass(slot)"
|
||||
:class="[slotClass(slot), (slot.type === 'available' || slot.isOwn || authStore.isAdmin) ? 'slot-tappable' : '']"
|
||||
:title="slotTitle(slot)"
|
||||
:role="(slot.type === 'available' || slot.isOwn || authStore.isAdmin) ? 'button' : undefined"
|
||||
:tabindex="(slot.type === 'available' || slot.isOwn || authStore.isAdmin) ? 0 : undefined"
|
||||
@click="handleSlotClick(slot)"
|
||||
@keydown.enter="handleSlotClick(slot)"
|
||||
>
|
||||
<span class="slot-time-sm">{{ fmtTime(slot.startTime) }}</span>
|
||||
<span class="slot-sep">–</span>
|
||||
<span class="slot-time-sm">{{ fmtTime(slot.endTime) }}</span>
|
||||
<span class="slot-time-sm">{{ fmtTime(slot.startTime) }}-{{ fmtTime(slot.endTime) }}</span>
|
||||
<span class="slot-status-sm">{{ slotLabelShort(slot) }}</span>
|
||||
<span class="slot-member-sm">{{ slot.memberName ?? '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -138,17 +142,24 @@
|
||||
import {
|
||||
IonPage, IonHeader, IonToolbar, IonTitle, IonContent,
|
||||
IonButtons, IonMenuButton, IonButton, IonIcon, IonBadge, IonSpinner,
|
||||
actionSheetController, alertController, onIonViewWillEnter,
|
||||
} from '@ionic/vue'
|
||||
import {
|
||||
chevronBackOutline, chevronForwardOutline, todayOutline,
|
||||
createOutline, trashOutline,
|
||||
} from 'ionicons/icons'
|
||||
|
||||
import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
import type { Database, ReservationStatus } from '~/types/supabase'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import {
|
||||
todayToronto, toDateToronto, addDays,
|
||||
fmtTime, fmtDateLong, fmtDayHeader, weekDates, utcRange,
|
||||
} from '~/utils/toronto'
|
||||
import { useAppCache } from '~/composables/useAppCache'
|
||||
import { useOfflineStatus } from '~/composables/useOfflineStatus'
|
||||
import { useBookingDraft } from '~/composables/useBookingDraft'
|
||||
|
||||
type Boat = Database['public']['Tables']['boats']['Row']
|
||||
type Interval = Database['public']['Tables']['intervals']['Row']
|
||||
@@ -161,12 +172,19 @@ interface SlotBlock {
|
||||
endTime: string
|
||||
type: 'available' | 'booked'
|
||||
status: ReservationStatus | null
|
||||
isOwn: boolean
|
||||
memberName: string | null
|
||||
}
|
||||
|
||||
definePageMeta({ layout: false, middleware: ['auth'] })
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const supabase = useSupabaseClient() as any
|
||||
const user = useSupabaseUser()
|
||||
const cache = useAppCache()
|
||||
const { isOnline } = useOfflineStatus()
|
||||
const { set: setDraft } = useBookingDraft()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// ── Responsive ──────────────────────────────────────────────
|
||||
const isMobile = ref(true)
|
||||
@@ -198,34 +216,59 @@ const loading = ref(true)
|
||||
const boats = ref<Boat[]>([])
|
||||
const intervals = ref<Interval[]>([])
|
||||
const slotViews = ref<SlotView[]>([])
|
||||
const myReservationIds = ref(new Set<string>())
|
||||
|
||||
async function fetchBoats() {
|
||||
if (!isOnline.value) {
|
||||
const cached = cache.peek<Boat[]>('boats')
|
||||
if (cached) boats.value = cached
|
||||
return
|
||||
}
|
||||
const { data } = await supabase.from('boats').select('*').order('name')
|
||||
boats.value = data ?? []
|
||||
if (data) cache.set('boats', data)
|
||||
}
|
||||
|
||||
async function fetchSchedule() {
|
||||
loading.value = true
|
||||
const dates = visibleDates.value
|
||||
const { from, to } = utcRange(dates[0]!, dates[dates.length - 1]!)
|
||||
const wk = cache.weekKey(dates[0]!)
|
||||
|
||||
const [intRes, slotRes] = await Promise.all([
|
||||
if (!isOnline.value) {
|
||||
const cachedIntervals = cache.peek<Interval[]>(`intervals:${wk}`)
|
||||
const cachedSlots = cache.peek<SlotView[]>(`slots:${wk}`)
|
||||
if (cachedIntervals) intervals.value = cachedIntervals
|
||||
if (cachedSlots) slotViews.value = cachedSlots
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const { from, to } = utcRange(dates[0]!, dates[dates.length - 1]!)
|
||||
const [intRes, slotRes, myRes] = await Promise.all([
|
||||
supabase.from('intervals').select('*').gte('start_time', from).lte('start_time', to),
|
||||
supabase.from('reservation_slots').select('*').gte('start_time', from).lte('start_time', to),
|
||||
supabase.from('reservations').select('id').eq('user_id', user.value?.id).gte('start_time', from).lte('start_time', to).neq('status', 'cancelled'),
|
||||
])
|
||||
|
||||
intervals.value = intRes.data ?? []
|
||||
slotViews.value = slotRes.data ?? []
|
||||
myReservationIds.value = new Set((myRes.data ?? []).map((r: { id: string }) => r.id))
|
||||
loading.value = false
|
||||
|
||||
if (intRes.data) cache.set(`intervals:${wk}`, intRes.data)
|
||||
if (slotRes.data) cache.set(`slots:${wk}`, slotRes.data)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadAll() {
|
||||
await fetchBoats()
|
||||
await fetchSchedule()
|
||||
})
|
||||
}
|
||||
|
||||
// Re-fetch when the visible date range changes
|
||||
watch(visibleDates, fetchSchedule)
|
||||
watch(user, (val) => { if (val) loadAll() }, { immediate: true })
|
||||
onIonViewWillEnter(() => { if (user.value) loadAll() })
|
||||
|
||||
// Re-fetch when the visible date range changes (user-driven navigation)
|
||||
watch(visibleDates, () => { if (user.value) fetchSchedule() })
|
||||
|
||||
// ── Slot computation ─────────────────────────────────────────
|
||||
function daySlots(boatId: string, dateIso: string): SlotBlock[] {
|
||||
@@ -248,8 +291,10 @@ function daySlots(boatId: string, dateIso: string): SlotBlock[] {
|
||||
boatId,
|
||||
startTime: interval.start_time,
|
||||
endTime: interval.end_time,
|
||||
type: 'booked',
|
||||
type: 'booked' as const,
|
||||
status: res.status,
|
||||
isOwn: myReservationIds.value.has(res.id),
|
||||
memberName: res.member_name ?? null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -257,8 +302,10 @@ function daySlots(boatId: string, dateIso: string): SlotBlock[] {
|
||||
boatId,
|
||||
startTime: interval.start_time,
|
||||
endTime: interval.end_time,
|
||||
type: 'available',
|
||||
type: 'available' as const,
|
||||
status: null,
|
||||
isOwn: false,
|
||||
memberName: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -284,14 +331,63 @@ function slotLabelShort(slot: SlotBlock): string {
|
||||
}
|
||||
|
||||
function bookSlot(slot: SlotBlock) {
|
||||
router.push({
|
||||
path: '/reservations/create',
|
||||
query: {
|
||||
boatId: slot.boatId,
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
const boat = boats.value.find(b => b.id === slot.boatId)
|
||||
if (boat) setDraft(boat, slot.startTime, slot.endTime)
|
||||
router.push('/reservations/create')
|
||||
}
|
||||
|
||||
function handleSlotClick(slot: SlotBlock) {
|
||||
if (slot.type === 'available') {
|
||||
bookSlot(slot)
|
||||
} else if (slot.isOwn || authStore.isAdmin) {
|
||||
openReservationSheet(slot)
|
||||
}
|
||||
}
|
||||
|
||||
async function openReservationSheet(slot: SlotBlock) {
|
||||
const isFuture = new Date(slot.startTime) > new Date()
|
||||
const memberName = slot.memberName ?? ''
|
||||
const header = memberName
|
||||
? `${memberName} · ${fmtTime(slot.startTime)}–${fmtTime(slot.endTime)}`
|
||||
: `${fmtTime(slot.startTime)}–${fmtTime(slot.endTime)}`
|
||||
const sheet = await actionSheetController.create({
|
||||
header,
|
||||
buttons: [
|
||||
...(isFuture ? [
|
||||
{
|
||||
text: 'Edit',
|
||||
icon: createOutline,
|
||||
handler: () => void router.push(`/reservations/edit/${slot.id}`),
|
||||
},
|
||||
{
|
||||
text: 'Cancel Reservation',
|
||||
role: 'destructive',
|
||||
icon: trashOutline,
|
||||
handler: () => void confirmCancelSlot(slot.id),
|
||||
},
|
||||
] : []),
|
||||
{ text: 'Dismiss', role: 'cancel' },
|
||||
],
|
||||
})
|
||||
await sheet.present()
|
||||
}
|
||||
|
||||
async function confirmCancelSlot(id: string) {
|
||||
const alert = await alertController.create({
|
||||
header: 'Cancel Reservation',
|
||||
message: 'Are you sure you want to cancel this reservation?',
|
||||
buttons: [
|
||||
{ text: 'Keep it', role: 'cancel' },
|
||||
{ text: 'Cancel Reservation', role: 'destructive', handler: () => void cancelSlot(id) },
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
async function cancelSlot(id: string) {
|
||||
await supabase.from('reservations').update({ status: 'cancelled' }).eq('id', id)
|
||||
slotViews.value = slotViews.value.filter(s => s.id !== id)
|
||||
myReservationIds.value.delete(id)
|
||||
}
|
||||
|
||||
function slotTitle(slot: SlotBlock): string {
|
||||
@@ -340,7 +436,9 @@ function slotTitle(slot: SlotBlock): string {
|
||||
}
|
||||
.slot-time { font-variant-numeric: tabular-nums; font-size: 0.9rem; }
|
||||
.slot-status { font-size: 0.75rem; opacity: 0.85; }
|
||||
.slot-member { font-size: 0.72rem; opacity: 0.8; font-style: italic; min-height: 1em; }
|
||||
.slot-chevron { font-size: 1rem; opacity: 0.8; flex-shrink: 0; }
|
||||
.slot-member-sm { font-size: 0.62rem; opacity: 0.8; font-style: italic; }
|
||||
|
||||
.slot-tappable {
|
||||
cursor: pointer;
|
||||
@@ -455,16 +553,17 @@ function slotTitle(slot: SlotBlock): string {
|
||||
|
||||
/* Compact slot variant for desktop cells */
|
||||
.slot-sm {
|
||||
padding: 0.2rem 0.4rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.05rem;
|
||||
border-radius: 0.3rem;
|
||||
min-height: 0;
|
||||
}
|
||||
.slot-time-sm { font-variant-numeric: tabular-nums; font-size: 0.72rem; line-height: 1.2; }
|
||||
.slot-sep { display: none; }
|
||||
.slot-status-sm { font-size: 0.65rem; opacity: 0.85; }
|
||||
.slot-time-sm { font-variant-numeric: tabular-nums; font-size: 0.72rem; line-height: 1.3; font-weight: 600; }
|
||||
.slot-status-sm { font-size: 0.65rem; opacity: 0.85; line-height: 1.3; }
|
||||
.slot-member-sm { font-size: 0.65rem; opacity: 0.8; font-style: italic; line-height: 1.3; min-height: 0.9em; }
|
||||
|
||||
/* ── Legend ─────────────────────────────────────────────────── */
|
||||
.legend {
|
||||
|
||||
@@ -8,7 +8,7 @@ export type Json =
|
||||
|
||||
// Domain types
|
||||
export type MemberRole = 'member' | 'skipper' | 'admin' | 'boatswain' | 'volunteer' | 'instructor'
|
||||
export type ReservationStatus = 'pending' | 'tentative' | 'confirmed'
|
||||
export type ReservationStatus = 'pending' | 'tentative' | 'confirmed' | 'cancelled'
|
||||
export interface Defect {
|
||||
type: string
|
||||
severity: string
|
||||
@@ -256,9 +256,11 @@ export type Database = {
|
||||
Row: {
|
||||
id: string
|
||||
boat_id: string
|
||||
user_id: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
status: ReservationStatus
|
||||
member_name: string | null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Session Handoff: Historical Booking Constraint + Offline Cache + Booking Draft
|
||||
**Date:** 2026-04-21
|
||||
**Session Duration:** ~2 hours
|
||||
**Session Focus:** Enforced historical booking rule, fixed Edge Function error surfacing, implemented offline cache system with Realtime updates, and fixed slot-click navigation from schedule to create page using a booking draft composable.
|
||||
**Context Usage at Handoff:** ~75%
|
||||
|
||||
---
|
||||
|
||||
## What Was Accomplished
|
||||
|
||||
1. **Historical booking constraint** — tests written and Edge Function enforced → `tests/integration/booking-constraints.test.ts`, `supabase/functions/create-reservation/index.ts`
|
||||
2. **Error message fix** — "Can not book a reservation in the past." is the user-facing message for `historical_booking_not_allowed` (422)
|
||||
3. **Fixed `functions-js` v2 error body parsing** — `response.data` is `null` on non-2xx; body must be read from `response.error.context.json()` → `app/pages/reservations/create.vue`
|
||||
4. **Historical booking uses `end_time`** — a booking is historical only when its `end_time < now()` (not `start_time`)
|
||||
5. **Offline cache system** — localStorage, 24h TTL, Realtime-updated → `app/composables/useAppCache.ts`, `app/composables/useOfflineStatus.ts`
|
||||
6. **Booking draft composable** — module-level ref for cross-page state (replaces broken query-param deep-link) → `app/composables/useBookingDraft.ts`
|
||||
7. **Offline indicator** — fixed chip in top-right corner in `app/app.vue`; Realtime channel `app-cache-sync` patches `slots:`, `intervals:`, and `boats` cache on DB changes
|
||||
8. **Schedule page cache integration** → `app/pages/schedule.vue`: `fetchBoats` and `fetchSchedule` write to cache on success, read from cache when offline
|
||||
9. **Create page booking draft** → `app/pages/reservations/create.vue`: `onIonViewWillEnter` reads draft (no async fetch needed), jumps to step 2 with pre-filled boat + slot; also cache-aware `loadSlots` for offline step 1
|
||||
10. **CLAUDE.md caching rule documented** — "Every table/view read from Supabase must be written to `useAppCache` on success and read from it when offline" — includes pattern, key conventions, Realtime guidance
|
||||
|
||||
---
|
||||
|
||||
## Exact State of Work in Progress
|
||||
|
||||
- **Booking draft navigation**: complete — clicking a slot in schedule sets draft and routes to `/reservations/create`; create page reads draft in `onIonViewWillEnter` and goes directly to step 2
|
||||
- **Offline cache**: complete for `boats`, `intervals`, `slots` (schedule + create pages); NOT yet applied to other pages (`/boat`, `/reference`, `/profile`, admin pages)
|
||||
- **Realtime**: subscribed to `reservations`, `intervals`, `boats` in `app.vue`; if new tables are cached in future, add subscriptions to `app-cache-sync` channel
|
||||
|
||||
---
|
||||
|
||||
## Decisions Made This Session
|
||||
|
||||
- **Historical = `end_time < now()`** BECAUSE a session that started in the past but hasn't ended yet is still bookable — STATUS: confirmed
|
||||
- **Query params abandoned for slot navigation** BECAUSE `+` in ISO timestamps (`+00:00`) URL-encodes to `%2B`, which can be decoded as a space by some parsers; async boat fetch adds an auth-timing race; `onIonViewWillEnter` route-timing is unreliable — CHOSE module-level reactive `ref` (booking draft) instead — STATUS: confirmed
|
||||
- **`functions-js` v2 error body is in `error.context`, not `data`** — `data` is `null` on non-2xx; `error.context` is the raw `Response` — applies to all error code handling in `create.vue` — STATUS: confirmed
|
||||
- **Cache key for schedule data = ISO week Monday** (`cache.weekKey(utcIso)` → `weekMonday(date)`) so desktop (week view) and mobile (day view) share the same cache entries — STATUS: confirmed
|
||||
- **Offline = read-only** — booking submission is not attempted offline (Edge Function call fails naturally); no explicit offline guard on submit — ASSUMED acceptable
|
||||
|
||||
---
|
||||
|
||||
## Key Numbers Generated or Discovered This Session
|
||||
|
||||
- `functions-js` version in use: `2.100.0` (confirmed from `node_modules`)
|
||||
- Cache TTL: 24 hours (ms: `86_400_000`)
|
||||
- Realtime channel name: `app-cache-sync`
|
||||
- Error code for historical booking: `historical_booking_not_allowed` (HTTP 422)
|
||||
- localStorage key prefix: `cache:` (e.g., `cache:boats`, `cache:slots:2026-04-20`, `cache:intervals:2026-04-20`)
|
||||
|
||||
---
|
||||
|
||||
## Files Created or Modified
|
||||
|
||||
| File Path | Action | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `app/composables/useOfflineStatus.ts` | Created | Module-level `isOnline` ref; wires `online`/`offline` browser events |
|
||||
| `app/composables/useAppCache.ts` | Created | localStorage cache; `get` (TTL-enforced), `peek` (stale-ok), `set`, `invalidate`, `invalidatePrefix`, `weekKey` |
|
||||
| `app/composables/useBookingDraft.ts` | Created | Module-level `BookingDraft` ref; `set(boat, startTime, endTime)` / `take()` |
|
||||
| `app/app.vue` | Modified | Offline chip (fixed top-right, warning colour); Realtime `app-cache-sync` channel for `reservations`, `intervals`, `boats` |
|
||||
| `app/pages/schedule.vue` | Modified | `fetchBoats`/`fetchSchedule` cache-aware; `bookSlot` sets draft instead of query params |
|
||||
| `app/pages/reservations/create.vue` | Modified | `onIonViewWillEnter` consumes draft (jumps to step 2); `loadSlots` cache-aware; `functions-js` v2 error body fix; `historical_booking_not_allowed` in `codeMessages` |
|
||||
| `supabase/functions/create-reservation/index.ts` | Modified | Historical guard: `endDate < now()` → admin-only (422 `historical_booking_not_allowed`); admin skips cert check + booking-limit checks |
|
||||
| `tests/integration/booking-constraints.test.ts` | Modified | Added `describe('historical booking constraint')`: member rejected (422), admin allowed (201), skipper rejected (422) |
|
||||
| `CLAUDE.md` | Modified | Added "Offline Cache" section under Architecture with pattern, key conventions, Realtime guidance, and booking-draft pattern |
|
||||
|
||||
---
|
||||
|
||||
## What the NEXT Session Should Do
|
||||
|
||||
1. **Verify slot-click flow end to end**: open schedule page, click an available slot, confirm create page opens at step 2 with the correct boat name and time pre-filled, submit a reservation
|
||||
2. **Apply cache pattern to remaining pages**: `/boat` (boats list), `/reference` (reference_docs), `/profile` (member record), admin pages as needed — follow the pattern documented in `CLAUDE.md`
|
||||
3. **Decide on the DatePicker replacement** (OPEN from previous session): `<DatePicker inline>` on `app/pages/index.vue:59` is the only PrimeVue usage — options are remove the calendar card, keep PrimeVue, or use native `<input type="date">`
|
||||
4. **Build `app/pages/admin/reservations.vue`** — admin view of all bookings (file exists, likely scaffolded)
|
||||
5. **Wire cancel-reservation** — OPEN: is a cancel Edge Function planned?
|
||||
|
||||
---
|
||||
|
||||
## Open Questions Requiring User Input
|
||||
|
||||
- [ ] `app/pages/index.vue:59` — what replaces `<DatePicker inline>`? Remove card, keep PrimeVue, or native input? Impacts whether PrimeVue stays in the stack
|
||||
- [ ] Is a cancel-reservation Edge Function planned? Impacts backend scope before April 30
|
||||
- [ ] Should offline submission attempts show an explicit "You are offline — cannot book" message rather than a generic network error?
|
||||
|
||||
---
|
||||
|
||||
## Assumptions That Need Validation
|
||||
|
||||
- ASSUMED: `app/pages/admin/reservations.vue` is scaffolded but incomplete — verify by reading the file
|
||||
- ASSUMED: offline booking submission failing silently (network error toast) is acceptable — validate with Patrick
|
||||
- ASSUMED: `onIonViewWillEnter` fires before the user can interact with the page — if there is a visible flash of step 1 before the draft is consumed, add `step.value = 2` synchronously before the `await` in the handler
|
||||
|
||||
---
|
||||
|
||||
## Files to Load Next Session
|
||||
|
||||
- `docs/summaries/handoff-2026-04-21-historical-booking-offline-cache.md` — this file
|
||||
- `docs/summaries/00-project-brief.md` — project context
|
||||
- `app/pages/reservations/create.vue` — if continuing booking flow work
|
||||
- `app/pages/admin/reservations.vue` — if building admin bookings view
|
||||
- `app/pages/index.vue` — if resolving the DatePicker question
|
||||
@@ -0,0 +1,55 @@
|
||||
# Session Handoff: Project Brief + Web Awesome Spike
|
||||
**Date:** 2026-04-21
|
||||
**Session Duration:** ~1.5 hours
|
||||
**Session Focus:** Created project brief, evaluated and spiked Web Awesome as a PrimeVue replacement, abandoned after discovering no calendar component
|
||||
**Context Usage at Handoff:** ~50%
|
||||
|
||||
## What Was Accomplished
|
||||
1. Created project brief template → `docs/summaries/00-project-brief.md` (user filled in club name, deadline, boat/member counts, booking rules)
|
||||
2. Evaluated Web Awesome as PrimeVue replacement — determined feasible given PrimeVue was only used for `<DatePicker>` in one file
|
||||
3. Spiked Web Awesome Pro installation: configured `@web.awesome.me` registry on Cloudsmith, fixed stray backslash in auth token, installed `@web.awesome.me/webawesome-pro@3.5.0`
|
||||
4. Discovered Web Awesome Pro 3.5.0 has no calendar component → abandoned spike, reverted to main
|
||||
|
||||
## Exact State of Work in Progress
|
||||
- Web Awesome spike: fully reverted — main branch is clean, `node_modules` restored from lockfile
|
||||
- `docs/summaries/00-project-brief.md`: created and partially filled; `[FILL: Current Phase]` and booking rule detail `[FILL: any other rules]` remain open
|
||||
|
||||
## Decisions Made This Session
|
||||
- **Abandon Web Awesome**: no calendar/date-picker component in v3.5.0 — STATUS: confirmed
|
||||
- **Keep PrimeVue**: only one component in use (`<DatePicker inline>` on home page); not worth replacing until a suitable alternative exists — STATUS: confirmed
|
||||
- **`WEBAWESOME_NPM_TOKEN` in `.env`**: registry token stored in `.env` (gitignored), referenced via `${WEBAWESOME_NPM_TOKEN}` in `.npmrc`/`.yarnrc` — STATUS: confirmed pattern for future private registries
|
||||
- **`webawesome` branch deleted (implicitly)**: all work was uncommitted; restored via `git restore` + `yarn install` — no branch to clean up
|
||||
|
||||
## Key Numbers Generated or Discovered This Session
|
||||
- PrimeVue usage in app: 1 component (`<DatePicker>` in `app/pages/index.vue:59`)
|
||||
- Web Awesome Pro version spiked: 3.5.0
|
||||
- Cloudsmith registry: `https://npm.cloudsmith.io/fortawesome/webawesome-pro/`
|
||||
- Deadline: April 30 (9 days away at time of handoff)
|
||||
- Boats in program: 4
|
||||
- Members: 20–30
|
||||
- Weekly pre-booking limit: 2
|
||||
|
||||
## Files Created or Modified
|
||||
| File Path | Action | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `docs/summaries/00-project-brief.md` | Created | Project brief — club name, personas, stack, CI/CD, constraints, booking rules; partially filled |
|
||||
|
||||
## What the NEXT Session Should Do
|
||||
1. **First**: Decide what replaces the `<DatePicker>` on the home page — options: remove the Calendar card entirely (simplest, given deadline), keep PrimeVue just for that one component, or use a native `<input type="date">` unstyled
|
||||
2. **Then**: Build out reservations UI — `app/pages/reservations/create.vue` exists (scaffolded with Ionic components); wire it to the `create-reservation` Edge Function
|
||||
3. **Then**: Build `app/pages/admin/reservations.vue` (exists as untracked file per git status) — admin view of all bookings
|
||||
|
||||
## Open Questions Requiring User Input
|
||||
- [ ] What replaces `<DatePicker inline>` on the home page? Remove card, keep PrimeVue, or native input? — impacts whether PrimeVue stays in the stack
|
||||
- [ ] What is the full set of admin pages needed beyond `reservations` and `boat`? — impacts session planning before April 30
|
||||
- [ ] Are cancel-reservation and other Edge Functions planned? — impacts backend scope
|
||||
|
||||
## Assumptions That Need Validation
|
||||
- ASSUMED: `app/pages/admin/reservations.vue` is scaffolded but incomplete — verify by reading file
|
||||
- ASSUMED: `app/pages/reservations/create.vue` is scaffolded but not wired to Edge Function — verify by reading file
|
||||
|
||||
## Files to Load Next Session
|
||||
- `docs/summaries/handoff-2026-04-21-project-brief-webawesome-spike.md` — this file
|
||||
- `docs/summaries/00-project-brief.md` — for project context
|
||||
- `app/pages/reservations/create.vue` — if working on reservations UI
|
||||
- `app/pages/admin/reservations.vue` — if working on admin bookings view
|
||||
73
docs/planning/user-stories.md
Normal file
73
docs/planning/user-stories.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# User Stories
|
||||
|
||||
## Open Reservations (Crew Sign-up)
|
||||
|
||||
A reservation can be marked **open**, meaning other members can sign up as crew. Skippers control whether joining is open (anyone can join) or requires approval.
|
||||
|
||||
| ID | Persona | Story |
|
||||
|----|---------|-------|
|
||||
| US-01 | Member | As a Member, I want to be able to make Open Reservations, so that other members can sign up to be crew. |
|
||||
| US-02 | Member | As a Member, I want to be able to join an open reservation as crew, so I can sail with a skilled skipper. |
|
||||
| US-03 | Member | As a Member, I want to be able to manage my registration to join a reservation, so I can free up the spot if I can't make it. |
|
||||
| US-04 | Skipper | As a skipper, I want to note details about the sailing reservation, to help others decide if they want to join. |
|
||||
| US-05 | Skipper | As a skipper, I want to be able to set an open reservation as "anyone can join" or "approval required." |
|
||||
| US-06 | Skipper | As a skipper, I want to be able to approve crew members, so that I can ensure I have the right mix of people/skills. |
|
||||
| US-07 | Skipper | As a skipper, I want to be able to add members, and non-members to my reservation, regardless of whether it is open or not. |
|
||||
|
||||
## Notifications
|
||||
|
||||
| ID | Persona | Story | Phase |
|
||||
|----|---------|-------|-------|
|
||||
| US-08 | Member | As a member, I want to be notified in a dedicated Discord channel whenever a new open reservation is created, so that I can join these sailings. | Near-term |
|
||||
| US-09 | Member | As a member, I want to receive SMS or in-app notifications for events like: upcoming reservation reminders, crew-joined notifications, etc. | Longer-term |
|
||||
|
||||
**Notes:**
|
||||
- US-08 (Discord): POST to a Discord webhook when an open reservation is created. Likely an Edge Function triggered on insert. The channel and webhook URL are configuration values.
|
||||
- US-09 (SMS/in-app): Requires a notification service (e.g. Twilio for SMS, or Supabase Realtime + push for in-app). Defer until after launch.
|
||||
|
||||
## Check-out / Check-in Forms
|
||||
|
||||
Required pre-sail and post-sail paperwork. Both skipper and crew may submit.
|
||||
|
||||
| ID | Persona | Story |
|
||||
|----|---------|-------|
|
||||
| US-10 | Skipper / Crew | As a skipper or crew member, I want to submit the check-out and check-in forms, which are required. |
|
||||
| US-11 | Skipper | As a skipper, I would like reminders to fill out the check-out/in forms at the beginning and end of my reservation. |
|
||||
|
||||
**Notes:**
|
||||
- US-10: Form fields TBD — likely: condition notes, crew list, fuel level, departure/return times.
|
||||
- US-11: Reminder timing — check-out reminder at reservation start time; check-in reminder at reservation end time. Notification channel TBD (Discord, SMS, or in-app per US-08/09).
|
||||
- Both stories depend on the check-out/in form schema being defined with the Program Administrator.
|
||||
|
||||
## Member Profiles
|
||||
|
||||
Members can view each other's profiles. Some fields are always public; others are selectable public/private by the member.
|
||||
|
||||
| ID | Persona | Story |
|
||||
|----|---------|-------|
|
||||
| US-12 | Member | As a member, I want to be able to see profile information of other members, like a social network site. |
|
||||
| US-13 | Member | As a member, I want to control which parts of my profile are public or private. |
|
||||
|
||||
**Known visibility rules:**
|
||||
|
||||
| Field | Visibility |
|
||||
|-------|------------|
|
||||
| First name, Last name | Always public |
|
||||
| Certifications | Always public (relevant to crew sign-up) |
|
||||
| Email | Member-controlled (public / private) |
|
||||
| Phone | Member-controlled (public / private) |
|
||||
|
||||
**Notes:**
|
||||
- US-12: Profile view should be accessible by clicking a member's name anywhere it appears in the app (e.g. schedule, crew list).
|
||||
- US-13: Privacy settings stored per-field on the `members` row (e.g. `email_public: boolean`, `phone_public: boolean`). Enforced via RLS or a view that filters hidden fields for non-owners.
|
||||
- Admin can always see all fields regardless of member's privacy setting.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- [ ] US-07: What constitutes a "non-member"? Does a non-member need an account, or just a name on the reservation?
|
||||
- [ ] US-05: Is approval per-crew-member (skipper approves each request) or a toggle that gates all join requests?
|
||||
- [ ] US-10: What fields are on the check-out/in forms? Are these the same forms as the current paper process?
|
||||
- [ ] US-11: Which notification channel is available for form reminders before US-09 is implemented?
|
||||
- [ ] US-08: Discord webhook URL and channel name — stored in Vault at `kv/oys/`?
|
||||
- [ ] US-13: Full list of member-controlled fields — is phone already in the schema? Any other fields (e.g. Slack ID, bio)?
|
||||
- [ ] US-13: Should privacy enforcement be RLS on the `members` table, or a separate `member_profiles` view that masks hidden fields?
|
||||
106
docs/summaries/00-project-brief.md
Normal file
106
docs/summaries/00-project-brief.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Project Brief: OYS Borrow a Boat (oysqn.app)
|
||||
**Created:** 2026-04-21
|
||||
**Owner:** Patrick Toal
|
||||
|
||||
---
|
||||
|
||||
## Identity
|
||||
|
||||
- **App name:** OYS Borrow a Boat (oysqn.app)
|
||||
- **Previous app:** bab-app (Appwrite backend — retired)
|
||||
- **Purpose:** Manage a Borrow a Boat program for a Yacht Club (OYS = Oakville Yacht Squadron)
|
||||
- **Club:** Oakville Yacht Club, Oakville, ON Canada
|
||||
- **Target users:** Club members enrolled in the Borrow a Boat program
|
||||
|
||||
---
|
||||
|
||||
## Personas
|
||||
|
||||
| Persona | Description |
|
||||
|---------|-------------|
|
||||
| BAB Member | Enrolled club member; can browse boats and make reservations |
|
||||
| Certified Skipper | Credentialed to take boats without supervision |
|
||||
| Program Administrator | Manages boats, intervals, rules, and member access |
|
||||
| Boatswain | Responsible for boat maintenance and readiness |
|
||||
| Volunteer | Assists with program logistics |
|
||||
| Instructor | Delivers sailing instruction; may certify members |
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Framework | Nuxt 4 (SSR=false, SPA mode) |
|
||||
| UI | Ionic Vue (@ionic/vue) + PrimeVue 4 |
|
||||
| Language | TypeScript |
|
||||
| Backend | Supabase (Auth, DB, Edge Functions, Storage) |
|
||||
| Testing | Vitest (unit), Vitest (integration, hits real Supabase), Playwright (E2E) |
|
||||
| Package manager | Yarn |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Highlights
|
||||
|
||||
- `app/app.vue` — IonApp + IonMenu + IonRouterOutlet (no NuxtLayout/NuxtPage)
|
||||
- Auth: magic link + OTP only; no password auth; no self-service signup; admin-only invite
|
||||
- Edge Functions: Bearer JWT → `adminClient.auth.getUser(token)` pattern; all DB ops via service role
|
||||
- Icons: Ionicons only (`ionicons/icons`)
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
- **Source control:** Gitea
|
||||
- **CI:** Gitea Actions (unit tests + build on PR)
|
||||
- **Deploy (dev):** EDA (Event-Driven Ansible) → AAP → nginx artifact swap on bab1
|
||||
- **Deploy (prod):** EDA → AAP (manual approval gate) → S3 sync
|
||||
- **E2E:** Playwright, run post-deploy via AAP
|
||||
- **Supabase environments:** Two projects — dev and prod (project IDs in Vault)
|
||||
- **Secrets:** HashiCorp Vault at `http://nas.lan.toal.ca:8200`, path `kv/oys/`
|
||||
|
||||
---
|
||||
|
||||
## Current Phase
|
||||
|
||||
[FILL: e.g., "Active development — reservations UI", "Beta testing", "Production"]
|
||||
|
||||
---
|
||||
|
||||
## Key Constraints
|
||||
|
||||
- Deadline: April 30th (Launch Day)
|
||||
- Four boats in the program
|
||||
- Between 20 - 30 Members
|
||||
|
||||
---
|
||||
|
||||
## Booking Rules (known so far)
|
||||
|
||||
- Weekly pre-booking limit enforced (exact number: 2)
|
||||
- Overlap constraint: same boat cannot be double-booked
|
||||
|
||||
---
|
||||
|
||||
## Planned Features (not yet built)
|
||||
|
||||
See `docs/planning/user-stories.md` for full stories and open questions.
|
||||
|
||||
| Feature | Stories | Phase |
|
||||
|---------|---------|-------|
|
||||
| Open Reservations + crew sign-up | US-01 – US-07 | Near-term |
|
||||
| Discord notifications (open reservations) | US-08 | Near-term |
|
||||
| Check-out / Check-in forms | US-10, US-11 | Near-term |
|
||||
| SMS / in-app notifications | US-09 | Longer-term |
|
||||
|
||||
---
|
||||
|
||||
## Open Items
|
||||
|
||||
- [ ] Are cancel-reservation and admin Edge Functions planned?
|
||||
- [ ] What is the full set of admin pages needed?
|
||||
- [ ] What is the club name for display in the app?
|
||||
- [X] What is the season start date / go-live target?
|
||||
- [ ] US-07: What constitutes a "non-member" on a reservation?
|
||||
- [ ] US-10: What fields are on the check-out/in forms?
|
||||
- [ ] US-08: Discord webhook URL / channel — confirm stored in Vault at kv/oys/
|
||||
@@ -0,0 +1,124 @@
|
||||
# Session Handoff: Schedule Interactivity, Admin Features, Routing Fix
|
||||
**Date:** 2026-04-22
|
||||
**Session Duration:** ~3 hours
|
||||
**Session Focus:** Fixed IonRouterOutlet route params bug; made schedule page fully interactive on desktop; added admin create/edit capabilities; added member names to schedule; documented Ionic+Nuxt watch-outs and new user stories.
|
||||
**Context Usage at Handoff:** ~85%
|
||||
|
||||
---
|
||||
|
||||
## What Was Accomplished
|
||||
|
||||
1. **Diagnosed and fixed `params: {}` bug on edit page** → root cause: Nuxt's auto-imported `useRoute()` always returns empty params inside IonRouterOutlet. Fix: `import { useRoute, useRouter } from 'vue-router'` directly. Documented in CLAUDE.md under "Ionic + Nuxt Watch-outs".
|
||||
- Output: `app/pages/reservations/edit/[id].vue` (import added)
|
||||
- Output: `CLAUDE.md` (new "Ionic + Nuxt Watch-outs" section under Routing)
|
||||
|
||||
2. **Made desktop schedule slots interactive** → previously desktop grid had no click handlers. Added same tappable logic as mobile: available slots → create flow; own booked slots → action sheet with Edit/Cancel.
|
||||
- Output: `app/pages/schedule.vue` (template changes in desktop grid)
|
||||
|
||||
3. **Added confirmation dialog before cancelling from schedule** → both mobile and desktop Cancel actions now show `alertController` confirmation before proceeding.
|
||||
- Output: `app/pages/schedule.vue` (`confirmCancelSlot()` wrapping `cancelSlot()`)
|
||||
|
||||
4. **Added admin interactivity on schedule** → admins can tap any booked slot (not just own). Action sheet header shows member name for admin context.
|
||||
- Output: `app/pages/schedule.vue` (`authStore.isAdmin` checks in tappable conditions and `handleSlotClick`)
|
||||
|
||||
5. **Added member names to schedule slots** → put `member_name` into `reservation_slots` view via LEFT JOIN on `members`. `security_invoker=false` means the JOIN bypasses RLS — no separate query needed, no RLS block for non-admins.
|
||||
- Output: `supabase/migrations/20260422000000_reservation_slots_add_user_id.sql`
|
||||
- Output: `app/types/supabase.ts` (added `user_id`, `member_name` to `reservation_slots` Row)
|
||||
- Output: `app/pages/schedule.vue` (replaced `getUserNameById` calls with `slot.memberName`)
|
||||
|
||||
6. **Fixed slot display** → desktop slots now show `HH:MM-HH:MM` on one line. All slots (available + booked, mobile + desktop) render a consistent 3-row structure (time, status, member) with `min-height` reserved for the member row so sizes are uniform.
|
||||
- Output: `app/pages/schedule.vue` (template restructure + CSS)
|
||||
|
||||
7. **Admin member selector on create reservation** → when admin, step 2 of create flow shows a Member dropdown (defaults to "Self (admin)"). Selected member passed as `target_user_id` to edge function.
|
||||
- Output: `app/pages/reservations/create.vue`
|
||||
|
||||
8. **Admin member selector on edit reservation** → when admin, edit page loads all members and shows a Member selector pre-populated from the reservation's `user_id`. Saved in update payload.
|
||||
- Output: `app/pages/reservations/edit/[id].vue`
|
||||
|
||||
9. **Edge function: admin target_user_id support** → accepts optional `target_user_id` in body. If caller is admin and field is set, uses it as the reservation owner. Admins bypass cert check, weekly limit, and weekend limit. Historical booking guard simplified (was duplicating the admin fetch). Overlap constraint still applies to all.
|
||||
- Output: `supabase/functions/create-reservation/index.ts`
|
||||
|
||||
10. **Documented user stories** → created `docs/planning/user-stories.md` with US-01–US-13 across four feature areas. Updated `docs/summaries/00-project-brief.md` with planned features table.
|
||||
- Output: `docs/planning/user-stories.md` (new file)
|
||||
- Output: `docs/summaries/00-project-brief.md` (planned features + open items)
|
||||
|
||||
---
|
||||
|
||||
## Exact State of Work in Progress
|
||||
|
||||
- All changes typecheck clean (`yarn typecheck` → no errors)
|
||||
- Migration `20260422000000` applied to local DB via `supabase db reset`
|
||||
- Migration NOT yet pushed to dev/prod Supabase — needs `supabase db push` or deploy pipeline
|
||||
- Edit page fix (import from `vue-router`) confirmed working conceptually; not browser-tested this session
|
||||
|
||||
---
|
||||
|
||||
## Decisions Made This Session
|
||||
|
||||
- **Import `useRoute`/`useRouter` from `vue-router` directly, never use Nuxt auto-import** BECAUSE Nuxt's auto-imported `useRoute()` always returns `params: {}` inside IonRouterOutlet — confirmed by official docs at ionic.nuxtjs.org/get-started/watch-outs and by console.log showing `params: {}` at runtime — STATUS: CONFIRMED. Apply to all future param-reading pages.
|
||||
|
||||
- **Put `member_name` in the view via JOIN rather than lazy-fetching via `getUserNameById`** BECAUSE `getUserNameById` relies on a members query that is blocked by RLS for non-admin users (returns null → "Unknown"). `security_invoker=false` on the view makes the JOIN run as owner, bypassing RLS — STATUS: CONFIRMED.
|
||||
|
||||
- **Admins bypass all booking limits and cert checks in the edge function** BECAUSE admin-created bookings are operational overrides, consistent with how `admin/reservations.vue` (direct insert) already worked — STATUS: CONFIRMED. Overlap constraint still applies to everyone.
|
||||
|
||||
- **Always render member name row in slot blocks (even if empty string)** BECAUSE rendering it conditionally causes slots to have different heights — STATUS: CONFIRMED.
|
||||
|
||||
---
|
||||
|
||||
## Key Numbers Generated or Discovered This Session
|
||||
|
||||
- `reservation_slots` view now has 7 columns: `id, boat_id, user_id, start_time, end_time, status, member_name`
|
||||
- US-12, US-13 added (member profiles) — total user stories now: 13
|
||||
- Migration sequence: `...000002_prevent_past_reservation_updates.sql` → `...20260422000000_reservation_slots_add_user_id.sql`
|
||||
|
||||
---
|
||||
|
||||
## Files Created or Modified
|
||||
|
||||
| File Path | Action | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `supabase/migrations/20260422000000_reservation_slots_add_user_id.sql` | Created | Drops and recreates `reservation_slots` view with `user_id` and `member_name` (LEFT JOIN members) |
|
||||
| `app/types/supabase.ts` | Modified | Added `user_id: string` and `member_name: string \| null` to `reservation_slots` Row |
|
||||
| `app/pages/schedule.vue` | Modified | Desktop interactivity; admin access; member names; confirmation dialog; slot display fix |
|
||||
| `app/pages/reservations/edit/[id].vue` | Modified | Import `useRoute`/`useRouter` from `vue-router`; admin member selector |
|
||||
| `app/pages/reservations/create.vue` | Modified | Admin member selector (step 2); passes `target_user_id` to edge function |
|
||||
| `supabase/functions/create-reservation/index.ts` | Modified | `target_user_id` support; admin bypasses limits; deduped admin check |
|
||||
| `CLAUDE.md` | Modified | Added "Ionic + Nuxt Watch-outs" section (6 rules from official docs) |
|
||||
| `docs/planning/user-stories.md` | Created | US-01–US-13 across Open Reservations, Notifications, Check-out/in, Member Profiles |
|
||||
| `docs/summaries/00-project-brief.md` | Modified | Added planned features table linking to user stories |
|
||||
|
||||
---
|
||||
|
||||
## What the NEXT Session Should Do
|
||||
|
||||
1. **First**: Browser-test the edit page fix — navigate from home/schedule to an edit page and confirm reservation loads. Check console for any remaining `params: {}` warnings.
|
||||
2. **Then**: Push migration `20260422000000` to dev Supabase (`supabase db push --linked` or via deploy pipeline).
|
||||
3. **Consider**: Any other pages in the app that use `useRoute()` for params need the same `import { useRoute } from 'vue-router'` fix. Run a grep for auto-imported `useRoute` usage on param pages.
|
||||
4. **Consider next feature**: Open Reservations (US-01–US-07) or Member Profiles (US-12–US-13) — ask Patrick which to tackle.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions Requiring User Input
|
||||
|
||||
- [ ] US-07: What constitutes a "non-member" on a reservation? (name-only entry, or guest account?) — impacts crew schema design
|
||||
- [ ] US-10: What fields are on the check-out/in forms? — impacts DB schema
|
||||
- [ ] US-13: Full list of member-controlled privacy fields (is phone in schema? any other fields?) — impacts members table migration
|
||||
- [ ] US-13: Privacy enforcement via RLS on `members`, or a separate `member_profiles` view? — impacts DB design
|
||||
- [ ] US-08: Discord webhook URL/channel — confirm stored in Vault at `kv/oys/`?
|
||||
|
||||
---
|
||||
|
||||
## Assumptions That Need Validation
|
||||
|
||||
- ASSUMED: `import { useRoute } from 'vue-router'` fixes params for ALL IonRouterOutlet param pages, not just the edit page — validate by testing at least one other param route if more are added.
|
||||
- ASSUMED: `security_invoker=false` on `reservation_slots` view is sufficient to expose member names to all authenticated users without violating privacy intent — validate with Patrick (member names may eventually be subject to US-13 privacy rules).
|
||||
|
||||
---
|
||||
|
||||
## Files to Load Next Session
|
||||
|
||||
- `docs/summaries/handoff-2026-04-22-schedule-admin-routing-fixes.md` — this file
|
||||
- `docs/summaries/00-project-brief.md` — project state and open items
|
||||
- `docs/planning/user-stories.md` — if working on any new feature
|
||||
- `app/pages/schedule.vue` — if continuing schedule work
|
||||
- `CLAUDE.md` — always (routing rules, data fetching pattern, Ionic watch-outs)
|
||||
@@ -21,7 +21,13 @@ const OysTheme = definePreset(Aura, {
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
devtools: {
|
||||
enabled: true,
|
||||
|
||||
timeline: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
|
||||
ssr: false,
|
||||
|
||||
|
||||
@@ -87,4 +87,4 @@ Error generating stack: `+l.message+`
|
||||
<div id='root'></div>
|
||||
</body>
|
||||
</html>
|
||||
<template id="playwrightReportBase64">data:application/zip;base64,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</template>
|
||||
<template id="playwrightReportBase64">data:application/zip;base64,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</template>
|
||||
@@ -8,6 +8,7 @@ interface CreateReservationBody {
|
||||
comment?: string
|
||||
member_ids?: string[]
|
||||
guest_ids?: string[]
|
||||
target_user_id?: string // admin only: create on behalf of another member
|
||||
}
|
||||
|
||||
interface BookingConfig {
|
||||
@@ -83,7 +84,7 @@ Deno.serve(async (req: Request) => {
|
||||
const { data: { user }, error: authError } = await adminClient.auth.getUser(token)
|
||||
if (authError || !user) return errorResponse('unauthorized', 'Invalid session', 401)
|
||||
|
||||
const userId = user.id
|
||||
const callerId = user.id
|
||||
|
||||
// Parse body
|
||||
let body: CreateReservationBody
|
||||
@@ -93,7 +94,18 @@ Deno.serve(async (req: Request) => {
|
||||
return errorResponse('invalid_body', 'Request body must be JSON', 400)
|
||||
}
|
||||
|
||||
const { boat_id, start_time, end_time, reason = '', comment = '', member_ids = [], guest_ids = [] } = body
|
||||
const { boat_id, start_time, end_time, reason = '', comment = '', member_ids = [], guest_ids = [], target_user_id } = body
|
||||
|
||||
// Resolve admin status once — used for limit bypass and target_user_id trust
|
||||
const { data: callerMember } = await adminClient
|
||||
.from('members')
|
||||
.select('role')
|
||||
.eq('user_id', callerId)
|
||||
.single()
|
||||
const isAdmin = callerMember?.role === 'admin'
|
||||
|
||||
// Effective user: admin may create on behalf of another member
|
||||
const userId = (isAdmin && target_user_id) ? target_user_id : callerId
|
||||
|
||||
if (!boat_id || !start_time || !end_time) {
|
||||
return errorResponse('missing_fields', 'boat_id, start_time, and end_time are required', 400)
|
||||
@@ -110,6 +122,13 @@ Deno.serve(async (req: Request) => {
|
||||
return errorResponse('invalid_dates', 'end_time must be after start_time', 400)
|
||||
}
|
||||
|
||||
// ── Historical booking guard ────────────────────────────────────────────────
|
||||
const isPast = endDate.getTime() < Date.now()
|
||||
|
||||
if (isPast && !isAdmin) {
|
||||
return errorResponse('historical_booking_not_allowed', 'Can not book a reservation in the past.', 422)
|
||||
}
|
||||
|
||||
// ── 1. Load booking config ──────────────────────────────────────────────────
|
||||
const { data: configRows, error: configErr } = await adminClient
|
||||
.from('booking_config')
|
||||
@@ -142,7 +161,7 @@ Deno.serve(async (req: Request) => {
|
||||
if (boatErr || !boat) return errorResponse('not_found', 'Boat not found', 404)
|
||||
if (!boat.booking_available) return errorResponse('boat_unavailable', 'This boat is currently out of service', 422)
|
||||
|
||||
if (boat.required_certs.length > 0) {
|
||||
if (!isAdmin && !isPast && boat.required_certs.length > 0) {
|
||||
const { data: member } = await adminClient
|
||||
.from('members')
|
||||
.select('certifications')
|
||||
@@ -164,7 +183,7 @@ Deno.serve(async (req: Request) => {
|
||||
const advanceMs = config.open_session_advance_hours * 60 * 60 * 1000
|
||||
const isOpenSession = startDate.getTime() - Date.now() <= advanceMs
|
||||
|
||||
if (!isOpenSession) {
|
||||
if (!isAdmin && !isPast && !isOpenSession) {
|
||||
// ── 5. Weekly pre-booking limit ─────────────────────────────────────────
|
||||
const weekStart = isoWeekStart(startDate)
|
||||
const weekEnd = new Date(weekStart.getTime() + 7 * 24 * 60 * 60 * 1000)
|
||||
@@ -173,6 +192,7 @@ Deno.serve(async (req: Request) => {
|
||||
.from('reservations')
|
||||
.select('start_time, created_at')
|
||||
.eq('user_id', userId)
|
||||
.neq('status', 'cancelled')
|
||||
.gte('start_time', weekStart.toISOString())
|
||||
.lt('start_time', weekEnd.toISOString())
|
||||
|
||||
@@ -205,6 +225,7 @@ Deno.serve(async (req: Request) => {
|
||||
.from('reservations')
|
||||
.select('start_time, created_at')
|
||||
.eq('user_id', userId)
|
||||
.neq('status', 'cancelled')
|
||||
.gte('start_time', periodStart.toISOString())
|
||||
.lt('start_time', periodEnd.toISOString())
|
||||
|
||||
@@ -239,7 +260,7 @@ Deno.serve(async (req: Request) => {
|
||||
comment,
|
||||
member_ids,
|
||||
guest_ids,
|
||||
status: 'pending',
|
||||
status: 'confirmed',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Allow members to cancel their own reservations and edit reason/comment.
|
||||
|
||||
-- 1. Expand status check constraint to include 'cancelled'
|
||||
alter table public.reservations
|
||||
drop constraint reservations_status_check;
|
||||
|
||||
alter table public.reservations
|
||||
add constraint reservations_status_check
|
||||
check (status in ('pending', 'tentative', 'confirmed', 'cancelled'));
|
||||
|
||||
-- 2. Exclude cancelled reservations from the public slots view so cancelled
|
||||
-- slots appear as available again on the schedule.
|
||||
drop view if exists public.reservation_slots;
|
||||
|
||||
create view public.reservation_slots
|
||||
with (security_invoker = false)
|
||||
as
|
||||
select id, boat_id, start_time, end_time, status
|
||||
from public.reservations
|
||||
where status <> 'cancelled';
|
||||
|
||||
grant select on public.reservation_slots to authenticated;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- The overlap exclusion constraint must not apply to cancelled reservations,
|
||||
-- so that a cancelled slot can be re-booked by another member.
|
||||
|
||||
alter table public.reservations
|
||||
drop constraint no_overlapping_reservations;
|
||||
|
||||
alter table public.reservations
|
||||
add constraint no_overlapping_reservations
|
||||
exclude using gist (
|
||||
boat_id with =,
|
||||
tstzrange(start_time, end_time, '[)') with &&
|
||||
) where (status <> 'cancelled');
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Prevent members from modifying (cancelling or editing) reservations whose
|
||||
-- session has already started. Admins bypass this via service-role updates;
|
||||
-- the trigger only fires on connections where auth.uid() is a non-admin member.
|
||||
-- For simplicity we enforce it for all non-service-role connections.
|
||||
|
||||
create or replace function public.prevent_past_reservation_updates()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
if old.start_time < now() then
|
||||
raise exception 'past_reservation: Reservations that have already started cannot be modified.';
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create trigger check_past_reservation_update
|
||||
before update on public.reservations
|
||||
for each row execute function public.prevent_past_reservation_updates();
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Expose user_id and member_name in reservation_slots.
|
||||
-- JOIN with members is safe here because security_invoker=false runs as view owner,
|
||||
-- bypassing RLS — so any authenticated user can see names without a separate members query.
|
||||
-- Must drop+recreate because CREATE OR REPLACE VIEW cannot insert a column mid-list.
|
||||
drop view if exists public.reservation_slots;
|
||||
|
||||
create view public.reservation_slots
|
||||
with (security_invoker = false)
|
||||
as
|
||||
select
|
||||
r.id,
|
||||
r.boat_id,
|
||||
r.user_id,
|
||||
r.start_time,
|
||||
r.end_time,
|
||||
r.status,
|
||||
coalesce(
|
||||
nullif(trim(m.first_name || ' ' || m.last_name), ''),
|
||||
m.email
|
||||
) as member_name
|
||||
from public.reservations r
|
||||
left join public.members m on m.user_id = r.user_id
|
||||
where r.status <> 'cancelled';
|
||||
|
||||
grant select on public.reservation_slots to authenticated;
|
||||
@@ -35,7 +35,7 @@ test.describe('Auth flow', () => {
|
||||
|
||||
// Auth callback redirects to home (authenticated state)
|
||||
await expect(page).toHaveURL('/')
|
||||
await expect(page.getByText('Upcoming Reservations')).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Upcoming Reservations' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('unauthenticated access to protected route redirects to splash', async ({ page }) => {
|
||||
|
||||
@@ -393,6 +393,280 @@ describe('open-session window bypasses pre-booking limits', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ── Historical booking constraint ────────────────────────────────────────────
|
||||
describe('historical booking constraint', () => {
|
||||
const emailMember = `test-hist-member-${Date.now()}@oysqn.test`
|
||||
const emailAdmin = `test-hist-admin-${Date.now()}@oysqn.test`
|
||||
let memberUserId: string
|
||||
let adminUserId: string
|
||||
let boatId: string
|
||||
let memberToken: string
|
||||
let adminToken: string
|
||||
|
||||
function pastSlot(): { start_time: string; end_time: string } {
|
||||
const d = new Date()
|
||||
d.setUTCDate(d.getUTCDate() - 7)
|
||||
d.setUTCHours(9, 0, 0, 0)
|
||||
const e = new Date(d)
|
||||
e.setUTCHours(12, 30, 0, 0)
|
||||
return { start_time: d.toISOString(), end_time: e.toISOString() }
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
boatId = await createTestBoat()
|
||||
memberUserId = await createTestUser(emailMember, [], 'member')
|
||||
adminUserId = await createTestUser(emailAdmin, [], 'admin')
|
||||
memberToken = await getSessionToken(emailMember)
|
||||
adminToken = await getSessionToken(emailAdmin)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await adminClient.from('reservations').delete().eq('boat_id', boatId)
|
||||
await adminClient.from('boats').delete().eq('id', boatId)
|
||||
await adminClient.auth.admin.deleteUser(memberUserId)
|
||||
await adminClient.auth.admin.deleteUser(adminUserId)
|
||||
})
|
||||
|
||||
it('rejects a historical booking by a regular member', async () => {
|
||||
const slot = pastSlot()
|
||||
const { status, body } = await callCreateReservation(memberToken, { boat_id: boatId, ...slot })
|
||||
expect(status).toBe(422)
|
||||
expect(body.error.code).toBe('historical_booking_not_allowed')
|
||||
})
|
||||
|
||||
it('allows a historical booking by an admin', async () => {
|
||||
const slot = pastSlot()
|
||||
const { status } = await callCreateReservation(adminToken, { boat_id: boatId, ...slot })
|
||||
expect(status).toBe(201)
|
||||
})
|
||||
|
||||
it('rejects a historical booking by a skipper (non-admin role)', async () => {
|
||||
const emailSkipper = `test-hist-skipper-${Date.now()}@oysqn.test`
|
||||
const skipperId = await createTestUser(emailSkipper, [], 'skipper')
|
||||
const skipperToken = await getSessionToken(emailSkipper)
|
||||
try {
|
||||
const slot = pastSlot()
|
||||
const { status, body } = await callCreateReservation(skipperToken, { boat_id: boatId, ...slot })
|
||||
expect(status).toBe(422)
|
||||
expect(body.error.code).toBe('historical_booking_not_allowed')
|
||||
} finally {
|
||||
await adminClient.auth.admin.deleteUser(skipperId)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ── New reservation status ────────────────────────────────────────────────────
|
||||
describe('new reservation status', () => {
|
||||
const email = `test-status-${Date.now()}@oysqn.test`
|
||||
let userId: string
|
||||
let boatId: string
|
||||
let token: string
|
||||
|
||||
beforeAll(async () => {
|
||||
userId = await createTestUser(email)
|
||||
boatId = await createTestBoat()
|
||||
token = await getSessionToken(email)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await adminClient.from('reservations').delete().eq('boat_id', boatId)
|
||||
await adminClient.from('boats').delete().eq('id', boatId)
|
||||
await adminClient.auth.admin.deleteUser(userId)
|
||||
})
|
||||
|
||||
it('creates reservation with status confirmed', async () => {
|
||||
const slot = futureSlot(70)
|
||||
const { status, body } = await callCreateReservation(token, { boat_id: boatId, ...slot })
|
||||
expect(status).toBe(201)
|
||||
expect(body.reservation.status).toBe('confirmed')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Cancel reservation ────────────────────────────────────────────────────────
|
||||
describe('cancel reservation', () => {
|
||||
const emailA = `test-cancel-a-${Date.now()}@oysqn.test`
|
||||
const emailB = `test-cancel-b-${Date.now()}@oysqn.test`
|
||||
let userA: string
|
||||
let userB: string
|
||||
let boatId: string
|
||||
let tokenA: string
|
||||
let tokenB: string
|
||||
|
||||
const slot = futureSlot(72)
|
||||
let reservationId: string
|
||||
|
||||
beforeAll(async () => {
|
||||
boatId = await createTestBoat()
|
||||
userA = await createTestUser(emailA)
|
||||
userB = await createTestUser(emailB)
|
||||
tokenA = await getSessionToken(emailA)
|
||||
tokenB = await getSessionToken(emailB)
|
||||
|
||||
// User A books the slot
|
||||
const { body } = await callCreateReservation(tokenA, { boat_id: boatId, ...slot })
|
||||
reservationId = body.reservation.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await adminClient.from('reservations').delete().eq('boat_id', boatId)
|
||||
await adminClient.from('boats').delete().eq('id', boatId)
|
||||
await adminClient.auth.admin.deleteUser(userA)
|
||||
await adminClient.auth.admin.deleteUser(userB)
|
||||
})
|
||||
|
||||
it('user can cancel their own reservation', async () => {
|
||||
const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
global: { headers: { Authorization: `Bearer ${tokenA}` } },
|
||||
})
|
||||
const { error } = await client
|
||||
.from('reservations')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', reservationId)
|
||||
expect(error).toBeNull()
|
||||
|
||||
const { data } = await adminClient
|
||||
.from('reservations')
|
||||
.select('status')
|
||||
.eq('id', reservationId)
|
||||
.single()
|
||||
expect(data!.status).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('cancelled reservation is excluded from reservation_slots view', async () => {
|
||||
const { data } = await adminClient
|
||||
.from('reservation_slots' as never)
|
||||
.select('id')
|
||||
.eq('id', reservationId)
|
||||
expect(data).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('cancelled slot can be re-booked by another user', async () => {
|
||||
const { status } = await callCreateReservation(tokenB, { boat_id: boatId, ...slot })
|
||||
expect(status).toBe(201)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Edit reservation ──────────────────────────────────────────────────────────
|
||||
describe('edit reservation', () => {
|
||||
const email = `test-edit-${Date.now()}@oysqn.test`
|
||||
let userId: string
|
||||
let boatId: string
|
||||
let token: string
|
||||
let reservationId: string
|
||||
|
||||
beforeAll(async () => {
|
||||
userId = await createTestUser(email)
|
||||
boatId = await createTestBoat()
|
||||
token = await getSessionToken(email)
|
||||
|
||||
const { body } = await callCreateReservation(token, { boat_id: boatId, ...futureSlot(74), reason: 'Open Sail' })
|
||||
reservationId = body.reservation.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await adminClient.from('reservations').delete().eq('boat_id', boatId)
|
||||
await adminClient.from('boats').delete().eq('id', boatId)
|
||||
await adminClient.auth.admin.deleteUser(userId)
|
||||
})
|
||||
|
||||
it('user can update reason and comment on own reservation', async () => {
|
||||
const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
global: { headers: { Authorization: `Bearer ${token}` } },
|
||||
})
|
||||
const { error } = await client
|
||||
.from('reservations')
|
||||
.update({ reason: 'Racing', comment: 'Crew of 4' })
|
||||
.eq('id', reservationId)
|
||||
expect(error).toBeNull()
|
||||
|
||||
const { data } = await adminClient
|
||||
.from('reservations')
|
||||
.select('reason, comment')
|
||||
.eq('id', reservationId)
|
||||
.single()
|
||||
expect(data!.reason).toBe('Racing')
|
||||
expect(data!.comment).toBe('Crew of 4')
|
||||
})
|
||||
|
||||
it('editing does not change the reservation status', async () => {
|
||||
const { data } = await adminClient
|
||||
.from('reservations')
|
||||
.select('status')
|
||||
.eq('id', reservationId)
|
||||
.single()
|
||||
expect(data!.status).toBe('confirmed')
|
||||
})
|
||||
})
|
||||
|
||||
// ── RLS: cancel/edit permissions ──────────────────────────────────────────────
|
||||
describe('RLS: cancel/edit permissions', () => {
|
||||
const emailA = `test-perm-a-${Date.now()}@oysqn.test`
|
||||
const emailB = `test-perm-b-${Date.now()}@oysqn.test`
|
||||
let userA: string
|
||||
let userB: string
|
||||
let boatId: string
|
||||
let tokenA: string
|
||||
let tokenB: string
|
||||
let reservationId: string
|
||||
|
||||
beforeAll(async () => {
|
||||
boatId = await createTestBoat()
|
||||
userA = await createTestUser(emailA)
|
||||
userB = await createTestUser(emailB)
|
||||
tokenA = await getSessionToken(emailA)
|
||||
tokenB = await getSessionToken(emailB)
|
||||
|
||||
const { body } = await callCreateReservation(tokenA, { boat_id: boatId, ...futureSlot(76) })
|
||||
reservationId = body.reservation.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await adminClient.from('reservations').delete().eq('boat_id', boatId)
|
||||
await adminClient.from('boats').delete().eq('id', boatId)
|
||||
await adminClient.auth.admin.deleteUser(userA)
|
||||
await adminClient.auth.admin.deleteUser(userB)
|
||||
})
|
||||
|
||||
it('user B cannot cancel user A reservation', async () => {
|
||||
const clientB = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
global: { headers: { Authorization: `Bearer ${tokenB}` } },
|
||||
})
|
||||
// RLS silently ignores the update (matches 0 rows) — no error, but status unchanged
|
||||
await clientB
|
||||
.from('reservations')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', reservationId)
|
||||
|
||||
const { data } = await adminClient
|
||||
.from('reservations')
|
||||
.select('status')
|
||||
.eq('id', reservationId)
|
||||
.single()
|
||||
expect(data!.status).not.toBe('cancelled')
|
||||
})
|
||||
|
||||
it('user B cannot edit user A reservation', async () => {
|
||||
const clientB = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
global: { headers: { Authorization: `Bearer ${tokenB}` } },
|
||||
})
|
||||
await clientB
|
||||
.from('reservations')
|
||||
.update({ comment: 'injected by B' })
|
||||
.eq('id', reservationId)
|
||||
|
||||
const { data } = await adminClient
|
||||
.from('reservations')
|
||||
.select('comment')
|
||||
.eq('id', reservationId)
|
||||
.single()
|
||||
expect(data!.comment).not.toBe('injected by B')
|
||||
})
|
||||
})
|
||||
|
||||
// ── RBAC visibility: reservation_slots view ───────────────────────────────────
|
||||
describe('RBAC: reservation_slots view', () => {
|
||||
const emailOwner = `test-rbac-owner-${Date.now()}@oysqn.test`
|
||||
@@ -459,3 +733,211 @@ describe('RBAC: reservation_slots view', () => {
|
||||
expect(ownerRows).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Cancelled reservations do not count toward weekly limit ───────────────────
|
||||
describe('cancelled reservations do not count toward weekly limit', () => {
|
||||
const email = `test-cancel-weekly-${Date.now()}@oysqn.test`
|
||||
let userId: string
|
||||
let boatId: string
|
||||
let token: string
|
||||
|
||||
// Fixed far-future week: 2026-07-06 (Mon) — no holidays, isolated from other tests
|
||||
const WEEK_BASE = new Date('2026-07-06T09:00:00Z')
|
||||
function weekSlot(dayOffset: number): { start_time: string; end_time: string } {
|
||||
const s = new Date(WEEK_BASE)
|
||||
s.setUTCDate(WEEK_BASE.getUTCDate() + dayOffset)
|
||||
const e = new Date(s)
|
||||
e.setUTCHours(s.getUTCHours() + 3, 30, 0, 0)
|
||||
return { start_time: s.toISOString(), end_time: e.toISOString() }
|
||||
}
|
||||
|
||||
let firstReservationId: string
|
||||
|
||||
beforeAll(async () => {
|
||||
userId = await createTestUser(email)
|
||||
boatId = await createTestBoat()
|
||||
token = await getSessionToken(email)
|
||||
await adminClient.from('booking_config')
|
||||
.upsert({ key: 'max_sessions_per_week', value: 2 }, { onConflict: 'key' })
|
||||
await adminClient.from('booking_config')
|
||||
.upsert({ key: 'open_session_advance_hours', value: 1 }, { onConflict: 'key' })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await adminClient.from('reservations').delete().eq('boat_id', boatId)
|
||||
await adminClient.from('boats').delete().eq('id', boatId)
|
||||
await adminClient.auth.admin.deleteUser(userId)
|
||||
await adminClient.from('booking_config')
|
||||
.upsert({ key: 'open_session_advance_hours', value: 24 }, { onConflict: 'key' })
|
||||
})
|
||||
|
||||
it('first booking succeeds', async () => {
|
||||
const { status, body } = await callCreateReservation(token, { boat_id: boatId, ...weekSlot(0) })
|
||||
expect(status).toBe(201)
|
||||
firstReservationId = body.reservation.id
|
||||
})
|
||||
|
||||
it('second booking succeeds', async () => {
|
||||
const { status } = await callCreateReservation(token, { boat_id: boatId, ...weekSlot(1) })
|
||||
expect(status).toBe(201)
|
||||
})
|
||||
|
||||
it('third booking is rejected (limit reached)', async () => {
|
||||
const { status, body } = await callCreateReservation(token, { boat_id: boatId, ...weekSlot(2) })
|
||||
expect(status).toBe(422)
|
||||
expect(body.error.code).toBe('booking_limit_weekly')
|
||||
})
|
||||
|
||||
it('cancelling the first booking frees up the slot count', async () => {
|
||||
await adminClient.from('reservations').update({ status: 'cancelled' }).eq('id', firstReservationId)
|
||||
})
|
||||
|
||||
it('third booking now succeeds after cancellation', async () => {
|
||||
const { status } = await callCreateReservation(token, { boat_id: boatId, ...weekSlot(2) })
|
||||
expect(status).toBe(201)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Cancelled reservations do not count toward weekend limit ──────────────────
|
||||
describe('cancelled reservations do not count toward weekend limit', () => {
|
||||
const email = `test-cancel-weekend-${Date.now()}@oysqn.test`
|
||||
let userId: string
|
||||
let boatId: string
|
||||
let token: string
|
||||
|
||||
// 2026-08-01 (Saturday) and 2026-08-02 (Sunday) — same 2-week period
|
||||
const SAT = '2026-08-01T09:00:00Z'
|
||||
const SAT_END = '2026-08-01T12:30:00Z'
|
||||
const SUN = '2026-08-02T09:00:00Z'
|
||||
const SUN_END = '2026-08-02T12:30:00Z'
|
||||
|
||||
let satReservationId: string
|
||||
|
||||
beforeAll(async () => {
|
||||
userId = await createTestUser(email)
|
||||
boatId = await createTestBoat()
|
||||
token = await getSessionToken(email)
|
||||
await adminClient.from('booking_config')
|
||||
.upsert({ key: 'max_weekend_sessions_per_period', value: 1 }, { onConflict: 'key' })
|
||||
await adminClient.from('booking_config')
|
||||
.upsert({ key: 'weekend_period_weeks', value: 2 }, { onConflict: 'key' })
|
||||
await adminClient.from('booking_config')
|
||||
.upsert({ key: 'open_session_advance_hours', value: 1 }, { onConflict: 'key' })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await adminClient.from('reservations').delete().eq('boat_id', boatId)
|
||||
await adminClient.from('boats').delete().eq('id', boatId)
|
||||
await adminClient.auth.admin.deleteUser(userId)
|
||||
await adminClient.from('booking_config')
|
||||
.upsert({ key: 'open_session_advance_hours', value: 24 }, { onConflict: 'key' })
|
||||
})
|
||||
|
||||
it('first weekend booking succeeds', async () => {
|
||||
const { status, body } = await callCreateReservation(token, {
|
||||
boat_id: boatId, start_time: SAT, end_time: SAT_END,
|
||||
})
|
||||
expect(status).toBe(201)
|
||||
satReservationId = body.reservation.id
|
||||
})
|
||||
|
||||
it('second weekend booking in same period is rejected', async () => {
|
||||
const { status, body } = await callCreateReservation(token, {
|
||||
boat_id: boatId, start_time: SUN, end_time: SUN_END,
|
||||
})
|
||||
expect(status).toBe(422)
|
||||
expect(body.error.code).toBe('booking_limit_weekend')
|
||||
})
|
||||
|
||||
it('cancelling the first weekend booking frees up the period count', async () => {
|
||||
await adminClient.from('reservations').update({ status: 'cancelled' }).eq('id', satReservationId)
|
||||
})
|
||||
|
||||
it('second weekend booking now succeeds after cancellation', async () => {
|
||||
const { status } = await callCreateReservation(token, {
|
||||
boat_id: boatId, start_time: SUN, end_time: SUN_END,
|
||||
})
|
||||
expect(status).toBe(201)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Cannot modify past reservations ───────────────────────────────────────────
|
||||
describe('cannot modify past reservations', () => {
|
||||
const email = `test-past-mod-${Date.now()}@oysqn.test`
|
||||
let userId: string
|
||||
let boatId: string
|
||||
let token: string
|
||||
let pastReservationId: string
|
||||
let futureReservationId: string
|
||||
|
||||
function pastSlot() {
|
||||
const s = new Date()
|
||||
s.setUTCDate(s.getUTCDate() - 3)
|
||||
s.setUTCHours(9, 0, 0, 0)
|
||||
const e = new Date(s)
|
||||
e.setUTCHours(12, 30, 0, 0)
|
||||
return { start_time: s.toISOString(), end_time: e.toISOString() }
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
userId = await createTestUser(email)
|
||||
boatId = await createTestBoat()
|
||||
token = await getSessionToken(email)
|
||||
|
||||
// Admin inserts a past reservation for the test user
|
||||
const past = pastSlot()
|
||||
const { data: pastRes } = await adminClient.from('reservations').insert({
|
||||
boat_id: boatId, user_id: userId, start_time: past.start_time, end_time: past.end_time,
|
||||
reason: 'Open Sail', status: 'confirmed',
|
||||
}).select('id').single()
|
||||
pastReservationId = pastRes!.id
|
||||
|
||||
// And a future one for comparison
|
||||
const { body } = await callCreateReservation(token, { boat_id: boatId, ...futureSlot(90) })
|
||||
futureReservationId = body.reservation.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await adminClient.from('reservations').delete().eq('boat_id', boatId)
|
||||
await adminClient.from('boats').delete().eq('id', boatId)
|
||||
await adminClient.auth.admin.deleteUser(userId)
|
||||
})
|
||||
|
||||
it('cannot cancel a past reservation', async () => {
|
||||
const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
global: { headers: { Authorization: `Bearer ${token}` } },
|
||||
})
|
||||
const { error } = await client
|
||||
.from('reservations')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', pastReservationId)
|
||||
expect(error).not.toBeNull()
|
||||
expect(error!.message).toMatch(/past_reservation/)
|
||||
})
|
||||
|
||||
it('cannot edit reason/comment on a past reservation', async () => {
|
||||
const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
global: { headers: { Authorization: `Bearer ${token}` } },
|
||||
})
|
||||
const { error } = await client
|
||||
.from('reservations')
|
||||
.update({ comment: 'should not work' })
|
||||
.eq('id', pastReservationId)
|
||||
expect(error).not.toBeNull()
|
||||
expect(error!.message).toMatch(/past_reservation/)
|
||||
})
|
||||
|
||||
it('can still cancel a future reservation', async () => {
|
||||
const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
global: { headers: { Authorization: `Bearer ${token}` } },
|
||||
})
|
||||
const { error } = await client
|
||||
.from('reservations')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', futureReservationId)
|
||||
expect(error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { loadEnv } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode ?? 'test', process.cwd(), '')
|
||||
return {
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['tests/integration/**/*.test.ts'],
|
||||
testTimeout: 30000,
|
||||
typecheck: { tsconfig: './tests/tsconfig.json' },
|
||||
env,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': new URL('./app', import.meta.url).pathname,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user